From 2cd0be54f0523f2f799a8ba898a73dba0e02fee3 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Fri, 12 Jun 2026 14:58:15 +0000 Subject: [PATCH 1/4] 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 0000000..e42cc50 --- /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 0000000..75e7e23 --- /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 0000000..51cc7c8 --- /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 0000000..0d7a285 --- /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" + + + + + From 00787c854a1de9bafdec198d754f08a5836e1094 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 10:26:41 +0000 Subject: [PATCH 2/4] Improve app bar in sidebarnav story --- .../navigation/SidebarNav.stories.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index e42cc50..e6dc170 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -19,10 +19,18 @@ import { 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: Story) => ( + + + + ), + ], tags: ["autodocs"], parameters: { docs: { @@ -43,17 +51,17 @@ const navigation = [ { label: "Setup", icon: , - linkProps: { href: "#1" }, + linkProps: { to: "/1", component: NavLink }, }, { label: "Acquisition", icon: , - linkProps: { href: "#2" }, + linkProps: { to: "/2", component: NavLink }, }, { label: "Analysis", icon: , - linkProps: { href: "#3" }, + linkProps: { to: "/3", component: NavLink }, }, ], }, @@ -62,7 +70,7 @@ const navigation = [ { label: "Organisation", icon: , - linkProps: { href: "" }, + linkProps: { href: "#4" }, }, ], }, @@ -82,10 +90,13 @@ export const WithAppBar: Story = { theme.zIndex.drawer + 1, + borderBottom: "1px solid", + borderColor: "divider", }} + elevation={0} > - - + + From 7187694211cdd4fa72e8629a8d361d80bf3eec46 Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 10:38:07 +0000 Subject: [PATCH 3/4] Fix CI error --- src/components/navigation/SidebarNav.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index e6dc170..7ed33da 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -25,7 +25,7 @@ const meta: Meta = { title: "Components/Navigation/SidebarNav", component: SidebarNav, decorators: [ - (Story: Story) => ( + (Story) => ( From 07f40ba6c39a4f36a5654e2115d4f13c7006f90a Mon Sep 17 00:00:00 2001 From: Douglas Winter Date: Wed, 17 Jun 2026 14:54:42 +0000 Subject: [PATCH 4/4] Handle selected state The solution in this commit is to simply have a `selected` optional prop on the NavItemDefinition which propagates to the button component. When using standard anchor tags, the caller will need to keep track of this state and set it some way e.g. selected: window.location.pathname === "/docs" When using react-router-dom, the state is handled in internally within the NavLink component. Stories have been added to demonstrate the two cases. --- .../navigation/SidebarNav.stories.tsx | 111 +++++++++++++++++- src/components/navigation/SidebarNav.tsx | 14 ++- src/components/navigation/SidebarNavDocs.mdx | 7 -- 3 files changed, 116 insertions(+), 16 deletions(-) delete mode 100644 src/components/navigation/SidebarNavDocs.mdx diff --git a/src/components/navigation/SidebarNav.stories.tsx b/src/components/navigation/SidebarNav.stories.tsx index 7ed33da..e2850fb 100644 --- a/src/components/navigation/SidebarNav.stories.tsx +++ b/src/components/navigation/SidebarNav.stories.tsx @@ -3,7 +3,9 @@ import { ArrowForward, CorporateFare, GraphicEq, + Insights, Menu, + Schedule, } from "@mui/icons-material"; import { SidebarNav } from "./SidebarNav"; import { Meta, StoryObj } from "@storybook/react"; @@ -45,7 +47,45 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const navigation = [ +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: [ { @@ -70,17 +110,78 @@ const navigation = [ { label: "Organisation", icon: , - linkProps: { href: "#4" }, + linkProps: { to: "/4", component: NavLink }, }, ], }, ]; -export const Basic: Story = { +export const RouterLinks: Story = { args: { - navigation, + 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 = { @@ -135,7 +236,7 @@ export const WithAppBar: Story = { - + ); }, diff --git a/src/components/navigation/SidebarNav.tsx b/src/components/navigation/SidebarNav.tsx index 51cc7c8..d508817 100644 --- a/src/components/navigation/SidebarNav.tsx +++ b/src/components/navigation/SidebarNav.tsx @@ -24,6 +24,7 @@ type NavItemDefinition = { label: string; icon: ReactNode; linkProps: LinkProps; + selected?: boolean; }; type LinkProps = ExternalLinkProps | InternalLinkProps; @@ -87,7 +88,11 @@ export function SidebarNav({ navigation, open }: NavProps) { {groupIndex > 0 && } {group.navItems.map((item, itemIndex) => { return ( - + ); })} @@ -108,12 +113,12 @@ function SectionDivider() { interface NavItemProps { definition: NavItemDefinition; - open: boolean; + sidebarOpen: boolean; } function NavItem(props: NavItemProps) { const item = props.definition; - const open = props.open; + const open = props.sidebarOpen; const icon = ( - - -