{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "site-search",
"title": "Site Search",
"description": "Cmd/Ctrl-K site search palette on the Base UI autocomplete and dialog primitives — grouped results, keyboard filtering and an onSelect callback for framework routing.",
"dependencies": [
"@base-ui/react",
"clsx",
"tailwind-merge"
],
"registryDependencies": [
"https://ui.digital.nsw.gov.au/registry/r/theme.json",
"https://ui.digital.nsw.gov.au/registry/r/button.json",
"https://ui.digital.nsw.gov.au/registry/r/icons.json"
],
"files": [
{
"path": "src/components/site-search.tsx",
"content": "'use client'\n\nimport { Autocomplete } from '@base-ui/react/autocomplete'\nimport { Dialog } from '@base-ui/react/dialog'\nimport React from 'react'\n\nimport { IconSearch } from '@/icons/search'\nimport { cn } from '@/lib/utils'\n\nimport { Button } from '@/components/button'\n\n/** A single searchable destination. `keywords` extend matching beyond the title. */\ntype SiteSearchItem = {\n title: string\n href: string\n keywords?: string[]\n}\n\n/** A titled section of results, rendered with a muted group heading. */\ntype SiteSearchGroup = {\n title: string\n items: SiteSearchItem[]\n}\n\ntype SiteSearchProps = {\n /** The searchable site map, as titled groups of items. */\n groups: SiteSearchGroup[]\n /**\n * Called with the chosen item on click or Enter, after the palette closes.\n * Navigation is the APP's job — call your router here (e.g. next/navigation's\n * `router.push(item.href)`). The design system ships no framework code, so it\n * never navigates itself; this mirrors the DS-wide framework-free rule\n * (see `Link` / `LinkProvider`).\n */\n onSelect: (item: SiteSearchItem) => void\n /** Whether the palette is open. Use when controlled. */\n open?: boolean\n /** Called whenever the palette asks to open or close. */\n onOpenChange?: (open: boolean) => void\n /** Whether the palette is initially open (uncontrolled). */\n defaultOpen?: boolean\n /**\n * Wire the global Cmd/Ctrl-K toggle on `document`, with cleanup on unmount.\n * Defaults to true. Only one shortcut-enabled SiteSearch should be mounted\n * per page, or each press toggles all of them.\n */\n shortcut?: boolean\n /**\n * Accessible name for the palette, applied as `aria-label` to the dialog\n * panel, the search input and the default trigger. Defaults to\n * `'Search site'`. Mirrors ExpandableSearch's `label` prop — localise it\n * rather than hardcoding English into consumers' pages.\n */\n label?: string\n /** Placeholder for the search input. */\n placeholder?: string\n /** Message shown when no items match the query. */\n emptyMessage?: string\n /**\n * The element that opens the palette. Defaults to a ghost icon `Button`\n * named by `label`. Pass an element to replace it — it is composed via Base\n * UI's `render` prop, so it inherits the trigger behaviour and ARIA — or\n * `null` to render no trigger at all (open via `open` or the shortcut).\n * A custom trigger owns its own accessible name — `label` is applied as\n * `aria-label` to the default icon button only, since overriding a visible\n * label that way fails WCAG 2.2, 2.5.3 Label in Name.\n *\n * Conditional triggers must resolve to `null`, not `false`: write\n * `cond ? : null`, since `cond && ` yields `false`, which\n * Base UI's `render` prop rejects with an invalid-element error. TypeScript\n * catches it (`false` is not assignable here); plain-JS consumers see the\n * error at render.\n */\n trigger?: React.ReactElement | null\n /**\n * Extra content rendered at the foot of the panel, below the results —\n * e.g. shortcut hints or a \"browse all\" link.\n */\n children?: React.ReactNode\n /** Extra classes for the centred panel. */\n className?: string\n}\n\n/**\n * Cmd/Ctrl-K command-palette site search: a centred modal panel with a\n * filter-as-you-type input over a grouped list of destinations.\n *\n * Ported from nswds-app's `MobileSearch`, rebuilt framework-free: the app\n * version composed cmdk's `Command*` widgets with next/navigation's router;\n * this version composes Base UI's Autocomplete (the combobox/listbox pattern)\n * inside Base UI's Dialog, and hands the chosen item to `onSelect` instead of\n * navigating. The Autocomplete renders in `inline` mode — its input and list\n * sit statically inside the dialog panel with no popup of their own — which is\n * Base UI's documented composition for palettes (it avoids any portal /\n * focus-trap interplay between the two primitives; the always-`open` inline\n * root unmounts with the dialog, so the query and highlight reset on close).\n *\n * Accessibility contract (inherited, not hand-rolled):\n * - Dialog provides the modal behaviour: focus trap, scroll lock, Escape and\n * backdrop-press dismissal, and focus restoration to the trigger on close.\n * The panel is named via `aria-label` (WCAG 2.2, 4.1.2).\n * - Autocomplete provides the combobox pattern: the input is announced as a\n * combobox controlling a listbox, arrow keys move `aria-activedescendant`\n * highlight while DOM focus stays in the input, Enter activates the\n * highlighted item, and the empty state is announced politely.\n * - The trigger (default or custom) renders through `Dialog.Trigger`, so it\n * carries `aria-haspopup=\"dialog\"` / `aria-expanded` automatically.\n * - Result rows are at least 44px tall (2.5.8 Target Size, AAA-sized).\n * - Panel and backdrop transitions honour `prefers-reduced-motion` (2.3.3).\n *\n * Departures from the nswds-app source, beyond the rebuild above:\n * - `groups`/`onSelect` replace the app's `NavigationItem[]` + router: the\n * data shape is explicit (`title`/`href`/`keywords`) instead of the app's\n * nav config, and untitled/unlinked entries can't exist by construction\n * (the source filtered them at render time).\n * - Filtering matches `title` AND `keywords`, case-insensitively, via the\n * Autocomplete `filter` prop. Base UI's `useFilter` helper only matches one\n * string per item, so a custom predicate is the documented escape hatch for\n * multi-field matching; groups with no matching items are dropped by the\n * primitive, headings included.\n * - The Cmd/Ctrl-K listener can be disabled (`shortcut={false}`) — required\n * for pages that mount more than one instance. It still toggles, per the\n * source.\n */\nfunction SiteSearch({\n groups,\n onSelect,\n open: openProp,\n onOpenChange,\n defaultOpen = false,\n shortcut = true,\n label = 'Search site',\n placeholder = 'Type to search across the site...',\n emptyMessage = 'No results found.',\n trigger,\n children,\n className,\n}: SiteSearchProps) {\n const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)\n const isControlled = openProp !== undefined\n const open = isControlled ? openProp : uncontrolledOpen\n\n const inputRef = React.useRef(null)\n\n // Latest-value refs so the document-level shortcut listener (bound once per\n // `shortcut` value) never acts on a stale open state or stale callbacks.\n // They are written in a passive effect (not during render, per\n // react-hooks/refs) — safe because they are only read from event listeners,\n // which always fire after the commit that synced them.\n const openRef = React.useRef(open)\n const requestOpenChangeRef = React.useRef<(next: boolean) => void>(() => {})\n\n const requestOpenChange = (next: boolean) => {\n if (isControlled) {\n // Dedupe against the render-authoritative prop only — never write\n // openRef optimistically here. An optimistic write would let a parent\n // that vetoes a request (ignores it, so no re-render runs the resync\n // effect) permanently swallow every later identical request. Accepted\n // trade-off: a same-tick duplicate close (Escape reaching both the\n // Autocomplete and the Dialog) may reach `onOpenChange` twice with the\n // same value — idempotent for a setState parent, and correctness in\n // the veto case beats dedupe cosmetics.\n if (open === next) {\n return\n }\n onOpenChange?.(next)\n return\n }\n // Uncontrolled, the component owns the state, so the optimistic write is\n // safe (the commit below confirms it) and dedupes the double-report case\n // (both primitives announcing the same close) down to one callback.\n if (openRef.current === next) {\n return\n }\n openRef.current = next\n setUncontrolledOpen(next)\n onOpenChange?.(next)\n }\n\n React.useEffect(() => {\n // `open` (not `next` from an event) is authoritative here: in controlled\n // mode this effect is the ref's ONLY writer, so the shortcut listener\n // always toggles from the last value the parent actually rendered.\n openRef.current = open\n requestOpenChangeRef.current = requestOpenChange\n })\n\n React.useEffect(() => {\n if (!shortcut) {\n return undefined\n }\n const handleKeyDown = (event: KeyboardEvent) => {\n if (\n event.key.toLowerCase() === 'k' &&\n (event.metaKey || event.ctrlKey) &&\n !event.altKey &&\n !event.defaultPrevented\n ) {\n event.preventDefault()\n // Toggle, per the nswds-app source: the same chord opens and closes.\n requestOpenChangeRef.current(!openRef.current)\n }\n }\n document.addEventListener('keydown', handleKeyDown)\n return () => document.removeEventListener('keydown', handleKeyDown)\n }, [shortcut])\n\n const handleSelect = (item: SiteSearchItem) => {\n // Close first, then hand over — mirrors the source's `runCommand`, so the\n // app's navigation starts from a closed palette.\n requestOpenChange(false)\n onSelect(item)\n }\n\n const filter = React.useCallback((item: SiteSearchItem, query: string) => {\n const q = query.trim().toLowerCase()\n if (q === '') {\n return true\n }\n if (item.title.toLowerCase().includes(q)) {\n return true\n }\n return (item.keywords ?? []).some((keyword) => keyword.toLowerCase().includes(q))\n }, [])\n\n const defaultTrigger = (\n \n )\n\n return (\n requestOpenChange(next)}>\n {trigger === null ? null : (\n \n )}\n\n \n \n \n item.title}\n autoHighlight\n // Inline mode: the list renders statically inside the dialog panel\n // (no Autocomplete portal/positioner), with `open` pinned per the\n // Base UI docs. Opening/closing belongs to the Dialog; the whole\n // Autocomplete unmounts with it, resetting query and highlight.\n inline\n open\n onOpenChange={(nextOpen, eventDetails) => {\n // The pinned-open inline Autocomplete still *requests* closes;\n // forward the ones that should dismiss the palette. Other\n // reasons (input-clear, focus-out) must not close the dialog —\n // clearing the query or tabbing within the trap isn't dismissal.\n if (\n !nextOpen &&\n (eventDetails.reason === 'escape-key' || eventDetails.reason === 'item-press')\n ) {\n requestOpenChange(false)\n }\n }}\n >\n
\n \n \n
\n\n {/* Base UI renders Empty's children only while the list is empty;\n the padding lives on the inner div so the (always-mounted, for\n polite announcements) outer element paints nothing otherwise. */}\n \n