{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "side-nav", "title": "Side Navigation", "description": "Vertical section navigation with arbitrary collapsible nesting, current-page marking and auto-expansion of the active branch.", "dependencies": [ "class-variance-authority", "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/collapsible.json", "https://ui.digital.nsw.gov.au/registry/r/link.json", "https://ui.digital.nsw.gov.au/registry/r/icons.json" ], "files": [ { "path": "src/components/side-nav.tsx", "content": "'use client'\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/collapsible'\nimport { Link } from '@/components/link'\nimport { IconChevronRight } from '@/icons/chevron-right'\n\n/**\n * One entry in a `SideNav` tree.\n *\n * - `href` and no `links` — a leaf link.\n * - `links` — a branch. At the top level it renders as a section heading over an\n * always-visible list; at any deeper level it renders as a collapsible\n * trigger. A branch's own `href` is IGNORED (the row is a disclosure button,\n * not a link) — a dev-only warning flags it, mirroring the nswds-app\n * `SidebarNavigation` behaviour where branch rows only toggled.\n * - Neither — a dead entry; rendered as inert text and flagged with a dev-only\n * warning rather than a decoy `` with no destination.\n */\ntype SideNavItem = {\n /** Visible text for the link, section heading, or branch trigger. */\n title: string\n href?: string\n links?: SideNavItem[]\n}\n\n/**\n * Row treatment shared by leaf links and branch triggers — the nswds-app\n * left-rail language: each row carries its own left border sitting exactly on\n * the list's rail (the `-ml-px` on the `
  • `), so hover and the current-page\n * state recolour the rail segment beside the row.\n *\n * Departures from the source, both deliberate:\n * - The idle border is `border-transparent` rather than the bare `border-l`\n * default: idle rows show the rail through their transparent border, so the\n * idle rail colour is defined once (on the list) instead of twice.\n * - The current row adds `dark:text-white`. The source left dark active text\n * to fall through to the idle `dark:text-grey-400`, which on the\n * `dark:bg-white/20` highlight is both low-contrast and cascade-order\n * dependent; bold white ink clears WCAG 2.2 AA (1.4.3) on that overlay.\n *\n * The source's hover font-weight change (`hover:font-semibold`) is NOT ported.\n * DESIGN.md's Derived State Rule has hover, active and focus deriving from one\n * ink via `color-mix`, and a weight jump is not a derivation: it changes glyph\n * advance widths, so a label sitting near its wrap point can reflow — and grow\n * the row — under the pointer. The remaining hover treatment (rail colour,\n * 10% ink tint, ink shift) already distinguishes the state without moving\n * anything. The persistent `current` row keeps `font-bold`: that is a resting\n * state, so it never reflows on interaction.\n *\n * Rows are floored at 44px on coarse pointers. DESIGN.md commits the system to\n * \"44px+ touch floors\", and the sibling PushMenu enforces it with `min-h-11` —\n * but the rail's own metrics (`py-1` over a 24px line box from `sm:` up) come\n * to 32px, so a tablet reader was tapping targets a third under the stated\n * floor. This clears WCAG 2.2 AA 2.5.8 either way (24px); the floor is the\n * design system's own promise, held here the same way `Button` holds it, on\n * pointer type rather than breakpoint so a desktop rail stays dense.\n *\n * Focus uses the house visible-focus pattern (outline-current, offset 2), so\n * the indicator always contrasts with whatever text colour the row currently\n * has (WCAG 2.2, 2.4.13 Focus Appearance).\n */\nconst sideNavRowVariants = cva(\n [\n 'flex w-full cursor-pointer items-center rounded-r-sm border-l border-transparent py-1 pr-2 pl-4 text-left max-sm:text-base/8 sm:text-sm/6',\n '[@media(pointer:coarse)]:min-h-11',\n 'motion-safe:transition-colors',\n 'hover:border-grey-950 hover:bg-primary-800/10 hover:text-grey-950',\n 'dark:text-grey-400 dark:hover:border-grey-400 dark:hover:text-white',\n 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current',\n ],\n {\n variants: {\n current: {\n true: 'border-primary-800 bg-primary-800/10 font-bold text-primary-800 dark:border-white dark:bg-white/20 dark:text-white',\n false: '',\n },\n },\n defaultVariants: {\n current: false,\n },\n },\n)\n\n/**\n * Port of nswds-app's `isLinkOrDescendantActive`: true when the item itself,\n * or any item anywhere beneath it, is the current page. Drives `defaultOpen`\n * on branch collapsibles so the rail opens onto the reader's location.\n */\nfunction isItemOrDescendantCurrent(item: SideNavItem, currentHref: string | undefined): boolean {\n if (currentHref === undefined) {\n return false\n }\n if (item.href === currentHref) {\n return true\n }\n return item.links?.some((child) => isItemOrDescendantCurrent(child, currentHref)) ?? false\n}\n\n/**\n * Dev-only guard, mirroring the icon-only Button check in button.tsx: an item\n * with neither destination nor children renders as inert text (a decoy link\n * would violate WCAG 2.2, 4.1.2 Name, Role, Value expectations), and a branch's\n * `href` is silently unreachable because the row renders as a disclosure\n * button. Both are almost certainly data mistakes. No-op in production.\n */\nfunction warnIfItemMisshapen(item: SideNavItem) {\n if (process.env.NODE_ENV === 'production') {\n return\n }\n const hasChildren = (item.links?.length ?? 0) > 0\n if (!hasChildren && item.href === undefined) {\n console.warn(\n `[nswds/ui] SideNav item \"${item.title}\" has neither href nor links — it renders as inert text. Give it a destination or children.`,\n )\n }\n if (hasChildren && item.href !== undefined) {\n console.warn(\n `[nswds/ui] SideNav item \"${item.title}\" has both href and links — branch rows render as collapsible triggers, so its href is unreachable. Add a separate leaf item for the destination.`,\n )\n }\n}\n\ntype SideNavListProps = {\n items: SideNavItem[]\n currentHref?: string\n onNavigate?: React.MouseEventHandler\n /** Deeper than the section rail — tighter gap, indented, per the source. */\n nested?: boolean\n}\n\n/**\n * A rail of rows: the bordered `
      ` every level of the tree renders into.\n * `role='list'` is kept from the source — Safari/VoiceOver strips list\n * semantics from lists whose `list-style` is removed, and the explicit role\n * restores them (this is the one sanctioned hand-written role in the file).\n */\nfunction SideNavList({ items, currentHref, onNavigate, nested = false }: SideNavListProps) {\n return (\n \n {items.map((item) => (\n \n ))}\n
    \n )\n}\n\ntype SideNavRowProps = {\n item: SideNavItem\n currentHref?: string\n onNavigate?: React.MouseEventHandler\n}\n\n/**\n * One row of the rail — a leaf link, a collapsible branch, or (data mistake)\n * inert text. Recurses through `SideNavList` for branch children.\n */\nfunction SideNavRow({ item, currentHref, onNavigate }: SideNavRowProps) {\n const hasChildren = (item.links?.length ?? 0) > 0\n const isCurrent = currentHref !== undefined && item.href === currentHref\n\n warnIfItemMisshapen(item)\n\n if (hasChildren) {\n return (\n
  • \n {/* defaultOpen (not controlled `open`) so the branch holding the\n current page starts expanded but the reader can still fold it —\n the source's auto-expansion behaviour exactly. */}\n \n {/* Base UI's Trigger owns the disclosure semantics (aria-expanded,\n aria-controls, keyboard activation) — nothing hand-rolled here.\n `group` scopes the chevron's rotation to THIS trigger: nested\n triggers are never ancestors of each other's icons, and the\n Collapsible root carries data-open (not data-panel-open), so an\n open outer branch cannot rotate descendant chevrons. */}\n \n {item.title}\n \n
  • \n )\n }\n\n if (item.href === undefined) {\n // No destination, no children: inert text, not a decoy anchor. The\n // dev-only warning above names the item.\n return (\n
  • \n \n {item.title}\n \n
  • \n )\n }\n\n return (\n
  • \n {/* variant='unstyled' — the rail supplies the complete row treatment;\n Link's underline/colour variants would fight it. Rendering through\n Link still picks up the framework link component from LinkProvider\n (next/link et al). */}\n \n {item.title}\n \n
  • \n )\n}\n\ntype SideNavProps = Omit, 'children'> & {\n /**\n * The navigation tree. A top-level item with `links` renders as a section\n * heading over an always-visible rail; one without renders as a plain rail\n * link — mirroring nswds-app `Navigation`'s section shape. Deeper items with\n * `links` render as collapsible branches (nswds-app `SidebarNavigation`).\n */\n sections: SideNavItem[]\n /**\n * The current page's href. The matching leaf gets `aria-current='page'` and\n * the active rail treatment, and every branch on the path to it starts\n * expanded — at MOUNT only. Branches are `defaultOpen`, not controlled: a\n * later `currentHref` change (SPA navigation) re-highlights the leaf but\n * never re-expands a branch the reader has folded, so the highlight can\n * land inside a collapsed branch. Branch expansion is seeded from\n * `currentHref` on mount; pass a `key` (e.g. `key={currentHref}`) to\n * remount and re-open the path on navigation — the same contract as\n * PushMenu's level stack. Frameworkless replacement for the source's\n * `usePathname()` — pass your router's pathname in.\n */\n currentHref?: string\n /**\n * Fired from every leaf link (never from branch triggers). The mobile-drawer\n * close hook, as in the source's `onLinkClick`.\n */\n onNavigate?: React.MouseEventHandler\n /**\n * Heading level for section titles. Defaults to `2` — correct when the nav\n * sits at the top level of the document outline. Step it down when the page\n * nests the nav under another heading (WCAG 1.3.1). `1` is excluded: a\n * section-nav heading is never the page's own title. Same contract as\n * `FooterNavColumn`.\n */\n headingLevel?: 2 | 3 | 4 | 5 | 6\n /**\n * Shown when `sections` is empty. Defaults to \"No navigation items\n * available.\"; pass `null` to render nothing.\n *\n * An empty tree is a legitimate runtime state (unpublished content,\n * permission-filtered menus, a failed fetch), not a data mistake — without a\n * message the rail renders an empty `
      ` and the reader is left with a\n * blank column and no explanation.\n */\n emptyMessage?: React.ReactNode\n ref?: React.Ref\n}\n\n/**\n * Left-rail section navigation — the consolidation of nswds-app's flat\n * `Navigation` and recursive `SidebarNavigation` into one tree-shaped\n * component. Renders a `\n )\n}\n\nexport { SideNav, sideNavRowVariants }\nexport type { SideNavItem, SideNavProps }\nexport type SideNavRowVariantProps = VariantProps\n", "type": "registry:ui", "target": "components/side-nav.tsx" }, { "path": "src/lib/utils.ts", "content": "import { clsx, type ClassValue } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n", "type": "registry:lib", "target": "lib/utils.ts" } ], "type": "registry:ui", "meta": { "nswdsVersion": "5.1.0" } }