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/src/components/navigation/NavigationLayout.stories.tsx b/src/components/navigation/NavigationLayout.stories.tsx
new file mode 100644
index 00000000..ce453649
--- /dev/null
+++ b/src/components/navigation/NavigationLayout.stories.tsx
@@ -0,0 +1,299 @@
+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, useTheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
+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 theme = useTheme();
+ const desktopLayout = useMediaQuery(theme.breakpoints.up("sm"));
+
+ const [sidebarOpen, setSidebarOpen] = React.useState(true);
+ const [secondaryNavOpen, setSecondaryNavOpen] = 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",
+ opensSecondaryNav: boolean,
+ ) => {
+ const Component = React.forwardRef(
+ (props, ref) => (
+ {
+ props.onClick?.(e);
+ setSecondaryNavOpen(opensSecondaryNav);
+ 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) {
+ setSecondaryNavOpen(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) {
+ setSecondaryNavOpen(false);
+ setSidebarOpen(false);
+ }
+ }}
+ />
+ ),
+ );
+ Component.displayName = "ChildLink";
+ return Component;
+ }, [desktopLayout]);
+
+ const setupGroups = React.useMemo(
+ () => [
+ {
+ items: [
+ {
+ id: "general",
+ label: "General",
+ linkProps: { to: "/setup/general", component: ChildLink },
+ },
+ {
+ id: "devices",
+ label: "Devices",
+ linkProps: { to: "/setup/devices", component: ChildLink },
+ },
+ {
+ id: "permissions",
+ 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
+
+
+
+
+
+
+
+
+
+ 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..083e9e94
--- /dev/null
+++ b/src/components/navigation/NavigationLayout.test.tsx
@@ -0,0 +1,244 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { useState } from "react";
+import { NavigationLayout } from "./NavigationLayout";
+import type { Navigation } from "./SidebarNav";
+import type { SecondaryNavContentProps } 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 and shows the secondary content in its own temporary drawer", () => {
+ renderHarness({
+ initialSidebarOpen: true,
+ initialSecondaryNavOpen: 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("clicking a nav item inside the secondary drawer closes it", async () => {
+ const user = userEvent.setup();
+ renderHarness({
+ initialSidebarOpen: true,
+ initialSecondaryNavOpen: true,
+ });
+
+ await user.click(screen.getByRole("link", { name: "Detail" }));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ });
+
+ it("clicking the backdrop closes the secondary drawer", async () => {
+ const user = userEvent.setup();
+ renderHarness({
+ initialSidebarOpen: true,
+ initialSecondaryNavOpen: true,
+ });
+
+ const backdrop = document.querySelector(".MuiBackdrop-root");
+ expect(backdrop).toBeInTheDocument();
+
+ await user.click(backdrop!);
+
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ });
+
+ 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();
+ });
+
+ it("the back button still reaches the sidebar even if secondary nav opened while sidebarOpen was false", async () => {
+ // Opens the secondary panel without the sidebar ever having been
+ // marked open first (e.g. deep-linking straight into it) - exercises
+ // NavigationLayout's self-heal of `sidebarOpen`.
+ const user = userEvent.setup();
+ renderHarness({
+ initialSidebarOpen: false,
+ initialSecondaryNavOpen: 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("the device back action drills back to the sidebar instead of leaving the page", async () => {
+ renderHarness({
+ initialSidebarOpen: true,
+ initialSecondaryNavOpen: true,
+ });
+
+ expect(screen.queryByText("Setup")).not.toBeInTheDocument();
+
+ window.dispatchEvent(new PopStateEvent("popstate"));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Secondary")).not.toBeInTheDocument();
+ });
+ expect(screen.getByText("Setup")).toBeVisible();
+ });
+ });
+});
diff --git a/src/components/navigation/NavigationLayout.tsx b/src/components/navigation/NavigationLayout.tsx
new file mode 100644
index 00000000..22749859
--- /dev/null
+++ b/src/components/navigation/NavigationLayout.tsx
@@ -0,0 +1,161 @@
+import { Box, Drawer, Toolbar } from "@mui/material";
+import { useTheme } from "@mui/material/styles";
+import useMediaQuery from "@mui/material/useMediaQuery";
+import { useEffect, useRef, type ReactNode } from "react";
+import { SidebarNav, drawerTransition, type Navigation } from "./SidebarNav";
+import {
+ SecondaryNavContent,
+ type SecondaryNavContentProps,
+} from "./SecondaryNav";
+
+const SECONDARY_NAV_WIDTH = 256; // matches SidebarNav's open-state baseline width
+
+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 with SecondaryNavContent, owning all of the responsive
+ * behaviour between them: 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
+ * `secondaryNavOpen` back to false. On desktop both are shown side by side,
+ * the secondary content in a plain panel rather than a drawer.
+ */
+function NavigationLayout(props: NavigationLayoutProps) {
+ const theme = useTheme();
+ const desktopLayout = useMediaQuery(theme.breakpoints.up("sm"));
+
+ const effectiveSidebarOpen = desktopLayout
+ ? props.sidebarOpen
+ : props.sidebarOpen && !props.secondaryNavOpen;
+
+ // Mobile: the back affordance (device/browser back, or the panel's own
+ // back arrow) only has something to fall back to if `sidebarOpen` is true
+ // once `secondaryNavOpen` flips false again. A consumer can open the
+ // secondary panel without `sidebarOpen` set yet - e.g. deep-linking
+ // straight into it - so this self-heals that invariant.
+ const setSidebarOpenRef = useRef(props.setSidebarOpen);
+ setSidebarOpenRef.current = props.setSidebarOpen;
+
+ useEffect(() => {
+ if (!desktopLayout && props.secondaryNavOpen) {
+ setSidebarOpenRef.current(true);
+ }
+ }, [desktopLayout, props.secondaryNavOpen]);
+
+ // 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 setSecondaryNavOpenRef = useRef(props.setSecondaryNavOpen);
+ setSecondaryNavOpenRef.current = props.setSecondaryNavOpen;
+
+ useEffect(() => {
+ if (desktopLayout || !props.secondaryNavOpen) {
+ return;
+ }
+
+ window.history.pushState({ secondaryNavOpen: true }, "");
+ const onPopState = () => setSecondaryNavOpenRef.current(false);
+ window.addEventListener("popstate", onPopState);
+
+ return () => window.removeEventListener("popstate", onPopState);
+ }, [desktopLayout, props.secondaryNavOpen]);
+
+ return (
+
+
+ {props.secondaryNav &&
+ (desktopLayout ? (
+
+
+
+ ) : (
+ props.setSecondaryNavOpen(false)}
+ onClick={() => props.setSecondaryNavOpen(false)} // close after making a selection
+ sx={{
+ width: SECONDARY_NAV_WIDTH,
+ flexShrink: 0,
+ [`& .MuiDrawer-paper`]: {
+ width: SECONDARY_NAV_WIDTH,
+ boxSizing: "border-box",
+ backgroundImage: "none",
+ bgcolor: theme.palette.surface.elevated(1),
+ borderRight: "1px solid",
+ borderColor: "divider",
+ },
+ }}
+ >
+
+ props.setSecondaryNavOpen(false)}
+ />
+
+ ))}
+
+ {/* spacer equal to the AppBar's height */}
+ {props.children}
+
+
+ );
+}
+
+/**
+ * Desktop layout: a plain flex sibling of SidebarNav, 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
+ * SidebarNav's width-transition mechanism.
+ */
+function SecondaryNavPanel(props: { open: boolean; children: ReactNode }) {
+ const theme = useTheme();
+ const width = props.open ? SECONDARY_NAV_WIDTH + 1 : 0; // +1 pixel for the border
+
+ return (
+
+ {/* 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..253e30a3
--- /dev/null
+++ b/src/components/navigation/SecondaryNav.stories.tsx
@@ -0,0 +1,182 @@
+import { Abc, ArrowForward, GraphicEq } from "@mui/icons-material";
+import { SecondaryNavContent } 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: SecondaryNavContent,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component: `The content of an optional contextual navigation panel that sits next to SidebarNav: a header (title/search/back) plus a grouped, optionally-expandable list. Use NavigationLayout to get the responsive mobile drill-down / desktop side-by-side drawer-or-panel behaviour around it.`,
+ },
+ },
+ },
+};
+
+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,
+ },
+ 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 WithTitleAndSearch: Story = {
+ render: () => {
+ const [value, setValue] = React.useState("");
+ return (
+
+ );
+ },
+};
+
+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,
+ },
+};
+
+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,
+ },
+ 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,
+ 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..7df84c01
--- /dev/null
+++ b/src/components/navigation/SecondaryNav.test.tsx
@@ -0,0 +1,260 @@
+import { render, screen } from "@testing-library/react";
+import { SecondaryNavContent, SecondaryNavGroup } from "./SecondaryNav";
+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("SecondaryNavContent", () => {
+ 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 renderSecondaryNavContent(
+ 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. NavigationLayout'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", () => {
+ renderSecondaryNavContent();
+
+ 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", () => {
+ renderSecondaryNavContent({ title: "Secondary" });
+ expect(screen.getByRole("heading", { name: "Secondary" })).toBeVisible();
+ });
+
+ it("dense defaults to true, applying compact row styling", () => {
+ renderSecondaryNavContent();
+ expect(screen.getByRole("link", { name: "Setup" })).toHaveClass(
+ "MuiListItemButton-dense",
+ );
+ });
+
+ it("dense can be turned off for taller rows", () => {
+ renderSecondaryNavContent({ dense: false });
+ expect(screen.getByRole("link", { name: "Setup" })).not.toHaveClass(
+ "MuiListItemButton-dense",
+ );
+ });
+
+ it("never renders a Drawer itself - it has no responsive presentation of its own", () => {
+ renderSecondaryNavContent();
+ expect(document.querySelector(".MuiDrawer-root")).not.toBeInTheDocument();
+ });
+
+ it("does not render a header when no header props are provided", () => {
+ renderSecondaryNavContent();
+ 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();
+
+ renderSecondaryNavContent({
+ 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();
+
+ renderSecondaryNavContent({ 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();
+ renderSecondaryNavContent();
+
+ 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();
+ renderSecondaryNavContent();
+
+ 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();
+ renderSecondaryNavContent();
+
+ 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();
+ renderSecondaryNavContent();
+
+ 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", () => {
+ renderSecondaryNavContent({
+ groups: [
+ {
+ items: [
+ {
+ id: "analysis",
+ label: "Analysis",
+ children: [
+ { id: "analysis-a", label: "Analysis A", selected: true },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(screen.getByText("Analysis A")).toBeVisible();
+ });
+
+ // A consumer (e.g. NavigationLayout'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("a plain link row's click bubbles up to an ancestor", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSecondaryNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByRole("link", { name: "Setup" }));
+
+ expect(onOuterClick).toHaveBeenCalled();
+ });
+
+ it("a toggle-only row's click does not bubble up to an ancestor", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSecondaryNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByText("Analysis"));
+
+ expect(screen.getByText("Analysis A")).toBeVisible();
+ expect(onOuterClick).not.toHaveBeenCalled();
+ });
+
+ it("expanding via the chevron alone does not bubble up to an ancestor", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSecondaryNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByRole("button", { name: "Expand Analysis" }));
+
+ expect(onOuterClick).not.toHaveBeenCalled();
+ });
+
+ it("a row that is both a link and expandable still bubbles up on click", async () => {
+ const user = userEvent.setup();
+ const onOuterClick = vi.fn();
+ renderSecondaryNavContent({}, { onOuterClick });
+
+ await user.click(screen.getByRole("link", { name: "Expandable link" }));
+
+ expect(onOuterClick).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/navigation/SecondaryNav.tsx b/src/components/navigation/SecondaryNav.tsx
new file mode 100644
index 00000000..0efca1f4
--- /dev/null
+++ b/src/components/navigation/SecondaryNav.tsx
@@ -0,0 +1,344 @@
+import {
+ Box,
+ Collapse,
+ Divider,
+ IconButton,
+ InputAdornment,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ ListSubheader,
+ TextField,
+ 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 ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import SearchIcon from "@mui/icons-material/Search";
+import type { LinkProps } from "./types";
+
+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 SecondaryNavContentProps = {
+ 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 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/search/back) plus a
+ * grouped, optionally-expandable list. Presentation (Drawer vs. side-by-side
+ * panel, responsive switching) is NavigationLayout's job, not this
+ * component's.
+ */
+function SecondaryNavContent(props: SecondaryNavContentProps) {
+ const dense = props.dense ?? true;
+
+ return (
+
+
+
+
+ {props.groups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && }
+ {group.subheader && (
+
+ {group.subheader}
+
+ )}
+ {group.items.map((item) => (
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+function SecondaryNavHeader(props: SecondaryNavContentProps) {
+ 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 and stop
+ // propagation, so a consumer wrapping this in a closable container (e.g.
+ // NavigationLayout'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) => (
+
+ ))}
+
+
+ )}
+ >
+ );
+}
+
+function SecondaryNavChildItem({
+ item,
+ dense,
+}: {
+ item: SecondaryNavChildItemDefinition;
+ dense: boolean;
+}) {
+ const iconSize = dense ? 24 : 28;
+
+ return (
+
+
+ {item.icon && (
+
+ {item.icon}
+
+ )}
+
+
+
+ );
+}
+
+export { SecondaryNavContent };
+export type {
+ SecondaryNavContentProps,
+ SecondaryNavGroup,
+ SecondaryNavItemDefinition,
+ SecondaryNavChildItemDefinition,
+};
diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx
index e72c6b52..9aaa7355 100644
--- a/src/components/navigation/SidebarNav.stories.tsx
+++ b/src/components/navigation/SidebarNav.stories.tsx
@@ -275,4 +275,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,
+ },
};
diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx
index 327f7106..f7d1c95f 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 (