diff --git a/packages/demo/src/components/demo/nav-list.tsx b/packages/demo/src/components/demo/nav-list.tsx new file mode 100644 index 00000000..004bcc63 --- /dev/null +++ b/packages/demo/src/components/demo/nav-list.tsx @@ -0,0 +1,301 @@ +import { + useState, + type CSSProperties, + type MouseEvent, + type ReactNode, +} from "react"; +import { + Badge, + Button, + NavDrill, + NavDrillLevel, + NavList, + NavListGroup, + NavListItem, + NavListSeparator, + Sheet, + SheetContainer, + SheetTitle, + SheetTrigger, +} from "@eqtylab/equality"; +import { + Activity, + Award, + BookOpen, + Bot, + ChartLine, + Layers, + LayoutGrid, + Settings, +} from "lucide-react"; + +const frame: CSSProperties = { + width: 240, + padding: 8, + borderRadius: 8, + border: "1px solid var(--color-border-raised)", + background: "var(--color-background-raised)", +}; + +// Rows are links; the demos keep you on the page and move the current row instead +function useCurrent(initial: string) { + const [current, setCurrent] = useState(initial); + const link = (id: string) => ({ + href: `#${id}`, + current: current === id, + onClick: (event: MouseEvent) => { + event.preventDefault(); + setCurrent(id); + }, + }); + return link; +} + +function NavListGroupsDemo() { + const link = useCurrent("dashboard"); + return ( +
+ + + } + {...link("dashboard")} + /> + } + {...link("indicators")} + /> + } + {...link("agents")} + /> + + + } + {...link("eu-ai-act")} + /> + } + {...link("nist-ai-rmf")} + /> + + + + } + {...link("settings")} + /> + + +
+ ); +} + +function NavListPrefixSuffixDemo() { + return ( +
+ + + } + suffix={2} + href="#policies" + current + /> + } + suffix={12} + href="#controls" + /> + + +
+ ); +} + +const APPLIED_POLICIES = [ + ["eu-ai-act", "EU AI Act"], + ["nist-ai-rmf", "NIST AI RMF"], +]; + +// Levels must be direct children of NavDrill, so they are returned as an array, not a component +function policyLevels(link: ReturnType): ReactNode[] { + return [ + + + } + {...link("dashboard")} + /> + } + {...link("indicators")} + /> + } + {...link("agents")} + /> + + + {APPLIED_POLICIES.map(([value, name]) => ( + } + drill={value} + /> + ))} + + , + ...APPLIED_POLICIES.map(([value, name]) => ( + + + } + {...link(`${value}-controls`)} + /> + } + {...link(`${value}-monitoring`)} + /> + } + {...link(`${value}-evaluations`)} + /> + } + {...link(`${value}-certification`)} + /> + + + )), + ]; +} + +function NavListDrillDemo() { + const link = useCurrent("dashboard"); + return ( +
+ + {policyLevels(link)} + +
+ ); +} + +const visuallyHidden: CSSProperties = { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + clip: "rect(0 0 0 0)", + whiteSpace: "nowrap", +}; + +function NavListDrillSheetDemo() { + const link = useCurrent("dashboard"); + return ( + + + + + + Navigation + + {policyLevels(link)} + + + + ); +} + +// 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}`; + return ( +
+
+ + setHistory([...history, next])} + > + {policyLevels(link)} + + +
+
+

+ URL: {url} +

+ +
+
+ ); +} + +const VARIANTS = { + default: NavListGroupsDemo, + "prefix-suffix": NavListPrefixSuffixDemo, + drill: NavListDrillDemo, + "drill-sheet": NavListDrillSheetDemo, + "drill-controlled": NavListDrillControlledDemo, +}; + +export function NavListDemo({ + variant = "default", +}: { + variant?: keyof typeof VARIANTS; +}) { + const Demo = VARIANTS[variant]; + return ( +
+ +
+ ); +} diff --git a/packages/demo/src/components/demo/nav.tsx b/packages/demo/src/components/demo/nav.tsx new file mode 100644 index 00000000..a5489109 --- /dev/null +++ b/packages/demo/src/components/demo/nav.tsx @@ -0,0 +1,171 @@ +import { useState, type MouseEvent } from "react"; +import { + NavDrill, + NavDrillLevel, + NavList, + NavListGroup, + NavListItem, + NavPanel, + NavRail, + NavRailItem, + NavSwitcher, +} from "@eqtylab/equality"; +import { + Activity, + Award, + Blocks, + BookOpen, + Bot, + ChartLine, + ClipboardCheck, + Folder, + Layers, + LayoutGrid, + LibraryBig, + Logs, + Network, + Settings, +} from "lucide-react"; + +const PROJECTS = [ + { value: "ben", label: "Ben Project" }, + { value: "ada", label: "Ada Project" }, +]; + +const POLICIES = [ + { slug: "eu-ai-act", name: "EU AI Act" }, + { slug: "nist-ai-rmf", name: "NIST AI RMF" }, +]; + +const PROJECT_ITEMS = [ + { id: "dashboard", label: "Dashboard", icon: }, + { id: "indicators", label: "Indicators", icon: }, + { id: "lineage", label: "Lineage Graphs", icon: }, + { id: "agents", label: "Associated Agents", icon: }, + { id: "plugins", label: "Plugin Configurations", icon: }, + { id: "settings", label: "Project Settings", icon: }, +]; + +const POLICY_ITEMS = [ + { id: "controls", label: "Controls", icon: }, + { id: "monitoring", label: "Monitoring", icon: }, + { id: "evaluations", label: "Indicator Evaluations", icon: }, + { id: "certification", label: "Certification", icon: }, +]; + +export function NavDemo() { + const [project, setProject] = useState("ben"); + const [view, setView] = useState("project"); + const [current, setCurrent] = useState("dashboard"); + const projectName = PROJECTS.find( + (option) => option.value === project, + )!.label; + const policy = POLICIES.find((item) => view === `policy:${item.slug}`); + + // 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 ( +
+ + } + label="Projects" + href="#projects" + current + /> + } label="Agents" href="#agents" /> + } label="Library" href="#library" /> + } + label="Assessments" + href="#assessments" + /> + } label="Activity Logs" href="#logs" /> + + { + setProject(next); + setView("project"); + }} + /> + } + > + + setView(next)}> + + + {PROJECT_ITEMS.map((item) => ( + + ))} + + + {POLICIES.map((item) => ( + } + drill={`policy:${item.slug}`} + {...link(item.slug)} + /> + ))} + + + {policy && ( + + + {POLICY_ITEMS.map((item) => ( + + ))} + + + )} + + + +
+

Page content

+
+
+ ); +} diff --git a/packages/demo/src/content/docs/components/nav.mdx b/packages/demo/src/content/docs/components/nav.mdx new file mode 100644 index 00000000..0df32914 --- /dev/null +++ b/packages/demo/src/content/docs/components/nav.mdx @@ -0,0 +1,324 @@ +--- +title: "Nav" +description: "Sidebar navigation with an icon rail, a switcher and links that drill in and out" +--- + +import { NavDemo } from "@demo/components/demo/nav"; +import { NavListDemo } from "@demo/components/demo/nav-list"; + +## Overview + +Nav builds an app's sidebar. `NavRail` is the column of icon links on the left, and `NavPanel` is the panel beside it, with a header and a scrolling body. `NavSwitcher` sits in the panel's header to switch between items such as projects; with one option and no action it is a plain heading. + +Inside the panel, `NavList` holds the links, one per row, in groups. A row can open a deeper level, which slides in with a ‹ to go back. A nav list also works on its own, such as in a popover, a sheet or a settings list. For actions in a pop-up, use a [Dropdown Menu](/components/dropdown-menu): menus are for actions, nav lists are for going places. + +## Usage + +Import the parts: + +```tsx +import { + NavDrill, + NavDrillLevel, + NavList, + NavListGroup, + NavListItem, + NavListSeparator, + NavPanel, + NavRail, + NavRailItem, + NavSwitcher, +} from "@eqtylab/equality"; +``` + +Group the rows, each group with an optional label, and put a separator between groups. Rows are 36px, and grow to 44px on touch screens: + + + +```jsx + + + } href="/" current /> + } href="/indicators" /> + + + } + href="/policies/eu-ai-act" + /> + + + + } + href="/settings" + /> + + +``` + +Put a group inside another group to nest it one level, under a tree line. Nested rows take no `prefix` icon. + +`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: + +```jsx + + + +``` + +## Variants + +### 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). + + + +```jsx +} + suffix={2} + href="/policies" +/> +} + suffix={12} + href="/controls" +/> +``` + +## Drill-in + +A row with `drill="eu-ai-act"` opens the level whose `value` is `"eu-ai-act"`. A level with a `parent` gets a ‹ row at the top, named by its `label`, that returns to the parent. Levels must be direct children of `NavDrill`. Put `NavList` inside an element with a background colour, such as a popover, sheet or panel: the sliding levels borrow it to cover each other. + +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 + +`NavDrill` changes level when someone clicks a drill row or ‹. Set the first level with `defaultValue`. + + + +```jsx + + + + + } + href="/dashboard" + /> + + + } + drill="eu-ai-act" + /> + + + + + } + href="/policies/eu-ai-act/controls" + /> + + + + +``` + +### 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`. + + + +```jsx +const { policy } = useParams(); +const navigate = useNavigate(); + +function openLevel(level) { + navigate(level === "project" ? "/project" : `/policies/${level}`); +} + + + … +; +``` + +## Examples + +### App Sidebar + +A rail of sections beside a panel, with a switcher in the panel's header and a nav list that drills into a policy. + + + +```tsx +
+ + } label="Projects" href="/projects" current /> + } label="Agents" href="/agents" /> + + + } + > + + + … + {policy && ( + + … + + )} + + + +
…
+
+``` + +A `NavDrill` inside a `NavPanel` scrolls the panel back to the top on every change of level. + +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). + +### 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. + + + +```jsx + + + + + + Navigation + + … + + + +``` + +## 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 | + +## Props + +### NavRail + +| Name | Description | Type | Default | Required | +| ------------ | ------------------------- | -------- | ------- | -------- | +| `aria-label` | Names the rail's landmark | `string` | | ✅ | + +### 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` | ❌ | + +### 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` | | ❌ | + +### 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 warm its destination | `(value: string) => void` | | ❌ | +| `onHighlightEnd` | Hover or focus leaves an option | `() => 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 }` | | ❌ | +| `loading` | Holds the switcher's height while the current item loads | `boolean` | `false` | ❌ | + +With `loading`, the switcher takes no other props. + +### NavListGroup + +| Name | Description | Type | Default | Required | +| ------- | ---------------------- | -------- | ------- | -------- | +| `label` | Names the group's list | `string` | | ❌ | + +### 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` | ❌ | + +### NavDrill + +| Name | Description | Type | Default | Required | +| --------------- | ------------------------------------------------------------------- | ----------------------------------------------------- | ------- | -------- | +| `value` | Controlled: the level on show | `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` | ❌ | diff --git a/packages/demo/src/content/docs/components/select.mdx b/packages/demo/src/content/docs/components/select.mdx index 69d31b4b..b6e4e781 100644 --- a/packages/demo/src/content/docs/components/select.mdx +++ b/packages/demo/src/content/docs/components/select.mdx @@ -180,6 +180,21 @@ the last; ↑ on the first option and ↓ on the last retu to the search box, so the arrows cycle through it. Typing or Backspace while an option is focused keeps editing the query. +#### A select that navigates + +While a `Select` is closed, typing a letter on its trigger picks the first matching option. When +picking an option navigates, such as switching project, a stray letter changes the page. Pass +`blockClosedTypeahead` to the trigger's `onKeyDown` to stop it. Typeahead still works in the open +list, and Space, Enter and shortcuts are untouched. + +```tsx +import { blockClosedTypeahead } from "@eqtylab/equality"; + + + +; +``` + ### Accessibility The search box and its live regions sit inside the list, which Radix gives the `listbox` role. ARIA only allows options and groups there, so automated checkers such as axe report `aria-required-children`, and some screen readers skip the search box in browse mode. They can't move outside the list: while the select is open, Radix hides everything outside it from assistive technology. Keyboard use and announcements work as described above. diff --git a/packages/skills/equality-design-system/SKILL.md b/packages/skills/equality-design-system/SKILL.md index f47d2866..7fb59d03 100644 --- a/packages/skills/equality-design-system/SKILL.md +++ b/packages/skills/equality-design-system/SKILL.md @@ -42,20 +42,23 @@ If you have no network access or web fetching fails, tell your user that you cou ### Name translation -| If you're thinking of… | Use in Equality | Don't hand-roll it from | -| ---------------------------------------------------------- | ---------------------------- | ------------------------------ | -| Combobox, searchable select, filterable single-select | `Select` with `SelectSearch` | `Popover` + `Command` | -| Command palette, ⌘K launcher | `Command` | `Dialog` + `Input` + a list | -| Modal, lightbox | `Dialog` | A fixed-position `div` | -| Confirmation or destructive-action prompt | `AlertDialog` | `Dialog` with two buttons | -| Drawer, side panel, slide-over | `Sheet` | `Dialog` with custom styles | -| Snackbar, notification | `Toast` | A timed, positioned `div` | -| Toggle | `Switch` | A styled checkbox | -| Chip, pill, tag, status label | `Badge` | A rounded `span` | -| Loader, activity indicator | `Spinner` | An animated SVG | -| Button group or toggle group for mutually exclusive values | `SegmentedControls` | A row of `Button`s | -| Multi-select filter | `FilterDropdown` | `DropdownMenu` with checkboxes | -| Footer action, "Select all" or "Other" while searching | `persistent` items | Buttons outside the list items | +| If you're thinking of… | Use in Equality | Don't hand-roll it from | +| ---------------------------------------------------------- | ------------------------------- | ---------------------------------- | +| Combobox, searchable select, filterable single-select | `Select` with `SelectSearch` | `Popover` + `Command` | +| Command palette, ⌘K launcher | `Command` | `Dialog` + `Input` + a list | +| Modal, lightbox | `Dialog` | A fixed-position `div` | +| Confirmation or destructive-action prompt | `AlertDialog` | `Dialog` with two buttons | +| Drawer, side panel, slide-over | `Sheet` | `Dialog` with custom styles | +| Snackbar, notification | `Toast` | A timed, positioned `div` | +| Toggle | `Switch` | A styled checkbox | +| Chip, pill, tag, status label | `Badge` | A rounded `span` | +| Loader, activity indicator | `Spinner` | An animated SVG | +| Button group or toggle group for mutually exclusive values | `SegmentedControls` | A row of `Button`s | +| Multi-select filter | `FilterDropdown` | `DropdownMenu` with checkboxes | +| Footer action, "Select all" or "Other" while searching | `persistent` items | Buttons outside the list items | +| 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 | ## Common Mistakes diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 2e3b56ec..d198e9aa 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -2,6 +2,39 @@ Notable changes to Equality are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 4.15.0 - 2026-10-27 + +Sidebar navigation comes to Equality as two sets of parts: `NavList` rows for going somewhere, +anywhere on screen, and a `Nav` sidebar frame. Menus stay for actions: use `DropdownMenu` for those. +Existing code needs no changes. + +### Added + +- `NavList`, `NavListGroup`, `NavListItem` and `NavListSeparator`: rows of links or buttons in + groups. Rows are 36px, and 44px under a coarse pointer such as a touch screen. A row's `suffix`, + such as a badge or count, is read by screen readers; the › caret comes from `drill`. +- `NavDrill` and `NavDrillLevel`: levels that slide in and out, controlled or uncontrolled. A level + with a `parent` draws its own ‹ row. They slide only after a drill row or ‹ asked for them, and + move focus after the changes they asked for. +- `NavRail`, `NavRailItem`, `NavPanel` and `NavSwitcher` (with `loading`): the sidebar frame. Rail + items and the switcher also grow to 44px under a coarse pointer. +- `blockClosedTypeahead`: stops a typed letter from picking an option while a `Select` is closed, + for a select that navigates. +- `theme/theme-decisions.css`: the list, control and touch sizes the parts read, such as + `--eq-list-item-height` and `--eq-touch-target`, which is 44px under a coarse pointer. +- Two colours mixed from `--color-text-primary`, `--color-list-item-current` and + `--color-list-item-hover`, with the `bg-list-item-current` and `bg-list-item-hover` utilities. + They switch for dark mode on their own. + +### Changed + +- `IconButton` forwards its ref to the button or link it renders. + +### Fixed + +- `IconButton` with `href` passes its other props to the link, so it works as a tooltip trigger and + keeps `onClick` and `data-*` attributes. + ## 4.14.1 - 2026-10-20 ### Fixed diff --git a/packages/ui/package.json b/packages/ui/package.json index 12d40b3d..610ed1e7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/eqtylab/equality.git", "directory": "packages/ui" }, - "version": "4.14.1", + "version": "4.15.0", "license": "Apache-2.0", "keywords": [ "component library", @@ -111,8 +111,9 @@ "build": "pnpm run build:tokens && pnpm run build:lib", "build:tokens": "node src/scripts/build-tokens.js && prettier --write ../tokens/equality-tokens.json", "build:preflight": "node src/scripts/build-preflight.js && prettier --write src/theme/theme-preflight-global.css", + "build:decisions": "node src/scripts/build-decisions.js && prettier --write src/theme/theme-decisions-scoped.css", "check:grammars": "node src/scripts/check-grammars.js", - "build:lib": "pnpm run build:preflight && pnpm run check:grammars && tsup --config tsup.config.ts && node src/scripts/document-props.js && cp src/theme/*.css dist/ && mkdir -p dist/generated && cp src/theme/generated/*.css dist/generated/", + "build:lib": "pnpm run build:preflight && pnpm run build:decisions && pnpm run check:grammars && tsup --config tsup.config.ts && node src/scripts/document-props.js && cp src/theme/*.css dist/ && mkdir -p dist/generated && cp src/theme/generated/*.css dist/generated/", "watch": "tsup --config tsup.config.ts --watch", "test": "vitest run", "watch:tokens": "chokidar '../tokens/equality-tokens.json' --debounce 300 --initial=false -c 'pnpm run build:tokens'", diff --git a/packages/ui/src/components/icon-button/icon-button.tsx b/packages/ui/src/components/icon-button/icon-button.tsx index 605c311d..b3e2c3cf 100644 --- a/packages/ui/src/components/icon-button/icon-button.tsx +++ b/packages/ui/src/components/icon-button/icon-button.tsx @@ -40,54 +40,63 @@ export interface IconButtonProps download?: string | boolean; } -function IconButton({ - className, - size = 'sm', - variant = 'primary', - name, - label, - href, - target, - download, - disabled, - ...props -}: IconButtonProps) { - const Icon = LucideIcons[name] as LucideIcons.LucideIcon; - const iconSize = iconSizeMap[size || 'sm']; - const IconComponent = Icon as React.ComponentType<{ size?: number }>; +const IconButton = React.forwardRef( + function IconButton( + { + className, + size = 'sm', + variant = 'primary', + name, + label, + href, + target, + download, + disabled, + ...props + }, + ref + ) { + const Icon = LucideIcons[name] as LucideIcons.LucideIcon; + const iconSize = iconSizeMap[size || 'sm']; + const IconComponent = Icon as React.ComponentType<{ size?: number }>; - if (!Icon) { - throw new Error(`Icon "${name}" not found in lucide-react`); - } + if (!Icon) { + throw new Error(`Icon "${name}" not found in lucide-react`); + } + + const content = ; - const content = ; + if (href && !disabled) { + return ( + } + href={href} + target={target} + download={download} + aria-label={label} + rel={target === '_blank' ? 'noopener noreferrer' : undefined} + className={cn(iconButtonVariants({ size, variant }), className)} + {...(props as React.AnchorHTMLAttributes)} + > + {content} + + ); + } - if (href && !disabled) { return ( - } + type="button" aria-label={label} - rel={target === '_blank' ? 'noopener noreferrer' : undefined} + disabled={disabled} className={cn(iconButtonVariants({ size, variant }), className)} + {...props} > {content} - + ); } - - return ( - - ); -} +); +IconButton.displayName = 'IconButton'; export { IconButton }; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 63ae32fc..1fedde04 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -31,6 +31,11 @@ export * from './list-or-grid-view-toggle/list-or-grid-view-toggle'; export * from './loading-overlay/loading-overlay'; export * from './metric-card/metric-card'; export * from './motion-collapsible/motion-collapsible'; +export * from './nav-list/nav-drill'; +export * from './nav-list/nav-list'; +export * from './nav/nav-panel'; +export * from './nav/nav-rail'; +export * from './nav/nav-switcher'; export * from './page-not-found/page-not-found'; export * from './pagination/pagination'; export * from './panel-label/panel-label'; @@ -46,6 +51,7 @@ export * from './search-bar/search-bar'; export * from './section-heading/section-heading'; export * from './segmented-controls/segmented-controls'; export * from './select/select'; +export * from './select/select-typeahead'; export * from './separator/separator'; export * from './sheet/sheet'; export * from './skeleton/skeleton'; diff --git a/packages/ui/src/components/nav-list/nav-drill-context.ts b/packages/ui/src/components/nav-list/nav-drill-context.ts new file mode 100644 index 00000000..e5376c64 --- /dev/null +++ b/packages/ui/src/components/nav-list/nav-drill-context.ts @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export interface NavDrillContextValue { + requestDrill(view: string): void; + leaving: boolean; +} + +export const NavDrillContext = React.createContext(null); diff --git a/packages/ui/src/components/nav-list/nav-drill.tsx b/packages/ui/src/components/nav-list/nav-drill.tsx new file mode 100644 index 00000000..5c2fd4ae --- /dev/null +++ b/packages/ui/src/components/nav-list/nav-drill.tsx @@ -0,0 +1,382 @@ +import * as React from 'react'; +import { ChevronLeft } from 'lucide-react'; + +import { NavDrillContext } from '@/components/nav-list/nav-drill-context'; +import styles from '@/components/nav-list/nav-list.module.css'; +import { useIsomorphicLayoutEffect } from '@/lib/use-isomorphic-layout-effect'; +import { cn } from '@/lib/utils'; + +type NavDrillRequest = 'drill' | 'back'; + +// The leaving level is gone in the first third, so the two levels' rows never read as doubled text +const MOTION: Record = { + drill: { + current: [ + { transform: 'translateX(12px)', opacity: 0 }, + { transform: 'translateX(0)', opacity: 1 }, + ], + previous: [ + { transform: 'translateX(0)', opacity: 1 }, + { opacity: 0, offset: 0.35 }, + { transform: 'translateX(-12px)', opacity: 0 }, + ], + }, + back: { + current: [ + { transform: 'translateX(-12px)', opacity: 0 }, + { transform: 'translateX(0)', opacity: 1 }, + ], + previous: [ + { transform: 'translateX(0)', opacity: 1 }, + { opacity: 0, offset: 0.35 }, + { transform: 'translateX(12px)', opacity: 0 }, + ], + }, +}; +const TIMING: KeyframeAnimationOptions = { duration: 300, easing: 'cubic-bezier(.2, .8, .2, 1)' }; + +const LEVEL = '[data-slot="nav-drill-level"]'; + +interface PendingRequest { + view: string; + direction: NavDrillRequest; + // The level on show when the request was made, which slides away if the request lands + element: React.ReactElement | null; + top: number; + slide: boolean; +} + +interface Leaving { + element: React.ReactElement; + direction: NavDrillRequest; + top: number; + id: number; +} + +interface FocusRequest { + direction: NavDrillRequest; + view: string; + from: string | undefined; + landed: boolean; +} + +const NavDrillBackContext = React.createContext<(parent: string) => void>(() => {}); + +const LEAVING_DRILL_CONTEXT = { requestDrill: () => {}, leaving: true }; +const NO_BACK = () => {}; + +interface NavDrillProps extends React.HTMLAttributes { + /** Controlled: the `value` of the level on show */ + value?: string; + /** Uncontrolled: the `value` of the first level */ + defaultValue?: string; + /** Called when a drill row or ‹ asks for a level; a controlled caller sets `value` from it */ + onValueChange?: (value: string, request: NavDrillRequest) => void; + /** Slides levels in and out after a drill row or ‹; off swaps them at once */ + animate?: boolean; +} + +const NavDrill = React.forwardRef( + ( + { + value: valueProp, + defaultValue, + onValueChange, + animate = true, + className, + children, + ...props + }, + ref + ) => { + const [ownView, setOwnView] = React.useState(defaultValue); + const view = valueProp ?? ownView; + const [shownView, setShownView] = React.useState(view); + const [request, setRequest] = React.useState(null); + const [leaving, setLeaving] = React.useState(null); + const rootRef = React.useRef(null); + const ghostRef = React.useRef(null); + const running = React.useRef([]); + const focusRequest = React.useRef(null); + const observer = React.useRef(null); + const previousView = React.useRef(view); + + const level = + React.Children.toArray(children).find( + (child): child is React.ReactElement => + React.isValidElement(child) && child.props.value === view + ) ?? null; + + // Only a change that lands on the level the last request asked for slides; any change + // clears the request, so Back, Forward, deep links and redirects swap at once + if (view !== shownView) { + setShownView(view); + setRequest(null); + setLeaving( + request?.slide && request.view === view && request.element && level + ? { + element: request.element, + direction: request.direction, + top: request.top, + id: (leaving?.id ?? 0) + 1, + } + : null + ); + } + + const liveLevel = () => + rootRef.current?.querySelector(`:scope > ${LEVEL}`) ?? null; + + const tryFocus = () => { + const pending = focusRequest.current; + const root = rootRef.current; + if (!pending?.landed || !root) return; + const active = document.activeElement; + if (active && active !== document.body && !root.contains(active)) { + focusRequest.current = null; + return; + } + const live = liveLevel(); + const target = + pending.direction === 'drill' + ? (live?.querySelector('[data-slot="nav-drill-back"]') ?? + live?.querySelector('[data-slot="nav-list-item"]')) + : (live?.querySelector(`[data-drill="${CSS.escape(pending.from ?? '')}"]`) ?? + live?.querySelector('[aria-current="page"]')); + if (!target) return; + focusRequest.current = null; + observer.current?.disconnect(); + // focus() alone centres the row in the window; "nearest" moves the list first + target.focus({ preventScroll: true }); + target.scrollIntoView({ block: 'nearest' }); + }; + + const makeRequest = (target: string, direction: NavDrillRequest) => { + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + setRequest({ + view: target, + direction, + element: level, + top: liveLevel()?.getBoundingClientRect().top ?? 0, + slide: animate && !reduced, + }); + focusRequest.current = { direction, view: target, from: view, landed: false }; + observer.current?.disconnect(); + // ‹ may land before the parent's rows exist, and a child component can mount them later + // without NavDrill rendering again + if (direction === 'back' && rootRef.current) { + observer.current ??= new MutationObserver(() => tryFocus()); + observer.current.observe(rootRef.current, { childList: true, subtree: true }); + } + if (valueProp === undefined) setOwnView(target); + onValueChange?.(target, direction); + }; + + const makeRequestRef = React.useRef(makeRequest); + useIsomorphicLayoutEffect(() => { + makeRequestRef.current = makeRequest; + }); + const drillContext = React.useMemo( + () => ({ + requestDrill: (target: string) => makeRequestRef.current(target, 'drill'), + leaving: false, + }), + [] + ); + const requestBack = React.useCallback( + (parent: string) => makeRequestRef.current(parent, 'back'), + [] + ); + + useIsomorphicLayoutEffect(() => { + if (previousView.current === view) return; + previousView.current = view; + const pending = focusRequest.current; + if (pending && !pending.landed && pending.view === view) pending.landed = true; + else { + focusRequest.current = null; + observer.current?.disconnect(); + } + // As a fresh panel would: a drill-in from far down a list shows the new rows' top + const scroller = rootRef.current?.closest('[data-nav-scroller]'); + if (scroller) scroller.scrollTop = 0; + }, [view]); + + useIsomorphicLayoutEffect(() => { + running.current.forEach((animation) => animation.cancel()); + running.current = []; + const live = liveLevel(); + const ghost = ghostRef.current; + const ghostLevel = ghost?.querySelector(LEVEL); + if (!leaving || !live || !ghost || !ghostLevel) return; + // The old rows leave from where the user saw them: neither the scroll reset nor a tighter + // header moves them. A margin, because the slide owns transform + ghostLevel.style.marginTop = `${leaving.top - live.getBoundingClientRect().top}px`; + const animations = [ + live.animate(MOTION[leaving.direction].current, TIMING), + ghost.animate(MOTION[leaving.direction].previous, TIMING), + ]; + running.current = animations; + Promise.all(animations.map((animation) => animation.finished)).then( + () => { + // A finished animation drops its effect, so the leaving level is back at full opacity + // in place. Going back it is on top: hide it now, before the next paint, or it + // flashes for a frame until React unmounts it + ghost.style.visibility = 'hidden'; + setLeaving((current) => (current === leaving ? null : current)); + }, + // Cancelled by a later change, which owns the ghost now + () => {} + ); + }, [leaving]); + + React.useEffect(() => { + tryFocus(); + }); + + // NavDrill cannot see the caller's routing. A page link or anything else the user does + // outside the drill is a different intent, so it drops what a drill row or ‹ asked for: + // neither the slide nor the focus may land on a later, unrelated change + React.useEffect(() => { + const dropOutside = (event: Event) => { + if (rootRef.current?.contains(event.target as Node)) return; + focusRequest.current = null; + observer.current?.disconnect(); + setRequest((current) => (current ? null : current)); + }; + const events = ['pointerdown', 'keydown', 'focusin'] as const; + events.forEach((type) => document.addEventListener(type, dropOutside, true)); + return () => { + events.forEach((type) => document.removeEventListener(type, dropOutside, true)); + running.current.forEach((animation) => animation.cancel()); + observer.current?.disconnect(); + }; + }, []); + + const setRootRef = React.useCallback( + (node: HTMLDivElement | null) => { + rootRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref] + ); + + const setGhostRef = React.useCallback((node: HTMLDivElement | null) => { + ghostRef.current = node; + // Through the DOM so it works in React 18, which drops an `inert` prop + if (node) node.inert = true; + }, []); + + return ( +
+ + + {level && {level}} + + + {leaving && ( + + )} +
+ ); + } +); +NavDrill.displayName = 'NavDrill'; + +interface NavDrillLevelBaseProps extends React.HTMLAttributes { + /** The `NavDrill` value that shows this level; must be a direct child of `NavDrill` */ + value: string; +} + +interface NavDrillChildLevelProps extends NavDrillLevelBaseProps { + /** The `value` ‹ returns to; draws the ‹ row at the top of the level */ + parent?: string; + /** Names where you are, on the ‹ row */ + label: string; + /** The ‹ row's accessible name; must start with `label` (WCAG 2.5.3). @default "{label}, back" */ + backLabel?: string; +} + +interface NavDrillTopLevelProps extends NavDrillLevelBaseProps { + parent?: undefined; + label?: undefined; + backLabel?: undefined; +} + +type NavDrillLevelProps = NavDrillChildLevelProps | NavDrillTopLevelProps; + +const NavDrillLevel = React.forwardRef( + ({ value, parent, label, backLabel, className, children, ...props }, ref) => { + const requestBack = React.useContext(NavDrillBackContext); + + React.useEffect(() => { + if ( + typeof process !== 'undefined' && + process.env.NODE_ENV !== 'production' && + parent !== undefined && + backLabel && + label && + !backLabel.startsWith(label) + ) { + console.warn( + `NavDrillLevel: backLabel "${backLabel}" must start with label "${label}", so voice users can say what they see (WCAG 2.5.3)` + ); + } + }, [parent, label, backLabel]); + + return ( +
+ {parent !== undefined && ( + <> + +
+ + )} + {children} +
+ ); + } +); +NavDrillLevel.displayName = 'NavDrillLevel'; + +export { NavDrill, NavDrillLevel }; +export type { NavDrillLevelProps, NavDrillProps, NavDrillRequest }; diff --git a/packages/ui/src/components/nav-list/nav-list.module.css b/packages/ui/src/components/nav-list/nav-list.module.css new file mode 100644 index 00000000..281acc7a --- /dev/null +++ b/packages/ui/src/components/nav-list/nav-list.module.css @@ -0,0 +1,133 @@ +@reference '../../theme/theme.module.css'; + +/* Inherited so a level sliding in covers the one leaving */ +.nav-list { + @apply flex flex-col; + background-color: inherit; +} + +.nav-list-group { + @apply gap-(--eq-list-label-gap) pt-(--eq-list-group-gap) flex flex-col; +} + +.nav-list-group--unlabelled { + @apply pt-0; +} + +.nav-list-group-label { + @apply px-(--eq-list-item-padding-x) flex items-center gap-2; + @apply text-(length:--eq-list-label-font-size) text-text-secondary font-medium; +} + +.nav-list-group-label::after { + content: ''; + @apply bg-border-raised h-px flex-1; +} + +.nav-list-items { + @apply gap-(--eq-list-gap) m-0 flex list-none flex-col p-0; +} + +/* The tree line is the list's edge and its rows start right of it, so the line never crosses a + highlight. Inset to the parent's text: past the icon when the parent rows have one */ +.nav-list-items[data-depth='1'] { + @apply border-border-raised ml-(--eq-list-indent) border-l; +} + +.nav-list-items:has(> li > [data-slot='nav-list-item'] > .nav-list-item-prefix) + > li + .nav-list-items[data-depth='1'] { + margin-left: calc(var(--eq-list-item-icon-size) + var(--eq-list-item-gap)); +} + +.nav-list-separator { + @apply border-border-raised mt-(--eq-list-group-gap) m-0 h-0 shrink-0 border-0 border-t; +} + +.nav-list-separator:first-child, +.nav-list-separator:last-child { + @apply hidden; +} + +.nav-list-item { + @apply h-(--eq-list-item-height) min-h-(--eq-touch-target) gap-(--eq-list-item-gap) flex w-full min-w-0 items-center; + @apply px-(--eq-list-item-padding-x) rounded-md border border-transparent bg-transparent text-left no-underline; + @apply text-(length:--eq-list-item-font-size) text-text-secondary; + @apply transition-colors duration-150 motion-reduce:transition-none; +} + +.nav-list-item-prefix { + @apply size-(--eq-list-item-icon-size) flex shrink-0 items-center justify-center; +} + +.nav-list-item-prefix svg, +.nav-list-item-suffix svg, +.nav-list-item-caret svg { + @apply size-(--eq-list-item-icon-size); +} + +.nav-list-item-label { + @apply min-w-0 flex-1 truncate; +} + +.nav-list-item-suffix, +.nav-list-item-caret { + @apply flex shrink-0 items-center; +} + +.nav-list-item:hover { + @apply bg-list-item-hover text-text-primary; +} + +.nav-list-item[aria-current='page'], +.nav-list-item[data-current='page'] { + @apply bg-list-item-current text-text-primary font-medium; +} + +/* The offset keeps the ring visible against the current row's fill */ +.nav-list-item:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +/* The live level and, while it slides, the leaving one share one grid cell. The padding leaves + room for a row's focus ring inside the clip that hides the nudge */ +.nav-drill { + @apply -mx-1 grid px-1; + overflow-x: clip; + background-color: inherit; +} + +.nav-drill-level { + @apply flex min-w-0 flex-col; + grid-area: 1 / 1; + z-index: 1; + background-color: inherit; +} + +/* Its own stacking context, under the live level going in and above it going back. The clip + stops a level moved up by the old scroll from showing behind a header */ +.nav-drill-ghost { + @apply grid; + grid-area: 1 / 1; + z-index: 0; + overflow: clip; + background-color: inherit; +} + +.nav-drill-ghost[data-motion='back'] { + z-index: 2; +} + +.nav-list-item--back { + @apply text-text-secondary mt-0.5 font-normal; +} + +.nav-list-item--back .nav-list-item-prefix { + @apply text-text-tertiary; +} + +/* A border, not a fill: forced colours drop fills */ +.nav-drill-rule { + @apply border-border-raised m-0 mb-1 mt-2 h-0 shrink-0 border-0 border-t; +} diff --git a/packages/ui/src/components/nav-list/nav-list.tsx b/packages/ui/src/components/nav-list/nav-list.tsx new file mode 100644 index 00000000..f6a7b0c1 --- /dev/null +++ b/packages/ui/src/components/nav-list/nav-list.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { Slot, Slottable } from '@radix-ui/react-slot'; +import { ChevronRight } from 'lucide-react'; + +import { NavDrillContext } from '@/components/nav-list/nav-drill-context'; +import styles from '@/components/nav-list/nav-list.module.css'; +import { cn } from '@/lib/utils'; + +const ListDepthContext = React.createContext(0); + +type NavListProps = React.HTMLAttributes; + +const NavList = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +NavList.displayName = 'NavList'; + +interface NavListGroupProps extends React.HTMLAttributes { + /** Names the group's list; leave it out where a ‹ row already names what the rows belong to */ + label?: string; +} + +const NavListGroup = React.forwardRef( + ({ label, className, children, ...props }, ref) => { + const labelId = React.useId(); + const depth = React.useContext(ListDepthContext); + + const group = ( +
+ {label && ( +
+ {label} +
+ )} +
    + {children} +
+
+ ); + + // Inside another group's list, anything but an
  • is invalid HTML + return depth > 0 ?
  • {group}
  • : group; + } +); +NavListGroup.displayName = 'NavListGroup'; + +interface NavListItemProps + extends + Omit, 'prefix'>, + Pick, 'href' | 'target' | 'rel' | 'download'> { + /** The row's text, also its full title when it truncates */ + label: string; + /** An icon before the label, on top-level rows only; hidden from screen readers */ + prefix?: React.ReactNode; + /** Badges or marks after the label; screen readers read them as part of the row */ + suffix?: React.ReactNode; + /** Marks the row as the page on show */ + current?: boolean; + /** The `value` of the `NavDrillLevel` this row opens; adds the › caret */ + drill?: string; + /** Renders the row as its only child, such as a router link */ + asChild?: boolean; +} + +const NavListItem = React.forwardRef( + ( + { + label, + prefix, + suffix, + current = false, + drill, + asChild = false, + className, + children, + onClick, + ...props + }, + ref + ) => { + const drillContext = React.useContext(NavDrillContext); + const Comp: React.ElementType = asChild ? Slot : props.href ? 'a' : 'button'; + const leaving = drillContext?.leaving ?? false; + + // Runs after the child's own onClick (Slot calls the child first). defaultPrevented is not + // checked: router links prevent the default before they navigate + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (!drill || !drillContext) return; + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) + return; + drillContext.requestDrill(drill); + }; + + return ( +
  • + + {prefix && ( + + )} + + {label} + + {children} + {suffix && {suffix}} + {drill && ( + + )} + +
  • + ); + } +); +NavListItem.displayName = 'NavListItem'; + +const NavListSeparator = React.forwardRef>( + ({ className, ...props }, ref) => { + const nested = React.useContext(ListDepthContext) > 0; + const Comp = (nested ? 'li' : 'hr') as React.ElementType; + return ( + + ); + } +); +NavListSeparator.displayName = 'NavListSeparator'; + +export { NavList, NavListGroup, NavListItem, NavListSeparator }; +export type { NavListGroupProps, NavListItemProps, NavListProps }; diff --git a/packages/ui/src/components/nav/nav-panel.tsx b/packages/ui/src/components/nav/nav-panel.tsx new file mode 100644 index 00000000..08f28ffd --- /dev/null +++ b/packages/ui/src/components/nav/nav-panel.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; + +import styles from '@/components/nav/nav.module.css'; +import { cn } from '@/lib/utils'; + +interface NavPanelProps extends Omit, 'title'> { + /** Names the panel's landmark */ + 'aria-label': string; + /** Above the rows, such as a `NavSwitcher` or a heading */ + header?: React.ReactNode; +} + +// NavDrill finds this scroller by data-nav-scroller to reset it; renaming the attribute breaks that +// without an error +const NavPanel = React.forwardRef( + ({ header, className, children, ...props }, ref) => ( + + ) +); +NavPanel.displayName = 'NavPanel'; + +export { NavPanel }; +export type { NavPanelProps }; diff --git a/packages/ui/src/components/nav/nav-rail.tsx b/packages/ui/src/components/nav/nav-rail.tsx new file mode 100644 index 00000000..1fd0d4c0 --- /dev/null +++ b/packages/ui/src/components/nav/nav-rail.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { Slot, Slottable } from '@radix-ui/react-slot'; + +import styles from '@/components/nav/nav.module.css'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/tooltip/tooltip'; +import { cn } from '@/lib/utils'; + +interface NavRailProps extends React.HTMLAttributes { + /** Names the rail's landmark */ + 'aria-label': string; +} + +const NavRail = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + ) +); +NavRail.displayName = 'NavRail'; + +interface NavRailItemBaseProps extends Omit, 'href'> { + /** The item's only visible content; hidden from screen readers */ + icon: React.ReactNode; + /** The item's accessible name and its tooltip */ + label: string; + /** Marks the item as the section on show */ + current?: boolean; +} + +interface NavRailItemLinkProps extends NavRailItemBaseProps { + /** Where the item goes */ + href: string; + asChild?: false; +} + +interface NavRailItemSlotProps extends NavRailItemBaseProps { + href?: string; + /** Renders the item as its only child, such as a router link */ + asChild: true; +} + +type NavRailItemProps = NavRailItemLinkProps | NavRailItemSlotProps; + +const NavRailItem = React.forwardRef( + ({ icon, label, current = false, asChild = false, className, children, ...props }, ref) => { + const Comp = asChild ? Slot : 'a'; + + return ( +
  • + + + + + {children} + + + {label} + +
  • + ); + } +); +NavRailItem.displayName = 'NavRailItem'; + +export { NavRail, NavRailItem }; +export type { NavRailItemProps, NavRailProps }; diff --git a/packages/ui/src/components/nav/nav-switcher.tsx b/packages/ui/src/components/nav/nav-switcher.tsx new file mode 100644 index 00000000..e1739aad --- /dev/null +++ b/packages/ui/src/components/nav/nav-switcher.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; +import { ChevronsUpDown } from 'lucide-react'; + +import { buttonVariants } from '@/components/button/button'; +import styles from '@/components/nav/nav.module.css'; +import { + Select, + SelectContent, + SelectEmpty, + SelectItem, + SelectSearch, + SelectSeparator, + SelectTrigger, +} from '@/components/select/select'; +import { blockClosedTypeahead } from '@/components/select/select-typeahead'; +import { Skeleton } from '@/components/skeleton/skeleton'; +import { cn } from '@/lib/utils'; + +interface NavSwitcherOption { + /** The option's value, passed to `onValueChange` */ + value: string; + /** The option's name in the list */ + label: string; + /** Matched by search as well as the label, for identifiers a user may know but never sees */ + keywords?: string[]; +} + +interface NavSwitcherAction { + /** The action's link text */ + label: string; + /** Runs once the list has closed */ + onSelect: () => void; +} + +interface NavSwitcherLoadedProps { + /** Shows a placeholder at the switcher's height while the current item loads */ + loading?: false; + /** The current item's name, shown as the panel's heading */ + label: string; + /** Singular and lowercase, for the trigger's name: "{label}, switch {noun}" */ + noun: string; + /** Plural and lowercase, for the search and empty copy */ + collection: string; + /** The items to switch between */ + options: NavSwitcherOption[]; + /** The current item's value */ + value: string; + /** Called with the picked item's value */ + onValueChange: (value: string) => void; + /** Hover or focus on an option, for a caller that warms its destination */ + onHighlight?: (value: string) => void; + /** Hover or focus leaves an option */ + onHighlightEnd?: () => void; + /** A command at the bottom of the list, kept while searching, such as "Create Project" */ + action?: NavSwitcherAction; +} + +interface NavSwitcherLoadingProps { + loading: true; +} + +type NavSwitcherProps = NavSwitcherLoadedProps | NavSwitcherLoadingProps; + +// The heading is named by the label alone; the trigger's name starts with it and says what the +// control does (WCAG 2.5.3) +const NavSwitcherSelect = React.forwardRef( + ( + { label, noun, collection, options, value, onValueChange, onHighlight, onHighlightEnd, action }, + ref + ) => { + const labelId = React.useId(); + const actionValue = React.useId(); + const actionPending = React.useRef(false); + + if (options.length <= 1 && !action) { + return ( +

    + + {label} + +

    + ); + } + + return ( +

    + +

    + ); + } +); +NavSwitcherSelect.displayName = 'NavSwitcherSelect'; + +const NavSwitcher = React.forwardRef((props, ref) => + props.loading ? ( +
    + + Loading +
    + ) : ( + + ) +); +NavSwitcher.displayName = 'NavSwitcher'; + +export { NavSwitcher }; +export type { NavSwitcherAction, NavSwitcherOption, NavSwitcherProps }; diff --git a/packages/ui/src/components/nav/nav.module.css b/packages/ui/src/components/nav/nav.module.css new file mode 100644 index 00000000..f663ee35 --- /dev/null +++ b/packages/ui/src/components/nav/nav.module.css @@ -0,0 +1,184 @@ +@reference '../../theme/theme.module.css'; + +.nav-rail, +.nav-panel { + @apply bg-background-raised border-border-raised flex shrink-0 flex-col border-r; + /* Without size containment a long list grows the row and stretches the page */ + contain: size; +} + +.nav-rail { + @apply w-[72px]; +} + +.nav-panel { + @apply w-[200px]; +} + +/* Inherited so a drill level sliding in covers the one leaving */ +.nav-scroller { + @apply sticky top-0 flex flex-col overflow-y-auto; + max-height: min(100dvh, 100%); + background-color: inherit; +} + +.nav-rail .nav-scroller { + @apply items-center py-4; +} + +.nav-panel .nav-scroller { + @apply px-2 py-4; +} + +/* An inert ‹ belongs to the leaving level and must not tighten the header */ +.nav-scroller:has([data-slot='nav-drill-back']:not([inert] *)) > .nav-panel-header { + @apply pb-0; +} + +.nav-rail-list { + @apply m-0 flex list-none flex-col items-center gap-2 p-0; +} + +.nav-rail-item { + @apply size-(--eq-control-height) min-h-(--eq-touch-target) min-w-(--eq-touch-target) flex items-center justify-center; + @apply text-text-primary rounded-md border border-transparent; + @apply transition-colors duration-150 motion-reduce:transition-none; +} + +.nav-rail-item svg { + @apply size-5; +} + +/* Above the rows, which sit 2px under it in a drilled panel, so its focus ring stays whole */ +.nav-panel-header { + @apply text-text-primary z-3 relative m-0 truncate px-2 pb-2 text-sm font-semibold; +} + +/* The group below brings its own top padding; the header's would double it */ +.nav-panel-header:has(> .nav-switcher-heading) { + @apply overflow-visible px-0 pb-0; +} + +/* One height whether it holds the switcher, a plain name or the loading bar: the site card's 32px */ +.nav-switcher-heading { + @apply text-text-primary min-h-(--eq-touch-target) m-0 flex h-8 min-w-0 items-center text-sm font-semibold; +} + +.nav-switcher-plain { + @apply min-w-0 truncate px-2; +} + +.nav-switcher-placeholder { + @apply px-2; +} + +.nav-switcher-skeleton { + @apply h-5 w-32; +} + +.nav-switcher-loading-label { + @apply sr-only; +} + +/* Under the heading, so it outranks the size, fill and border SelectTrigger sets at rest whatever + order the two modules load in */ +.nav-switcher-heading .nav-switcher { + @apply min-h-(--eq-touch-target) flex h-8 w-full min-w-0 items-center gap-2 rounded-md border border-transparent px-2; + @apply text-text-primary bg-transparent text-left text-sm font-semibold transition-colors duration-150; + @apply motion-reduce:transition-none; +} + +/* As wide as the name, so the fade spans the text, not the row. Outranks the trigger's one-line + clamp on its spans */ +.nav-switcher-heading .nav-switcher-label { + @apply block min-w-0 truncate; +} + +/* The site card's name: the fade clipped to the text. Padding keeps descenders out of the clip. + It ends at secondary text, so the name is never dimmer than the rows */ +.nav-switcher-name { + @apply py-[0.05em] text-sm font-semibold; + background-image: linear-gradient( + to right, + var(--color-text-primary), + var(--color-text-secondary) + ); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + + /* Forced colours drop the gradient but keep the transparent fill; put the paint back */ + @media (forced-colors: active) { + background-image: none; + color: CanvasText; + -webkit-text-fill-color: currentColor; + } +} + +.nav-switcher-icon { + @apply text-text-tertiary ml-auto size-4 shrink-0; +} + +/* The trigger draws ↕ itself, so SelectTrigger's ⌄ goes. SelectTrigger always renders lucide's + ChevronDown and has no prop to drop it; Studio's SW-017 fails if it comes back */ +.nav-switcher > :global(.lucide-chevron-down) { + display: none; +} + +.nav-switcher-heading .nav-switcher:hover:not(:disabled) { + @apply bg-list-item-hover border-transparent; +} + +.nav-switcher-heading .nav-switcher[data-state='open']:not(:disabled) { + @apply bg-background border-brand-primary; +} + +.nav-switcher-heading .nav-switcher:focus-visible { + box-shadow: none; + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.nav-switcher-list { + width: 14rem; +} + +.nav-switcher-option { + @apply block truncate; +} + +.nav-switcher-footer { + @apply sticky -bottom-1 pb-1; + background: var(--color-background-overlay); +} + +/* Keeps the footer from covering the ticked item the list scrolls to on open */ +.nav-switcher-list:has(.nav-switcher-footer) { + scroll-padding-bottom: 2.5rem; +} + +/* Two classes to outrank the Select's own item layout */ +.nav-switcher-list .nav-switcher-action { + @apply justify-center; +} + +/* The action is never ticked, so its check column only pushes the label off centre. SelectItem + leaves that column an empty span; Studio's SW-019 fails if that changes */ +.nav-switcher-action > span:empty { + @apply hidden; +} + +.nav-rail-item:hover { + @apply bg-list-item-hover text-text-primary; +} + +.nav-rail-item[aria-current='page'] { + @apply bg-list-item-current text-text-primary font-medium; +} + +/* The offset keeps the ring visible against the current item's fill */ +.nav-rail-item:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} diff --git a/packages/ui/src/components/select/select-typeahead.ts b/packages/ui/src/components/select/select-typeahead.ts new file mode 100644 index 00000000..23595c11 --- /dev/null +++ b/packages/ui/src/components/select/select-typeahead.ts @@ -0,0 +1,8 @@ +import type { KeyboardEvent } from 'react'; + +// Radix selects by typeahead while the list is closed, which in a switcher navigates; a letter +// typed on the trigger, where focus lands after a switch, must not change the page (WCAG 3.2.2) +export const blockClosedTypeahead = (event: KeyboardEvent) => { + const typed = event.key.length === 1 && event.key !== ' '; + if (typed && !event.ctrlKey && !event.metaKey && !event.altKey) event.preventDefault(); +}; diff --git a/packages/ui/src/scripts/build-decisions.js b/packages/ui/src/scripts/build-decisions.js new file mode 100644 index 00000000..292ac293 --- /dev/null +++ b/packages/ui/src/scripts/build-decisions.js @@ -0,0 +1,40 @@ +/** + * Generates the scoped decisions from the global ones. + * + * `theme-decisions.css` is the single authored copy. The scoped build differs + * only in where the variables land, `.root` instead of `html`, so deriving it + * here keeps them off the host page without a second copy to drift. + */ +import { readFileSync, writeFileSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const SOURCE = resolve(__dirname, '../theme/theme-decisions.css'); +const OUTPUT = resolve(__dirname, '../theme/theme-decisions-scoped.css'); + +const BANNER = `/* ========================================================= + SCOPED DECISIONS — GENERATED FILE, DO NOT EDIT + + Generated from \`theme-decisions.css\` by + \`src/scripts/build-decisions.js\`. Edit that file instead, then run + \`pnpm build:decisions\`. + ========================================================= */`; + +export function scopeDecisions(source) { + const start = source.search(/^html \{/m); + if (start === -1) { + throw new Error(`Expected an "html {" rule in ${SOURCE}. Did the global root selector change?`); + } + const body = source + .slice(start) + .replace(/^(\s*)html\[data-equality-theme='dark'\]/gm, "$1[data-equality-theme='dark'] .root") + .replace(/^(\s*)html(?= \{)/gm, '$1.root'); + return `${BANNER}\n\n${body}`; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + writeFileSync(OUTPUT, scopeDecisions(readFileSync(SOURCE, 'utf8'))); + console.log(`Generated ${OUTPUT} from ${SOURCE}`); +} diff --git a/packages/ui/src/theme/global-theme-config.css b/packages/ui/src/theme/global-theme-config.css index 95560c13..1164f1aa 100644 --- a/packages/ui/src/theme/global-theme-config.css +++ b/packages/ui/src/theme/global-theme-config.css @@ -10,6 +10,7 @@ @import './generated/tailwind-tokens.css'; @import './theme-base.css'; +@import './theme-decisions.css'; @import './theme-utilities.css'; /* NOTE: preflight is deliberately NOT imported here. It is an opt-in import so diff --git a/packages/ui/src/theme/theme-base.css b/packages/ui/src/theme/theme-base.css index b6dc3d05..dc105ace 100644 --- a/packages/ui/src/theme/theme-base.css +++ b/packages/ui/src/theme/theme-base.css @@ -87,3 +87,8 @@ } } } + +@theme inline reference { + --color-list-item-current: var(--color-list-item-current); + --color-list-item-hover: var(--color-list-item-hover); +} diff --git a/packages/ui/src/theme/theme-decisions-scoped.css b/packages/ui/src/theme/theme-decisions-scoped.css new file mode 100644 index 00000000..0669b646 --- /dev/null +++ b/packages/ui/src/theme/theme-decisions-scoped.css @@ -0,0 +1,42 @@ +/* ========================================================= + SCOPED DECISIONS — GENERATED FILE, DO NOT EDIT + + Generated from `theme-decisions.css` by + `src/scripts/build-decisions.js`. Edit that file instead, then run + `pnpm build:decisions`. + ========================================================= */ + +.root { + /* list-item: one selectable row (NavListItem; later DropdownMenuItem, SelectItem, CommandItem) */ + --eq-list-item-height: calc(var(--spacing) * 9); /* 36 */ + --eq-list-item-padding-x: calc(var(--spacing) * 2); /* 8 */ + --eq-list-item-gap: calc(var(--spacing) * 2); /* 8, icon to text */ + --eq-list-item-icon-size: calc(var(--spacing) * 4); /* 16 */ + --eq-list-item-font-size: var(--text-sm); /* 14 */ + /* list: the rows together */ + --eq-list-gap: calc(var(--spacing) * 0.5); /* 2, between items */ + --eq-list-group-gap: calc(var(--spacing) * 3); /* 12, above a group label */ + --eq-list-label-gap: calc(var(--spacing) * 1); /* 4, label to its first item */ + --eq-list-label-font-size: var(--text-xs); /* 12 */ + --eq-list-indent: calc(var(--spacing) * 3); /* 12, child indent when items have no icons */ + /* control: a stand-alone element */ + --eq-control-height: calc(var(--spacing) * 10); /* 40: rail item; later Button, Input */ + /* touch: the minimum tap target */ + --eq-touch-target: 0px; + + /* The current fill must stay stronger than the hover fill: Studio checks the pair with APCA */ + --color-list-item-current: color-mix(in oklch, var(--color-text-primary) 10%, transparent); + --color-list-item-hover: color-mix(in oklch, var(--color-text-primary) 6%, transparent); +} + +[data-equality-theme='dark'] .root { + --color-list-item-current: color-mix(in oklch, var(--color-text-primary) 14%, transparent); + --color-list-item-hover: color-mix(in oklch, var(--color-text-primary) 8%, transparent); +} + +/* Touch is not a size. */ +@media (pointer: coarse) { + .root { + --eq-touch-target: 2.75rem; /* 44, WCAG 2.5.8 */ + } +} diff --git a/packages/ui/src/theme/theme-decisions.css b/packages/ui/src/theme/theme-decisions.css new file mode 100644 index 00000000..54de0ce1 --- /dev/null +++ b/packages/ui/src/theme/theme-decisions.css @@ -0,0 +1,40 @@ +/* Never declare @theme { --spacing } here: it rewrites every utility to var(--spacing, …) and + changes every component's compiled CSS. */ + +/* DECISIONS: Equality's names, --eq-{thing}-{property}. Parts read only these: h-(--eq-list-item-height). + theme-decisions-scoped.css is generated from this file: run `pnpm build:decisions` after + changing it, or tests/theme-css.test.ts fails. */ +html { + /* list-item: one selectable row (NavListItem; later DropdownMenuItem, SelectItem, CommandItem) */ + --eq-list-item-height: calc(var(--spacing) * 9); /* 36 */ + --eq-list-item-padding-x: calc(var(--spacing) * 2); /* 8 */ + --eq-list-item-gap: calc(var(--spacing) * 2); /* 8, icon to text */ + --eq-list-item-icon-size: calc(var(--spacing) * 4); /* 16 */ + --eq-list-item-font-size: var(--text-sm); /* 14 */ + /* list: the rows together */ + --eq-list-gap: calc(var(--spacing) * 0.5); /* 2, between items */ + --eq-list-group-gap: calc(var(--spacing) * 3); /* 12, above a group label */ + --eq-list-label-gap: calc(var(--spacing) * 1); /* 4, label to its first item */ + --eq-list-label-font-size: var(--text-xs); /* 12 */ + --eq-list-indent: calc(var(--spacing) * 3); /* 12, child indent when items have no icons */ + /* control: a stand-alone element */ + --eq-control-height: calc(var(--spacing) * 10); /* 40: rail item; later Button, Input */ + /* touch: the minimum tap target */ + --eq-touch-target: 0px; + + /* The current fill must stay stronger than the hover fill: Studio checks the pair with APCA */ + --color-list-item-current: color-mix(in oklch, var(--color-text-primary) 10%, transparent); + --color-list-item-hover: color-mix(in oklch, var(--color-text-primary) 6%, transparent); +} + +html[data-equality-theme='dark'] { + --color-list-item-current: color-mix(in oklch, var(--color-text-primary) 14%, transparent); + --color-list-item-hover: color-mix(in oklch, var(--color-text-primary) 8%, transparent); +} + +/* Touch is not a size. */ +@media (pointer: coarse) { + html { + --eq-touch-target: 2.75rem; /* 44, WCAG 2.5.8 */ + } +} diff --git a/packages/ui/src/theme/theme.module.css b/packages/ui/src/theme/theme.module.css index 5a08f45b..b43a2930 100644 --- a/packages/ui/src/theme/theme.module.css +++ b/packages/ui/src/theme/theme.module.css @@ -10,5 +10,6 @@ @import './tokens.css'; @import './theme-base.css'; +@import './theme-decisions-scoped.css'; @import './theme-preflight-scoped.css' layer(base); @import './theme-utilities.css'; diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 0ec56bf1..3c606bea 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -1,4 +1,6 @@ declare global { + var process: { env: { NODE_ENV?: string } }; + interface Window { __equalityIsUsingLocalStorage: boolean; __equalityTheme: string; diff --git a/packages/ui/tests/animation-stub.ts b/packages/ui/tests/animation-stub.ts new file mode 100644 index 00000000..52afe2e9 --- /dev/null +++ b/packages/ui/tests/animation-stub.ts @@ -0,0 +1,59 @@ +import { afterEach, vi } from 'vitest'; + +// jsdom has neither the Web Animations API nor matchMedia. Each animate call is recorded with a +// `finished` promise the test resolves, as the browser would after the duration +export interface RecordedAnimation { + target: Element; + keyframes: Keyframe[]; + options: KeyframeAnimationOptions; + cancel: ReturnType; + finish(): void; +} + +export const animateCalls: RecordedAnimation[] = []; + +let reducedMotion = false; + +export function setReducedMotion(value: boolean) { + reducedMotion = value; +} + +export function finishAll() { + animateCalls.forEach((animation) => animation.finish()); +} + +Element.prototype.animate = function (this: Element, keyframes, options) { + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const finished = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + finished.catch(() => {}); + const cancel = vi.fn(() => reject(new DOMException('Aborted', 'AbortError'))); + animateCalls.push({ + target: this, + keyframes: keyframes as Keyframe[], + options: options as KeyframeAnimationOptions, + cancel, + finish: resolve, + }); + return { finished, cancel } as unknown as Animation; +}; + +window.matchMedia = (query: string) => + ({ + matches: query.includes('prefers-reduced-motion: reduce') ? reducedMotion : false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }) as MediaQueryList; + +afterEach(() => { + animateCalls.length = 0; + reducedMotion = false; +}); diff --git a/packages/ui/tests/compile-css.ts b/packages/ui/tests/compile-css.ts new file mode 100644 index 00000000..30d7c18c --- /dev/null +++ b/packages/ui/tests/compile-css.ts @@ -0,0 +1,11 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import tailwindcss from '@tailwindcss/postcss'; +import postcss from 'postcss'; + +// Resolved from the working directory: under jsdom, paths built from import.meta.url fail with ENOENT +export async function compileCss(file: string): Promise { + const from = path.resolve(process.cwd(), file); + const result = await postcss([tailwindcss()]).process(await readFile(from, 'utf8'), { from }); + return result.css; +} diff --git a/packages/ui/tests/icon-button.test.tsx b/packages/ui/tests/icon-button.test.tsx new file mode 100644 index 00000000..a817a007 --- /dev/null +++ b/packages/ui/tests/icon-button.test.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { IconButton } from '@/components/icon-button/icon-button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/tooltip/tooltip'; + +describe('IconButton', () => { + it('passes its ref to the button', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBe(screen.getByRole('button', { name: 'Close' })); + }); + + it('passes its ref to the link when it has href', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBe(screen.getByRole('link', { name: 'Open' })); + }); + + it('takes a tooltip trigger’s props when it has href', () => { + render( + + + + + + Open the docs + + + ); + expect(screen.getByRole('link', { name: 'Open' }).getAttribute('data-state')).toBe('closed'); + }); +}); diff --git a/packages/ui/tests/nav-drill.test.tsx b/packages/ui/tests/nav-drill.test.tsx new file mode 100644 index 00000000..426925be --- /dev/null +++ b/packages/ui/tests/nav-drill.test.tsx @@ -0,0 +1,489 @@ +import * as React from 'react'; +import { renderToString } from 'react-dom/server'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { NavDrill, NavDrillLevel, type NavDrillProps } from '@/components/nav-list/nav-drill'; +import { NavList, NavListGroup, NavListItem } from '@/components/nav-list/nav-list'; + +import { animateCalls, finishAll, setReducedMotion } from './animation-stub'; + +function levels() { + return [ + + + + + + , + + + + + + , + + + + + , + , + ]; +} + +function Uncontrolled(props: Omit) { + return ( + + + {levels()} + + + ); +} + +// Holds `value` in state. `follow` decides whether a request changes it; the "go" buttons change it +// with no request, as a route change would +function Controlled({ + initial = 'projects', + follow = true, + onValueChange, + children = levels(), + ...props +}: Omit & { initial?: string; follow?: boolean }) { + const [view, setView] = React.useState(initial); + return ( + <> + + { + onValueChange?.(next, request); + if (follow) setView(next); + }} + {...props} + > + {children} + + + {['projects', 'ben', 'x'].map((target) => ( + + ))} + + ); +} + +const ghost = () => document.querySelector('[data-slot="nav-drill-ghost"]'); +const liveLevels = () => + [...document.querySelectorAll('[data-slot="nav-drill-level"]')].filter( + (el) => !ghost()?.contains(el) + ); + +describe('NavDrill', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('server-renders the current level only', () => { + const html = renderToString( + + {levels()} + + ); + expect(html).toContain('title="Ben overview"'); + expect(html).not.toContain('title="Overview"'); + expect(html).not.toContain('title="General"'); + }); + + it('uncontrolled: a drill row opens its level and ‹ returns to the parent', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + expect(screen.queryByRole('link', { name: 'Overview' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + expect(screen.getByRole('link', { name: 'Overview' })).toBeTruthy(); + }); + + it('controlled: a drill row calls onValueChange(value, "drill") and does not change level by itself', () => { + const onValueChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(onValueChange).toHaveBeenCalledWith('ben', 'drill'); + expect(screen.getByRole('link', { name: 'Overview' })).toBeTruthy(); + expect(screen.queryByRole('link', { name: 'Ben overview' })).toBeNull(); + }); + + it('controlled: ‹ calls onValueChange(parent, "back")', () => { + const onValueChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + expect(onValueChange).toHaveBeenCalledWith('projects', 'back'); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + }); + + it('‹ goes to the named parent after a deep link', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Settings, back' })); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + }); + + it('renders no ‹ on a level without a parent', () => { + render( + + + + + + ); + expect(screen.queryByRole('button')).toBeNull(); + expect(document.querySelector('hr')).toBeNull(); + }); + + it('draws ‹ at the top of a level with a parent, named "{label}, back"', () => { + render(); + const level = liveLevels()[0]; + const back = screen.getByRole('button', { name: 'Ben Project, back' }); + expect(level.firstElementChild).toBe(back); + expect(back.getAttribute('data-slot')).toBe('nav-drill-back'); + expect(back.nextElementSibling?.tagName).toBe('HR'); + }); + + it('warns when backLabel does not start with label', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + + + + ); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('Ben Project'); + }); + + it('focuses the first row after a drill into a level with no parent', () => { + render( + + + + + + + + + + + + + + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open B' })); + expect(document.activeElement).toBe(screen.getByRole('link', { name: 'B row' })); + }); + + it('names ‹ with backLabel when given', () => { + render( + + + + + + + ); + const back = screen.getByRole('button', { name: 'Ben Project, back to Projects' }); + expect(back.textContent).toBe('Ben Project'); + }); + + it('drills in with two animate calls: a 12px nudge, the old level gone in the first third, 300ms with push easing', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(animateCalls).toHaveLength(2); + const [current, previous] = animateCalls; + expect(current.target).toBe(liveLevels()[0]); + expect(current.target.textContent).toContain('Ben overview'); + expect(current.keyframes).toEqual([ + { transform: 'translateX(12px)', opacity: 0 }, + { transform: 'translateX(0)', opacity: 1 }, + ]); + expect(previous.target).toBe(ghost()); + expect(previous.keyframes).toEqual([ + { transform: 'translateX(0)', opacity: 1 }, + { opacity: 0, offset: 0.35 }, + { transform: 'translateX(-12px)', opacity: 0 }, + ]); + for (const call of animateCalls) { + expect(call.options).toEqual({ duration: 300, easing: 'cubic-bezier(.2, .8, .2, 1)' }); + } + }); + + it('goes back with the mirrored nudge and the leaving level above', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + const [current, previous] = animateCalls; + expect(current.keyframes).toEqual([ + { transform: 'translateX(-12px)', opacity: 0 }, + { transform: 'translateX(0)', opacity: 1 }, + ]); + expect(previous.keyframes).toEqual([ + { transform: 'translateX(0)', opacity: 1 }, + { opacity: 0, offset: 0.35 }, + { transform: 'translateX(12px)', opacity: 0 }, + ]); + expect(ghost()!.getAttribute('data-motion')).toBe('back'); + }); + + // A finished animation drops its effect, so the leaving level jumps back to full opacity in + // place. Going back it sits on top, so it must be hidden before the next paint, not when + // React gets round to unmounting it + it('hides the leaving level in the same microtask its animations finish', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + const leaving = ghost()!; + finishAll(); + for (let i = 0; i < 5; i++) await Promise.resolve(); + expect(leaving.isConnected && leaving.style.visibility !== 'hidden').toBe(false); + await act(async () => {}); + expect(ghost()).toBeNull(); + }); + + it('removes the leaving level once both animations finish', async () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(ghost()?.textContent).toContain('Overview'); + const root = document.querySelector('[data-slot="nav-drill"]')!; + expect(root.getAttribute('data-motion')).toBe('drill'); + animateCalls[0].finish(); + await act(async () => {}); + expect(ghost()).not.toBeNull(); + animateCalls[1].finish(); + await waitFor(() => expect(ghost()).toBeNull()); + expect(root.hasAttribute('data-motion')).toBe(false); + }); + + it('swaps without motion when view changes with no request', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'go:ben' })); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + expect(animateCalls).toHaveLength(0); + expect(ghost()).toBeNull(); + }); + + it('clears the request on any view change', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + fireEvent.click(screen.getByRole('button', { name: 'go:x' })); + fireEvent.click(screen.getByRole('button', { name: 'go:ben' })); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + expect(animateCalls).toHaveLength(0); + expect(ghost()).toBeNull(); + }); + + it('enter-on-drill-row-requests', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + screen.getByRole('link', { name: 'Ben Project' }).focus(); + await user.keyboard('{Enter}'); + expect(onValueChange).toHaveBeenCalledWith('ben', 'drill'); + expect(animateCalls).toHaveLength(2); + }); + + it('swaps without motion under reduced motion', () => { + setReducedMotion(true); + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + expect(animateCalls).toHaveLength(0); + expect(ghost()).toBeNull(); + }); + + it('swaps without motion when animate={false}', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(animateCalls).toHaveLength(0); + expect(ghost()).toBeNull(); + }); + + it('one-current-row-mid-slide', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(document.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + expect(ghost()!.querySelector('[data-current="page"]')?.textContent).toContain('Overview'); + expect(ghost()!.getAttribute('aria-hidden')).toBe('true'); + expect((ghost() as HTMLElement & { inert: boolean }).inert).toBe(true); + }); + + it('second-request-mid-slide', async () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + const first = animateCalls.slice(0, 2); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + expect(screen.getByRole('link', { name: 'Overview' })).toBeTruthy(); + expect(first.every((call) => call.cancel.mock.calls.length === 1)).toBe(true); + expect(animateCalls).toHaveLength(4); + finishAll(); + await waitFor(() => expect(ghost()).toBeNull()); + expect(liveLevels()).toHaveLength(1); + }); + + it('view-without-level-renders-empty', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'go:x' })); + expect(document.querySelectorAll('[data-slot="nav-drill-level"]')).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: 'go:ben' })); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + }); + + it('resets the nearest [data-nav-scroller] on every view change', () => { + render( +
    + +
    + ); + const scroller = screen.getByTestId('scroller'); + Object.defineProperty(scroller, 'scrollTop', { value: 120, writable: true }); + fireEvent.click(screen.getByRole('button', { name: 'go:ben' })); + expect(scroller.scrollTop).toBe(0); + scroller.scrollTop = 80; + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + expect(scroller.scrollTop).toBe(0); + }); + + it('keeps the leaving level where it was on screen', () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: Element + ) { + const top = this.textContent?.includes('Ben overview') ? 40 : 100; + return { + top, + bottom: top, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: top, + toJSON() {}, + } as DOMRect; + }); + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + const leavingLevel = ghost()!.querySelector('[data-slot="nav-drill-level"]')!; + expect(leavingLevel.style.marginTop).toBe('60px'); + }); + + it('focuses ‹ after a drill it saw', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Ben Project, back' })); + }); + + it('focuses the row you came from after ‹', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + expect(document.activeElement).toBe(screen.getByRole('link', { name: 'Ben Project' })); + }); + + it('focuses the current row after ‹ when no row leads back', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Orphan, back' })); + expect(document.activeElement).toBe(screen.getByRole('link', { name: 'Overview' })); + }); + + it('focuses-row-mounted-later-by-a-child', async () => { + let release!: () => void; + function LateRows() { + const [ready, setReady] = React.useState(false); + React.useEffect(() => { + release = () => setReady(true); + }, []); + return ready ? ( + + + + ) : null; + } + render( + + + + + + + + + ); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + expect(document.activeElement).toBe(document.body); + act(() => release()); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByRole('link', { name: 'Ben Project' })) + ); + }); + + it('leaves focus alone after ‹ if focus moved outside the drill', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + const outside = screen.getByRole('button', { name: 'go:projects' }); + outside.focus(); + fireEvent.click(outside); + expect(screen.getByRole('link', { name: 'Overview' })).toBeTruthy(); + expect(document.activeElement).toBe(outside); + }); + + it('leaves focus alone on a view change it did not request', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'go:ben' })); + expect(document.activeElement).toBe(document.body); + }); + it('drops a pending ‹ focus request when the user acts outside the drill', async () => { + let release!: () => void; + function LateRows() { + const [ready, setReady] = React.useState(false); + React.useEffect(() => { + release = () => setReady(true); + }, []); + return ready ? ( + + + + ) : null; + } + render( + <> + + + + + + + + + A page link + + ); + fireEvent.click(screen.getByRole('button', { name: 'Ben Project, back' })); + fireEvent.pointerDown(screen.getByRole('link', { name: 'A page link' })); + act(() => release()); + await act(async () => {}); + expect(document.activeElement).toBe(document.body); + }); + + it('drops a pending drill request when the user acts outside the drill', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Ben Project' })); + fireEvent.pointerDown(screen.getByRole('button', { name: 'go:ben' })); + fireEvent.click(screen.getByRole('button', { name: 'go:ben' })); + expect(screen.getByRole('link', { name: 'Ben overview' })).toBeTruthy(); + expect(animateCalls).toHaveLength(0); + expect(document.activeElement).toBe(document.body); + }); +}); diff --git a/packages/ui/tests/nav-list.test.tsx b/packages/ui/tests/nav-list.test.tsx new file mode 100644 index 00000000..bfea0de0 --- /dev/null +++ b/packages/ui/tests/nav-list.test.tsx @@ -0,0 +1,233 @@ +import * as React from 'react'; +import { renderToString } from 'react-dom/server'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Badge } from '@/components/badge/badge'; +import { NavDrillContext } from '@/components/nav-list/nav-drill-context'; +import { + NavList, + NavListGroup, + NavListItem, + NavListSeparator, +} from '@/components/nav-list/nav-list'; + +function withDrill(ui: React.ReactNode, requestDrill = vi.fn(), leaving = false) { + return { + requestDrill, + ...render( + {ui} + ), + }; +} + +describe('NavList rows', () => { + it('server-renders every row part', () => { + const html = renderToString( + + + } suffix={3} current /> + + + + + ); + expect(html).toContain('href="/"'); + expect(html).toContain('aria-current="page"'); + expect(html).toContain('data-drill="policies"'); + expect(html).toContain(' { + render( + + + + + + + + + + ); + expect(screen.getByRole('link', { name: 'Home' }).getAttribute('href')).toBe('/home'); + const docs = screen.getByRole('link', { name: 'Docs' }); + expect(docs.hasAttribute('data-router-link')).toBe(true); + expect(docs.getAttribute('data-slot')).toBe('nav-list-item'); + const settings = screen.getByRole('button', { name: 'Settings' }); + expect(settings.getAttribute('type')).toBe('button'); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + }); + + it('marks the current row with aria-current="page" and no other', () => { + render( + + + + + + + ); + expect(screen.getByRole('link', { name: 'Docs' }).getAttribute('aria-current')).toBe('page'); + expect(screen.getByRole('link', { name: 'Home' }).hasAttribute('aria-current')).toBe(false); + expect(document.querySelectorAll('[aria-current]')).toHaveLength(1); + }); + + it('reads the suffix out and hides the drill caret', () => { + render( + + + } + suffix={3} + drill="p" + /> + + + ); + const row = screen.getByRole('button', { name: /^Policies\s*3$/ }); + expect(row).toBeTruthy(); + const caret = row.querySelector('.lucide-chevron-right')!; + expect(caret.closest('[aria-hidden="true"]')).not.toBeNull(); + expect(screen.getByTestId('prefix').closest('[aria-hidden="true"]')).not.toBeNull(); + expect(screen.getByText('3').closest('[aria-hidden="true"]')).toBeNull(); + }); + + it('names a list by its group label', () => { + render( + + + + + + + + + ); + const [labelled, unlabelled] = screen.getAllByRole('list'); + const id = labelled.getAttribute('aria-labelledby')!; + expect(document.getElementById(id)?.textContent).toBe('Applied Policies'); + expect(screen.getByRole('list', { name: 'Applied Policies' })).toBe(labelled); + expect(unlabelled.hasAttribute('aria-labelledby')).toBe(false); + }); + + it('takes no size', () => { + // @ts-expect-error NavList has one row size + const { container } = render(); + expect(container.firstElementChild!.hasAttribute('data-size')).toBe(false); + }); + + it('wraps a nested group in its own list item and marks it nested', () => { + render( + + + + + + + + + ); + const outer = screen.getByRole('list', { name: 'Docs' }); + const inner = screen.getByRole('list', { name: 'Components' }); + expect(outer.getAttribute('data-depth')).toBe('0'); + expect(inner.getAttribute('data-depth')).toBe('1'); + expect([...outer.children].every((child) => child.tagName === 'LI')).toBe(true); + expect(inner.closest('li')?.parentElement).toBe(outer); + }); + + it('puts a separator inside a group in its own list item', () => { + render( + + + + + + + + ); + const list = screen.getByRole('list', { name: 'Pages' }); + expect([...list.children].every((child) => child.tagName === 'LI')).toBe(true); + expect(screen.getByRole('separator').tagName).toBe('LI'); + }); + + it('drill-without-nav-drill: renders › and still runs onClick', () => { + const onClick = vi.fn(); + render( + + + + + + ); + const row = screen.getByRole('button', { name: 'Policies' }); + expect(row.querySelector('.lucide-chevron-right')).not.toBeNull(); + fireEvent.click(row); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('requests a drill on a plain click', () => { + const { requestDrill } = withDrill( + + + + ); + fireEvent.click(screen.getByRole('button', { name: 'Policies' })); + expect(requestDrill).toHaveBeenCalledWith('p'); + }); + + it('requests-drill-after-child-prevents-default', () => { + const { requestDrill } = withDrill( + + + e.preventDefault()} /> + + + ); + fireEvent.click(screen.getByRole('link', { name: 'Policies' })); + expect(requestDrill).toHaveBeenCalledWith('p'); + }); + + it('makes no request on a ctrl, meta, shift, alt or middle click', () => { + const { requestDrill } = withDrill( + + + + ); + const row = screen.getByRole('link', { name: 'Policies' }); + fireEvent.click(row, { ctrlKey: true }); + fireEvent.click(row, { metaKey: true }); + fireEvent.click(row, { shiftKey: true }); + fireEvent.click(row, { altKey: true }); + fireEvent.click(row, { button: 1 }); + expect(requestDrill).not.toHaveBeenCalled(); + }); + + it('marks the current row with data-current inside a leaving level', () => { + withDrill( + + + , + vi.fn(), + true + ); + const row = screen.getByRole('link', { name: 'Docs' }); + expect(row.hasAttribute('aria-current')).toBe(false); + expect(row.getAttribute('data-current')).toBe('page'); + }); + + it('truncates a long label and keeps the full text in title', () => { + const long = 'A very long policy name that will not fit in two hundred pixels'; + render( + + + + + + ); + const label = screen.getByText(long); + expect(label.getAttribute('title')).toBe(long); + expect(label.className).toMatch(/nav-list-item-label/); + }); +}); diff --git a/packages/ui/tests/nav.test.tsx b/packages/ui/tests/nav.test.tsx new file mode 100644 index 00000000..6584ff39 --- /dev/null +++ b/packages/ui/tests/nav.test.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import { renderToString } from 'react-dom/server'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { NavPanel } from '@/components/nav/nav-panel'; +import { NavRail, NavRailItem } from '@/components/nav/nav-rail'; +import { NavSwitcher } from '@/components/nav/nav-switcher'; + +const OPTIONS = [ + { value: 'ben', label: 'Ben Project' }, + { value: 'ada', label: 'Ada Project' }, +]; + +describe('Nav shell', () => { + it('server-renders the rail, panel and switcher placeholder', () => { + const html = renderToString( + <> + + } label="Projects" href="/projects" current /> + + }> +

    rows

    +
    + + ); + expect(html).toContain('aria-label="Main"'); + expect(html).toContain('href="/projects"'); + expect(html).toMatch(/skeleton/); + expect(html).toContain('data-nav-scroller'); + }); + + it('marks the loading switcher busy and says so to screen readers', () => { + const { container } = render(); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(screen.getByText('Loading')).toBeTruthy(); + }); + + it('names the panel landmark and draws no heading of its own', () => { + // @ts-expect-error NavPanel takes no title; a heading goes in header + render(); + expect(screen.getByRole('navigation', { name: 'Settings' })).toBeTruthy(); + expect(screen.queryByRole('heading')).toBeNull(); + }); + + it('marks the current rail item and names it by label', () => { + render( + + } label="Projects" href="/projects" current /> + } label="Agents" href="/agents" /> + + ); + expect(screen.getByRole('navigation', { name: 'Main' })).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Projects' }).getAttribute('aria-current')).toBe( + 'page' + ); + expect(screen.getByRole('link', { name: 'Agents' }).hasAttribute('aria-current')).toBe(false); + }); + + it('renders the rail item as its child with asChild', () => { + render( + + } label="Projects" asChild> +
    + + + ); + expect(screen.getByRole('link', { name: 'Projects' }).hasAttribute('data-router-link')).toBe( + true + ); + }); + + it('requires href unless the rail item is asChild', () => { + // @ts-expect-error without href the takes no focus and is not a link + const item = } label="Projects" />; + expect(item).toBeTruthy(); + }); + + it('marks its scroller with data-nav-scroller', () => { + render( + +

    rows

    +
    + ); + const scroller = screen.getByText('rows').parentElement!; + expect(scroller.hasAttribute('data-nav-scroller')).toBe(true); + expect(screen.getByRole('navigation').contains(scroller)).toBe(true); + }); + + it('renders a plain heading when there is one option and no action', () => { + render( + {}} + /> + ); + expect(screen.getByRole('heading', { name: 'Ben Project' })).toBeTruthy(); + expect(screen.queryByRole('combobox')).toBeNull(); + }); + + it('names the switcher trigger by the label and what it does', () => { + const ref = React.createRef(); + render( + {}} + /> + ); + const trigger = screen.getByRole('combobox', { name: 'Ben Project, switch project' }); + expect(ref.current).toBe(trigger); + expect(screen.getByRole('heading', { name: 'Ben Project' })).toBeTruthy(); + }); +}); diff --git a/packages/ui/tests/select.test.tsx b/packages/ui/tests/select.test.tsx index 874a5411..80ee8d7d 100644 --- a/packages/ui/tests/select.test.tsx +++ b/packages/ui/tests/select.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -16,6 +16,7 @@ import { SelectValue, useSelectSearchQuery, } from '@/components/select/select'; +import { blockClosedTypeahead } from '@/components/select/select-typeahead'; const REGIONS = [ { label: 'Americas', countries: ['Brazil', 'Canada'] }, @@ -352,3 +353,25 @@ describe('Select', () => { }); }); }); + +describe('blockClosedTypeahead', () => { + it('prevents letters but not Space or shortcuts', () => { + const onKeyDown = vi.fn(blockClosedTypeahead); + render( + + ); + const button = screen.getByRole('button'); + const press = (init: KeyboardEventInit) => { + fireEvent.keyDown(button, init); + return (onKeyDown.mock.lastCall![0] as React.KeyboardEvent).defaultPrevented; + }; + expect(press({ key: 'a' })).toBe(true); + expect(press({ key: ' ' })).toBe(false); + expect(press({ key: 'Enter' })).toBe(false); + expect(press({ key: 'a', ctrlKey: true })).toBe(false); + expect(press({ key: 'a', metaKey: true })).toBe(false); + expect(press({ key: 'a', altKey: true })).toBe(false); + }); +}); diff --git a/packages/ui/tests/theme-css.test.ts b/packages/ui/tests/theme-css.test.ts new file mode 100644 index 00000000..b25b0fe4 --- /dev/null +++ b/packages/ui/tests/theme-css.test.ts @@ -0,0 +1,51 @@ +import { readFile } from 'node:fs/promises'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { scopeDecisions } from '../src/scripts/build-decisions.js'; +import { compileCss } from './compile-css'; + +let scoped: string; + +beforeAll(async () => { + scoped = await compileCss('src/theme/theme.module.css'); +}, 30_000); + +// Top-level rules with their nested blocks: Tailwind adds `@supports` fallbacks inside each colour rule +function rulesDeclaring(css: string, name: string): { selector: string; body: string }[] { + const rules: { selector: string; body: string }[] = []; + let depth = 0; + let start = 0; + let open = 0; + for (let i = 0; i < css.length; i++) { + if (css[i] === '{') { + if (depth === 0) open = i; + depth++; + } else if (css[i] === '}') { + depth--; + if (depth === 0) { + rules.push({ selector: css.slice(start, open).trim(), body: css.slice(open + 1, i) }); + start = i + 1; + } + } + } + return rules.filter((r) => new RegExp(`${name}\\s*:`).test(r.body)); +} + +describe('scoped build', () => { + it('declares none of its decisions outside .root, so none reach the host page', () => { + const leaks = rulesDeclaring(scoped, '--(eq-[\\w-]+|color-list-item-(current|hover))').filter( + (r) => /\bhtml\b|:root/.test(`${r.selector} ${r.body}`) + ); + expect(leaks.map((r) => r.selector)).toEqual([]); + }); +}); + +describe('theme-decisions-scoped.css', () => { + it('is up to date with theme-decisions.css; run pnpm build:decisions if not', async () => { + const [source, generated] = await Promise.all([ + readFile('src/theme/theme-decisions.css', 'utf8'), + readFile('src/theme/theme-decisions-scoped.css', 'utf8'), + ]); + expect(generated).toBe(scopeDecisions(source)); + }); +});