From 7f9d4ae7e2472e46cdc8fc6fc468581fddcc7183 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 12 Jun 2026 14:58:15 +0000 Subject: [PATCH 1/8] 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 13cae64bf19e1dba0ddcc84f2b9e33318dba9d3e Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 10:26:41 +0000 Subject: [PATCH 2/8] 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 2a02ea3f1fe7a223fd49a54aea449901146ef3ce Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 10:38:07 +0000 Subject: [PATCH 3/8] 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 c61ebf6f4db80a8b8c84d3673bb9e106fc28afa5 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 14:54:42 +0000 Subject: [PATCH 4/8] 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 5d3d6fcc7959acc1f36f05a51729cbc796c12568 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Thu, 2 Jul 2026 11:03:22 +0000 Subject: [PATCH 5/8] 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 4ba35f8396159205ce39aa6d065c9dc4f8642eba Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 3 Jul 2026 10:53:48 +0100 Subject: [PATCH 6/8] 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 e380dbec0890a44b24218fe0c89831a396388490 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 3 Jul 2026 10:43:00 +0000 Subject: [PATCH 7/8] 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", () => { From 7464f00f907fc4e73f378cdaad5b0c49745db4e1 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Fri, 17 Jul 2026 14:20:46 +0100 Subject: [PATCH 8/8] Add SecondaryNav and NavigationLayout for contextual secondary navigation --- .storybook/preview.tsx | 10 +- .../navigation/NavigationLayout.stories.tsx | 230 ++++++++++ .../navigation/NavigationLayout.test.tsx | 173 +++++++ .../navigation/NavigationLayout.tsx | 70 +++ .../navigation/SecondaryNav.stories.tsx | 194 ++++++++ .../navigation/SecondaryNav.test.tsx | 283 ++++++++++++ src/components/navigation/SecondaryNav.tsx | 421 ++++++++++++++++++ .../navigation/SidebarNav.stories.tsx | 4 + src/components/navigation/SidebarNav.tsx | 23 +- src/components/navigation/types.ts | 18 + src/index.ts | 4 + 11 files changed, 1411 insertions(+), 19 deletions(-) create mode 100644 src/components/navigation/NavigationLayout.stories.tsx create mode 100644 src/components/navigation/NavigationLayout.test.tsx create mode 100644 src/components/navigation/NavigationLayout.tsx create mode 100644 src/components/navigation/SecondaryNav.stories.tsx create mode 100644 src/components/navigation/SecondaryNav.test.tsx create mode 100644 src/components/navigation/SecondaryNav.tsx create mode 100644 src/components/navigation/types.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 06b272ff..10fe6479 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -11,7 +11,15 @@ import "../src/styles/diamondDS/DiamondDSTokens.css"; const TextThemeDiamondDS = "Theme: DiamondDS"; export const decorators = [ - (StoriesWithPadding: React.FC) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (StoriesWithPadding: React.FC, context: any) => { + /* Fixed-position content (for example permanent Drawers) ignores this + wrapper's padding and stays aligned to the viewport, leaving a visible + gap beside padded content. + Full-page layout stories opt out with this flag. */ + if (context.parameters.fullBleed === true) { + return ; + } return (
diff --git a/src/components/navigation/NavigationLayout.stories.tsx b/src/components/navigation/NavigationLayout.stories.tsx new file mode 100644 index 00000000..16b4b6f0 --- /dev/null +++ b/src/components/navigation/NavigationLayout.stories.tsx @@ -0,0 +1,230 @@ +import { Abc, ArrowForward, GraphicEq, Menu } from "@mui/icons-material"; +import { NavigationLayout } from "./NavigationLayout"; +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"; +import { NavLink, MemoryRouter, type NavLinkProps } from "react-router-dom"; + +const meta: Meta = { + title: "Components/Navigation/NavigationLayout", + component: NavigationLayout, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ["autodocs"], + parameters: { + // NavigationLayout always renders SidebarNav, which is position:fixed on + // desktop - the story canvas's default padding wrapper would otherwise + // misalign it against the normal-flow SecondaryNav/main content beside it. + fullBleed: true, + docs: { + description: { + component: `Composes SidebarNav and SecondaryNav, owning the responsive coordination between them. On mobile only one drawer is visible at a time - opening the secondary panel drills in and hides the primary sidebar, and a back affordance drills back out. On desktop both panels are shown side by side. Which primary item a secondary panel belongs to (e.g. "Setup" having its own sub-navigation) is entirely up to the consumer - NavigationLayout only owns the responsive mechanics, not when the panel opens.`, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const setupGroups = [ + { + items: [ + { + id: "general", + label: "General", + linkProps: { to: "/setup/general", component: NavLink }, + }, + { + id: "devices", + label: "Devices", + linkProps: { to: "/setup/devices", component: NavLink }, + }, + { + id: "permissions", + label: "Permissions", + linkProps: { to: "/setup/permissions", component: NavLink }, + }, + ], + }, +]; + +export const WithAppBar: Story = { + render: () => { + const [sidebarOpen, setSidebarOpen] = React.useState(true); + const [secondaryNavOpen, setSecondaryNavOpen] = React.useState(false); + + // Only "Setup" has an associated secondary panel, so its link opens it and + // every other top-level link closes it - in a real app this would instead + // be derived from the current route, not from click handlers on each link. + const SetupLink = React.useMemo(() => { + const Component = React.forwardRef( + (props, ref) => ( + { + props.onClick?.(e); + setSecondaryNavOpen(true); + }} + /> + ), + ); + Component.displayName = "SetupLink"; + return Component; + }, []); + const OtherLink = React.useMemo(() => { + const Component = React.forwardRef( + (props, ref) => ( + { + props.onClick?.(e); + setSecondaryNavOpen(false); + }} + /> + ), + ); + Component.displayName = "OtherLink"; + return Component; + }, []); + + const navigation = [ + { + navItems: [ + { + label: "Setup", + icon: , + linkProps: { to: "/1", component: SetupLink }, + }, + { + label: "Acquisition", + icon: , + linkProps: { to: "/2", component: OtherLink }, + selected: true, + }, + { + label: "Analysis", + icon: , + linkProps: { to: "/3", component: OtherLink }, + }, + ], + }, + ]; + + return ( + + theme.zIndex.drawer + 1, + borderBottom: "1px solid", + borderColor: "divider", + }} + elevation={0} + > + + setSidebarOpen(!sidebarOpen)} + > + + + + + + + + + + + My app + + + + + + + + + + Main content here + + + ); + }, + parameters: { + docs: { + description: { + story: + 'Clicking "Setup" opens its secondary panel; clicking any other top-level item closes it. On a mobile viewport this drills in and replaces the sidebar, with a back arrow in the panel\'s header to drill back out. On a desktop viewport the panel appears side by side with the sidebar.', + }, + }, + }, +}; + +export const DesktopSideBySide: Story = { + args: { + navigation: [ + { + navItems: [ + { + label: "Setup", + icon: , + linkProps: { to: "/1", component: NavLink }, + selected: true, + }, + { + label: "Acquisition", + icon: , + linkProps: { to: "/2", component: NavLink }, + }, + { + label: "Analysis", + icon: , + linkProps: { to: "/3", component: NavLink }, + }, + ], + }, + ], + sidebarOpen: true, + setSidebarOpen: () => {}, + secondaryNav: { title: "Setup", groups: setupGroups }, + secondaryNavOpen: true, + setSecondaryNavOpen: () => {}, + children: Main content here, + }, +}; diff --git a/src/components/navigation/NavigationLayout.test.tsx b/src/components/navigation/NavigationLayout.test.tsx new file mode 100644 index 00000000..138517c5 --- /dev/null +++ b/src/components/navigation/NavigationLayout.test.tsx @@ -0,0 +1,173 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { NavigationLayout } from "./NavigationLayout"; +import type { Navigation } from "./SidebarNav"; +import type { SecondaryNavProps } from "./SecondaryNav"; +import { createMemoryRouter, NavLink, RouterProvider } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { addProviders } from "../../__test-utils__/helpers"; + +vi.mock("@mui/material/useMediaQuery"); + +const mockedUseMediaQuery = vi.mocked(useMediaQuery); + +const navigation: Navigation = [ + { + navItems: [ + { + label: "Setup", + icon:
, + linkProps: { component: NavLink, to: "/setup" }, + }, + ], + }, +]; + +const secondaryNav: Omit = { + title: "Secondary", + groups: [ + { + items: [ + { + id: "detail", + label: "Detail", + linkProps: { component: NavLink, to: "/detail" }, + }, + ], + }, + ], +}; + +function Harness({ + initialSidebarOpen = true, + initialSecondaryNavOpen = false, + withSecondaryNav = true, +}: { + initialSidebarOpen?: boolean; + initialSecondaryNavOpen?: boolean; + withSecondaryNav?: boolean; +}) { + const [sidebarOpen, setSidebarOpen] = useState(initialSidebarOpen); + const [secondaryNavOpen, setSecondaryNavOpen] = useState( + initialSecondaryNavOpen, + ); + + return ( + +
Main content
+
+ ); +} + +function renderHarness(props: React.ComponentProps = {}) { + const router = createMemoryRouter([ + { path: "/", element: }, + ]); + render(addProviders()); +} + +describe("NavigationLayout", () => { + describe("Desktop layout", () => { + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(true); + }); + + it("renders both panels simultaneously when both are open", () => { + renderHarness({ + initialSidebarOpen: true, + initialSecondaryNavOpen: true, + }); + + expect(screen.getByRole("link", { name: "Setup" })).toBeVisible(); + expect(screen.getByRole("heading", { name: "Secondary" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Detail" })).toBeVisible(); + }); + + it("renders only SidebarNav as a Drawer - the secondary panel is a plain flex sibling, not a second fixed-position Drawer", () => { + renderHarness({ + initialSidebarOpen: true, + initialSecondaryNavOpen: true, + }); + + expect(document.querySelectorAll(".MuiDrawer-root")).toHaveLength(1); + }); + + it("hides only the secondary panel when secondaryNavOpen is false", () => { + renderHarness({ + initialSidebarOpen: true, + initialSecondaryNavOpen: false, + }); + + expect(screen.getByRole("link", { name: "Setup" })).toBeVisible(); + expect(screen.queryByText("Secondary")).not.toBeVisible(); + }); + + it("renders no secondary panel when secondaryNav is omitted", () => { + renderHarness({ withSecondaryNav: false }); + + expect(screen.getByRole("link", { name: "Setup" })).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Secondary" }), + ).not.toBeInTheDocument(); + }); + }); + + describe("Mobile layout", () => { + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(false); + }); + + it("shows only the sidebar when secondary nav is not open", () => { + renderHarness({ + initialSidebarOpen: true, + initialSecondaryNavOpen: false, + }); + + expect(screen.getByText("Setup")).toBeVisible(); + expect(screen.queryByText("Secondary")).not.toBeInTheDocument(); + }); + + it("drilling into secondary nav hides the sidebar drawer", () => { + renderHarness({ + initialSidebarOpen: true, + initialSecondaryNavOpen: true, + }); + + expect(screen.queryByText("Setup")).not.toBeInTheDocument(); + expect(screen.getByText("Secondary")).toBeVisible(); + expect(screen.getByRole("link", { name: "Detail" })).toBeVisible(); + }); + + it("the back button drills back to the sidebar", async () => { + const user = userEvent.setup(); + renderHarness({ + initialSidebarOpen: true, + initialSecondaryNavOpen: true, + }); + + expect(screen.queryByText("Setup")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Back" })); + + await waitFor(() => { + expect(screen.queryByText("Secondary")).not.toBeInTheDocument(); + }); + expect(screen.getByText("Setup")).toBeVisible(); + }); + + it("renders no secondary panel when secondaryNav is omitted", () => { + renderHarness({ withSecondaryNav: false }); + + expect(screen.getByText("Setup")).toBeVisible(); + expect(screen.queryByText("Secondary")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/navigation/NavigationLayout.tsx b/src/components/navigation/NavigationLayout.tsx new file mode 100644 index 00000000..5797b532 --- /dev/null +++ b/src/components/navigation/NavigationLayout.tsx @@ -0,0 +1,70 @@ +import { Box, Toolbar } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import type { ReactNode } from "react"; +import { SidebarNav, type Navigation } from "./SidebarNav"; +import { SecondaryNav, type SecondaryNavProps } from "./SecondaryNav"; + +type NavigationLayoutProps = { + navigation: Navigation; + + sidebarOpen: boolean; + setSidebarOpen: (open: boolean) => void; + + /** Omit to render primary nav only (no secondary panel at all). */ + secondaryNav?: Omit; + + /** + * Desktop: whether the secondary panel is shown side-by-side. + * Mobile: whether the view has drilled into the secondary panel. + * One flag serves both responsive roles by design - see NavigationLayout's + * derivation of `effectiveSidebarOpen` below. + */ + secondaryNavOpen: boolean; + setSecondaryNavOpen: (open: boolean) => void; + + children: ReactNode; +}; + +/** + * Composes SidebarNav and SecondaryNav, owning the responsive coordination + * between them: on mobile only one temporary drawer can be visible at a + * time, so drilling into the secondary panel implicitly hides the primary + * one, and its back affordance is a pure consequence of flipping + * `secondaryNavOpen` back to false. On desktop both panels are independent. + */ +function NavigationLayout(props: NavigationLayoutProps) { + const theme = useTheme(); + const desktopLayout = useMediaQuery(theme.breakpoints.up("sm")); + + const effectiveSidebarOpen = desktopLayout + ? props.sidebarOpen + : props.sidebarOpen && !props.secondaryNavOpen; + + return ( + + + {props.secondaryNav && ( + props.setSecondaryNavOpen(false) + } + /> + )} + + {/* spacer equal to the AppBar's height */} + {props.children} + + + ); +} + +export { NavigationLayout }; +export type { NavigationLayoutProps }; diff --git a/src/components/navigation/SecondaryNav.stories.tsx b/src/components/navigation/SecondaryNav.stories.tsx new file mode 100644 index 00000000..a85e60f8 --- /dev/null +++ b/src/components/navigation/SecondaryNav.stories.tsx @@ -0,0 +1,194 @@ +import { Abc, ArrowForward, GraphicEq } from "@mui/icons-material"; +import { SecondaryNav } from "./SecondaryNav"; +import { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { NavLink, MemoryRouter } from "react-router-dom"; + +const meta: Meta = { + title: "Components/Navigation/SecondaryNav", + component: SecondaryNav, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `An optional contextual navigation panel that sits next to SidebarNav. Mostly ListItems, optionally with a title, search, grouped sections, and one-level expandable rows. Use NavigationLayout to compose it with SidebarNav and get the responsive mobile drill-down / desktop side-by-side behaviour for free.`, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const basicGroups = [ + { + items: [ + { + id: "setup", + label: "Setup", + linkProps: { to: "/1", component: NavLink }, + }, + { + id: "acquisition", + label: "Acquisition", + linkProps: { to: "/2", component: NavLink }, + selected: true, + }, + { + id: "analysis", + label: "Analysis", + linkProps: { to: "/3", component: NavLink }, + }, + ], + }, +]; + +export const Basic: Story = { + args: { + groups: basicGroups, + open: true, + setOpen: () => {}, + }, + parameters: { + docs: { + description: { + story: "dense defaults to true - rows are compact by default.", + }, + }, + }, +}; + +export const Comfortable: Story = { + args: { + groups: basicGroups, + open: true, + setOpen: () => {}, + dense: false, + }, + parameters: { + docs: { + description: { + story: "Set dense={false} for taller, more touch-friendly rows.", + }, + }, + }, +}; + +export const WithTitleAndSearch: Story = { + render: () => { + const [value, setValue] = React.useState(""); + return ( + {}} + search={{ value, onChange: setValue, placeholder: "Search items" }} + /> + ); + }, +}; + +const groupedGroups = [ + { + subheader: "Recent", + items: [ + { + id: "setup", + label: "Setup", + icon: , + linkProps: { to: "/1", component: NavLink }, + }, + { + id: "acquisition", + label: "Acquisition", + icon: , + linkProps: { to: "/2", component: NavLink }, + }, + ], + }, + { + subheader: "All experiments", + items: [ + { + id: "analysis", + label: "Analysis", + icon: , + linkProps: { to: "/3", component: NavLink }, + }, + ], + }, +]; + +export const GroupedWithSubheaders: Story = { + args: { + groups: groupedGroups, + open: true, + setOpen: () => {}, + }, +}; + +const expandableGroups = [ + { + items: [ + { + id: "analysis", + label: "Analysis", + icon: , + defaultExpanded: true, + children: [ + { id: "analysis-a", label: "Run A" }, + { id: "analysis-b", label: "Run B" }, + ], + }, + { + id: "acquisition", + label: "Acquisition", + icon: , + linkProps: { to: "/2", component: NavLink }, + children: [{ id: "acquisition-a", label: "Session 1" }], + }, + ], + }, +]; + +export const WithExpandableItems: Story = { + args: { + groups: expandableGroups, + open: true, + setOpen: () => {}, + }, + parameters: { + docs: { + description: { + story: + "One level of expand/collapse only. A row with both a link and children navigates and expands together on label click, or can be expanded on its own via the chevron. A selected item (or one with a selected child) auto-expands.", + }, + }, + }, +}; + +export const WithBackButton: Story = { + args: { + title: "Experiments", + groups: basicGroups, + open: true, + setOpen: () => {}, + onBack: () => {}, + }, + parameters: { + docs: { + description: { + story: + "onBack is normally supplied by NavigationLayout on mobile to drill back to the primary sidebar, shown here in isolation.", + }, + }, + }, +}; diff --git a/src/components/navigation/SecondaryNav.test.tsx b/src/components/navigation/SecondaryNav.test.tsx new file mode 100644 index 00000000..dc6ce4e3 --- /dev/null +++ b/src/components/navigation/SecondaryNav.test.tsx @@ -0,0 +1,283 @@ +import { render, screen } from "@testing-library/react"; +import { SecondaryNav, SecondaryNavGroup } from "./SecondaryNav"; +import { createMemoryRouter, NavLink, RouterProvider } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import type { ComponentProps } from "react"; +import { addProviders } from "../../__test-utils__/helpers"; + +vi.mock("@mui/material/useMediaQuery"); + +const mockedUseMediaQuery = vi.mocked(useMediaQuery); + +describe("SecondaryNav", () => { + const groups: SecondaryNavGroup[] = [ + { + subheader: "Group one", + items: [ + { + id: "setup", + label: "Setup", + linkProps: { component: NavLink, to: "/setup" }, + }, + { + id: "acquisition", + label: "Acquisition", + linkProps: { component: NavLink, to: "/acq" }, + }, + ], + }, + { + subheader: "Group two", + items: [ + { + id: "analysis", + label: "Analysis", + children: [ + { id: "analysis-a", label: "Analysis A" }, + { id: "analysis-b", label: "Analysis B" }, + ], + }, + { + id: "expandable-link", + label: "Expandable link", + linkProps: { href: "https://www.example.com" }, + children: [{ id: "expandable-link-child", label: "Child" }], + }, + ], + }, + ]; + + function renderSecondaryNav( + props: Partial> = {}, + ) { + const setOpen = props.setOpen ?? vi.fn(); + const router = createMemoryRouter([ + { + path: "/", + element: ( + + ), + }, + ]); + render(addProviders()); + return { setOpen }; + } + + describe("Desktop layout", () => { + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(true); + }); + + it("renders grouped items with subheaders and a divider between groups", () => { + renderSecondaryNav(); + + expect(screen.getByText("Group one")).toBeVisible(); + expect(screen.getByText("Group two")).toBeVisible(); + expect(screen.getByRole("link", { name: "Setup" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Acquisition" })).toBeVisible(); + expect(screen.queryByRole("separator")).toBeInTheDocument(); + }); + + it("renders a title when provided", () => { + renderSecondaryNav({ title: "Secondary" }); + expect(screen.getByRole("heading", { name: "Secondary" })).toBeVisible(); + }); + + it("dense defaults to true, applying compact row styling", () => { + renderSecondaryNav(); + expect(screen.getByRole("link", { name: "Setup" })).toHaveClass( + "MuiListItemButton-dense", + ); + }); + + it("dense can be turned off for taller rows", () => { + renderSecondaryNav({ dense: false }); + expect(screen.getByRole("link", { name: "Setup" })).not.toHaveClass( + "MuiListItemButton-dense", + ); + }); + + it("does not use a fixed-position Drawer on desktop (would overlap a sibling panel)", () => { + renderSecondaryNav(); + expect(document.querySelector(".MuiDrawer-root")).not.toBeInTheDocument(); + }); + + it("does not render a header when no header props are provided", () => { + renderSecondaryNav(); + expect(screen.queryByRole("searchbox")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Back" }), + ).not.toBeInTheDocument(); + }); + + it("search input calls onChange and does not filter the passed-in groups itself", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + renderSecondaryNav({ + search: { value: "", onChange, placeholder: "Search" }, + }); + + const input = screen.getByPlaceholderText("Search"); + await user.type(input, "a"); + + expect(onChange).toHaveBeenCalledWith("a"); + // groups are rendered unfiltered regardless of search value + expect(screen.getByRole("link", { name: "Setup" })).toBeVisible(); + }); + + it("renders a back button only when onBack is provided", async () => { + const user = userEvent.setup(); + const onBack = vi.fn(); + + renderSecondaryNav({ onBack }); + + const back = screen.getByRole("button", { name: "Back" }); + expect(back).toBeVisible(); + + await user.click(back); + expect(onBack).toHaveBeenCalled(); + }); + + it("expanding an item reveals its children and toggles aria-expanded", async () => { + const user = userEvent.setup(); + renderSecondaryNav(); + + expect(screen.queryByText("Analysis A")).not.toBeInTheDocument(); + + const expandButton = screen.getByRole("button", { + name: "Expand Analysis", + }); + expect(expandButton).toHaveAttribute("aria-expanded", "false"); + + await user.click(expandButton); + + expect(screen.getByText("Analysis A")).toBeVisible(); + expect( + screen.getByRole("button", { name: "Collapse Analysis" }), + ).toHaveAttribute("aria-expanded", "true"); + }); + + it("clicking the row itself (not just the chevron) toggles a toggle-only item", async () => { + const user = userEvent.setup(); + renderSecondaryNav(); + + expect(screen.queryByText("Analysis A")).not.toBeInTheDocument(); + + // Clicking the label text, not the chevron IconButton - regression test + // for the chevron previously being nested inside the row's own button. + await user.click(screen.getByText("Analysis")); + + expect(screen.getByText("Analysis A")).toBeVisible(); + }); + + it("a row with both linkProps and children navigates and toggles together on label click", async () => { + const user = userEvent.setup(); + renderSecondaryNav(); + + const link = screen.getByRole("link", { name: "Expandable link" }); + expect(link).toHaveAttribute("href", "https://www.example.com"); + + expect(screen.queryByText("Child")).not.toBeInTheDocument(); + + await user.click(link); + expect(screen.getByText("Child")).toBeVisible(); + }); + + it("a row with both linkProps and children can also be toggled via the chevron alone", async () => { + const user = userEvent.setup(); + renderSecondaryNav(); + + expect(screen.queryByText("Child")).not.toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "Expand Expandable link" }), + ); + expect(screen.getByText("Child")).toBeVisible(); + }); + + it("auto-expands an item that is selected or has a selected child", () => { + renderSecondaryNav({ + groups: [ + { + items: [ + { + id: "analysis", + label: "Analysis", + children: [ + { id: "analysis-a", label: "Analysis A", selected: true }, + ], + }, + ], + }, + ], + }); + + expect(screen.getByText("Analysis A")).toBeVisible(); + }); + }); + + describe("Mobile layout", () => { + beforeEach(() => { + mockedUseMediaQuery.mockReturnValue(false); + }); + + it("renders temporary drawer with visible content when open", () => { + renderSecondaryNav({ open: true }); + + expect(document.querySelector(".MuiDrawer-root")).toBeInTheDocument(); + expect(screen.getByText("Setup")).toBeVisible(); + }); + + it("closed drawer is not visible", () => { + renderSecondaryNav({ open: false }); + expect(screen.queryByText("Setup")).not.toBeInTheDocument(); + }); + + it("clicking a nav item closes the drawer", async () => { + const user = userEvent.setup(); + const { setOpen } = renderSecondaryNav({ setOpen: vi.fn() }); + + await user.click(screen.getByRole("link", { name: "Setup" })); + + expect(setOpen).toHaveBeenCalledWith(false); + }); + + it("clicking backdrop closes the drawer", async () => { + const user = userEvent.setup(); + const { setOpen } = renderSecondaryNav({ setOpen: vi.fn() }); + + const backdrop = document.querySelector(".MuiBackdrop-root"); + expect(backdrop).toBeInTheDocument(); + + await user.click(backdrop!); + + expect(setOpen).toHaveBeenCalledWith(false); + }); + + it("expanding a toggle-only item does not close the drawer", async () => { + const user = userEvent.setup(); + const { setOpen } = renderSecondaryNav({ setOpen: vi.fn() }); + + await user.click(screen.getByRole("button", { name: "Expand Analysis" })); + + expect(screen.getByText("Analysis A")).toBeVisible(); + expect(setOpen).not.toHaveBeenCalled(); + }); + + it("clicking a row that is both a link and expandable still closes the drawer", async () => { + const user = userEvent.setup(); + const { setOpen } = renderSecondaryNav({ setOpen: vi.fn() }); + + await user.click(screen.getByRole("link", { name: "Expandable link" })); + + expect(setOpen).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/src/components/navigation/SecondaryNav.tsx b/src/components/navigation/SecondaryNav.tsx new file mode 100644 index 00000000..ad50d4fe --- /dev/null +++ b/src/components/navigation/SecondaryNav.tsx @@ -0,0 +1,421 @@ +import { + Box, + Collapse, + Divider, + Drawer, + IconButton, + InputAdornment, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + TextField, + Toolbar, + Typography, +} from "@mui/material"; +import { useTheme, Theme } from "@mui/material/styles"; +import { + Fragment, + useEffect, + useState, + type MouseEvent, + type ReactNode, +} from "react"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import SearchIcon from "@mui/icons-material/Search"; +import { drawerTransition } from "./SidebarNav"; +import type { LinkProps } from "./types"; + +const SECONDARY_NAV_WIDTH = 256; // matches SidebarNav's open-state baseline width + +type SecondaryNavGroup = { + /** Rendered as an overline ListSubheader when present; omit for an ungrouped list. */ + subheader?: string; + items: SecondaryNavItemDefinition[]; +}; + +type SecondaryNavChildItemDefinition = { + id: string; + label: string; + icon?: ReactNode; + linkProps?: LinkProps; + selected?: boolean; +}; + +type SecondaryNavItemDefinition = SecondaryNavChildItemDefinition & { + /** One level only - children cannot themselves expand. */ + children?: SecondaryNavChildItemDefinition[]; + /** Initial Collapse state for this item; uncontrolled thereafter. */ + defaultExpanded?: boolean; +}; + +type SecondaryNavProps = { + open: boolean; + setOpen: (open: boolean) => void; + + title?: string; + + search?: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + }; + + groups: SecondaryNavGroup[]; + + /** + * Renders a back affordance above the title/search when provided. + * NavigationLayout supplies this on mobile only; omit for standalone/desktop use. + */ + onBack?: () => void; + + /** Compact row height/spacing, suited to longer lists. Defaults to true. */ + dense?: boolean; +}; + +function SecondaryNav(props: SecondaryNavProps) { + const theme = useTheme(); + const desktopLayout = useMediaQuery(theme.breakpoints.up("sm")); + const resolvedProps = { ...props, dense: props.dense ?? true }; + + if (desktopLayout) { + return ; + } + return ; +} + +/** + * Desktop layout: a plain flex sibling of whatever sits to its left (e.g. + * SidebarNav) - not a Drawer. MUI's Drawer paper is position:fixed regardless + * of variant, so two permanent Drawers side by side render on top of each + * other rather than beside each other; a normal Box avoids that entirely. + * Transitions between full width and fully hidden, reusing SidebarNav's + * width-transition mechanism rather than a second show/hide pattern. + */ +function SecondaryPanel(props: SecondaryNavProps) { + const width = props.open ? SECONDARY_NAV_WIDTH + 1 : 0; // +1 pixel for the border + + return ( + ({ + width, + minHeight: "100vh", + flexShrink: 0, + overflowX: "hidden", + visibility: props.open ? "visible" : "hidden", + transition: drawerTransition(theme, props.open), + bgcolor: theme.palette.surface.elevated(1), + borderRight: props.open ? "1px solid" : "none", + borderColor: "divider", + })} + > + {/* spacer equal to the AppBar's height */} + + + + + ); +} + +/** + * Small-screen layout: a temporary drawer overlayed over main content, closed + * on backdrop click or on selecting a navigable item (not on expand/collapse). + */ +function TemporarySecondaryDrawer(props: SecondaryNavProps) { + return ( + props.setOpen(false)} + onClick={() => props.setOpen(false)} + sx={{ + width: SECONDARY_NAV_WIDTH, + flexShrink: 0, + [`& .MuiDrawer-paper`]: { + width: SECONDARY_NAV_WIDTH, + boxSizing: "border-box", + backgroundImage: "none", + bgcolor: (theme: Theme) => theme.palette.surface.elevated(1), + borderRight: "1px solid", + borderColor: "divider", + }, + }} + > + + + + ); +} + +function SecondaryNavContent(props: SecondaryNavProps) { + const dense = props.dense ?? true; + + return ( + + + + + {props.groups.map((group, groupIndex) => ( + + {groupIndex > 0 && } + {group.subheader && ( + + {group.subheader} + + )} + {group.items.map((item) => ( + + ))} + + ))} + + + + ); +} + +function SecondaryNavHeader(props: SecondaryNavProps) { + const hasHeader = props.onBack || props.title || props.search; + + if (!hasHeader) { + return null; + } + + return ( + + {(props.onBack || props.title) && ( + + {props.onBack && ( + + + + )} + {props.title && ( + + {props.title} + + )} + + )} + + {props.search && ( + props.search!.onChange(e.target.value)} + placeholder={props.search.placeholder ?? "Search"} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + )} + + ); +} + +function SectionDivider() { + return ( + + + + ); +} + +function getItemButtonSx(dense: boolean) { + return { + p: dense ? 0.5 : 1, + borderRadius: 2, + gap: dense ? 1 : 1.5, + "&.active, &.Mui-selected": { + bgcolor: "action.selected", + color: "primary.onContainer", + }, + }; +} + +function SecondaryNavItem({ + item, + dense, +}: { + item: SecondaryNavItemDefinition; + dense: boolean; +}) { + const hasChildren = !!item.children?.length; + const isActive = + !!item.selected || !!item.children?.some((child) => child.selected); + const [expanded, setExpanded] = useState(item.defaultExpanded ?? isActive); + // A selected item (or one with a selected child) should reveal its + // children even if it wasn't expanded to begin with - e.g. the consumer + // marks an item selected once its route becomes active. + useEffect(() => { + if (isActive) { + setExpanded(true); + } + }, [isActive]); + const toggle = () => setExpanded((value) => !value); + const toggleFromEvent = (e: MouseEvent) => { + e.stopPropagation(); + toggle(); + }; + // Toggle-only rows (no linkProps) toggle on the whole row, stopping + // propagation so it doesn't also trigger the mobile drawer's + // close-on-select. Rows that are also links toggle on click too, but let + // the click keep bubbling so navigation and the drawer's close-on-select + // still happen alongside the toggle. + const onRowClick = hasChildren + ? item.linkProps + ? toggle + : toggleFromEvent + : undefined; + + const iconSize = dense ? 28 : 32; + const buttonSx = getItemButtonSx(dense); + + return ( + <> + , and nesting one inside + // another breaks click handling and is invalid HTML. + + theme.transitions.create("transform"), + }} + > + + + ) + } + > + + {item.icon && ( + + {item.icon} + + )} + + + + {hasChildren && ( + + + {item.children!.map((child) => ( + + ))} + + + )} + + ); +} + +function SecondaryNavChildItem({ + item, + dense, +}: { + item: SecondaryNavChildItemDefinition; + dense: boolean; +}) { + const iconSize = dense ? 24 : 28; + + return ( + + + {item.icon && ( + + {item.icon} + + )} + + + + ); +} + +export { SecondaryNav }; +export type { + SecondaryNavProps, + SecondaryNavGroup, + SecondaryNavItemDefinition, + SecondaryNavChildItemDefinition, +}; diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index cf0dd85d..adba884d 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -249,6 +249,10 @@ export const WithAppBar: Story = { ); }, parameters: { + // SidebarNav's permanent Drawer is position:fixed on desktop - the story + // canvas's default padding wrapper would otherwise misalign it against + // the normal-flow main content beside it. + fullBleed: true, docs: { description: { story: diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx index 7ec326cb..e6b6c3ed 100644 --- a/src/components/navigation/SidebarNav.tsx +++ b/src/components/navigation/SidebarNav.tsx @@ -11,8 +11,9 @@ import { Tooltip, } from "@mui/material"; import { useTheme, Theme } from "@mui/material/styles"; -import { Fragment, type ElementType, type ReactNode } from "react"; +import { Fragment, type ReactNode } from "react"; import useMediaQuery from "@mui/material/useMediaQuery"; +import type { LinkProps } from "./types"; export type Navigation = NavItemGroup[]; @@ -28,23 +29,9 @@ type NavItemDefinition = { selected?: boolean; }; -type LinkProps = ExternalLinkProps | InternalLinkProps; +const getSidebarNavWidth = (open: boolean) => (open ? 257 : 65); // 256/64 + 1 pixel for the border -/** 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) => { +export const drawerTransition = (theme: Theme, opening: boolean) => { return theme.transitions.create("width", { easing: opening ? theme.transitions.easing.easeIn @@ -77,7 +64,7 @@ export function SidebarNav(props: NavProps) { * Pushes main content to the right. */ function PermanentDrawer(props: NavProps) { - const width = props.open ? 257 : 65; // 256/64 + 1 pixel for the border + const width = getSidebarNavWidth(props.open); return (