From 13802511c2cca54b6ec0bee1ed72547382ebef47 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 12 Jun 2026 14:58:15 +0000 Subject: [PATCH 1/7] Initial SidebarNav implementation --- .../navigation/SidebarNav.stories.tsx | 139 +++++++++++++++ src/components/navigation/SidebarNav.test.tsx | 121 +++++++++++++ src/components/navigation/SidebarNav.tsx | 168 ++++++++++++++++++ src/components/navigation/SidebarNavDocs.mdx | 7 + 4 files changed, 435 insertions(+) create mode 100644 src/components/navigation/SidebarNav.stories.tsx create mode 100644 src/components/navigation/SidebarNav.test.tsx create mode 100644 src/components/navigation/SidebarNav.tsx create mode 100644 src/components/navigation/SidebarNavDocs.mdx diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx new file mode 100644 index 00000000..e42cc502 --- /dev/null +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -0,0 +1,139 @@ +import { + Abc, + ArrowForward, + CorporateFare, + GraphicEq, + Menu, +} from "@mui/icons-material"; +import { SidebarNav } from "./SidebarNav"; +import { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { + AppBar, + Box, + Divider, + IconButton, + Toolbar, + Typography, +} from "../MUI/MuiWrapped"; +import { Theme } from "@mui/material/styles"; +import { Logo } from "../controls/Logo"; +import { ColourSchemeButton } from "../controls/ColourSchemeButton"; + +const meta: Meta = { + title: "Components/Navigation/SidebarNav", + component: SidebarNav, + tags: ["autodocs"], + parameters: { + docs: { + pages: {}, + description: { + component: `A collapsing/expanding sidebar for your app's primary navigation. Click on the individual stories to see the examples.`, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const navigation = [ + { + navItems: [ + { + label: "Setup", + icon: , + linkProps: { href: "#1" }, + }, + { + label: "Acquisition", + icon: , + linkProps: { href: "#2" }, + }, + { + label: "Analysis", + icon: , + linkProps: { href: "#3" }, + }, + ], + }, + { + navItems: [ + { + label: "Organisation", + icon: , + linkProps: { href: "" }, + }, + ], + }, +]; + +export const Basic: Story = { + args: { + navigation, + open: true, + }, +}; + +export const WithAppBar: Story = { + render: (_args) => { + const [open, setOpen] = React.useState(true); + return ( + + theme.zIndex.drawer + 1, + }} + > + + setOpen(!open)} + > + + + + + + + + + + + My app + + + + + + + + + + + ); + }, + parameters: { + docs: { + description: { + story: + "MUI wants to draw a Drawer above everything, so in this example the AppBar's zIndex is increased.", + }, + }, + }, +}; diff --git a/src/components/navigation/SidebarNav.test.tsx b/src/components/navigation/SidebarNav.test.tsx new file mode 100644 index 00000000..75e7e234 --- /dev/null +++ b/src/components/navigation/SidebarNav.test.tsx @@ -0,0 +1,121 @@ +import { render, screen } from "@testing-library/react"; +import { Navigation, SidebarNav } from "./SidebarNav"; +import { createMemoryRouter, NavLink, RouterProvider } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; + +describe("SidebarNav", () => { + const navigation: Navigation = [ + { + navItems: [ + { + label: "Setup", + icon:
, + linkProps: { component: NavLink, to: "/setup" }, + }, + { + label: "Acquisition", + icon:
, + linkProps: { component: NavLink, to: "/acq" }, + }, + { + label: "Analysis", + icon:
, + linkProps: { component: NavLink, to: "/analysis" }, + }, + ], + }, + { + navItems: [ + { + label: "Organisation", + icon:
, + linkProps: { href: "https://www.example.com" }, + }, + ], + }, + ]; + + function renderSidenav(open: boolean) { + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); + render(); + } + + it("Shows icons and names when open", () => { + renderSidenav(true); + + const items = navigation[0].navItems; + + items.forEach((item) => { + const button = screen.getByRole("link", { name: item.label }); + expect(button).toBeVisible(); + const label = screen.getByText(item.label); + expect(label).toBeVisible(); + }); + ["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) => + expect(screen.getByTestId(id)).toBeVisible(), + ); + }); + + it("Shows icons only when closed", () => { + renderSidenav(false); + const items = navigation[0].navItems; + items.forEach((item) => { + const button = screen.getByRole("link", { name: item.label }); + expect(button).toBeVisible(); // a11y-wise still visible + const label = screen.getByText(item.label); + expect(label).toBeInTheDocument(); // label exists but + expect(label).not.toBeVisible(); // not visible + }); + ["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) => + expect(screen.getByTestId(id)).toBeVisible(), + ); + }); + + it("shows tooltip on buttons when closed", async () => { + renderSidenav(false); + + const icon = screen.getByTestId("navicon2"); + const user = userEvent.setup(); + await user.hover(icon); + + // notice we await because the tooltip appears after some time + const tooltip = await screen.findByRole("tooltip", { name: "Acquisition" }); + expect(tooltip).toBeVisible(); + }); + + it("shows no tooltip on buttons when open", async () => { + renderSidenav(true); + + const icon = screen.getByTestId("navicon2"); + const user = userEvent.setup(); + await user.hover(icon); + + const tooltip = screen.queryByRole("tooltip", { + name: "Acquisition", + }); + expect(tooltip).not.toBeInTheDocument(); + }); + + it("creates divider between nav sections", () => { + renderSidenav(true); + const divider = screen.queryByRole("separator"); + expect(divider).toBeInTheDocument(); + }); + + it("renders internal and external links with correct href", () => { + // even though specified differently, ultimately both types + // should have the correct href attribute + renderSidenav(true); + + const externalLink = screen.getByRole("link", { name: "Organisation" }); + expect(externalLink).toHaveAttribute("href", "https://www.example.com"); + + const internalLink = screen.getByRole("link", { name: "Setup" }); + expect(internalLink).toHaveAttribute("href", "/setup"); + }); +}); diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx new file mode 100644 index 00000000..51cc7c88 --- /dev/null +++ b/src/components/navigation/SidebarNav.tsx @@ -0,0 +1,168 @@ +import { + Box, + Divider, + Drawer, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Tooltip, + type Theme, +} from "@mui/material"; +import { Fragment, type ElementType, type ReactNode } from "react"; + +export type Navigation = NavItemGroup[]; + +type NavItemGroup = { + name?: string; + navItems: NavItemDefinition[]; +}; + +type NavItemDefinition = { + label: string; + icon: ReactNode; + linkProps: LinkProps; +}; + +type LinkProps = ExternalLinkProps | InternalLinkProps; + +/** For native anchor tags */ +type ExternalLinkProps = { + href: string; + component?: never; + to?: never; +}; + +/** For SPA navigation */ +type InternalLinkProps = { + component: ElementType; + to: string; + href?: never; +}; + +const drawerTransition = (theme: Theme, opening: boolean) => { + return theme.transitions.create("width", { + easing: opening + ? theme.transitions.easing.easeIn + : theme.transitions.easing.easeOut, + duration: opening + ? theme.transitions.duration.enteringScreen + : theme.transitions.duration.leavingScreen, + }); +}; + +type NavProps = { + navigation: Navigation; + open: boolean; +}; + +export function SidebarNav({ navigation, open }: NavProps) { + const width = open ? 257 : 65; // 256/64 + 1 pixel for the border + return ( + ({ + width: width, + flexShrink: 0, + transition: (theme) => drawerTransition(theme, open), + [`& .MuiDrawer-paper`]: { + width: width, + boxSizing: "border-box", + transition: drawerTransition(theme, open), + }, + })} + > + {/* spacer equal to the AppBar's height*/} + + + {navigation.map((group, groupIndex) => ( + + {groupIndex > 0 && } + {group.navItems.map((item, itemIndex) => { + return ( + + ); + })} + + ))} + + + + ); +} + +function SectionDivider() { + return ( + + + + ); +} + +interface NavItemProps { + definition: NavItemDefinition; + open: boolean; +} + +function NavItem(props: NavItemProps) { + const item = props.definition; + const open = props.open; + const icon = ( + + {item.icon} + + ); + + return ( + + + {open ? ( + icon + ) : ( + + {icon} + + )} + + theme.transitions.create("opacity", { + duration: theme.transitions.duration.shorter, + }), + }} + /> + + + ); +} diff --git a/src/components/navigation/SidebarNavDocs.mdx b/src/components/navigation/SidebarNavDocs.mdx new file mode 100644 index 00000000..0d7a2854 --- /dev/null +++ b/src/components/navigation/SidebarNavDocs.mdx @@ -0,0 +1,7 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import * as SidebarNavStories from "./SidebarNav.stories" + + + + + From 4f3c59911b5595d43ba82ee50c53c727b482462b Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 10:26:41 +0000 Subject: [PATCH 2/7] Improve app bar in sidebarnav story --- .../navigation/SidebarNav.stories.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index e42cc502..e6dc170e 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -19,10 +19,18 @@ import { import { Theme } from "@mui/material/styles"; import { Logo } from "../controls/Logo"; import { ColourSchemeButton } from "../controls/ColourSchemeButton"; +import { NavLink, MemoryRouter } from "react-router-dom"; const meta: Meta = { title: "Components/Navigation/SidebarNav", component: SidebarNav, + decorators: [ + (Story: Story) => ( + + + + ), + ], tags: ["autodocs"], parameters: { docs: { @@ -43,17 +51,17 @@ const navigation = [ { label: "Setup", icon: , - linkProps: { href: "#1" }, + linkProps: { to: "/1", component: NavLink }, }, { label: "Acquisition", icon: , - linkProps: { href: "#2" }, + linkProps: { to: "/2", component: NavLink }, }, { label: "Analysis", icon: , - linkProps: { href: "#3" }, + linkProps: { to: "/3", component: NavLink }, }, ], }, @@ -62,7 +70,7 @@ const navigation = [ { label: "Organisation", icon: , - linkProps: { href: "" }, + linkProps: { href: "#4" }, }, ], }, @@ -82,10 +90,13 @@ export const WithAppBar: Story = { theme.zIndex.drawer + 1, + borderBottom: "1px solid", + borderColor: "divider", }} + elevation={0} > - - + + From 0b625be0433ddcc6019851b974a393df3e9b0674 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 10:38:07 +0000 Subject: [PATCH 3/7] Fix CI error --- src/components/navigation/SidebarNav.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index e6dc170e..7ed33da1 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -25,7 +25,7 @@ const meta: Meta = { title: "Components/Navigation/SidebarNav", component: SidebarNav, decorators: [ - (Story: Story) => ( + (Story) => ( From 682333e70a0c77d769f6ccfc79991c6b6f650f04 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 14:54:42 +0000 Subject: [PATCH 4/7] Handle selected state The solution in this commit is to simply have a `selected` optional prop on the NavItemDefinition which propagates to the button component. When using standard anchor tags, the caller will need to keep track of this state and set it some way e.g. selected: window.location.pathname === "/docs" When using react-router-dom, the state is handled in internally within the NavLink component. Stories have been added to demonstrate the two cases. --- .../navigation/SidebarNav.stories.tsx | 111 +++++++++++++++++- src/components/navigation/SidebarNav.tsx | 14 ++- src/components/navigation/SidebarNavDocs.mdx | 7 -- 3 files changed, 116 insertions(+), 16 deletions(-) delete mode 100644 src/components/navigation/SidebarNavDocs.mdx diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index 7ed33da1..e2850fb9 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -3,7 +3,9 @@ import { ArrowForward, CorporateFare, GraphicEq, + Insights, Menu, + Schedule, } from "@mui/icons-material"; import { SidebarNav } from "./SidebarNav"; import { Meta, StoryObj } from "@storybook/react"; @@ -45,7 +47,45 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const navigation = [ +const standardLinks = [ + { + navItems: [ + { + label: "Setup", + icon: , + linkProps: { href: "" }, + }, + { + label: "Acquisition", + icon: , + linkProps: { href: "" }, + selected: true, + }, + { + label: "Analysis", + icon: , + linkProps: { href: "" }, + }, + ], + }, +]; + +export const NormalLinks: Story = { + args: { + navigation: standardLinks, + open: true, + }, + parameters: { + docs: { + description: { + story: + "When using standard links, the caller must handle the selected state and set it to the correct item.", + }, + }, + }, +}; + +const reactRouterNavigation = [ { navItems: [ { @@ -70,17 +110,78 @@ const navigation = [ { label: "Organisation", icon: , - linkProps: { href: "#4" }, + linkProps: { to: "/4", component: NavLink }, }, ], }, ]; -export const Basic: Story = { +export const RouterLinks: Story = { args: { - navigation, + navigation: reactRouterNavigation, + open: false, + }, + parameters: { + docs: { + description: { + story: `React Router _NavLinks_ will handle selected state internally.`, + }, + }, + }, +}; + +const groupedNavigation = [ + { + navItems: [ + { + label: "Setup", + icon: , + linkProps: { to: "/1", component: NavLink }, + }, + { + label: "Acquisition", + icon: , + linkProps: { to: "/2", component: NavLink }, + }, + ], + }, + { + navItems: [ + { + label: "Analysis", + icon: , + linkProps: { to: "/3", component: NavLink }, + }, + { + label: "Data Browse", + icon: , + linkProps: { to: "/4", component: NavLink }, + }, + ], + }, + { + navItems: [ + { + label: "Log", + icon: , + linkProps: { to: "/5", component: NavLink }, + }, + ], + }, +]; + +export const GroupedNavigation: Story = { + args: { + navigation: groupedNavigation, open: true, }, + parameters: { + docs: { + description: { + story: "Sections are grouped with dividers", + }, + }, + }, }; export const WithAppBar: Story = { @@ -135,7 +236,7 @@ export const WithAppBar: Story = { - + ); }, diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx index 51cc7c88..d5088176 100644 --- a/src/components/navigation/SidebarNav.tsx +++ b/src/components/navigation/SidebarNav.tsx @@ -24,6 +24,7 @@ type NavItemDefinition = { label: string; icon: ReactNode; linkProps: LinkProps; + selected?: boolean; }; type LinkProps = ExternalLinkProps | InternalLinkProps; @@ -87,7 +88,11 @@ export function SidebarNav({ navigation, open }: NavProps) { {groupIndex > 0 && } {group.navItems.map((item, itemIndex) => { return ( - + ); })} @@ -108,12 +113,12 @@ function SectionDivider() { interface NavItemProps { definition: NavItemDefinition; - open: boolean; + sidebarOpen: boolean; } function NavItem(props: NavItemProps) { const item = props.definition; - const open = props.open; + const open = props.sidebarOpen; const icon = ( - - - From 85c937e3fb1e7b36813975fbe1d3522734322594 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Thu, 2 Jul 2026 11:03:22 +0000 Subject: [PATCH 5/7] Add responsive layout At 'sm' breakpoint we switch to a temporary drawer instead of a permanent one. We could set the style conditionally but I noticed weird flashing that I don't see when using different components. --- .../navigation/SidebarNav.stories.tsx | 10 +- src/components/navigation/SidebarNav.tsx | 109 +++++++++++++----- 2 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index e2850fb9..cf0dd85d 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -236,7 +236,15 @@ export const WithAppBar: Story = { - + + + + Main content here + ); }, diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx index d5088176..9a5a2b5b 100644 --- a/src/components/navigation/SidebarNav.tsx +++ b/src/components/navigation/SidebarNav.tsx @@ -9,9 +9,11 @@ import { ListItemText, Toolbar, Tooltip, - type Theme, } from "@mui/material"; +import { useTheme, Theme } from "@mui/material/styles"; import { Fragment, type ElementType, type ReactNode } from "react"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useState } from "react"; export type Navigation = NavItemGroup[]; @@ -57,52 +59,99 @@ const drawerTransition = (theme: Theme, opening: boolean) => { type NavProps = { navigation: Navigation; open: boolean; + setOpen: (open: boolean) => void; }; -export function SidebarNav({ navigation, open }: NavProps) { - const width = open ? 257 : 65; // 256/64 + 1 pixel for the border +export function SidebarNav(props: NavProps) { + const theme = useTheme(); + const desktopLayout = useMediaQuery(theme.breakpoints.up("sm")); + + if (desktopLayout) { + return ; + } + return ; +} + +/** + * Main layout: a permanant-variant drawer + * which toggles between full width and slim states. + * Pushes main content to the right. + */ +function PermanentDrawer(props: NavProps) { + const width = props.open ? 257 : 65; // 256/64 + 1 pixel for the border return ( ({ + sx={(theme: Theme) => ({ width: width, flexShrink: 0, - transition: (theme) => drawerTransition(theme, open), + transition: (theme: Theme) => drawerTransition(theme, props.open), [`& .MuiDrawer-paper`]: { width: width, boxSizing: "border-box", - transition: drawerTransition(theme, open), + transition: drawerTransition(theme, props.open), }, })} > {/* spacer equal to the AppBar's height*/} - - - {navigation.map((group, groupIndex) => ( - - {groupIndex > 0 && } - {group.navItems.map((item, itemIndex) => { - return ( - - ); - })} - - ))} - - + ); } +/** + * Small-screen layout: a temporary drawer which toggles between + * not visible and something resembling the full-width variant of the main layout. + * Overlayed over main content. + */ +function TemporaryDrawer(props: NavProps) { + const width = 257; + return ( + props.setOpen(false)} + onClick={() => props.setOpen(false)} + sx={{ + width: width, + flexShrink: 0, + [`& .MuiDrawer-paper`]: { + width: width, + boxSizing: "border-box", + backgroundImage: 'none', + }, + }} + > + + + + ); +} + +function NavigationItems({ navigation, open }: NavProps) { + return ( + + + {navigation.map((group, groupIndex) => ( + + {groupIndex > 0 && } + {group.navItems.map((item, itemIndex) => { + return ( + + ); + })} + + ))} + + + ); +} + function SectionDivider() { return ( @@ -162,7 +211,7 @@ function NavItem(props: NavItemProps) { sx={{ overflow: "hidden", opacity: open ? 1 : 0, - transition: (theme) => + transition: (theme: Theme) => theme.transitions.create("opacity", { duration: theme.transitions.duration.shorter, }), From 1cd65655b1b10b83b5f364c953eb3b01d3e16ad6 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 3 Jul 2026 10:53:48 +0100 Subject: [PATCH 6/7] Add tests for mobile layout --- src/components/navigation/SidebarNav.test.tsx | 184 ++++++++++++------ src/components/navigation/SidebarNav.tsx | 9 +- 2 files changed, 132 insertions(+), 61 deletions(-) diff --git a/src/components/navigation/SidebarNav.test.tsx b/src/components/navigation/SidebarNav.test.tsx index 75e7e234..baaf15db 100644 --- a/src/components/navigation/SidebarNav.test.tsx +++ b/src/components/navigation/SidebarNav.test.tsx @@ -2,8 +2,14 @@ import { render, screen } from "@testing-library/react"; import { Navigation, SidebarNav } from "./SidebarNav"; import { createMemoryRouter, NavLink, RouterProvider } from "react-router-dom"; import userEvent from "@testing-library/user-event"; +import useMediaQuery from "@mui/material/useMediaQuery"; + +vi.mock("@mui/material/useMediaQuery"); + +const mockedUseMediaQuery = vi.mocked(useMediaQuery); describe("SidebarNav", () => { + const navigation: Navigation = [ { navItems: [ @@ -35,87 +41,151 @@ describe("SidebarNav", () => { }, ]; - function renderSidenav(open: boolean) { + function renderSidenav(open: boolean, setOpen = vi.fn()) { const router = createMemoryRouter([ { path: "/", - element: , + element: , }, ]); render(); } - it("Shows icons and names when open", () => { - renderSidenav(true); + describe("Desktop layout", () => { + + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(true); + }); - const items = navigation[0].navItems; + it("Shows icons and names when open", () => { + renderSidenav(true); - items.forEach((item) => { - const button = screen.getByRole("link", { name: item.label }); - expect(button).toBeVisible(); - const label = screen.getByText(item.label); - expect(label).toBeVisible(); + const items = navigation[0].navItems; + + items.forEach((item) => { + const button = screen.getByRole("link", { name: item.label }); + expect(button).toBeVisible(); + const label = screen.getByText(item.label); + expect(label).toBeVisible(); + }); + ["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) => + expect(screen.getByTestId(id)).toBeVisible(), + ); }); - ["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) => - expect(screen.getByTestId(id)).toBeVisible(), - ); - }); - it("Shows icons only when closed", () => { - renderSidenav(false); - const items = navigation[0].navItems; - items.forEach((item) => { - const button = screen.getByRole("link", { name: item.label }); - expect(button).toBeVisible(); // a11y-wise still visible - const label = screen.getByText(item.label); - expect(label).toBeInTheDocument(); // label exists but - expect(label).not.toBeVisible(); // not visible + it("Shows icons only when closed", () => { + renderSidenav(false); + const items = navigation[0].navItems; + items.forEach((item) => { + const button = screen.getByRole("link", { name: item.label }); + expect(button).toBeVisible(); // a11y-wise still visible + const label = screen.getByText(item.label); + expect(label).toBeInTheDocument(); // label exists but + expect(label).not.toBeVisible(); // not visible + }); + ["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) => + expect(screen.getByTestId(id)).toBeVisible(), + ); }); - ["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) => - expect(screen.getByTestId(id)).toBeVisible(), - ); - }); - it("shows tooltip on buttons when closed", async () => { - renderSidenav(false); + it("shows tooltip on buttons when closed", async () => { + renderSidenav(false); - const icon = screen.getByTestId("navicon2"); - const user = userEvent.setup(); - await user.hover(icon); + const icon = screen.getByTestId("navicon2"); + const user = userEvent.setup(); + await user.hover(icon); - // notice we await because the tooltip appears after some time - const tooltip = await screen.findByRole("tooltip", { name: "Acquisition" }); - expect(tooltip).toBeVisible(); - }); + // notice we await because the tooltip appears after some time + const tooltip = await screen.findByRole("tooltip", { name: "Acquisition" }); + expect(tooltip).toBeVisible(); + }); - it("shows no tooltip on buttons when open", async () => { - renderSidenav(true); + it("shows no tooltip on buttons when open", async () => { + renderSidenav(true); - const icon = screen.getByTestId("navicon2"); - const user = userEvent.setup(); - await user.hover(icon); + const icon = screen.getByTestId("navicon2"); + const user = userEvent.setup(); + await user.hover(icon); - const tooltip = screen.queryByRole("tooltip", { - name: "Acquisition", + const tooltip = screen.queryByRole("tooltip", { + name: "Acquisition", + }); + expect(tooltip).not.toBeInTheDocument(); }); - expect(tooltip).not.toBeInTheDocument(); - }); - it("creates divider between nav sections", () => { - renderSidenav(true); - const divider = screen.queryByRole("separator"); - expect(divider).toBeInTheDocument(); + it("creates divider between nav sections", () => { + renderSidenav(true); + const divider = screen.queryByRole("separator"); + expect(divider).toBeInTheDocument(); + }); + + it("renders internal and external links with correct href", () => { + // even though specified differently, ultimately both types + // should have the correct href attribute + renderSidenav(true); + + const externalLink = screen.getByRole("link", { name: "Organisation" }); + expect(externalLink).toHaveAttribute("href", "https://www.example.com"); + + const internalLink = screen.getByRole("link", { name: "Setup" }); + expect(internalLink).toHaveAttribute("href", "/setup"); + }); }); - it("renders internal and external links with correct href", () => { - // even though specified differently, ultimately both types - // should have the correct href attribute - renderSidenav(true); + describe("Mobile layout", () => { + + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(false); + }); + + it("renders temporary drawer", () => { + renderSidenav(true); + + // Drawer paper is rendered + expect(document.querySelector(".MuiDrawer-root")).toBeInTheDocument(); + + // nav content is visible + expect(screen.getByText("Setup")).toBeVisible(); + }); + + it("closed drawer is not visible", () => { + renderSidenav(false); + + expect(screen.queryByText("Setup")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Setup" })).not.toBeInTheDocument(); + }); + + it("open drawer is visible", () => { + renderSidenav(true); + + expect(screen.getByText("Setup")).toBeVisible(); + expect(screen.getByTestId("navicon1")).toBeVisible(); + }); + + it("clicking a nav item closes the drawer", async () => { + const user = userEvent.setup(); + const setOpen = vi.fn(); + + renderSidenav(true, setOpen); + + await user.click(screen.getByRole("link", { name: "Setup" })); + + expect(setOpen).toHaveBeenCalledWith(false); + }); - const externalLink = screen.getByRole("link", { name: "Organisation" }); - expect(externalLink).toHaveAttribute("href", "https://www.example.com"); + it("clicking backdrop closes the drawer", async () => { + const user = userEvent.setup(); + const setOpen = vi.fn(); - const internalLink = screen.getByRole("link", { name: "Setup" }); - expect(internalLink).toHaveAttribute("href", "/setup"); + renderSidenav(true, setOpen); + + // backdrop is rendered by MUI in portal + const backdrop = document.querySelector(".MuiBackdrop-root"); + expect(backdrop).toBeInTheDocument(); + + await user.click(backdrop!); + + expect(setOpen).toHaveBeenCalledWith(false); + }); }); }); diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx index 9a5a2b5b..7ec326cb 100644 --- a/src/components/navigation/SidebarNav.tsx +++ b/src/components/navigation/SidebarNav.tsx @@ -13,7 +13,6 @@ import { import { useTheme, Theme } from "@mui/material/styles"; import { Fragment, type ElementType, type ReactNode } from "react"; import useMediaQuery from "@mui/material/useMediaQuery"; -import { useState } from "react"; export type Navigation = NavItemGroup[]; @@ -110,15 +109,17 @@ function TemporaryDrawer(props: NavProps) { props.setOpen(false)} - onClick={() => props.setOpen(false)} + onClose={() => props.setOpen(false)} // close when clicking off the drawer + onClick={() => props.setOpen(false)} // close after making a selection sx={{ width: width, flexShrink: 0, [`& .MuiDrawer-paper`]: { width: width, boxSizing: "border-box", - backgroundImage: 'none', + backgroundImage: "none", + borderRight: "1px solid", + borderColor: "divider", }, }} > From 60e2b4b25e9ccaf581eb9f1ee6edf31d680ec102 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 3 Jul 2026 10:43:00 +0000 Subject: [PATCH 7/7] Fix linting --- src/components/navigation/SidebarNav.test.tsx | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/components/navigation/SidebarNav.test.tsx b/src/components/navigation/SidebarNav.test.tsx index baaf15db..cb3a6518 100644 --- a/src/components/navigation/SidebarNav.test.tsx +++ b/src/components/navigation/SidebarNav.test.tsx @@ -9,7 +9,6 @@ vi.mock("@mui/material/useMediaQuery"); const mockedUseMediaQuery = vi.mocked(useMediaQuery); describe("SidebarNav", () => { - const navigation: Navigation = [ { navItems: [ @@ -45,14 +44,15 @@ describe("SidebarNav", () => { const router = createMemoryRouter([ { path: "/", - element: , + element: ( + + ), }, ]); render(); } describe("Desktop layout", () => { - beforeEach(() => { mockedUseMediaQuery.mockReturnValue(true); }); @@ -96,7 +96,9 @@ describe("SidebarNav", () => { await user.hover(icon); // notice we await because the tooltip appears after some time - const tooltip = await screen.findByRole("tooltip", { name: "Acquisition" }); + const tooltip = await screen.findByRole("tooltip", { + name: "Acquisition", + }); expect(tooltip).toBeVisible(); }); @@ -133,26 +135,27 @@ describe("SidebarNav", () => { }); describe("Mobile layout", () => { - beforeEach(() => { mockedUseMediaQuery.mockReturnValue(false); }); it("renders temporary drawer", () => { - renderSidenav(true); + renderSidenav(true); - // Drawer paper is rendered - expect(document.querySelector(".MuiDrawer-root")).toBeInTheDocument(); + // Drawer paper is rendered + expect(document.querySelector(".MuiDrawer-root")).toBeInTheDocument(); - // nav content is visible - expect(screen.getByText("Setup")).toBeVisible(); - }); + // nav content is visible + expect(screen.getByText("Setup")).toBeVisible(); + }); it("closed drawer is not visible", () => { renderSidenav(false); expect(screen.queryByText("Setup")).not.toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "Setup" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Setup" }), + ).not.toBeInTheDocument(); }); it("open drawer is visible", () => {