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 (