From 6447121063ced3000ede44ce4666cdb06b8962de Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 12 Jun 2026 14:58:15 +0000 Subject: [PATCH] Initial SidebarNav implementation --- .../navigation/SidebarNav.stories.tsx | 139 +++++++++++++++ src/components/navigation/SidebarNav.test.tsx | 121 +++++++++++++ src/components/navigation/SidebarNav.tsx | 168 ++++++++++++++++++ src/components/navigation/SidebarNavDocs.mdx | 7 + 4 files changed, 435 insertions(+) create mode 100644 src/components/navigation/SidebarNav.stories.tsx create mode 100644 src/components/navigation/SidebarNav.test.tsx create mode 100644 src/components/navigation/SidebarNav.tsx create mode 100644 src/components/navigation/SidebarNavDocs.mdx diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx new file mode 100644 index 00000000..e42cc502 --- /dev/null +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -0,0 +1,139 @@ +import { + Abc, + ArrowForward, + CorporateFare, + GraphicEq, + Menu, +} 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"; + +const meta: Meta = { + title: "Components/Navigation/SidebarNav", + component: SidebarNav, + 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 navigation = [ + { + navItems: [ + { + label: "Setup", + icon: , + linkProps: { href: "#1" }, + }, + { + label: "Acquisition", + icon: , + linkProps: { href: "#2" }, + }, + { + label: "Analysis", + icon: , + linkProps: { href: "#3" }, + }, + ], + }, + { + navItems: [ + { + label: "Organisation", + icon: , + linkProps: { href: "" }, + }, + ], + }, +]; + +export const Basic: Story = { + args: { + navigation, + open: true, + }, +}; + +export const WithAppBar: Story = { + render: (_args) => { + const [open, setOpen] = React.useState(true); + return ( + + theme.zIndex.drawer + 1, + }} + > + + setOpen(!open)} + > + + + + + + + + + + + My app + + + + + + + + + + + ); + }, + parameters: { + 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..75e7e234 --- /dev/null +++ b/src/components/navigation/SidebarNav.test.tsx @@ -0,0 +1,121 @@ +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"; + +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) { + const router = createMemoryRouter([ + { + path: "/", + element: , + }, + ]); + render(); + } + + 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"); + }); +}); diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx new file mode 100644 index 00000000..51cc7c88 --- /dev/null +++ b/src/components/navigation/SidebarNav.tsx @@ -0,0 +1,168 @@ +import { + Box, + Divider, + Drawer, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Tooltip, + type Theme, +} from "@mui/material"; +import { Fragment, type ElementType, type ReactNode } from "react"; + +export type Navigation = NavItemGroup[]; + +type NavItemGroup = { + name?: string; + navItems: NavItemDefinition[]; +}; + +type NavItemDefinition = { + label: string; + icon: ReactNode; + linkProps: LinkProps; +}; + +type LinkProps = ExternalLinkProps | InternalLinkProps; + +/** 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) => { + 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; +}; + +export function SidebarNav({ navigation, open }: NavProps) { + const width = open ? 257 : 65; // 256/64 + 1 pixel for the border + return ( + ({ + width: width, + flexShrink: 0, + transition: (theme) => drawerTransition(theme, open), + [`& .MuiDrawer-paper`]: { + width: width, + boxSizing: "border-box", + transition: drawerTransition(theme, open), + }, + })} + > + {/* spacer equal to the AppBar's height*/} + + + {navigation.map((group, groupIndex) => ( + + {groupIndex > 0 && } + {group.navItems.map((item, itemIndex) => { + return ( + + ); + })} + + ))} + + + + ); +} + +function SectionDivider() { + return ( + + + + ); +} + +interface NavItemProps { + definition: NavItemDefinition; + open: boolean; +} + +function NavItem(props: NavItemProps) { + const item = props.definition; + const open = props.open; + const icon = ( + + {item.icon} + + ); + + return ( + + + {open ? ( + icon + ) : ( + + {icon} + + )} + + theme.transitions.create("opacity", { + duration: theme.transitions.duration.shorter, + }), + }} + /> + + + ); +} diff --git a/src/components/navigation/SidebarNavDocs.mdx b/src/components/navigation/SidebarNavDocs.mdx new file mode 100644 index 00000000..0d7a2854 --- /dev/null +++ b/src/components/navigation/SidebarNavDocs.mdx @@ -0,0 +1,7 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; +import * as SidebarNavStories from "./SidebarNav.stories" + + + + +