diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 0dd0ed0d..7d1321cb 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -9,7 +9,15 @@ import { ThemeSwapper, TextLight, TextDark } from "./ThemeSwapper";
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/.storybook/storybook.css b/.storybook/storybook.css
index 03cbfd65..4a701853 100644
--- a/.storybook/storybook.css
+++ b/.storybook/storybook.css
@@ -1,3 +1,13 @@
+/* Gives the plain story canvas (not the Docs page, which sets its own
+ fixed height per docs.story.height) a definite height, so a story's
+ `minHeight: "100%"` resolves against the real viewport instead of
+ collapsing to its content's height. */
+html,
+body,
+#storybook-root {
+ height: 100%;
+}
+
:root {
--sb-ds-background: #f6f6f9;
--sb-ds-surface: #ffffff;
diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx
index 15649fae..61a3b187 100644
--- a/src/components/navigation/SidebarNav.stories.tsx
+++ b/src/components/navigation/SidebarNav.stories.tsx
@@ -25,10 +25,11 @@ import {
ListItemIcon,
ListItemText,
} from "@mui/material";
-import { Theme } from "@mui/material/styles";
+import { Theme, useTheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
import { Logo } from "../controls/Logo";
import { ColourSchemeButton } from "../controls/ColourSchemeButton";
-import { NavLink, MemoryRouter } from "react-router-dom";
+import { NavLink, MemoryRouter, type NavLinkProps } from "react-router-dom";
const meta: Meta
= {
title: "Components/Navigation/SidebarNav",
@@ -42,15 +43,27 @@ const meta: Meta = {
],
tags: ["autodocs"],
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 content beside it.
+ fullBleed: true,
docs: {
- disable: true,
- pages: {},
description: {
- component: `
-A collapsing/expanding sidebar for your app's primary navigation.
+ component: `A collapsing/expanding sidebar for your app's primary navigation, with an optional contextual secondary panel alongside it.
-For normal screen sizes, the implementation uses MUI's permanent drawer toggling between two widths showing either icon and text or just icon.
-For smaller screens, we use the temporary variant instead.`,
+- **\`hasAppBar\`**: set this when your app renders a fixed AppBar above SidebarNav, so its drawers/panels reserve space for it. Omit it if there's no AppBar, or content is pushed down by an empty gap.
+- **Without \`subNav\`**: renders the primary drawer alone. For normal screen sizes, the implementation uses MUI's permanent drawer toggling between two widths, showing either icon and text or just icon. For smaller screens, we use the temporary variant instead.
+- **With \`subNav\`**: SidebarNav also owns the responsive coordination between the primary drawer and a secondary panel. On mobile only one drawer is visible at a time: opening the secondary panel drills in and hides the primary sidebar, with a back affordance to drill 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 — SidebarNav only owns the responsive mechanics, not when the panel opens.
+
+**Using an AppBar**
+- Render your \`AppBar\` as a sibling of SidebarNav, with \`position="fixed"\` and a \`zIndex\` above \`theme.zIndex.drawer\` — MUI wants to draw a Drawer above everything else otherwise.
+- Set \`hasAppBar\` on SidebarNav so its drawers/panels line up below the AppBar, rather than starting at the very top of the screen.
+- Wire the AppBar's own menu button to toggle \`open\`, as shown in \`WithAppBar\`.
+
+**Using SubNav**
+- Only pass \`subNav\` for the primary items that actually have their own sub-navigation. Most items can have none.
+- Drive \`subNavOpen\`/\`setSubNavOpen\` from whichever primary item is currently selected (see \`WithAppBarAndSubNav\`), not from SidebarNav itself. It only owns the responsive mechanics, not when the panel opens.
+- Use \`SubNav\`'s own \`beforeNavSlot\`/\`afterNavSlot\`/\`footerSlot\` for content like search or quick actions inside the secondary panel, rather than reaching into its internals.`,
},
story: {
height: "600px",
@@ -89,16 +102,17 @@ export const NormalLinks: Story = {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
-
+
setOpen(!open)}>
-
- When using standard links, the caller must handle the selected state
- and set it to the correct item.
-
+
+
+
+ Main content here
+
);
@@ -106,6 +120,14 @@ export const NormalLinks: Story = {
args: {
navigation: standardLinks,
},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "When using standard links, the caller must handle the selected state and set it to the correct item.",
+ },
+ },
+ },
};
const reactRouterNavigation = [
@@ -143,7 +165,7 @@ export const RouterLinks: Story = {
render: (_args) => {
const [open, setOpen] = React.useState(false);
return (
-
+
setOpen(!open)}>
-
- React Router NavLinks will handle selected state
- internally.
-
+
+
+
+ Main content here
+
);
@@ -164,6 +187,13 @@ export const RouterLinks: Story = {
args: {
navigation: reactRouterNavigation,
},
+ parameters: {
+ docs: {
+ description: {
+ story: "React Router NavLinks will handle selected state internally.",
+ },
+ },
+ },
};
const groupedNavigation = [
@@ -210,7 +240,7 @@ export const GroupedNavigation: Story = {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
-
+
setOpen(!open)}>
- Sections are grouped with dividers.
+
+
+
+ Main content here
+
);
},
+ parameters: {
+ docs: {
+ description: {
+ story: "Sections are grouped with dividers.",
+ },
+ },
+ },
};
/** A dashed, tinted wrapper so it's obvious in the story which content is coming from a slot vs. the `navigation` prop. */
@@ -265,7 +306,7 @@ export const WithSlots: Story = {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
-
+
setOpen(!open)}>
-
- Adds slots to the navbar, boxes are only there to highlight what
- each slot renders, they aren't part of the component.{" "}
- afterNavSlot renders inside the scrollable area, right
- after the navigation items. footerSlot is pinned to the
- bottom of the drawer, outside the scroll area.
-
+
+
+
+ Main content here
+
);
},
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The dashed boxes are only there to highlight what each slot renders - they aren't part of the component. afterNavSlot renders inside the scrollable area, right after the navigation items. footerSlot is pinned to the bottom of the drawer, outside the scroll area.",
+ },
+ },
+ },
};
export const WithAppBar: Story = {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
-
+
+
+ {/* spacer equal to the AppBar's height */}
+
+ Main content here
+
+
+
+ );
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "MUI wants to draw a Drawer above everything, so in this example the AppBar's zIndex is increased. Clicking the menu icon toggles the sidebar open and closed. hasAppBar reserves space at the top of the drawer for the AppBar - omit it when there's no AppBar.",
+ },
+ },
+ },
+};
+
+const setupGroups = [
+ {
+ items: [
+ {
+ label: "General",
+ linkProps: { to: "/setup/general", component: NavLink },
+ },
+ {
+ label: "Devices",
+ linkProps: { to: "/setup/devices", component: NavLink },
+ },
+ {
+ label: "Permissions",
+ linkProps: { to: "/setup/permissions", component: NavLink },
+ },
+ ],
+ },
+];
+
+export const WithAppBarAndSubNav: Story = {
+ render: () => {
+ const theme = useTheme();
+ const desktopLayout = useMediaQuery(theme.breakpoints.up("sm"));
+
+ const [sidebarOpen, setSidebarOpen] = React.useState(true);
+ const [subNavOpen, setSubNavOpen] = React.useState(false);
+ const [selectedItem, setSelectedItem] = React.useState<
+ "setup" | "acquisition" | "analysis"
+ >("acquisition");
+
+ // Only "Setup" has an associated secondary panel, so its link opens it
+ // and every other top-level link closes it.
+ const makeNavLink = React.useCallback(
+ (id: "setup" | "acquisition" | "analysis", opensSubNav: boolean) => {
+ const Component = React.forwardRef(
+ (props, ref) => (
+ {
+ props.onClick?.(e);
+ setSubNavOpen(opensSubNav);
+ setSelectedItem(id);
+ }}
+ />
+ ),
+ );
+ Component.displayName = `${id}Link`;
+ return Component;
+ },
+ [],
+ );
+ const SetupLink = React.useMemo(
+ () => makeNavLink("setup", true),
+ [makeNavLink],
+ );
+ const AcquisitionLink = React.useMemo(
+ () => makeNavLink("acquisition", false),
+ [makeNavLink],
+ );
+ const AnalysisLink = React.useMemo(
+ () => makeNavLink("analysis", false),
+ [makeNavLink],
+ );
+
+ // On desktop the secondary panel is persistent chrome for the active
+ // section, so it should always match `selectedItem` - even if it was
+ // closed while drilling into it on mobile (selecting "General" closes
+ // the mobile overlay without changing `selectedItem`).
+ React.useEffect(() => {
+ if (desktopLayout) {
+ setSubNavOpen(selectedItem === "setup");
+ }
+ }, [desktopLayout, selectedItem]);
+
+ // Selecting a destination inside the secondary panel closes both panels
+ // on mobile, dropping all the way to main content. No-op on desktop,
+ // where the panel stays open side by side.
+ const ChildLink = React.useMemo(() => {
+ const Component = React.forwardRef(
+ (props, ref) => (
+ {
+ props.onClick?.(e);
+ if (!desktopLayout) {
+ setSubNavOpen(false);
+ setSidebarOpen(false);
+ }
+ }}
+ />
+ ),
+ );
+ Component.displayName = "ChildLink";
+ return Component;
+ }, [desktopLayout]);
+
+ const setupGroupsWithChildLinks = React.useMemo(
+ () => [
+ {
+ items: [
+ {
+ label: "General",
+ linkProps: { to: "/setup/general", component: ChildLink },
+ },
+ {
+ label: "Devices",
+ linkProps: { to: "/setup/devices", component: ChildLink },
+ },
+ {
+ label: "Permissions",
+ linkProps: { to: "/setup/permissions", component: ChildLink },
+ },
+ ],
+ },
+ ],
+ [ChildLink],
+ );
+
+ const navigation = [
+ {
+ navItems: [
+ {
+ label: "Setup",
+ icon: ,
+ linkProps: { to: "/1", component: SetupLink },
+ selected: selectedItem === "setup",
+ },
+ {
+ label: "Acquisition",
+ icon: ,
+ linkProps: { to: "/2", component: AcquisitionLink },
+ selected: selectedItem === "acquisition",
+ },
+ {
+ label: "Analysis",
+ icon: ,
+ linkProps: { to: "/3", component: AnalysisLink },
+ selected: selectedItem === "analysis",
+ },
+ ],
+ },
+ ];
+
+ return (
+
+ theme.zIndex.drawer + 1,
+ borderBottom: "1px solid",
+ borderColor: "divider",
+ }}
+ elevation={0}
+ >
+
+ setSidebarOpen(!sidebarOpen)}
+ >
+
+
+
+
+
+
+
+
+
+
+ My app
+
+
+
+
+
+
+
+
+
+
+
+ {/* spacer equal to the AppBar's height */}
+
+ 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.',
+ },
+ },
+ },
+};
+
+const desktopSideBySideNavigation = [
+ {
+ 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 },
+ },
+ ],
+ },
+];
+
+export const DesktopSideBySide: Story = {
+ // Real state so the drawers stay closeable if autodocs' real browser
+ // viewport narrows this below desktop width.
+ render: () => {
+ const [sidebarOpen, setSidebarOpen] = React.useState(true);
+ const [subNavOpen, setSubNavOpen] = React.useState(true);
+
+ return (
+
+
-
-
-
- MUI wants to draw a Drawer above everything, so in this example the
- AppBar's zIndex is increased.
-
+
+ {/* spacer equal to the AppBar's height */}
+
+ Main content here
+
);
diff --git a/src/components/navigation/SidebarNav.test.tsx b/src/components/navigation/SidebarNav.test.tsx
index a282b333..d5bb8946 100644
--- a/src/components/navigation/SidebarNav.test.tsx
+++ b/src/components/navigation/SidebarNav.test.tsx
@@ -1,232 +1,485 @@
-import { render, screen } from "@testing-library/react";
-import { Navigation, SidebarNav } from "./SidebarNav";
+import { render, screen, waitFor } from "@testing-library/react";
+import { useState } from "react";
+import { SidebarNav, type Navigation } from "./SidebarNav";
+import type { SubNavContentProps } from "./SubNav";
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);
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: [
+ describe("primary nav only (no subNav)", () => {
+ 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,
+ setOpen = vi.fn(),
+ hasAppBar = false,
+ ) {
+ const router = createMemoryRouter([
{
- label: "Organisation",
- icon: ,
- linkProps: { href: "https://www.example.com" },
+ path: "/",
+ element: (
+
+ ),
},
- ],
- },
- ];
+ ]);
+ render();
+ }
- function renderSidenav(open: boolean, setOpen = vi.fn()) {
- const router = createMemoryRouter([
- {
- path: "/",
- element: (
-
- ),
- },
- ]);
- render();
- }
+ describe("Desktop layout", () => {
+ beforeEach(() => {
+ mockedUseMediaQuery.mockReturnValue(true);
+ });
- describe("Desktop layout", () => {
- beforeEach(() => {
- mockedUseMediaQuery.mockReturnValue(true);
- });
+ it("shows icons and names when open", () => {
+ renderSidenav(true);
- it("Shows icons and names when open", () => {
- renderSidenav(true);
+ const items = navigation[0].navItems;
- 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(),
+ );
+ });
- items.forEach((item) => {
- const button = screen.getByRole("link", { name: item.label });
- expect(button).toBeVisible();
- const label = screen.getByText(item.label);
- expect(label).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(),
+ );
});
- ["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 tooltip on buttons when closed", async () => {
- renderSidenav(false);
+ 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);
- // notice we await because the tooltip appears after some time
- const tooltip = await screen.findByRole("tooltip", {
- name: "Acquisition",
+ const tooltip = screen.queryByRole("tooltip", {
+ name: "Acquisition",
+ });
+ expect(tooltip).not.toBeInTheDocument();
});
- expect(tooltip).toBeVisible();
- });
- it("shows no tooltip on buttons when open", async () => {
- renderSidenav(true);
+ it("creates divider between nav sections", () => {
+ renderSidenav(true);
+ const divider = screen.queryByRole("separator");
+ expect(divider).toBeInTheDocument();
+ });
- const icon = screen.getByTestId("navicon2");
- const user = userEvent.setup();
- await user.hover(icon);
+ it("does not reserve space for an AppBar when hasAppBar is not set", () => {
+ renderSidenav(true);
+ expect(
+ document.querySelector(".MuiToolbar-root"),
+ ).not.toBeInTheDocument();
+ });
- const tooltip = screen.queryByRole("tooltip", {
- name: "Acquisition",
+ it("reserves space for an AppBar when hasAppBar is set", () => {
+ renderSidenav(true, vi.fn(), true);
+ expect(document.querySelector(".MuiToolbar-root")).toBeInTheDocument();
});
- expect(tooltip).not.toBeInTheDocument();
- });
- it("creates divider between nav sections", () => {
- renderSidenav(true);
- const divider = screen.queryByRole("separator");
- expect(divider).toBeInTheDocument();
+ it("renders afterNavSlot after the navigation items", () => {
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ element: (
+ Extra links }
+ />
+ ),
+ },
+ ]);
+ render();
+
+ expect(screen.getByTestId("after-nav")).toBeVisible();
+ });
+
+ it("renders footerSlot", () => {
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ element: (
+ User menu}
+ />
+ ),
+ },
+ ]);
+ render();
+
+ expect(screen.getByTestId("footer")).toBeVisible();
+ });
+
+ it("renders internal and external links with correct href", () => {
+ // even though specified differently, ultimately both types
+ // should have the correct href attribute
+ 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 afterNavSlot after the navigation items", () => {
- const router = createMemoryRouter([
- {
- path: "/",
- element: (
- Extra links}
- />
- ),
- },
- ]);
- render();
+ 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("is not visible when the drawer is closed", () => {
+ renderSidenav(false);
+
+ expect(screen.queryByText("Setup")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("link", { name: "Setup" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("is visible when the drawer is open", () => {
+ renderSidenav(true);
+
+ expect(screen.getByText("Setup")).toBeVisible();
+ expect(screen.getByTestId("navicon1")).toBeVisible();
+ });
+
+ it("closes the drawer when a nav item is clicked", async () => {
+ const user = userEvent.setup();
+ const setOpen = vi.fn();
+
+ renderSidenav(true, setOpen);
+
+ await user.click(screen.getByRole("link", { name: "Setup" }));
+
+ expect(setOpen).toHaveBeenCalledWith(false);
+ });
+
+ it("closes the drawer when the backdrop is clicked", async () => {
+ const user = userEvent.setup();
+ const setOpen = vi.fn();
+
+ renderSidenav(true, setOpen);
+
+ // backdrop is rendered by MUI in portal
+ const backdrop = document.querySelector(".MuiBackdrop-root");
+ expect(backdrop).toBeInTheDocument();
- expect(screen.getByTestId("after-nav")).toBeVisible();
+ await user.click(backdrop!);
+
+ expect(setOpen).toHaveBeenCalledWith(false);
+ });
});
+ });
- it("renders footerSlot", () => {
- const router = createMemoryRouter([
+ describe("with subNav", () => {
+ const navigation: Navigation = [
+ {
+ navItems: [
+ {
+ label: "Setup",
+ icon: ,
+ linkProps: { component: NavLink, to: "/setup" },
+ },
+ ],
+ },
+ ];
+
+ const subNav: Omit = {
+ title: "Secondary",
+ groups: [
{
- path: "/",
- element: (
- User menu}
- />
- ),
+ items: [
+ {
+ label: "Detail",
+ linkProps: { component: NavLink, to: "/detail" },
+ },
+ ],
},
+ ],
+ };
+
+ function Harness({
+ initialOpen = true,
+ initialSubNavOpen = false,
+ withSubNav = true,
+ }: {
+ initialOpen?: boolean;
+ initialSubNavOpen?: boolean;
+ withSubNav?: boolean;
+ }) {
+ const [open, setOpen] = useState(initialOpen);
+ const [subNavOpen, setSubNavOpen] = useState(initialSubNavOpen);
+
+ return withSubNav ? (
+
+ ) : (
+
+ );
+ }
+
+ function renderHarness(props: React.ComponentProps = {}) {
+ const router = createMemoryRouter([
+ { path: "/", element: },
]);
- render();
+ render(addProviders());
+ }
- expect(screen.getByTestId("footer")).toBeVisible();
- });
+ describe("Desktop layout", () => {
+ beforeEach(() => {
+ mockedUseMediaQuery.mockReturnValue(true);
+ });
- it("renders internal and external links with correct href", () => {
- // even though specified differently, ultimately both types
- // should have the correct href attribute
- renderSidenav(true);
+ it("renders both panels simultaneously when both are open", () => {
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: true,
+ });
+
+ expect(screen.getByRole("link", { name: "Setup" })).toBeVisible();
+ expect(
+ screen.getByRole("heading", { name: "Secondary" }),
+ ).toBeVisible();
+ expect(screen.getByRole("link", { name: "Detail" })).toBeVisible();
+ });
- const externalLink = screen.getByRole("link", { name: "Organisation" });
- expect(externalLink).toHaveAttribute("href", "https://www.example.com");
+ it("hides only the secondary panel when subNavOpen is false", () => {
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: false,
+ });
- const internalLink = screen.getByRole("link", { name: "Setup" });
- expect(internalLink).toHaveAttribute("href", "/setup");
- });
- });
+ expect(screen.getByRole("link", { name: "Setup" })).toBeVisible();
+ expect(screen.queryByText("Secondary")).not.toBeVisible();
+ });
+
+ it("renders no secondary panel when subNav is omitted", () => {
+ renderHarness({ withSubNav: false });
- describe("Mobile layout", () => {
- beforeEach(() => {
- mockedUseMediaQuery.mockReturnValue(false);
+ expect(screen.getByRole("link", { name: "Setup" })).toBeVisible();
+ expect(
+ screen.queryByRole("heading", { name: "Secondary" }),
+ ).not.toBeInTheDocument();
+ });
});
- it("renders temporary drawer", () => {
- renderSidenav(true);
+ describe("Mobile layout", () => {
+ beforeEach(() => {
+ mockedUseMediaQuery.mockReturnValue(false);
+ });
- // Drawer paper is rendered
- expect(document.querySelector(".MuiDrawer-root")).toBeInTheDocument();
+ it("shows only the sidebar when secondary nav is not open", () => {
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: false,
+ });
- // nav content is visible
- expect(screen.getByText("Setup")).toBeVisible();
- });
+ expect(screen.getByText("Setup")).toBeVisible();
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+
+ it("hides the sidebar drawer and shows the secondary content in its own temporary drawer when the secondary panel opens", () => {
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: true,
+ });
+
+ expect(screen.queryByText("Setup")).not.toBeInTheDocument();
+ expect(screen.getByText("Secondary")).toBeVisible();
+ expect(screen.getByRole("link", { name: "Detail" })).toBeVisible();
+ // Only one drawer mounted at a time on mobile - the sidebar's is
+ // closed (and unmounted), the secondary content's is open.
+ expect(document.querySelectorAll(".MuiDrawer-root")).toHaveLength(1);
+ });
- it("closed drawer is not visible", () => {
- renderSidenav(false);
+ it("closes the secondary drawer when a nav item inside it is clicked", async () => {
+ const user = userEvent.setup();
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: true,
+ });
- expect(screen.queryByText("Setup")).not.toBeInTheDocument();
- expect(
- screen.queryByRole("link", { name: "Setup" }),
- ).not.toBeInTheDocument();
- });
+ await user.click(screen.getByRole("link", { name: "Detail" }));
- it("open drawer is visible", () => {
- renderSidenav(true);
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ });
- expect(screen.getByText("Setup")).toBeVisible();
- expect(screen.getByTestId("navicon1")).toBeVisible();
- });
+ it("closes the secondary drawer when the backdrop is clicked", async () => {
+ const user = userEvent.setup();
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: true,
+ });
- it("clicking a nav item closes the drawer", async () => {
- const user = userEvent.setup();
- const setOpen = vi.fn();
+ const backdrop = document.querySelector(".MuiBackdrop-root");
+ expect(backdrop).toBeInTheDocument();
- renderSidenav(true, setOpen);
+ await user.click(backdrop!);
- await user.click(screen.getByRole("link", { name: "Setup" }));
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ });
- expect(setOpen).toHaveBeenCalledWith(false);
- });
+ it("drills back to the sidebar via the back button", async () => {
+ const user = userEvent.setup();
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: true,
+ });
- it("clicking backdrop closes the drawer", async () => {
- const user = userEvent.setup();
- const setOpen = vi.fn();
+ expect(screen.queryByText("Setup")).not.toBeInTheDocument();
- renderSidenav(true, setOpen);
+ await user.click(screen.getByRole("button", { name: "Back" }));
- // backdrop is rendered by MUI in portal
- const backdrop = document.querySelector(".MuiBackdrop-root");
- expect(backdrop).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ expect(screen.getByText("Setup")).toBeVisible();
+ });
- await user.click(backdrop!);
+ it("renders no secondary panel when subNav is omitted", () => {
+ renderHarness({ withSubNav: false });
- expect(setOpen).toHaveBeenCalledWith(false);
+ expect(screen.getByText("Setup")).toBeVisible();
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+
+ it("still reaches the sidebar via the back button even if secondary nav opened while open was false", async () => {
+ // Opens the secondary panel without the sidebar ever having been
+ // marked open first (e.g. deep-linking straight into it) - exercises
+ // SidebarNav's self-heal of `open`.
+ const user = userEvent.setup();
+ renderHarness({
+ initialOpen: false,
+ initialSubNavOpen: true,
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Back" })).toBeVisible();
+ });
+ await user.click(screen.getByRole("button", { name: "Back" }));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ expect(screen.getByText("Setup")).toBeVisible();
+ });
+
+ it("responds to a popstate notification (e.g. a real device/browser back navigation) by drilling back to the sidebar", async () => {
+ renderHarness({
+ initialOpen: true,
+ initialSubNavOpen: true,
+ });
+
+ expect(screen.queryByText("Setup")).not.toBeInTheDocument();
+
+ // SidebarNav pushed a history entry when the secondary panel opened
+ // (see the effect in SidebarNav.tsx), so dispatching the same
+ // "popstate" event a real back navigation would fire is a faithful
+ // simulation of the user pressing back, not just an arbitrary event.
+ window.dispatchEvent(new PopStateEvent("popstate"));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ expect(screen.getByText("Setup")).toBeVisible();
+ });
});
});
});
diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx
index 2e4ad2e8..a03f895b 100644
--- a/src/components/navigation/SidebarNav.tsx
+++ b/src/components/navigation/SidebarNav.tsx
@@ -11,8 +11,10 @@ import {
Tooltip,
} from "@mui/material";
import { useTheme, Theme } from "@mui/material/styles";
-import { Fragment, type ElementType, type ReactNode } from "react";
+import { Fragment, useEffect, useRef, type ReactNode } from "react";
import useMediaQuery from "@mui/material/useMediaQuery";
+import { SubNavContent, type SubNavContentProps } from "./SubNav";
+import { NAV_WIDTH, type NavItem } from "./types";
export type Navigation = NavItemGroup[];
@@ -21,30 +23,15 @@ type NavItemGroup = {
navItems: NavItemDefinition[];
};
-type NavItemDefinition = {
- label: string;
- icon: ReactNode;
- linkProps: LinkProps;
- selected?: boolean;
+type NavItemDefinition = NavItem & {
+ icon: NonNullable;
+ linkProps: NonNullable;
};
-type LinkProps = ExternalLinkProps | InternalLinkProps;
+const getPrimaryNavWidth = (open: boolean) =>
+ (open ? NAV_WIDTH : NAV_WIDTH / 4) + 1; // +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
@@ -55,17 +42,191 @@ const drawerTransition = (theme: Theme, opening: boolean) => {
});
};
-type NavProps = {
+type SidebarNavProps = {
navigation: Navigation;
+
open: boolean;
setOpen: (open: boolean) => void;
+
+ /**
+ * Set when a fixed AppBar sits above this component, so its drawers and
+ * panels reserve space for it. Omit if there's no AppBar.
+ */
+ hasAppBar?: boolean;
+
/** Rendered after the navigation items, inside the scrollable area. */
afterNavSlot?: ReactNode;
/** Rendered pinned to the bottom of the drawer, outside the scrollable area. */
footerSlot?: ReactNode;
+
+ /** Omit to render the primary nav only, with no secondary panel at all. */
+ subNav?: 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 the derivation of
+ * `effectiveOpen` below. Required whenever `subNav` is provided.
+ */
+ subNavOpen?: boolean;
+ setSubNavOpen?: (open: boolean) => void;
};
-export function SidebarNav(props: NavProps) {
+/**
+ * Primary navigation, with an optional contextual secondary panel alongside
+ * it. Without `subNav` this is just the collapsing/expanding primary
+ * drawer. With it, SidebarNav also owns the responsive behaviour between the
+ * two: on mobile only one temporary drawer can be visible at a time, so
+ * drilling into the secondary content implicitly hides the primary sidebar,
+ * and its back affordance is a pure consequence of flipping
+ * `subNavOpen` back to false. On desktop both are shown side by side,
+ * the secondary content in a plain panel rather than a drawer.
+ */
+function SidebarNav(props: SidebarNavProps) {
+ const theme = useTheme();
+ const desktopLayout = useMediaQuery(theme.breakpoints.up("sm"));
+
+ const subNavOpen = props.subNavOpen ?? false;
+
+ const effectiveOpen = desktopLayout ? props.open : props.open && !subNavOpen;
+
+ // Mobile: the back affordance (device/browser back, or the panel's own
+ // back arrow) only has something to fall back to if `open` is true once
+ // `subNavOpen` flips false again. A consumer can open the secondary
+ // panel without `open` set yet - e.g. deep-linking straight into it - so
+ // this self-heals that invariant.
+ const setOpenRef = useRef(props.setOpen);
+ setOpenRef.current = props.setOpen;
+
+ useEffect(() => {
+ if (!desktopLayout && subNavOpen) {
+ setOpenRef.current(true);
+ }
+ }, [desktopLayout, subNavOpen]);
+
+ // Mobile: drilling into the secondary panel pushes a history entry, so the
+ // device/browser back action steps back to the sidebar (a popstate we
+ // handle ourselves) instead of leaving the page entirely.
+ const setSubNavOpenRef = useRef(props.setSubNavOpen);
+ setSubNavOpenRef.current = props.setSubNavOpen;
+
+ useEffect(() => {
+ if (desktopLayout || !subNavOpen) {
+ return;
+ }
+
+ window.history.pushState({ subNavOpen: true }, "");
+ const onPopState = () => setSubNavOpenRef.current?.(false);
+ window.addEventListener("popstate", onPopState);
+
+ return () => window.removeEventListener("popstate", onPopState);
+ }, [desktopLayout, subNavOpen]);
+
+ return (
+ <>
+
+ {props.subNav &&
+ (desktopLayout ? (
+
+
+
+ ) : (
+ props.setSubNavOpen?.(false)}
+ onClick={() => props.setSubNavOpen?.(false)} // close after making a selection
+ sx={{
+ width: NAV_WIDTH,
+ flexShrink: 0,
+ [`& .MuiDrawer-paper`]: {
+ width: NAV_WIDTH,
+ boxSizing: "border-box",
+ backgroundImage: "none",
+ bgcolor: theme.palette.surface.elevated(1),
+ borderRight: "1px solid",
+ borderColor: "divider",
+ },
+ }}
+ >
+ {props.hasAppBar && }
+ props.setSubNavOpen?.(false)}
+ />
+
+ ))}
+ >
+ );
+}
+
+/**
+ * Desktop layout: a plain flex sibling of the primary drawer, not a Drawer -
+ * MUI's Drawer paper is position:fixed, so two side-by-side Drawers would
+ * render on top of each other. Transitions width between 0 and full, reusing
+ * the primary drawer's width-transition mechanism.
+ */
+function SubNavPanel(props: {
+ open: boolean;
+ hasAppBar?: boolean;
+ children: ReactNode;
+}) {
+ const theme = useTheme();
+ const width = props.open ? NAV_WIDTH + 1 : 0; // +1 pixel for the border
+
+ return (
+
+ {props.hasAppBar && }{" "}
+ {/* spacer equal to the AppBar's height */}
+ {/* flex:1 (not a percentage height) so this fills the panel without
+ depending on an ancestor having a "definite" height to resolve
+ against - percentage heights chained through a flex row were the
+ likely cause of the stray scrollbar this replaced. */}
+
+ {props.children}
+
+
+ );
+}
+
+type PrimaryNavProps = {
+ navigation: Navigation;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ /** Set when a fixed AppBar sits above the drawer, reserving space for it. */
+ hasAppBar?: boolean;
+ /** Rendered after the navigation items, inside the scrollable area. */
+ afterNavSlot?: ReactNode;
+ /** Rendered pinned to the bottom of the drawer, outside the scrollable area. */
+ footerSlot?: ReactNode;
+};
+
+/**
+ * The primary drawer alone: a permanent-variant drawer on desktop which
+ * toggles between full width and slim (icon-only) states, or a temporary
+ * (overlaid) drawer on smaller screens.
+ */
+function PrimaryNav(props: PrimaryNavProps) {
const theme = useTheme();
const desktopLayout = useMediaQuery(theme.breakpoints.up("sm"));
@@ -75,13 +236,8 @@ export function SidebarNav(props: NavProps) {
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
+function PermanentDrawer(props: PrimaryNavProps) {
+ const width = getPrimaryNavWidth(props.open);
return (
- {/* spacer equal to the AppBar's height*/}
+ {props.hasAppBar && }{" "}
+ {/* spacer equal to the AppBar's height*/}
);
}
-/**
- * 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;
+function TemporaryDrawer(props: PrimaryNavProps) {
+ const width = NAV_WIDTH + 1;
return (
-
+ {props.hasAppBar && }
);
}
-function DrawerContent(props: NavProps) {
+function DrawerContent(props: PrimaryNavProps) {
return (
- {props.footerSlot}
+ {props.footerSlot}
)}
);
}
-function NavigationItems({ navigation, open, afterNavSlot }: NavProps) {
+function NavigationItems({ navigation, open, afterNavSlot }: PrimaryNavProps) {
return (
0 && }
{group.navItems.map((item, itemIndex) => {
return (
-
+
);
})}
@@ -187,12 +343,12 @@ function SectionDivider() {
);
}
-interface NavItemProps {
+interface PrimaryNavItemProps {
definition: NavItemDefinition;
sidebarOpen: boolean;
}
-function NavItem(props: NavItemProps) {
+function PrimaryNavItem(props: PrimaryNavItemProps) {
const item = props.definition;
const open = props.sidebarOpen;
const icon = (
@@ -249,3 +405,6 @@ function NavItem(props: NavItemProps) {
);
}
+
+export { SidebarNav };
+export type { SidebarNavProps };
diff --git a/src/components/navigation/SubNav.stories.tsx b/src/components/navigation/SubNav.stories.tsx
new file mode 100644
index 00000000..46da7446
--- /dev/null
+++ b/src/components/navigation/SubNav.stories.tsx
@@ -0,0 +1,303 @@
+import {
+ Abc,
+ ArrowForward,
+ GraphicEq,
+ Insights,
+ Settings,
+} from "@mui/icons-material";
+import SearchIcon from "@mui/icons-material/Search";
+import {
+ Box,
+ InputAdornment,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { SubNavContent } from "./SubNav";
+import { Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import { NavLink, MemoryRouter } from "react-router-dom";
+
+const meta: Meta = {
+ title: "Components/Navigation/SubNav",
+ component: SubNavContent,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component: `The content of a contextual secondary navigation panel: a header (\`title\`/back) plus a grouped, optionally-expandable list. Presentation-agnostic — it renders no Drawer or panel of its own. Use SidebarNav's \`subNav\` prop to get the responsive mobile drill-down / desktop side-by-side behaviour around it.
+
+- **\`beforeNavSlot\` / \`afterNavSlot\`**: rendered inside the scrollable area, before/after the item list.
+- **\`footerSlot\`**: pinned to the bottom of the panel, outside the scrollable area.
+
+**Using slots**
+- There's no dedicated search prop. Put a search field in \`beforeNavSlot\` (see \`WithSlots\`) and filter \`groups\` yourself in response to its value.
+- Reach for \`footerSlot\` for persistent actions (like settings or account links) that should stay visible regardless of scroll position; use \`afterNavSlot\` for content that's part of the same scrollable list.`,
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const basicGroups = [
+ {
+ items: [
+ {
+ label: "Setup",
+ linkProps: { to: "/1", component: NavLink },
+ },
+ {
+ label: "Acquisition",
+ linkProps: { to: "/2", component: NavLink },
+ },
+ {
+ label: "Analysis",
+ linkProps: { to: "/3", component: NavLink },
+ },
+ ],
+ },
+];
+
+export const Basic: Story = {
+ args: {
+ groups: basicGroups,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "`dense` defaults to `true` - rows are compact by default.",
+ },
+ },
+ },
+};
+
+export const Comfortable: Story = {
+ args: {
+ groups: basicGroups,
+ dense: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Set `dense={false}` for taller, more touch-friendly rows.",
+ },
+ },
+ },
+};
+
+export const WithTitle: Story = {
+ args: {
+ title: "Experiments",
+ groups: basicGroups,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Shows the header's `title`, with no back button or slots.",
+ },
+ },
+ },
+};
+
+/** A dashed, tinted wrapper so it's obvious in the story which content is coming from a slot vs. the `groups` prop. */
+const SlotOutline = ({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) => (
+
+
+ {label}
+
+ {children}
+
+);
+
+const slotListItem = (icon: React.ReactNode, label: string, href: string) => (
+
+
+
+ {icon}
+
+
+
+
+);
+
+export const WithSlots: Story = {
+ render: () => {
+ const [value, setValue] = React.useState("");
+ return (
+
+ setValue(e.target.value)}
+ placeholder="Search items"
+ sx={{ p: 1 }}
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ />
+
+ }
+ afterNavSlot={
+
+ {slotListItem(
+ ,
+ "Documentation",
+ "https://www.example.com/docs",
+ )}
+
+ }
+ footerSlot={
+
+ {slotListItem(
+ ,
+ "Settings",
+ "#settings",
+ )}
+
+ }
+ />
+ );
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The dashed boxes are only there to highlight what each slot renders - they aren't part of the component. `beforeNavSlot` (here, a search field) and `afterNavSlot` render inside the scrollable area, right before and after the item list; `SubNavContent` has no search logic of its own, so filtering `groups` in response to the value is entirely up to the consumer. `footerSlot` is pinned to the bottom of the panel, outside the scroll area.",
+ },
+ },
+ },
+};
+
+const groupedGroups = [
+ {
+ subheader: "Recent",
+ items: [
+ {
+ label: "Setup",
+ icon: ,
+ linkProps: { to: "/1", component: NavLink },
+ },
+ {
+ label: "Acquisition",
+ icon: ,
+ linkProps: { to: "/2", component: NavLink },
+ },
+ ],
+ },
+ {
+ subheader: "All experiments",
+ items: [
+ {
+ label: "Analysis",
+ icon: ,
+ linkProps: { to: "/3", component: NavLink },
+ },
+ ],
+ },
+];
+
+export const GroupedWithSubheaders: Story = {
+ args: {
+ groups: groupedGroups,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Set `subheader` on a group in `groups` to label it - a divider is added between groups automatically.",
+ },
+ },
+ },
+};
+
+const expandableGroups = [
+ {
+ items: [
+ {
+ label: "Analysis",
+ icon: ,
+ defaultExpanded: true,
+ children: [{ label: "Run A" }, { label: "Run B" }],
+ },
+ {
+ label: "Acquisition",
+ icon: ,
+ linkProps: { to: "/2", component: NavLink },
+ children: [{ label: "Session 1" }],
+ },
+ ],
+ },
+];
+
+export const WithExpandableItems: Story = {
+ args: {
+ groups: expandableGroups,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "One level of expand/collapse only, via each item's `children`. A row with both `linkProps` 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; set `defaultExpanded` to start a row open.",
+ },
+ },
+ },
+};
+
+export const WithBackButton: Story = {
+ args: {
+ title: "Experiments",
+ groups: basicGroups,
+ onBack: () => {},
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`onBack` is normally supplied by SidebarNav on mobile to drill back to the primary sidebar, shown here in isolation.",
+ },
+ },
+ },
+};
diff --git a/src/components/navigation/SubNav.test.tsx b/src/components/navigation/SubNav.test.tsx
new file mode 100644
index 00000000..9064afc8
--- /dev/null
+++ b/src/components/navigation/SubNav.test.tsx
@@ -0,0 +1,285 @@
+import { render, screen } from "@testing-library/react";
+import { TextField } from "@mui/material";
+import { SubNavContent, SubNavGroup } from "./SubNav";
+import { createMemoryRouter, NavLink, RouterProvider } from "react-router-dom";
+import userEvent from "@testing-library/user-event";
+import type { ComponentProps } from "react";
+import { addProviders } from "../../__test-utils__/helpers";
+
+describe("SubNavContent", () => {
+ const groups: SubNavGroup[] = [
+ {
+ subheader: "Group one",
+ items: [
+ {
+ label: "Setup",
+ linkProps: { component: NavLink, to: "/setup" },
+ },
+ {
+ label: "Acquisition",
+ linkProps: { component: NavLink, to: "/acq" },
+ },
+ ],
+ },
+ {
+ subheader: "Group two",
+ items: [
+ {
+ label: "Analysis",
+ children: [{ label: "Analysis A" }, { label: "Analysis B" }],
+ },
+ {
+ label: "Expandable link",
+ linkProps: { href: "https://www.example.com" },
+ children: [{ label: "Child" }],
+ },
+ ],
+ },
+ ];
+
+ function renderSubNavContent(
+ props: Partial> = {},
+ { onOuterClick }: { onOuterClick?: () => void } = {},
+ ) {
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ element: (
+ // The outer click handler stands in for a consumer that closes
+ // itself on selection (e.g. SidebarNav's mobile drawer) - it's how
+ // these tests observe stopPropagation without depending on any
+ // particular consumer's implementation.
+
+
+
+ ),
+ },
+ ]);
+ render(addProviders());
+ }
+
+ it("renders grouped items with subheaders and a divider between groups", () => {
+ renderSubNavContent();
+
+ 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", () => {
+ renderSubNavContent({ title: "Secondary" });
+ expect(screen.getByRole("heading", { name: "Secondary" })).toBeVisible();
+ });
+
+ it("applies compact row styling by default, since dense defaults to true", () => {
+ renderSubNavContent();
+ expect(screen.getByRole("link", { name: "Setup" })).toHaveClass(
+ "MuiListItemButton-dense",
+ );
+ });
+
+ it("renders taller rows when dense is turned off", () => {
+ renderSubNavContent({ dense: false });
+ expect(screen.getByRole("link", { name: "Setup" })).not.toHaveClass(
+ "MuiListItemButton-dense",
+ );
+ });
+
+ it("does not render a header when no header props are provided", () => {
+ renderSubNavContent();
+ expect(
+ screen.queryByRole("button", { name: "Back" }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ });
+
+ it("renders whatever is passed as beforeNavSlot, with no search logic of its own", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ renderSubNavContent({
+ beforeNavSlot: (
+ onChange(e.target.value)}
+ placeholder="Search"
+ />
+ ),
+ });
+
+ const input = screen.getByPlaceholderText("Search");
+ await user.type(input, "a");
+
+ expect(onChange).toHaveBeenCalledWith("a");
+ // groups are rendered unfiltered - filtering on the slot's value, if
+ // any, is entirely up to the consumer.
+ expect(screen.getByRole("link", { name: "Setup" })).toBeVisible();
+ });
+
+ it("renders beforeNavSlot before the item list", () => {
+ renderSubNavContent({
+ beforeNavSlot: Quick actions
,
+ });
+
+ expect(screen.getByTestId("before-content")).toBeVisible();
+ });
+
+ it("renders afterNavSlot after the item list", () => {
+ renderSubNavContent({
+ afterNavSlot: Extra links
,
+ });
+
+ expect(screen.getByTestId("after-content")).toBeVisible();
+ });
+
+ it("renders footerSlot", () => {
+ renderSubNavContent({
+ footerSlot: User menu
,
+ });
+
+ expect(screen.getByTestId("footer")).toBeVisible();
+ });
+
+ it("renders a back button when onBack is provided", async () => {
+ const user = userEvent.setup();
+ const onBack = vi.fn();
+
+ renderSubNavContent({ onBack });
+
+ const back = screen.getByRole("button", { name: "Back" });
+ expect(back).toBeVisible();
+
+ await user.click(back);
+ expect(onBack).toHaveBeenCalled();
+ });
+
+ it("does not render a back button when onBack is not provided", () => {
+ renderSubNavContent({ title: "Secondary" });
+
+ expect(
+ screen.queryByRole("button", { name: "Back" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("reveals an item's children and toggles aria-expanded when expanded", async () => {
+ const user = userEvent.setup();
+ renderSubNavContent();
+
+ 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("toggles a toggle-only item when clicking the row itself, not just the chevron", async () => {
+ const user = userEvent.setup();
+ renderSubNavContent();
+
+ 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("navigates and toggles together on label click when a row has both a link and children", async () => {
+ const user = userEvent.setup();
+ renderSubNavContent();
+
+ 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("also toggles via the chevron alone when a row has both a link and children", async () => {
+ const user = userEvent.setup();
+ renderSubNavContent();
+
+ 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", () => {
+ renderSubNavContent({
+ groups: [
+ {
+ items: [
+ {
+ label: "Analysis",
+ children: [{ label: "Analysis A", selected: true }],
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(screen.getByText("Analysis A")).toBeVisible();
+ });
+
+ // A consumer (e.g. SidebarNav's mobile drawer) may close itself on any
+ // click that bubbles out - these confirm which rows let that happen and
+ // which stop it, independent of any particular consumer.
+ describe("click propagation", () => {
+ it("lets a plain link row's click bubble up to an ancestor", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSubNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByRole("link", { name: "Setup" }));
+
+ expect(onOuterClick).toHaveBeenCalled();
+ });
+
+ it("stops a toggle-only row's click from bubbling up to an ancestor", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSubNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByText("Analysis"));
+
+ expect(screen.getByText("Analysis A")).toBeVisible();
+ expect(onOuterClick).not.toHaveBeenCalled();
+ });
+
+ it("stops a chevron-only expand click from bubbling up to an ancestor", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSubNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByRole("button", { name: "Expand Analysis" }));
+
+ expect(onOuterClick).not.toHaveBeenCalled();
+ });
+
+ it("still bubbles up a click on a row that is both a link and expandable", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSubNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByRole("link", { name: "Expandable link" }));
+
+ expect(onOuterClick).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/navigation/SubNav.tsx b/src/components/navigation/SubNav.tsx
new file mode 100644
index 00000000..2fba8056
--- /dev/null
+++ b/src/components/navigation/SubNav.tsx
@@ -0,0 +1,291 @@
+import {
+ Box,
+ Collapse,
+ Divider,
+ IconButton,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ ListSubheader,
+ Typography,
+} from "@mui/material";
+import { Theme } from "@mui/material/styles";
+import {
+ Fragment,
+ useEffect,
+ useState,
+ type MouseEvent,
+ type ReactNode,
+} from "react";
+import ArrowBackIcon from "@mui/icons-material/ArrowBack";
+import { ChevronDown } from "lucide-react";
+import type { NavItem, NavItemWithChildren } from "./types";
+
+type SubNavGroup = {
+ /** Rendered as an overline ListSubheader when present; omit for an ungrouped list. */
+ subheader?: string;
+ items: NavItemWithChildren[];
+};
+
+type SubNavContentProps = {
+ title?: string;
+
+ groups: SubNavGroup[];
+
+ /** Rendered before the item list, inside the scrollable area - typically a search field. */
+ beforeNavSlot?: ReactNode;
+ /** Rendered after the item list, inside the scrollable area. */
+ afterNavSlot?: ReactNode;
+ /** Rendered pinned to the bottom of the panel, outside the scrollable area. */
+ footerSlot?: ReactNode;
+
+ /**
+ * Renders a back affordance above the title when provided.
+ * SidebarNav supplies this on mobile only; omit for standalone use.
+ */
+ onBack?: () => void;
+
+ /** Compact row height/spacing, suited to longer lists. Defaults to true. */
+ dense?: boolean;
+};
+
+/**
+ * Just the contextual nav's content - a header (title/back) plus a grouped,
+ * optionally-expandable list. Presentation (Drawer vs. side-by-side panel,
+ * responsive switching) is SidebarNav's job, not this component's.
+ */
+function SubNavContent(props: SubNavContentProps) {
+ const dense = props.dense ?? true;
+
+ return (
+
+
+
+ {props.beforeNavSlot}
+
+ {props.groups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && }
+ {group.subheader && (
+
+ {group.subheader}
+
+ )}
+ {group.items.map((item, itemIndex) => (
+
+ ))}
+
+ ))}
+
+ {props.afterNavSlot}
+
+ {props.footerSlot && (
+
+
+ {props.footerSlot}
+
+ )}
+
+ );
+}
+
+function SubNavHeader(props: SubNavContentProps) {
+ if (!props.onBack && !props.title) {
+ return null;
+ }
+
+ return (
+
+
+ {props.onBack && (
+
+
+
+ )}
+ {props.title && (
+
+ {props.title}
+
+ )}
+
+
+ );
+}
+
+function SectionDivider() {
+ return (
+
+
+
+ );
+}
+
+function getItemButtonSx(dense: boolean) {
+ return {
+ px: 1,
+ py: dense ? 0.5 : 1,
+ borderRadius: 2,
+ gap: dense ? 1 : 1.5,
+ "&.active, &.Mui-selected": {
+ bgcolor: "action.selected",
+ color: "primary.onContainer",
+ },
+ };
+}
+
+function SubNavItem({
+ item,
+ dense,
+}: {
+ item: NavItemWithChildren;
+ 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 and stop
+ // propagation, so a consumer wrapping this in a closable container (e.g.
+ // SidebarNav's mobile drawer) doesn't treat expand/collapse as a
+ // selection. Rows that are also links toggle on click too, but let it
+ // keep bubbling so navigation and close-on-select still happen.
+ 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, childIndex) => (
+
+ ))}
+
+
+ )}
+ >
+ );
+}
+
+function SubNavChildItem({ item, dense }: { item: NavItem; dense: boolean }) {
+ const iconSize = dense ? 24 : 28;
+
+ return (
+
+
+ {item.icon && (
+
+ {item.icon}
+
+ )}
+
+
+
+ );
+}
+
+export { SubNavContent };
+export type { SubNavContentProps, SubNavGroup };
diff --git a/src/components/navigation/types.ts b/src/components/navigation/types.ts
new file mode 100644
index 00000000..33898c56
--- /dev/null
+++ b/src/components/navigation/types.ts
@@ -0,0 +1,37 @@
+import type { ElementType, ReactNode } from "react";
+
+/** Shared link-prop union for any link-bearing row across navigation components. */
+export type LinkProps = ExternalLinkProps | InternalLinkProps;
+
+/** For native anchor tags */
+export type ExternalLinkProps = {
+ href: string;
+ component?: never;
+ to?: never;
+};
+
+/** For SPA navigation, e.g. react-router-dom's Link/NavLink, injected via `component`+`to` */
+export type InternalLinkProps = {
+ component: ElementType;
+ to: string;
+ href?: never;
+};
+
+/** Baseline open-state width (px) shared by the primary and secondary nav panels; add 1 where a consumer also draws a border. */
+export const NAV_WIDTH = 256;
+
+/** A single navigable row, shared by SidebarNav and SubNav. */
+export type NavItem = {
+ label: string;
+ icon?: ReactNode;
+ linkProps?: LinkProps;
+ selected?: boolean;
+};
+
+/** A NavItem that may itself expand to reveal one level of child NavItems. */
+export type NavItemWithChildren = NavItem & {
+ /** One level only - children cannot themselves expand. */
+ children?: NavItem[];
+ /** Initial Collapse state for this item; uncontrolled thereafter. */
+ defaultExpanded?: boolean;
+};
diff --git a/src/index.ts b/src/index.ts
index d7e0894f..6841e1a3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,6 +3,9 @@ export * from "./components/navigation/Breadcrumbs";
export * from "./components/navigation/Footer";
export * from "./components/navigation/Navbar";
export * from "./components/navigation/NavMenu";
+export * from "./components/navigation/SidebarNav";
+export * from "./components/navigation/SubNav";
+export * from "./components/navigation/types";
// components/controls
export * from "./components/controls/AppTitlebar";