From 52a55d53684c9e37d5b894a6ea30b1f3bd2f3f31 Mon Sep 17 00:00:00 2001 From: "matthew.marie-rhodes" Date: Fri, 30 Oct 2026 15:46:10 -0400 Subject: [PATCH 1/2] docs(demo): rewrite the Nav page around choosing a pattern Need: People and agents building a sidebar need to know when a row drills, expands or switches, and the rules to follow. Problem: The page stated the drill rule six times and never said when to expand instead. Solution: It opens with a "Which One to Use" table, shows full app, docs and phone sidebars, and ends with Best Practices, one heading per rule. Co-Authored-By: Claude Opus 5.5 --- .../demo/src/components/demo/nav-list.tsx | 423 ++++++++++++--- packages/demo/src/components/demo/nav.tsx | 171 +++--- .../components/motion-collapsible-content.mdx | 2 +- .../demo/src/content/docs/components/nav.mdx | 507 +++++++++++++----- .../skills/equality-design-system/SKILL.md | 2 + 5 files changed, 851 insertions(+), 254 deletions(-) diff --git a/packages/demo/src/components/demo/nav-list.tsx b/packages/demo/src/components/demo/nav-list.tsx index 004bcc63..75c4a62c 100644 --- a/packages/demo/src/components/demo/nav-list.tsx +++ b/packages/demo/src/components/demo/nav-list.tsx @@ -6,27 +6,31 @@ import { } from "react"; import { Badge, - Button, NavDrill, NavDrillLevel, NavList, + NavListExpandable, + NavListExpandableContent, + NavListExpandableTrigger, NavListGroup, NavListItem, NavListSeparator, - Sheet, - SheetContainer, - SheetTitle, - SheetTrigger, } from "@eqtylab/equality"; import { Activity, Award, BookOpen, + Blocks, Bot, + Globe, ChartLine, Layers, LayoutGrid, + Plus, + Rocket, Settings, + Trash2, + Users, } from "lucide-react"; const frame: CSSProperties = { @@ -67,11 +71,6 @@ function NavListGroupsDemo() { prefix={} {...link("indicators")} /> - } - {...link("agents")} - /> } {...link("eu-ai-act")} /> - } - {...link("nist-ai-rmf")} - /> @@ -205,76 +199,362 @@ function NavListDrillDemo() { ); } -const visuallyHidden: CSSProperties = { - position: "absolute", - width: 1, - height: 1, - overflow: "hidden", - clip: "rect(0 0 0 0)", - whiteSpace: "nowrap", -}; +// Stands in for the browser: a drill row or ‹ pushes a page, Back pops one without a request -function NavListDrillSheetDemo() { - const link = useCurrent("dashboard"); +function NavListExpandableBasicDemo() { + const link = useCurrent("installation"); + return ( +
+ + + + + + + + + + + +
+ ); +} + +function NavListExpandableNestedDemo() { + const link = useCurrent("k3s-coco"); + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +function NavListDocsSidebarDemo() { + const link = useCurrent("installation"); + return ( +
+ + + + } + /> + + + + + + + } + suffix={Beta} + /> + + + + + + + + + + + + + } /> + + + + + + } + href="https://status.eqtylab.io" + target="_blank" + rel="noopener noreferrer" + /> + + +
+ ); +} + +function DoDont({ + verdict, + caption, + children, +}: { + verdict: "do" | "dont"; + caption: string; + children: ReactNode; +}) { + return ( +
+
{children}
+
+ + + {verdict === "do" ? "Do" : "Don't"} + + + {caption} +
+
+ ); +} + +function NavListDoDontIconsDemo() { + const link = useCurrent("installation"); + const folders = (icons: [ReactNode, ReactNode]) => ( + + + + + + + + + + + + + + + + + + ); + return ( +
+ + {folders([, ])} + + + {folders([undefined, undefined])} + + + {folders([, undefined])} + +
+ ); +} + +function NavListDoDontStatusDemo() { + const link = useCurrent("policies"); return ( - - - - - + + + + } + suffix={New} + {...link("policies")} + /> + } + {...link("controls")} + /> + + + + - Navigation - - {policyLevels(link)} + + + New} + {...link("policies")} + /> + } + {...link("controls")} + /> + - - + + ); } -// Stands in for the browser: a drill row or ‹ pushes a page, Back pops one without a request -function NavListDrillControlledDemo() { - const link = useCurrent("dashboard"); - const [history, setHistory] = useState(["project"]); - const level = history[history.length - 1]; - const url = level === "project" ? "/project" : `/policies/${level}`; +function NavListDoDontActionsDemo() { + const link = useCurrent("overview"); + const pages = ( + <> + } + {...link("overview")} + /> + } {...link("members")} /> + } + {...link("settings")} + /> + + ); return (
+ + + {pages} + + + + + + {pages} + } + onClick={() => {}} + /> + } + onClick={() => {}} + /> + + + +
+ ); +} + +function NavListDoDontStylingDemo() { + const link = useCurrent("activity"); + return ( +
-
+ + + + } + {...link("activity")} + /> + } {...link("agents")} /> + } + {...link("alerts")} + /> + + + + - setHistory([...history, next])} - > - {policyLevels(link)} - + + } + {...link("activity")} + /> + } {...link("agents")} /> + } + style={{ + color: "var(--color-text-danger)", + fontSize: 16, + fontWeight: 600, + }} + {...link("alerts")} + /> + -
-
-

- URL: {url} -

- -
+
); } @@ -283,8 +563,13 @@ const VARIANTS = { default: NavListGroupsDemo, "prefix-suffix": NavListPrefixSuffixDemo, drill: NavListDrillDemo, - "drill-sheet": NavListDrillSheetDemo, - "drill-controlled": NavListDrillControlledDemo, + "expandable-basic": NavListExpandableBasicDemo, + "expandable-nested": NavListExpandableNestedDemo, + "docs-sidebar": NavListDocsSidebarDemo, + "do-dont-icons": NavListDoDontIconsDemo, + "do-dont-status": NavListDoDontStatusDemo, + "do-dont-actions": NavListDoDontActionsDemo, + "do-dont-styling": NavListDoDontStylingDemo, }; export function NavListDemo({ diff --git a/packages/demo/src/components/demo/nav.tsx b/packages/demo/src/components/demo/nav.tsx index 0486a4b8..d042d5c3 100644 --- a/packages/demo/src/components/demo/nav.tsx +++ b/packages/demo/src/components/demo/nav.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type MouseEvent } from "react"; +import { useState, type CSSProperties, type MouseEvent } from "react"; import { Button, NavDrill, @@ -10,6 +10,10 @@ import { NavRail, NavRailItem, NavSwitcher, + Sheet, + SheetContainer, + SheetTitle, + SheetTrigger, } from "@eqtylab/equality"; import { Activity, @@ -54,7 +58,7 @@ const POLICY_ITEMS = [ { id: "certification", label: "Certification", icon: }, ]; -export function NavDemo() { +function ProjectSidebar({ onNavigate }: { onNavigate?: () => void }) { const [project, setProject] = useState("ben"); const [view, setView] = useState("project"); const [current, setCurrent] = useState("dashboard"); @@ -70,19 +74,12 @@ export function NavDemo() { onClick: (event: MouseEvent) => { event.preventDefault(); setCurrent(id); + onNavigate?.(); }, }); return ( -
+ <> } @@ -164,72 +161,124 @@ export function NavDemo() { -
+ + ); +} + +export function NavDemo() { + return ( +
+ +

Page content

); } -export function NavSwitcherErrorDemo() { - const [attempts, setAttempts] = useState(0); - const [retrying, setRetrying] = useState(false); - // The first retry fails too, so both outcomes show - const loaded = attempts >= 2; +const visuallyHidden: CSSProperties = { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + clip: "rect(0 0 0 0)", + whiteSpace: "nowrap", +}; + +export function NavSheetDemo() { + const [open, setOpen] = useState(false); + return ( +
+ + + + + + Navigation +
+ setOpen(false)} /> +
+
+
+
+ ); +} + +export function NavSwitcherDemo() { + const [project, setProject] = useState("ben"); + const [current, setCurrent] = useState("dashboard"); + const projectName = PROJECTS.find( + (option) => option.value === project, + )!.label; - useEffect(() => { - if (!retrying) return; - const timer = setTimeout(() => { - setRetrying(false); - setAttempts((count) => count + 1); - }, 1500); - return () => clearTimeout(timer); - }, [retrying]); + // Rows are links; the demo keeps you on the page and moves the current row instead + const link = (id: string) => ({ + href: `#${id}`, + current: current === id, + onClick: (event: MouseEvent) => { + event.preventDefault(); + setCurrent(id); + }, + }); return (
-
{ + setProject(next); + setCurrent("dashboard"); + }} + action={{ label: "Create Project", onSelect: () => {} }} + /> + } > - {}} - error={!loaded} - retrying={retrying} - onRetry={() => setRetrying(true)} - /> - } - /> + + + {PROJECT_ITEMS.slice(0, 3).map((item) => ( + + ))} + + + +
+

Page content

-
); } diff --git a/packages/demo/src/content/docs/components/motion-collapsible-content.mdx b/packages/demo/src/content/docs/components/motion-collapsible-content.mdx index 4d727b92..6f2d3e03 100644 --- a/packages/demo/src/content/docs/components/motion-collapsible-content.mdx +++ b/packages/demo/src/content/docs/components/motion-collapsible-content.mdx @@ -7,7 +7,7 @@ import { MotionCollapsibleContentDemo } from "@demo/components/demo/motion-colla ## Overview -Motion Collapsible Content smoothly expands and collapses a region of content, animating both its height and opacity. It measures its children (and re-measures them as they change) so the open animation always transitions to the correct height, even for dynamic content. +Motion Collapsible Content smoothly expands and collapses a region of content, animating both its height and opacity. It animates to the content's natural height, so content that changes size is always shown in full, and content that is open when the page loads does not grow into place. Its open state is fully controlled through the `isOpen` prop — pair it with your own trigger (such as a [Button](/components/button)) that toggles the state. Use it for show/more panels, expandable rows, and inline disclosure sections. It is built on [Motion](https://motion.dev/), so the animation respects the transition timing defined by the component. diff --git a/packages/demo/src/content/docs/components/nav.mdx b/packages/demo/src/content/docs/components/nav.mdx index 3b442209..22201fda 100644 --- a/packages/demo/src/content/docs/components/nav.mdx +++ b/packages/demo/src/content/docs/components/nav.mdx @@ -1,21 +1,33 @@ --- title: "Nav" -description: "Sidebar navigation with an icon rail, a switcher and links that drill in and out" +description: "Sidebar navigation: an icon rail, a switcher, and lists of links that drill in or open as folders" --- -import { NavDemo, NavSwitcherErrorDemo } from "@demo/components/demo/nav"; +import { + NavDemo, + NavSheetDemo, + NavSwitcherDemo, +} from "@demo/components/demo/nav"; import { NavListDemo } from "@demo/components/demo/nav-list"; ## Overview -Nav builds an app's sidebar. `NavRail` composes the largest sections of an application as icons with tooltips, and `NavPanel` sits beside it containing each page within the section, a header, and a scrolling body. `NavSwitcher` sits in the panel's header to allow users to quickly switch between the parent item of a section such as projects in Guardian. +Nav builds sidebars. `NavRail` holds an app's largest areas as icons, and `NavPanel` beside it holds the current area's pages, with a `NavSwitcher` in its header. Every row goes to a page. When one list can't hold them all, a row drills in or expands. -Our Nav component progressively displays a tree of navigation options. In addition to navigating between pages, `NavListItem` can also open a deeper level. Users go back up a level with the ‹ row at the top. +### Which One to Use -Nav should not be used for actions, for these use a [Dropdown Menu](/components/dropdown-menu) instead. Always put `NavList` inside an element with a background colour, such as a popover, sheet or panel: the sliding levels inherit the background colour to cover each other. +| You want to | Use | For example | Not for | +| --------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------- | +| Go to a page | [A row in a group](#groups) | Dashboard, Indicators | Actions, such as Log out: use a [Dropdown Menu](/components/dropdown-menu) | +| Change to another one of the thing you're in | [`NavSwitcher`](#switcher) | Another project | Filtering a page: use a [Select](/components/select) | +| Go into something with its own pages, where other rows stop mattering | [Drill in](#drill-in) | A policy, an agent's run, an app's Settings pages | A few views of one screen: use [Tabs](/components/tabs) | +| Browse pages grouped by topic | [Expand](#expandable-groups) | A docs site's folders, at any depth | An area people work inside: [drill in](#drill-in) | +| Move between an app's largest areas | [`NavRail`](#usage) | Projects, Agents, Library | An app with one area | ## Usage + + Import the parts: ```tsx @@ -23,6 +35,9 @@ import { NavDrill, NavDrillLevel, NavList, + NavListExpandable, + NavListExpandableContent, + NavListExpandableTrigger, NavListGroup, NavListItem, NavListSeparator, @@ -33,7 +48,47 @@ import { } from "@eqtylab/equality"; ``` -Group the rows, each group with an optional label, and add a separator between groups. +A rail beside a panel, with a switcher in the panel's header and the rows below: + +```tsx +const projectOptions = projects.map((p) => ({ value: p.id, label: p.name })); + +
+ + } label="Projects" href="/projects" current /> + } label="Agents" href="/agents" /> + + + } + > + + + } + href="/" + current + /> + + + +
…
+
; +``` + +### Groups + +Give each group an optional label. A separator sets off a different kind of group, like settings at the foot. Mark the current page with `current`. @@ -61,21 +116,29 @@ Group the rows, each group with an optional label, and add a separator between g ``` -Put a group inside another group to nest it one level, under a tree line. Nested rows take no `prefix` icon. +### Links and Routers -`href` makes a plain link, which reloads the page. In an app with a router, such as React Router, put the router's link inside with `asChild` so it navigates without a reload: +`href` makes a plain link. With a router, pass its link through `asChild`, so the page doesn't reload. A drill row needs neither. Set `current` from the route. ```jsx - + ``` -## Variants +`target="_blank"` opens the row in a new tab, with a ↗. + +```jsx + +``` ### Prefix and Suffix -`prefix` holds an icon, hidden from screen readers. `suffix` holds badges and marks, which screen readers read with the label. The › comes from `drill`, never from `suffix`; see [Drill-in](#drill-in). +`prefix` takes an icon. `suffix` takes a status or count as `Badge size="sm"`, and screen readers read it with the label. @@ -86,23 +149,52 @@ Put a group inside another group to nest it one level, under a tree line. Nested suffix={2} href="/policies" /> -} - suffix={12} - href="/controls" -/> ``` -## Drill-in +## Switcher + +`NavSwitcher` goes in the panel's `header` and shows the current item's name. Picking another item calls `onValueChange`, and your app goes to that item's top level. A level drilled into, such as a policy, belonged to the old item, so don't carry it across. `action` adds a command at the foot of the list. + + -A `NavListItem` with `drill="eu-ai-act"` opens a level whose `value` is `"eu-ai-act"`. A `NavDrillLevel` with a `parent` attribute gets a ‹ row at the top, named by its `label`, that returns the user to the parent level of the tree. Levels must be direct children of `NavDrill`. +```tsx +const projectOptions = projects.map((p) => ({ value: p.id, label: p.name })); -After a drill row, focus moves to the new level's ‹; after ‹, back to the row you came from. Any other change of level leaves focus alone, and levels swap without sliding under reduced motion. +; +``` -### Uncontrolled: on its own +### When the List Fails to Load -`NavDrill` changes level when someone clicks a drill row or ‹. Set the first level with `defaultValue`. +Pass `error` and `onRetry`, and the list offers Retry. Pass `retrying` while it reloads. + +```tsx +const projects = projectsQuery.data ?? []; +const projectOptions = projects.map((p) => ({ value: p.id, label: p.name })); + + projectsQuery.refetch()} +/>; +``` + +## Drill-in + +A row with `drill` opens the `NavDrillLevel` with that `value`. A level with a `parent` gets a ‹ row that goes back. Levels are direct children of `NavDrill`. Put the list on a background colour, like a panel or sheet, or the levels show through as they slide. + +### Uncontrolled + +Set the first level with `defaultValue`. Use it where the URL doesn't change, like a popover or sheet. @@ -110,13 +202,6 @@ After a drill row, focus moves to the new level's ‹; after ‹, back to the ro - - } - href="/dashboard" - /> - ``` -### Controlled: follows the URL - -Your app sets the level with `value`, usually from the URL, so a bookmark or the Back button opens the right level. A click on a drill row or ‹ calls `onValueChange` with the level it wants: navigate there, and the new URL gives the new `value`. +### Controlled by the URL - +In an app, set `value` from the URL, so Back and bookmarks open the right level. `onValueChange` gets the level a click asks for: navigate there. ```jsx const { policy } = useParams(); @@ -157,83 +240,194 @@ function openLevel(level) { ; ``` +## Expandable Groups + +`NavListExpandable` is a folder. Put it in a `NavListGroup`, or inside another folder. A folder opens itself when a row inside it becomes `current`. + + + +```jsx + + + + + + + + + +``` + +### Nested Folders + +Folders nest to any depth. Each level indents, so long labels cut off sooner. + + + +```jsx + + + + + + + + + + + + +``` + ## Examples ### App Sidebar -A rail of top-level app sections in `NavRail` beside a `NavPanel`, with a switcher in the panel's header to quick switch between objects, and a nav list that drills into a policy. Not all applications or navigation implementations require this level of organisation. Use NavRail when an application is sufficiently complex, and only for app-level navigation. +The sidebar from [Usage](#usage) in full, with router links and a policy level that follows the URL. ```tsx +const { projectId, policyId } = useParams(); +const { pathname } = useLocation(); +const navigate = useNavigate(); +const base = `/projects/${projectId}`; +const policy = policies.find((item) => item.id === policyId); +const projectOptions = projects.map((p) => ({ value: p.id, label: p.name })); + +function goToLevel(level) { + navigate(level === "project" ? base : `${base}/policies/${level}`); +} +
- } label="Projects" href="/projects" current /> - } label="Agents" href="/agents" /> + } label="Projects" current asChild> + + + } label="Agents" asChild> + + navigate(`/projects/${id}`)} /> } > - - … + + + + } + current={pathname === base} + asChild + > + + + + + {policies.map((item) => ( + } + drill={item.id} + /> + ))} + + {policy && ( - … + + } asChild> + + + )}
…
-
+
; ``` -The switcher's trigger stops a typed letter from picking an item while its list is closed: a switch navigates, and focus lands on the trigger afterwards. To do the same on your own `Select`, see [A select that navigates](/components/select#a-select-that-navigates). +### Docs Sidebar -### When the List Fails to Load +Rows built from a tree of folders and pages. Folders around the `current` row open themselves. -Pass `error` with `onRetry` when the list of items couldn't be read. The switcher stays a dropdown, and its list says so with a Retry row above any options it already has. Picking Retry calls `onRetry` and keeps the list open; pass `retrying` while that read runs, and the row reads "Retrying...". `error` stays true while the read runs, so `retrying` is what tells the switcher it has started. - - + ```tsx - projectsQuery.refetch()} -/> +function Rows({ nodes, top = false }) { + const { pathname } = useLocation(); + return nodes.map((node) => { + const prefix = top && node.icon ? : undefined; + const suffix = node.badge && {node.badge}; + return node.children ? ( + + + + + + + ) : ( + + ); + }); +} + + + + + +; ``` ### Navigation in a Sheet -On a phone, the sidebar comes in from the left in a [Sheet](/components/sheet). Give the sheet a title for screen readers. A sheet is at least a third of the screen wide, so set `minWidth: 0` to fit it to the list. +On a phone, put the whole sidebar in a [Sheet](/components/sheet) from the left: the rail and panel side by side, the switcher in the panel's header. Close the sheet when the route changes. Give it a hidden title. Set `minWidth: 0` so the sheet fits the list. - + ```jsx - +const { pathname } = useLocation(); +const [open, setOpen] = useState(false); + +useEffect(() => setOpen(false), [pathname]); + + @@ -243,30 +437,80 @@ On a phone, the sidebar comes in from the left in a [Sheet](/components/sheet). style={{ minWidth: 0 }} > Navigation - - … - +
+ … + }> + … + +
-
+
; ``` +## Best Practices + +### Pages, Not Actions + +Every row takes the reader somewhere. Put actions elsewhere: Log Out in a [Dropdown Menu](/components/dropdown-menu), Create Project in the switcher's `action`. A reader who clicks a row expects to go somewhere, not to change something. + + + +### Statuses and Counts in the Suffix + +Use `Badge size="sm"`. Screen readers skip the prefix, and a badge there pushes the label out of line. + + + +### Icons on Every Top-Level Row, or None + +Mixed icons make the labels zigzag. + + + +### Don't Restyle Rows + +A row with its own colours or sizes reads as an error, or as a different control. To change every row on a site, set the `--eq-list-item-*` variables. + + + +### Don't Add Your Own Arrows + +The row adds them itself: › when you set `drill`, ↗ when you set `target="_blank"`. + +### Server-Rendered Pages: Start the Current Folder Open + +Pass `defaultOpen` to the folder holding the current page, so it's open before the page's scripts load. + +### Put a Folder's Own Page Inside It + +A folder's header only opens and closes the folder; it can't link to a page. If the folder has a page of its own, add it as the first row inside, such as Overview. + +### Name the Rail and Panel for What They Hold + +Screen readers list `NavRail` and `NavPanel` as navigation landmarks by their `aria-label`. Use names that tell them apart, like "Main" and `` `Project: ${project.name}` ``. + ## Slots -| Name | Description | -| ------------------ | ----------------------------------------------------------------------------------- | -| `NavRail` | The column of icon links | -| `NavRailItem` | An icon link in the rail, with its label as a tooltip | -| `NavPanel` | The sidebar panel; its `header` holds a `NavSwitcher`, or `` | -| `NavSwitcher` | Switches between items such as projects, in the panel's header | -| `NavList` | Root of the rows | -| `NavListGroup` | A list of rows, with an optional label | -| `NavListItem` | A row: a link, or a button when it has no `href` | -| `NavListSeparator` | Divider between groups | -| `NavDrill` | Shows one level at a time and slides between them | -| `NavDrillLevel` | One level of a drill-in, with its ‹ row | +| Name | Description | +| -------------------------- | ----------------------------------------------------- | +| `NavRail` | The column of icon links | +| `NavRailItem` | An icon link in the rail, with its label as a tooltip | +| `NavPanel` | The sidebar panel, with a `header` | +| `NavSwitcher` | Switches between items, in the panel's header | +| `NavList` | Root of the rows | +| `NavListGroup` | A list of rows, with an optional label | +| `NavListItem` | A row: a link, or a button for a drill row | +| `NavListSeparator` | Divider between kinds of group | +| `NavListExpandable` | A folder that opens and closes | +| `NavListExpandableTrigger` | The folder's header row | +| `NavListExpandableContent` | The folder's rows | +| `NavDrill` | Shows one level at a time and slides between them | +| `NavDrillLevel` | One level of a drill-in, with its ‹ row | ## Props +`NavList`, `NavListSeparator` and `NavListExpandableContent` take no props of their own. + ### NavRail | Name | Description | Type | Default | Required | @@ -275,40 +519,40 @@ On a phone, the sidebar comes in from the left in a [Sheet](/components/sheet). ### NavRailItem -| Name | Description | Type | Default | Required | -| --------- | --------------------------------------------------------- | ----------- | ------- | -------- | -| `icon` | The item's only visible content | `ReactNode` | | ✅ | -| `label` | The item's accessible name and its tooltip | `string` | | ✅ | -| `href` | Where the item goes; not needed with `asChild` | `string` | | ✅ | -| `current` | Marks the item as the section on show | `boolean` | `false` | ❌ | -| `asChild` | Renders the item as its only child, such as a router link | `boolean` | `false` | ❌ | +| Name | Description | Type | Default | Required | +| --------- | ------------------------------------------------- | ----------- | ------- | ----------------- | +| `icon` | The item's only visible content | `ReactNode` | | ✅ | +| `label` | The item's accessible name and its tooltip | `string` | | ✅ | +| `href` | Where the item goes | `string` | | Without `asChild` | +| `current` | Marks the current area | `boolean` | `false` | ❌ | +| `asChild` | Renders the item as its child, like a router link | `boolean` | `false` | ❌ | ### NavPanel -| Name | Description | Type | Default | Required | -| ------------ | ---------------------------------------------------- | ----------- | ------- | -------- | -| `aria-label` | Names the panel's landmark | `string` | | ✅ | -| `header` | Above the rows, such as a `NavSwitcher` or a heading | `ReactNode` | | ❌ | +| Name | Description | Type | Default | Required | +| ------------ | -------------------------------------------- | ----------- | ------- | -------- | +| `aria-label` | Names the panel's landmark | `string` | | ✅ | +| `header` | Above the rows: a `NavSwitcher` or a heading | `ReactNode` | | ❌ | ### NavSwitcher -| Name | Description | Type | Default | Required | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------- | ------------ | -| `label` | The current item's name, shown as the panel's heading | `string` | | ✅ | -| `noun` | Singular, for the trigger's name: `{label}, switch {noun}` | `string` | | ✅ | -| `collection` | Plural, for the search and empty copy | `string` | | ✅ | -| `options` | The items to switch between | `{ value, label, keywords? }[]` | | ✅ | -| `value` | The current item's value | `string` | | ✅ | -| `onValueChange` | Called with the picked item's value | `(value: string) => void` | | ✅ | -| `onHighlight` | Hover or focus on an option, to preload its destination | `(value: string) => void` | | ❌ | -| `onHighlightEnd` | Hover or focus leaves an option, to cancel its preload | `() => void` | | ❌ | -| `action` | A command at the bottom of the list, kept while searching, such as "Create Project"; `onSelect` runs after the list closes | `{ label: string; onSelect: () => void }` | | ❌ | -| `error` | The list failed to load: the list says so and offers Retry; needs `onRetry` | `boolean` | `false` | ❌ | -| `retrying` | A retry is in flight: the Retry row reads "Retrying..." | `boolean` | `false` | ❌ | -| `onRetry` | Called when Retry is picked; the list stays open | `() => void` | | With `error` | -| `loading` | Holds the switcher's height while the current item loads | `boolean` | `false` | ❌ | - -With `loading`, the switcher takes no other props. With no `options`, no `action` and no `error`, it is a plain heading. One option is still a switcher, so the list shows it is the only one. +| Name | Description | Type | Default | Required | +| ---------------- | ----------------------------------------------------------------------------------- | ----------------------------------------- | ------- | ------------ | +| `label` | The current item's name, shown as the panel's heading | `string` | | ✅ | +| `noun` | Singular, for the trigger's name: `{label}, switch {noun}` | `string` | | ✅ | +| `collection` | Plural, for the search and empty copy | `string` | | ✅ | +| `options` | The items to switch between | `{ value, label, keywords? }[]` | | ✅ | +| `value` | The current item's value | `string` | | ✅ | +| `onValueChange` | Called with the picked item's value | `(value: string) => void` | | ✅ | +| `onHighlight` | Hover or focus on an option, to preload its page | `(value: string) => void` | | ❌ | +| `onHighlightEnd` | Hover or focus leaves an option, to cancel the preload | `() => void` | | ❌ | +| `action` | A command at the foot of the list, kept while searching; runs after the list closes | `{ label: string; onSelect: () => void }` | | ❌ | +| `error` | The list failed to load, and offers Retry | `boolean` | `false` | ❌ | +| `retrying` | A retry is running, and the Retry row says so | `boolean` | `false` | ❌ | +| `onRetry` | Called when Retry is picked | `() => void` | | With `error` | +| `loading` | Holds the switcher's height while the current item loads | `boolean` | `false` | ❌ | + +With `loading`, pass no other props. With no `options`, `action` or `error`, it's a plain heading. ### NavListGroup @@ -318,30 +562,47 @@ With `loading`, the switcher takes no other props. With no `options`, no `action ### NavListItem -| Name | Description | Type | Default | Required | -| --------- | --------------------------------------------------- | ----------- | ------- | -------- | -| `label` | The row's text | `string` | | ✅ | -| `href` | Makes the row a link | `string` | | ❌ | -| `current` | Marks the row as the page on show | `boolean` | `false` | ❌ | -| `prefix` | An icon before the label; not on nested rows | `ReactNode` | | ❌ | -| `suffix` | Badges or marks after the label | `ReactNode` | | ❌ | -| `drill` | The level this row opens; adds › | `string` | | ❌ | -| `asChild` | Renders the row as its child, such as a router link | `boolean` | `false` | ❌ | +| Name | Description | Type | Default | Required | +| --------------- | ---------------------------------------------------------------------------------------------------- | ----------- | ------- | -------- | +| `label` | The row's text | `string` | | ✅ | +| `href` | Makes the row a link | `string` | | ❌ | +| `target`, `rel` | Passed to the link. `target="_blank"` adds ↗, "(opens in a new tab)" and `rel="noopener noreferrer"` | `string` | | ❌ | +| `current` | Marks the current page | `boolean` | `false` | ❌ | +| `prefix` | An icon before the label; top-level rows only | `ReactNode` | | ❌ | +| `suffix` | A status or count after the label, read with it | `ReactNode` | | ❌ | +| `drill` | The level this row opens; adds › | `string` | | ❌ | +| `asChild` | Renders the row as its child, like a router link | `boolean` | `false` | ❌ | + +### NavListExpandable + +| Name | Description | Type | Default | Required | +| -------------- | ------------------------------------------------------------------------------------------- | ------------------------- | ------- | -------- | +| `defaultOpen` | Uncontrolled: opens the folder on first render | `boolean` | `false` | ❌ | +| `open` | Controlled: whether the folder is open | `boolean` | | ❌ | +| `onOpenChange` | Called with the new state when the header is pressed, or when the current page moves inside | `(open: boolean) => void` | | ❌ | + +### NavListExpandableTrigger + +| Name | Description | Type | Default | Required | +| -------- | ------------------------------------------------ | ----------- | ------- | -------- | +| `label` | The folder's name | `string` | | ✅ | +| `prefix` | An icon before the label; top-level folders only | `ReactNode` | | ❌ | +| `suffix` | A status or count after the label, read with it | `ReactNode` | | ❌ | ### NavDrill | Name | Description | Type | Default | Required | | --------------- | ------------------------------------------------------------------- | ----------------------------------------------------- | ------- | -------- | -| `value` | Controlled: the level on show | `string` | | ❌ | +| `value` | Controlled: the current level | `string` | | ❌ | | `defaultValue` | Uncontrolled: the first level | `string` | | ❌ | | `onValueChange` | Called when a drill row or ‹ asks for a level; `request` says which | `(value: string, request: "drill" \| "back") => void` | | ❌ | | `animate` | Slides between levels | `boolean` | `true` | ❌ | ### NavDrillLevel -| Name | Description | Type | Default | Required | -| ----------- | --------------------------------------------------------- | -------- | --------------- | -------- | -| `value` | The `NavDrill` value that shows it | `string` | | ✅ | -| `parent` | The `value` ‹ returns to; adds the ‹ row | `string` | | ❌ | -| `label` | Names where you are, on the ‹ row; required with `parent` | `string` | | ❌ | -| `backLabel` | The ‹ row's accessible name; must start with `label` | `string` | `{label}, back` | ❌ | +| Name | Description | Type | Default | Required | +| ----------- | ---------------------------------------------------- | -------- | --------------- | ------------- | +| `value` | The `NavDrill` value that shows it | `string` | | ✅ | +| `parent` | The `value` ‹ returns to; adds the ‹ row | `string` | | ❌ | +| `label` | Names where you are, on the ‹ row | `string` | | With `parent` | +| `backLabel` | The ‹ row's accessible name; must start with `label` | `string` | `{label}, back` | ❌ | diff --git a/packages/skills/equality-design-system/SKILL.md b/packages/skills/equality-design-system/SKILL.md index 66d235f3..00f6ec28 100644 --- a/packages/skills/equality-design-system/SKILL.md +++ b/packages/skills/equality-design-system/SKILL.md @@ -59,6 +59,7 @@ If you have no network access or web fetching fails, tell your user that you cou | Sidebar rows, settings list, `NavItem`/`NavSection` | `NavListItem` in `NavListGroup` | Styled links in a `ul` | | App sidebar, icon rail | `NavPanel` + `NavRail` | A fixed `aside` with links | | Sliding sub-panel, drill-in nav | `NavDrill` | Swapped lists with CSS transitions | +| Collapsible nav group, tree sidebar, folder rows | `NavListExpandable` | `
` or a toggled `ul` | ## Common Mistakes @@ -68,6 +69,7 @@ If you have no network access or web fetching fails, tell your user that you cou - Use `ResourceBadge` when referencing integrity-backed resources and `ControlStatusBadge` when referencing control statuses. - Avoid using badge `size="sm"` except for specific scenarios where space is at a premium like within tables. Most badges should be the default `size="md"` - Icons should be used sparingly. Ensure consistency when using them, try not to conflate multiple concepts with the same icon +- Nav: decide between drill in, expand and `NavSwitcher` with "Which One to Use" on the Nav docs page, and follow its "Best Practices". Statuses go in a row's `suffix`, never its `prefix`. - No custom hex codes, no `bg-{color}-{number}` palette classes. - Keep interactive and status colours apart. They are two different purples, and they clash side by side: - `button-*` tokens and the `primary` variants of controls are lilac, for things you can click, select or toggle and their states. Never use them to colour a non-interactive surface, label or decoration. From 64d34805a8fb292405bf880562be6b85ffaa898f Mon Sep 17 00:00:00 2001 From: "matthew.marie-rhodes" Date: Fri, 30 Oct 2026 16:45:40 -0400 Subject: [PATCH 2/2] fix(demo): give each Nav demo its own landmark names Need: Screen reader users jump between landmarks by name. Problem: The Nav page showed three panels called "Project: Ben Project" and two rails called "Main". Solution: The second app sidebar and the Switcher demo name themselves as examples. Co-Authored-By: Claude Opus 5.5 --- packages/demo/src/components/demo/nav.tsx | 23 ++++++++++++------- .../demo/src/content/docs/components/nav.mdx | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/demo/src/components/demo/nav.tsx b/packages/demo/src/components/demo/nav.tsx index d042d5c3..d5e82eaa 100644 --- a/packages/demo/src/components/demo/nav.tsx +++ b/packages/demo/src/components/demo/nav.tsx @@ -58,7 +58,13 @@ const POLICY_ITEMS = [ { id: "certification", label: "Certification", icon: }, ]; -function ProjectSidebar({ onNavigate }: { onNavigate?: () => void }) { +function ProjectSidebar({ + onNavigate, + example, +}: { + onNavigate?: () => void; + example?: string; +}) { const [project, setProject] = useState("ben"); const [view, setView] = useState("project"); const [current, setCurrent] = useState("dashboard"); @@ -66,6 +72,7 @@ function ProjectSidebar({ onNavigate }: { onNavigate?: () => void }) { (option) => option.value === project, )!.label; const policy = POLICIES.find((item) => view === `policy:${item.slug}`); + const named = (label: string) => (example ? `${label}, ${example}` : label); // Rows are links; the demo keeps you on the page and moves the current row instead const link = (id: string) => ({ @@ -80,7 +87,7 @@ function ProjectSidebar({ onNavigate }: { onNavigate?: () => void }) { return ( <> - + } label="Projects" @@ -97,9 +104,9 @@ function ProjectSidebar({ onNavigate }: { onNavigate?: () => void }) { } label="Activity Logs" href="#logs" /> void }) { ); } -export function NavDemo() { +export function NavDemo({ example }: { example?: string }) { return (
- +

Page content

@@ -247,7 +254,7 @@ export function NavSwitcherDemo() { }} > + ```tsx const { projectId, policyId } = useParams();