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