{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "expandable-search", "title": "Expandable Search", "description": "A 48px search button that expands into a text field on focus, with ink-derived surface colours matching the footer palette.", "dependencies": [ "class-variance-authority", "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/icons.json" ], "files": [ { "path": "src/components/expandable-search.tsx", "content": "'use client'\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { IconSearch } from '@/icons/search'\n\n/**\n * Surface colours the search can be themed with — the footer's thirteen-name\n * vocabulary (see `footerColors` in footer.tsx) plus `default`, the nswds-app\n * grey-100 chip that headers embed on a white surface.\n *\n * Every derived colour resolves from a single `--search-ink` token, so a\n * variant only declares its surface utilities and that one value. This map\n * replaces the FOUR parallel 14-entry cva tables in the nswds-app source\n * (surface / collapsed / button / focus): each of those tables was a manual\n * projection of the same underlying ink, so they are collapsed into the\n * derivations below.\n *\n * The derivations are written literally here (once) rather than per-variant:\n * Tailwind scans source text for class names, so a template-built arbitrary\n * property would never be emitted. Mirrors footer.tsx (`--footer-*`) and\n * link.tsx (`--link-halo`).\n *\n * Expanded text/surface pairs are the ones verified for footer.tsx: every\n * light pair clears WCAG 2.2 AA (1.4.3, 4.5:1) — the worst are `primary-600`\n * (4.57:1) and `accent-600` (5.18:1) — and in dark mode all clear AAA (worst\n * 13.6:1). The collapsed chip renders no text, only the icon, which paints in\n * the same ink.\n */\nconst expandableSearchVariants = cva(\n [\n // `group/search` is named so the submit button's group-focus-within hook\n // can't collide with an ancestor's bare `group` (e.g. inside a card).\n 'group/search relative inline-block rounded-sm select-none',\n // Halo for the submit button's hover / the form's focus-within state, a\n // heavier press halo, and the placeholder colour — all mixed down from\n // the ink. 70% ink keeps the placeholder related to but visibly lighter\n // than the entered text (the source used grey-700 / current at 70%\n // opacity). Dilution costs contrast, though, so the percentage routes\n // through --search-placeholder-pct and the surfaces whose 70% composite\n // fails 1.4.3 raise it to 100% in the variant map below. Verified by\n // alpha-compositing the mix over each surface (light mode): at 70%\n // primary-600 is 3.0:1, accent-600 3.1:1 and accent-400 4.48:1 — all\n // AA failures, cured by full ink (4.57 / 5.18 / 8.34:1). The eleven\n // surfaces that keep 70% all clear 4.5:1, the closest being\n // primary-400 (4.57:1) and grey-400 (4.58:1); dark mode's worst 70%\n // composite is 7.0:1.\n '[--search-halo:color-mix(in_oklch,var(--search-ink)_10%,transparent)]',\n '[--search-halo-active:color-mix(in_oklch,var(--search-ink)_18%,transparent)]',\n '[--search-placeholder:color-mix(in_oklch,var(--search-ink)_var(--search-placeholder-pct,70%),transparent)]',\n ],\n {\n variants: {\n // Ink values use the RAW masterbrand tokens (--primary-800, --grey-800,\n // --accent-800) rather than Tailwind's --color-* bridge aliases. Tailwind\n // v4 tree-shakes an unreferenced @theme key, and referencing one from\n // inside an arbitrary property is not a usage signal — the raw tokens are\n // plain :root declarations from @nswds/tokens and always resolve.\n // (--color-white is safe: `text-white` below is a real utility, so that\n // key is always emitted.) Same reasoning as footer.tsx and header.tsx.\n //\n // Dark mode deepens every surface onto the same family's dark steps —\n // -800→-950, -600→-900, -400→-850, -200→-800, white→grey-900 — per\n // footer.tsx's family mapping, and `default` follows header.tsx's\n // `light` surface: grey-100→grey-850. Ink goes white on all fourteen.\n //\n // primary-600, accent-600 and accent-400 set --search-placeholder-pct\n // to 100%: their 70% placeholder composite fails AA (3.0 / 3.1 /\n // 4.48:1 — see the derivation comment above), so the placeholder\n // renders full ink there. The override is deliberately not\n // dark-scoped: every dark 70% composite clears 7:1, so 100% in dark\n // is merely a slightly stronger placeholder, not worth a second\n // declaration.\n //\n // `default` is the one variant whose ink is NOT its text colour: the\n // nswds-app chip pairs grey-800 entered text with a primary-blue icon\n // and primary-800/10 halos. Declaring ink as primary-800 reproduces the\n // icon and halos exactly; the text colour is a surface utility, so it\n // stays grey-800. (Side effect, documented: the placeholder mixes from\n // primary-800 instead of the source's grey-700 — a subtle blue shift.)\n variant: {\n default:\n 'bg-grey-100 text-grey-800 [--search-ink:var(--primary-800)] dark:bg-grey-850 dark:text-white dark:[--search-ink:var(--color-white)]',\n 'primary-800':\n 'bg-primary-800 text-white [--search-ink:var(--color-white)] dark:bg-primary-950',\n 'primary-600':\n 'bg-primary-600 text-white [--search-ink:var(--color-white)] [--search-placeholder-pct:100%] dark:bg-primary-900',\n 'primary-400':\n 'bg-primary-400 text-primary-800 [--search-ink:var(--primary-800)] dark:bg-primary-850 dark:text-white dark:[--search-ink:var(--color-white)]',\n 'primary-200':\n 'bg-primary-200 text-primary-800 [--search-ink:var(--primary-800)] dark:bg-primary-800 dark:text-white dark:[--search-ink:var(--color-white)]',\n 'grey-800': 'bg-grey-800 text-white [--search-ink:var(--color-white)] dark:bg-grey-950',\n 'grey-600': 'bg-grey-600 text-white [--search-ink:var(--color-white)] dark:bg-grey-900',\n 'grey-400':\n 'bg-grey-400 text-grey-800 [--search-ink:var(--grey-800)] dark:bg-grey-850 dark:text-white dark:[--search-ink:var(--color-white)]',\n 'grey-200':\n 'bg-grey-200 text-grey-800 [--search-ink:var(--grey-800)] dark:bg-grey-800 dark:text-white dark:[--search-ink:var(--color-white)]',\n 'accent-800':\n 'bg-accent-800 text-white [--search-ink:var(--color-white)] dark:bg-accent-950',\n 'accent-600':\n 'bg-accent-600 text-white [--search-ink:var(--color-white)] [--search-placeholder-pct:100%] dark:bg-accent-900',\n 'accent-400':\n 'bg-accent-400 text-accent-800 [--search-ink:var(--accent-800)] [--search-placeholder-pct:100%] dark:bg-accent-850 dark:text-white dark:[--search-ink:var(--color-white)]',\n 'accent-200':\n 'bg-accent-200 text-accent-800 [--search-ink:var(--accent-800)] dark:bg-accent-800 dark:text-white dark:[--search-ink:var(--color-white)]',\n white:\n 'bg-white text-grey-800 [--search-ink:var(--grey-800)] dark:bg-grey-900 dark:text-white dark:[--search-ink:var(--color-white)]',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n },\n)\n\ntype ExpandableSearchVariant = NonNullable['variant']>\n\n// One focus treatment for the input and the submit button, INSET rather than\n// the source's offset ring. The source ringed each variant in its own surface\n// colour with a hardcoded white offset — on the default chip that was\n// ring-grey-100 around a grey-100 chip on a white page: effectively invisible\n// (a 2.4.7 Focus Visible / 2.4.13 Focus Appearance failure). An outward\n// indicator can only guarantee contrast against the page, which this component\n// cannot know; the ink is guaranteed to contrast with the surface (that is its\n// whole job), so the indicator is drawn 2px INSIDE the chip in ink, where the\n// contrast maths always holds.\nconst searchFocusClassName =\n 'focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--search-ink)'\n\ntype ExpandableSearchContextValue = {\n value: string\n setValue: (value: string) => void\n formHasFocusWithin: boolean\n variant: ExpandableSearchVariant\n}\n\nconst ExpandableSearchContext = React.createContext(null)\n\ntype ExpandableSearchProps = React.ComponentPropsWithoutRef<'form'> &\n VariantProps & {\n /**\n * Called with the current query when the form submits (Enter in the field\n * or a click on the search button). Not called if a supplied `onSubmit`\n * prevented the event's default.\n */\n onAction?: (value: string) => void\n /** Initial query. A non-empty value renders the field expanded. */\n defaultValue?: string\n ref?: React.Ref\n }\n\ntype ExpandableSearchButtonProps = Omit, 'type'> & {\n [key: `data-${string}`]: string | number | boolean | undefined\n}\n\ntype ExpandableSearchFieldProps = Omit<\n React.ComponentPropsWithoutRef<'input'>,\n // `type` is pinned to `search`; `value`/`defaultValue` are owned by the\n // `ExpandableSearch` context (seed it via the root's `defaultValue` prop).\n 'type' | 'value' | 'defaultValue'\n> & {\n /**\n * Accessible name for the input, rendered as a visually-hidden `