Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 142 additions & 26 deletions src/components/navigation/SidebarNav.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import {
Toolbar,
Typography,
} from "../MUI/MuiWrapped";
import {
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import { Theme } from "@mui/material/styles";
import { Logo } from "../controls/Logo";
import { ColourSchemeButton } from "../controls/ColourSchemeButton";
Expand Down Expand Up @@ -45,6 +52,9 @@ A collapsing/expanding sidebar for your app's primary navigation.
For normal screen sizes, the implementation uses MUI's permanent drawer toggling between two widths showing either icon and text or just icon.
For smaller screens, we use the temporary variant instead.`,
},
story: {
height: "600px",
},
},
},
};
Expand Down Expand Up @@ -76,24 +86,25 @@ const standardLinks = [
];

export const NormalLinks: Story = {
render: (args) => {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
<Box sx={{ display: "flex" }}>
<SidebarNav
navigation={standardLinks}
open={args.open}
setOpen={args.setOpen}
/>
<Typography>
When using standard links, the caller must handle the selected state
and set it to the correct item.
</Typography>
<SidebarNav navigation={standardLinks} open={open} setOpen={setOpen} />
<Box sx={{ p: 2 }}>
<IconButton onClick={() => setOpen(!open)}>
<Menu />
</IconButton>
<Typography>
When using standard links, the caller must handle the selected state
and set it to the correct item.
</Typography>
</Box>
</Box>
);
},
args: {
navigation: standardLinks,
open: true,
},
};

Expand Down Expand Up @@ -129,23 +140,29 @@ const reactRouterNavigation = [
];

export const RouterLinks: Story = {
render: (args) => {
render: (_args) => {
const [open, setOpen] = React.useState(false);
return (
<Box sx={{ display: "flex" }}>
<SidebarNav
navigation={reactRouterNavigation}
open={args.open}
setOpen={args.setOpen}
open={open}
setOpen={setOpen}
/>
<Typography>
React Router <em>NavLinks</em> will handle selected state internally.
</Typography>
<Box sx={{ p: 2 }}>
<IconButton onClick={() => setOpen(!open)}>
<Menu />
</IconButton>
<Typography>
React Router <em>NavLinks</em> will handle selected state
internally.
</Typography>
</Box>
</Box>
);
},
args: {
navigation: reactRouterNavigation,
open: false,
},
};

Expand Down Expand Up @@ -190,21 +207,120 @@ const groupedNavigation = [
];

export const GroupedNavigation: Story = {
render: (args) => {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
<Box sx={{ display: "flex" }}>
<SidebarNav
navigation={args.navigation}
open={args.open}
setOpen={args.setOpen}
navigation={groupedNavigation}
open={open}
setOpen={setOpen}
/>
<Typography>Sections are grouped with dividers</Typography>
<Box sx={{ p: 2 }}>
<IconButton onClick={() => setOpen(!open)}>
<Menu />
</IconButton>
<Typography>Sections are grouped with dividers.</Typography>
</Box>
</Box>
);
},
args: {
navigation: groupedNavigation,
open: true,
};

/** A dashed, tinted wrapper so it's obvious in the story which content is coming from a slot vs. the `navigation` prop. */
const SlotOutline = ({
label,
open,
children,
}: {
label: string;
open: boolean;
children: React.ReactNode;
}) => (
<Box
sx={{
m: 1,
mt: 0,
border: "1px dashed",
borderColor: "divider",
borderRadius: 2,
bgcolor: "action.hover",
overflow: "hidden",
}}
>
{open && (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: "block", px: 1.5, pt: 0.5 }}
>
{label}
</Typography>
)}
<List sx={{ p: 0.5, pt: 0 }}>{children}</List>
</Box>
);

export const WithSlots: Story = {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
<Box sx={{ display: "flex" }}>
<SidebarNav
navigation={groupedNavigation}
open={open}
setOpen={setOpen}
afterNavSlot={
<SlotOutline label="afterNavSlot" open={open}>
<ListItem disablePadding>
<ListItemButton
href="https://www.example.com/docs"
sx={{ p: 1, borderRadius: 2, gap: 1.5 }}
>
<ListItemIcon sx={{ minWidth: 32 }}>
<Insights />
</ListItemIcon>
<ListItemText
primary="Documentation"
sx={{ opacity: open ? 1 : 0 }}
/>
</ListItemButton>
</ListItem>
</SlotOutline>
}
footerSlot={
<SlotOutline label="footerSlot" open={open}>
<ListItem disablePadding>
<ListItemButton
href="#settings"
sx={{ p: 1, borderRadius: 2, gap: 1.5 }}
>
<ListItemIcon sx={{ minWidth: 32 }}>
<CorporateFare />
</ListItemIcon>
<ListItemText
primary="Settings"
sx={{ opacity: open ? 1 : 0 }}
/>
</ListItemButton>
</ListItem>
</SlotOutline>
}
/>
<Box sx={{ p: 2 }}>
<IconButton onClick={() => setOpen(!open)}>
<Menu />
</IconButton>
<Typography>
Adds slots to the navbar, boxes are only there to highlight what
each slot renders, they aren&apos;t part of the component.{" "}
<em>afterNavSlot</em> renders inside the scrollable area, right
after the navigation items. <em>footerSlot</em> is pinned to the
bottom of the drawer, outside the scroll area.
</Typography>
</Box>
</Box>
);
},
};

Expand Down
38 changes: 38 additions & 0 deletions src/components/navigation/SidebarNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,44 @@ describe("SidebarNav", () => {
expect(divider).toBeInTheDocument();
});

it("renders afterNavSlot after the navigation items", () => {
const router = createMemoryRouter([
{
path: "/",
element: (
<SidebarNav
navigation={navigation}
open={true}
setOpen={vi.fn()}
afterNavSlot={<div data-testid="after-nav">Extra links</div>}
/>
),
},
]);
render(<RouterProvider router={router} />);

expect(screen.getByTestId("after-nav")).toBeVisible();
});

it("renders footerSlot", () => {
const router = createMemoryRouter([
{
path: "/",
element: (
<SidebarNav
navigation={navigation}
open={true}
setOpen={vi.fn()}
footerSlot={<div data-testid="footer">User menu</div>}
/>
),
},
]);
render(<RouterProvider router={router} />);

expect(screen.getByTestId("footer")).toBeVisible();
});

it("renders internal and external links with correct href", () => {
// even though specified differently, ultimately both types
// should have the correct href attribute
Expand Down
35 changes: 31 additions & 4 deletions src/components/navigation/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ type NavProps = {
navigation: Navigation;
open: boolean;
setOpen: (open: boolean) => void;
/** Rendered after the navigation items, inside the scrollable area. */
afterNavSlot?: ReactNode;
/** Rendered pinned to the bottom of the drawer, outside the scrollable area. */
footerSlot?: ReactNode;
};

export function SidebarNav(props: NavProps) {
Expand Down Expand Up @@ -87,13 +91,14 @@ function PermanentDrawer(props: NavProps) {
transition: (theme: Theme) => drawerTransition(theme, props.open),
[`& .MuiDrawer-paper`]: {
width: width,
height: "100vh",
boxSizing: "border-box",
transition: drawerTransition(theme, props.open),
},
})}
>
<Toolbar /> {/* spacer equal to the AppBar's height*/}
<NavigationItems {...props} />
<DrawerContent {...props} />
</Drawer>
);
}
Expand Down Expand Up @@ -124,14 +129,35 @@ function TemporaryDrawer(props: NavProps) {
}}
>
<Toolbar />
<NavigationItems {...props} />
<DrawerContent {...props} />
</Drawer>
);
}

function NavigationItems({ navigation, open }: NavProps) {
function DrawerContent(props: NavProps) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
minHeight: 0,
flex: 1,
}}
>
<NavigationItems {...props} />
{props.footerSlot && (
<Box sx={{ flexShrink: 0 }}>
<SectionDivider />
<Box sx={{ px: 1, pb: 1 }}>{props.footerSlot}</Box>
</Box>
)}
</Box>
);
}

function NavigationItems({ navigation, open, afterNavSlot }: NavProps) {
return (
<Box sx={{ overflow: "auto" }}>
<Box sx={{ overflow: "auto", flex: 1, minHeight: 0 }}>
<List
sx={{
p: 1,
Expand All @@ -149,6 +175,7 @@ function NavigationItems({ navigation, open }: NavProps) {
</Fragment>
))}
</List>
{afterNavSlot}
</Box>
);
}
Expand Down
Loading