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";