{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-search",
  "title": "Command search",
  "description": "A search field that opens a Cmd+K palette on the native dialog, with grouped results, a preview pane for the active result, and a no-results state with suggestions.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@manniche/badge"
  ],
  "files": [
    {
      "path": "registry/manniche/command-search/command-search.tsx",
      "content": "// CommandSearch: a site search that opens as a Cmd+K palette. The trigger is a search field set into the page (a real\n// GET form, so it still searches without JavaScript); typing in it, pressing Enter, clicking it, Cmd/Ctrl+K or \"/\"\n// opens a modal with the results grouped (Free, Pro, Templates, … from each item's `group`) and a preview of the\n// active one beside them. With nothing found it echoes the query and offers a few searches that do find something.\n//\n// Keyboard: Up and Down move through the results (and wrap), Home and End jump to the first and last while the field\n// is empty, Enter opens the active result, Cmd/Ctrl+Enter copies its install line, Esc closes. Focus goes back to\n// whatever opened the search. \"/\" is a single-key shortcut, so `shortcuts={false}` turns it off (WCAG 2.1.4); it is\n// also ignored while typing in a field. Cmd/Ctrl+K and the arrow keys always work.\n//\n// Screen readers: the field is a combobox over a listbox, the active result is announced through\n// aria-activedescendant, and the number of hits is read from a polite status inside the dialog once typing pauses.\n//\n// Motion: a click on the trigger lifts the field into the palette and unfolds the results under it; other clicks fade\n// and rise the palette in 220 ms. Opening or closing from the keyboard is instant, and so is everything under reduced\n// motion. Only transform, opacity and filter move.\nimport { Search, X } from 'lucide-react'\nimport {\n  Fragment,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from 'react'\nimport { cn } from '@/lib/utils'\nimport { Badge } from '@/registry/manniche/badge/badge'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\nexport type CommandSearchItem = {\n  /** Unique id, such as \"flux-image\". Shown in mono under the title and matched by the search. */\n  id: string\n  /** The name people know it by, such as \"Flux Image\". */\n  title: string\n  /** One or two sentences. Shown in the preview and searched for words of three letters or more. */\n  description?: string\n  /** The group it is listed under, such as \"Free\", \"Pro\" or \"Templates\". Groups show in the order they first appear. */\n  group: string\n  /** A category, shown as a neutral badge and matched from its start. */\n  category?: string\n  /** Shows a Free or Pro badge, and lets \"free\" and \"pro\" find it. */\n  tier?: 'free' | 'pro'\n  /** Marks it as recently added in the preview. */\n  isNew?: boolean\n  /** An image for the thumbnail and the preview. Without one, its initials and name stand in. */\n  poster?: string\n  /** The install line shown in the preview; Cmd/Ctrl+Enter copies it. */\n  install?: string\n  /** Where Enter or a click goes when there is no `onSelect`. */\n  href?: string\n  /** Extra words that should find it, matched like the description. */\n  keywords?: string[]\n}\n\nexport type CommandSearchLabels = {\n  /** The trigger field's placeholder; `{count}` becomes the number of items. Default \"Search components\". */\n  placeholder?: string\n  /** Names the trigger field and its search landmark. Give a second search on the page its own. Default \"Search\". */\n  searchLabel?: string\n  /** Read after the trigger field's name. Default \"When you type here, the search opens in a dialog with the results.\" */\n  triggerHint?: string\n  /** Between the Cmd+K and \"/\" hints. Default \"or\". */\n  or?: string\n  /** Names the dialog. Default \"Search\". */\n  dialog?: string\n  /** The palette field's placeholder, also its name. Default \"Name, category, free or pro\". */\n  inputPlaceholder?: string\n  /** The close button. Default \"Close search\". */\n  close?: string\n  /** Names the list of results. Default \"Results\". */\n  results?: string\n  /** Names the preview pane. Default \"Preview\". */\n  preview?: string\n  /** Tier badges. Defaults \"Free\", \"Pro\" and \"New\". */\n  free?: string\n  pro?: string\n  new?: string\n  /** A group's count with an empty field. Default \"{shown} of {total}\". */\n  groupCount?: string\n  /** A group's count while searching. Default \"{shown} of {total} hits\". */\n  groupHits?: string\n  /** The footer count with an empty field. Default \"{count} in total\". */\n  total?: string\n  /** The footer count while searching. Default \"{count} hits of {total}\". */\n  hitsOf?: string\n  /** Read after typing pauses. Defaults \"{count} hits\" and \"{count} hit\". */\n  hits?: string\n  hit?: string\n  /** The no-results heading; `{query}` becomes what was typed. Default \"Nothing matches “{query}”\". */\n  noneTitle?: string\n  /** Under it when there are suggestions. Default \"Try a shorter word, or one of these:\". */\n  noneText?: string\n  /** Under it when there are none. Default \"Try a shorter word.\" */\n  noneTextAlone?: string\n  /** Read when nothing matches. Default \"Nothing matches {query}.\" */\n  noneLive?: string\n  /** Names the suggestion buttons. Default \"Suggestions\". */\n  suggestions?: string\n  /** Shown when there are no items at all. Default \"Nothing to search yet.\" */\n  empty?: string\n  /** Footer key hints. Defaults \"select\", \"open\", \"copy install\" and \"close\". */\n  keySelect?: string\n  keyOpen?: string\n  keyCopy?: string\n  keyClose?: string\n  /** After Cmd/Ctrl+Enter; `{id}` becomes the item's id. Defaults \"Copied: {id}\" and \"Could not copy\". */\n  copied?: string\n  copyFailed?: string\n}\n\nexport type CommandSearchProps = {\n  /** Everything that can be found. Counts in the palette are taken from this list. */\n  items: CommandSearchItem[]\n  /** Called with the chosen item after the palette has closed. Without it, the item's `href` is followed. */\n  onSelect?: (item: CommandSearchItem) => void\n  /** Your own preview for the active item, in place of the poster, title, description, badges and install line. */\n  renderPreview?: (item: CommandSearchItem) => ReactNode\n  /**\n   * Searches to offer when nothing matches, shown with their hit counts (ones with no hits are left out, at most\n   * four). A function gets the query, for mapping words in another language. Default: the commonest categories.\n   */\n  suggestions?: string[] | ((query: string) => string[])\n  /** Single-key shortcuts: \"/\" opens the search. Turn off for WCAG 2.1.4; Cmd/Ctrl+K keeps working. Default true. */\n  shortcuts?: boolean\n  /** Listen on the whole page for Cmd/Ctrl+K (and \"/\"). Turn off on a second search on the same page. Default true. */\n  hotkeys?: boolean\n  /** Results per group while searching. Default 8. */\n  limit?: number\n  /** Results per group with an empty field. Default 5. */\n  initialLimit?: number\n  /** Controls whether the palette is open. Leave it out to let the component keep track. */\n  open?: boolean\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Controls the text in the field (the trigger and the palette share it). */\n  query?: string\n  /** The starting text, such as `?q=` from the URL. */\n  defaultQuery?: string\n  onQueryChange?: (query: string) => void\n  /** Where the trigger form sends `?q=` without JavaScript. Default: the current page. */\n  action?: string\n  /** The query parameter's name. Default \"q\". */\n  name?: string\n  /** Words for other languages. */\n  labels?: CommandSearchLabels\n  /** Classes for the trigger field. */\n  className?: string\n}\n\ntype Mode = 'key' | 'pointer' | 'lift'\ntype Hit = { item: CommandSearchItem; score: number; index: number }\n\nconst EASE = 'cubic-bezier(0.23, 1, 0.32, 1)'\nconst noop = () => () => {}\nconst isMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)\n\n// Another modal is up when a native modal dialog is open, or a visible dialog from a library such as Radix\n// (role=dialog, alertdialog or aria-modal). `own` and what is inside it do not count.\nfunction otherDialogOpen(own: Element | null = null) {\n  const found = document.querySelectorAll('dialog[open], [role=\"dialog\"], [role=\"alertdialog\"], [aria-modal=\"true\"]')\n  return [...found].some((el) => el !== own && !own?.contains(el) && el.getClientRects().length > 0)\n}\n\n// Only web and mail links are followed, so an item's href can never run script.\nfunction safeHref(href: string) {\n  try {\n    const url = new URL(href, location.href)\n    return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : null\n  } catch {\n    return null\n  }\n}\n\nfunction fill(template: string, values: Record<string, ReactNode>): ReactNode {\n  return template.split(/(\\{\\w+\\})/).map((part, i) => {\n    const key = /^\\{(\\w+)\\}$/.exec(part)?.[1]\n    return <Fragment key={i}>{key && key in values ? values[key] : part}</Fragment>\n  })\n}\nconst fillText = (template: string, values: Record<string, string | number>) =>\n  template.replace(/\\{(\\w+)\\}/g, (m, key: string) => (key in values ? String(values[key]) : m))\n\nconst termsOf = (q: string) => q.trim().toLowerCase().split(/\\s+/).filter(Boolean)\nconst initials = (id: string) =>\n  id\n    .split(/[-_\\s]+/)\n    .map((s) => s[0] ?? '')\n    .join('')\n    .slice(0, 3)\n\n// Each word must match somewhere; where it matches sets how high the item ranks.\nfunction score(item: CommandSearchItem, terms: string[], tierWords: string[]) {\n  const id = item.id.toLowerCase()\n  const title = item.title.toLowerCase()\n  const cat = item.category?.toLowerCase() ?? ''\n  const text = [item.description, ...(item.keywords ?? [])].join(' ').toLowerCase()\n  let total = 0\n  for (const w of terms) {\n    let k = 0\n    if (id.startsWith(w) || title.startsWith(w)) k = 5\n    else if (id.includes(w) || title.includes(w)) k = 4\n    else if (cat && cat.startsWith(w)) k = 3\n    else if (tierWords.some((t) => t === w || (w.length > 2 && t.startsWith(w)))) k = 2\n    else if (w.length > 2 && text.includes(w)) k = 1\n    if (!k) return 0\n    total += k\n  }\n  return total\n}\n\n// The words that find an item by its group or tier, such as \"pro\".\nconst groupWords = (item: CommandSearchItem, tierWords: string[]) =>\n  [item.group.toLowerCase(), item.tier ? tierWords[item.tier === 'free' ? 0 : 1] : ''].filter(Boolean)\n\nconst countHits = (items: CommandSearchItem[], q: string, tierWords: string[]) => {\n  const terms = termsOf(q)\n  return items.filter((i) => score(i, terms, groupWords(i, tierWords))).length\n}\n\n// A long command breaks between words first, then after a slash. Each piece is kept whole (so flux-image or\n// --header never splits at its hyphen) unless it is wider than the line on its own.\nconst breakable = (s: string) =>\n  s.split(/( +)/).map((word, w) =>\n    word.trim() ? (\n      <Fragment key={w}>\n        {word.split(/(?<=\\/)(?!\\/)/).map((part, i) => (\n          <span key={i} className=\"inline-block max-w-full\">\n            {part}\n          </span>\n        ))}\n      </Fragment>\n    ) : (\n      word\n    ),\n  )\n\nfunction marked(id: string, terms: string[]) {\n  const lower = id.toLowerCase()\n  const w = terms.find((t) => lower.includes(t))\n  if (!w) return id\n  const at = lower.indexOf(w)\n  return (\n    <>\n      {id.slice(0, at)}\n      <mark className=\"bg-transparent text-foreground underline decoration-foreground/40 underline-offset-[3px]\">\n        {id.slice(at, at + w.length)}\n      </mark>\n      {id.slice(at + w.length)}\n    </>\n  )\n}\n\nconst CAP =\n  'bg-muted shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_8%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.14),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)] dark:shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_8%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.5),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)]'\nconst WELL =\n  'bg-background shadow-[inset_0_1px_2px_rgb(0_0_0/0.08),inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_7%,transparent)] dark:shadow-[inset_0_1px_2px_rgb(0_0_0/0.55),inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_6%,transparent)]'\nconst RING =\n  'outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card'\nconst DOTS =\n  'bg-[radial-gradient(color-mix(in_oklab,var(--foreground)_9%,transparent)_1px,transparent_1.3px)] bg-background'\n\nfunction Kbd({ children, large }: { children: ReactNode; large?: boolean }) {\n  return (\n    <kbd\n      className={cn(\n        'inline-grid flex-none place-items-center font-mono leading-none font-medium text-muted-foreground',\n        large ? 'h-[26px] min-w-[26px] rounded-[7px] px-1.5 text-xs' : 'h-5 min-w-5 rounded-[5px] px-[5px] text-[11px]',\n        CAP,\n      )}\n    >\n      {children}\n    </kbd>\n  )\n}\n\n/** A search field that opens a Cmd+K palette with grouped results and a preview of the active one. */\nexport function CommandSearch({\n  items,\n  onSelect,\n  renderPreview,\n  suggestions,\n  shortcuts = true,\n  hotkeys = true,\n  limit = 8,\n  initialLimit = 5,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  query: queryProp,\n  defaultQuery = '',\n  onQueryChange,\n  action,\n  name = 'q',\n  labels = {},\n  className,\n}: CommandSearchProps) {\n  const L = {\n    placeholder: 'Search components',\n    searchLabel: 'Search',\n    triggerHint: 'When you type here, the search opens in a dialog with the results.',\n    or: 'or',\n    dialog: 'Search',\n    inputPlaceholder: 'Name, category, free or pro',\n    close: 'Close search',\n    results: 'Results',\n    preview: 'Preview',\n    free: 'Free',\n    pro: 'Pro',\n    new: 'New',\n    groupCount: '{shown} of {total}',\n    groupHits: '{shown} of {total} hits',\n    total: '{count} in total',\n    hitsOf: '{count} hits of {total}',\n    hits: '{count} hits',\n    hit: '{count} hit',\n    noneTitle: 'Nothing matches “{query}”',\n    noneText: 'Try a shorter word, or one of these:',\n    noneTextAlone: 'Try a shorter word.',\n    noneLive: 'Nothing matches {query}.',\n    suggestions: 'Suggestions',\n    empty: 'Nothing to search yet.',\n    keySelect: 'select',\n    keyOpen: 'open',\n    keyCopy: 'copy install',\n    keyClose: 'close',\n    copied: 'Copied: {id}',\n    copyFailed: 'Could not copy',\n    ...labels,\n  }\n\n  const [ownOpen, setOwnOpen] = useState(defaultOpen)\n  const isOpen = openProp ?? ownOpen\n  const setOpen = (v: boolean) => {\n    setOwnOpen(v)\n    onOpenChange?.(v)\n  }\n  const [ownQuery, setOwnQuery] = useState(defaultQuery)\n  const query = queryProp ?? ownQuery\n  const setQuery = (v: string) => {\n    setOwnQuery(v)\n    onQueryChange?.(v)\n  }\n\n  const [active, setActive] = useState(0)\n  const [live, setLive] = useState('')\n  const [toast, setToast] = useState<{ text: ReactNode; on: boolean }>({ text: '', on: false })\n  const reduced = useReducedMotion()\n  const mac = useSyncExternalStore(noop, isMac, () => true)\n\n  const fieldId = useId()\n  const hintId = useId()\n  const listId = useId()\n  const formRef = useRef<HTMLFormElement>(null)\n  const fieldRef = useRef<HTMLInputElement>(null)\n  const dialogRef = useRef<HTMLDialogElement>(null)\n  const scrimRef = useRef<HTMLDivElement>(null)\n  const boxRef = useRef<HTMLDivElement>(null)\n  const rowRef = useRef<HTMLDivElement>(null)\n  const sheetRef = useRef<HTMLDivElement>(null)\n  const inputRef = useRef<HTMLInputElement>(null)\n  const listRef = useRef<HTMLDivElement>(null)\n  const mode = useRef<Mode>('pointer')\n  const returnTo = useRef<HTMLElement | null>(null)\n  const pressedOn = useRef<EventTarget | null>(null)\n  const closing = useRef(false)\n  const scrollToActive = useRef(false)\n  const toastTimer = useRef<ReturnType<typeof setTimeout>>(undefined)\n\n  // ── Results ──\n  const tierWords = useMemo(() => [L.free.toLowerCase(), L.pro.toLowerCase()], [L.free, L.pro])\n  const terms = useMemo(() => termsOf(query), [query])\n  const searching = terms.length > 0\n\n  const { groups, options, found } = useMemo(() => {\n    const byGroup = new Map<string, { hits: Hit[]; size: number }>()\n    items.forEach((item, index) => {\n      const g = byGroup.get(item.group) ?? { hits: [], size: 0 }\n      g.size++\n      const s = searching ? score(item, terms, groupWords(item, tierWords)) : 1\n      if (s) g.hits.push({ item, score: s, index })\n      byGroup.set(item.group, g)\n    })\n    const cap = searching ? limit : initialLimit\n    const groups: { label: string; shown: CommandSearchItem[]; matched: number; size: number }[] = []\n    let found = 0\n    for (const [label, g] of byGroup) {\n      found += g.hits.length\n      if (!g.hits.length) continue\n      g.hits.sort((a, b) => b.score - a.score || a.index - b.index)\n      groups.push({ label, shown: g.hits.slice(0, cap).map((h) => h.item), matched: g.hits.length, size: g.size })\n    }\n    return { groups, options: groups.flatMap((g) => g.shown), found }\n  }, [items, terms, searching, limit, initialLimit, tierWords])\n\n  const none = options.length === 0\n  const current = none ? -1 : Math.min(active, options.length - 1)\n  const activeItem = none ? undefined : options[current]\n  const anyInstall = items.some((i) => i.install)\n\n  const suggested = useMemo(() => {\n    if (!none) return []\n    let words: string[]\n    if (typeof suggestions === 'function') words = suggestions(query.trim())\n    else if (suggestions) words = suggestions\n    else {\n      const freq = new Map<string, number>()\n      for (const i of items) if (i.category) freq.set(i.category, (freq.get(i.category) ?? 0) + 1)\n      words = [...freq].sort((a, b) => b[1] - a[1]).map(([c]) => c)\n    }\n    return [...new Set(words)]\n      .map((w) => [w, countHits(items, w, tierWords)] as const)\n      .filter(([, n]) => n > 0)\n      .slice(0, 4)\n  }, [none, suggestions, query, items, tierWords])\n\n  const liveText = !searching\n    ? ''\n    : none\n      ? fillText(L.noneLive, { query: query.trim() })\n      : fillText(found === 1 ? L.hit : L.hits, { count: found })\n\n  // ── Opening and closing ──\n  const slabVisible = () => {\n    const r = formRef.current?.getBoundingClientRect()\n    return !!r && r.bottom > 0 && r.top < innerHeight && !matchMedia('(max-width: 720px)').matches\n  }\n\n  // `from` is where focus goes back to; the search field when the field itself opened it.\n  const openWith = (how: Mode, from?: HTMLElement | null) => {\n    if (isOpen) return\n    mode.current = how\n    returnTo.current = from ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)\n    setOpen(true)\n  }\n\n  const close = async (how: 'key' | 'pointer') => {\n    const d = dialogRef.current\n    if (!d?.open || closing.current) return\n    closing.current = true\n    const runs: Animation[] = []\n    if (!reduced && how !== 'key') {\n      const opts = { easing: EASE, fill: 'forwards' as const }\n      runs.push(scrimRef.current!.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, ...opts }))\n      if (mode.current === 'lift' && slabVisible()) {\n        const s = formRef.current!.getBoundingClientRect()\n        const r = rowRef.current!.getBoundingClientRect()\n        const k = s.width / r.width\n        const dy = s.top + (s.height - r.height * k) / 2 - r.top\n        runs.push(\n          rowRef.current!.animate(\n            [{ transform: 'none' }, { transform: `translate(${s.left - r.left}px, ${dy}px) scale(${k})` }],\n            {\n              duration: 240,\n              ...opts,\n            },\n          ),\n          sheetRef.current!.animate([{ opacity: 1 }, { opacity: 0, transform: 'translateY(-8px)' }], {\n            duration: 140,\n            ...opts,\n          }),\n        )\n      } else {\n        runs.push(\n          boxRef.current!.animate([{ opacity: 1 }, { opacity: 0, transform: 'translateY(4px)' }], {\n            duration: 150,\n            ...opts,\n          }),\n        )\n      }\n      await Promise.all(runs.map((a) => a.finished.catch(() => {})))\n    }\n    if (formRef.current) delete formRef.current.dataset.lifted\n    d.close()\n    runs.forEach((a) => a.cancel())\n    closing.current = false\n  }\n\n  // Show or hide the dialog when `open` changes, and play the way in.\n  useLayoutEffect(() => {\n    const d = dialogRef.current\n    if (!d) return\n    if (isOpen && !d.open) {\n      returnTo.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null\n      d.showModal()\n      const input = inputRef.current\n      input?.focus()\n      input?.setSelectionRange(input.value.length, input.value.length)\n      if (reduced || mode.current === 'key') return\n      const opts = { easing: EASE }\n      scrimRef.current!.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 220, ...opts })\n      if (mode.current === 'lift' && slabVisible()) {\n        // The field rises out of the page into the palette, and the results unfold under it.\n        const s = formRef.current!.getBoundingClientRect()\n        const r = rowRef.current!.getBoundingClientRect()\n        const k = s.width / r.width\n        const dy = s.top + (s.height - r.height * k) / 2 - r.top\n        formRef.current!.dataset.lifted = ''\n        rowRef.current!.animate(\n          [{ transform: `translate(${s.left - r.left}px, ${dy}px) scale(${k})` }, { transform: 'none' }],\n          {\n            duration: 300,\n            ...opts,\n          },\n        )\n        sheetRef.current!.animate(\n          [\n            { opacity: 0, transform: 'translateY(-10px)', filter: 'blur(4px)' },\n            { opacity: 1, transform: 'none', filter: 'blur(0px)' },\n          ],\n          { duration: 260, delay: 40, fill: 'backwards', ...opts },\n        )\n      } else if (matchMedia('(max-width: 720px)').matches) {\n        boxRef.current!.animate(\n          [\n            { opacity: 0, transform: 'translateY(16px)' },\n            { opacity: 1, transform: 'none' },\n          ],\n          { duration: 260, ...opts },\n        )\n      } else {\n        boxRef.current!.animate(\n          [\n            { opacity: 0, transform: 'translateY(8px) scale(.98)' },\n            { opacity: 1, transform: 'none' },\n          ],\n          {\n            duration: 220,\n            ...opts,\n          },\n        )\n      }\n    } else if (!isOpen && d.open) {\n      d.close()\n    }\n    // Nothing to do when only `reduced` changes: it is read at the moment the palette opens.\n  }, [isOpen, reduced])\n\n  // After the dialog has closed, by any route: tell the owner, put the field back and return focus.\n  const onClosed = () => {\n    if (formRef.current) delete formRef.current.dataset.lifted\n    const from = returnTo.current\n    returnTo.current = null\n    mode.current = 'pointer'\n    if (isOpen) setOpen(false)\n    // The browser usually puts focus back itself, and onSelect may have moved it on purpose: leave that alone.\n    const now = document.activeElement\n    if (now && now !== document.body && !dialogRef.current?.contains(now)) return\n    const field = fieldRef.current\n    const back = !from || from === document.body || from === field || !from.isConnected ? field : from\n    back?.focus({ preventScroll: true })\n  }\n\n  const choose = (item: CommandSearchItem) => {\n    const d = dialogRef.current\n    if (d?.open) d.close()\n    if (onSelect) onSelect(item)\n    else if (item.href) {\n      const url = safeHref(item.href)\n      if (url) window.location.assign(url)\n    }\n  }\n\n  const showToast = (text: ReactNode) => {\n    clearTimeout(toastTimer.current)\n    setToast({ text, on: true })\n    toastTimer.current = setTimeout(() => setToast((t) => ({ ...t, on: false })), 2200)\n  }\n  useEffect(() => () => clearTimeout(toastTimer.current), [])\n\n  const copyInstall = async (item: CommandSearchItem) => {\n    if (!item.install) return\n    try {\n      await navigator.clipboard.writeText(item.install)\n      showToast(\n        fill(L.copied, { id: <code className=\"font-mono text-[12.5px] text-muted-foreground\">{item.id}</code> }),\n      )\n    } catch {\n      showToast(L.copyFailed)\n    }\n  }\n\n  // Cmd/Ctrl+K anywhere toggles the palette; \"/\" opens it when single-key shortcuts are on and no field has focus.\n  const keys = useRef({ openWith, close, isOpen, shortcuts })\n  useLayoutEffect(() => {\n    keys.current = { openWith, close, isOpen, shortcuts }\n  })\n  useEffect(() => {\n    if (!hotkeys) return\n    const onKey = (e: KeyboardEvent) => {\n      if (e.defaultPrevented) return\n      const k = keys.current\n      const otherModal = otherDialogOpen(dialogRef.current)\n      if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {\n        if (otherModal) return\n        e.preventDefault()\n        if (k.isOpen) void k.close('key')\n        else k.openWith('key')\n        return\n      }\n      if (!k.shortcuts || k.isOpen || otherModal || e.metaKey || e.ctrlKey || e.altKey || e.key !== '/') return\n      const t = e.target\n      if (\n        t instanceof Element &&\n        t.closest('input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])')\n      )\n        return\n      e.preventDefault()\n      k.openWith('key')\n    }\n    document.addEventListener('keydown', onKey)\n    return () => document.removeEventListener('keydown', onKey)\n  }, [hotkeys])\n\n  // Announce the number of hits once typing pauses.\n  useEffect(() => {\n    if (!isOpen) return\n    const t = setTimeout(() => setLive(liveText), 450)\n    return () => clearTimeout(t)\n  }, [isOpen, liveText])\n\n  // A new query starts the list from the top.\n  useEffect(() => {\n    listRef.current?.scrollTo({ top: 0 })\n  }, [query])\n\n  // Keep the active result in view when the keys move it.\n  useEffect(() => {\n    if (!scrollToActive.current || current < 0) return\n    scrollToActive.current = false\n    document.getElementById(`${listId}-${current}`)?.scrollIntoView({ block: 'nearest' })\n  }, [current, listId])\n\n  const move = (to: number) => {\n    if (none) return\n    scrollToActive.current = true\n    setActive((to + options.length) % options.length)\n  }\n  const type = (v: string) => {\n    setQuery(v)\n    setActive(0)\n  }\n\n  const onInputKey = (e: ReactKeyboardEvent<HTMLInputElement>) => {\n    // Enter and the arrows belong to the IME while it is composing (Chinese, Japanese, Korean input).\n    if (e.nativeEvent.isComposing) return\n    if (e.key === 'ArrowDown') {\n      e.preventDefault()\n      move(current + 1)\n    } else if (e.key === 'ArrowUp') {\n      e.preventDefault()\n      move(current - 1)\n    } else if (e.key === 'Home' && !query) {\n      e.preventDefault()\n      move(0)\n    } else if (e.key === 'End' && !query) {\n      e.preventDefault()\n      move(options.length - 1)\n    } else if (e.key === 'Enter' && activeItem) {\n      e.preventDefault()\n      if (e.metaKey || e.ctrlKey) void copyInstall(activeItem)\n      else choose(activeItem)\n    }\n  }\n\n  const keyHint = hotkeys ? (shortcuts ? 'Meta+K Control+K /' : 'Meta+K Control+K') : undefined\n  const mod = mac ? '⌘' : 'Ctrl'\n  let index = 0\n\n  return (\n    <>\n      <style href=\"manniche-command-search\" precedence=\"default\">\n        {'html:has(dialog[data-slot=\"command-search-dialog\"][open]){overflow:hidden}'}\n      </style>\n\n      <form\n        ref={formRef}\n        role=\"search\"\n        aria-label={L.searchLabel}\n        action={action}\n        method=\"get\"\n        data-slot=\"command-search\"\n        onSubmit={(e) => {\n          e.preventDefault()\n          openWith('key', fieldRef.current)\n        }}\n        // A mouse opens on press, which feels instant; touch opens on the click, so the tap cannot land on the palette.\n        onPointerDown={(e) => {\n          if (e.pointerType === 'mouse' && e.button === 0) {\n            e.preventDefault()\n            openWith('lift', fieldRef.current)\n          }\n        }}\n        onClick={(e) => {\n          if (e.detail !== 0) openWith('lift', fieldRef.current)\n        }}\n        className={cn(\n          '@container relative flex h-[72px] cursor-text items-center gap-4 rounded-[18px] pr-3.5 pl-6 data-[lifted]:opacity-0 has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-ring max-[480px]:gap-3 max-[480px]:pl-4',\n          'bg-background shadow-[inset_0_2px_8px_rgb(0_0_0/0.08),inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)] dark:shadow-[inset_0_2px_8px_rgb(0_0_0/0.6),inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_7%,transparent)]',\n          className,\n        )}\n      >\n        <Search className=\"size-[22px] flex-none text-muted-foreground\" strokeWidth={1.6} aria-hidden />\n        <label htmlFor={fieldId} className=\"sr-only\">\n          {L.searchLabel}\n        </label>\n        <input\n          ref={fieldRef}\n          id={fieldId}\n          name={name}\n          type=\"search\"\n          value={query}\n          onChange={(e) => {\n            type(e.target.value)\n            openWith('key', fieldRef.current)\n          }}\n          placeholder={fillText(L.placeholder, { count: items.length })}\n          autoComplete=\"off\"\n          spellCheck={false}\n          aria-haspopup=\"dialog\"\n          aria-describedby={hintId}\n          aria-keyshortcuts={keyHint}\n          className=\"h-full min-w-0 flex-1 bg-transparent text-[21px] tracking-[-0.012em] text-foreground outline-none placeholder:text-muted-foreground max-[480px]:text-lg [&::-webkit-search-cancel-button]:hidden\"\n        />\n        <span id={hintId} className=\"sr-only\">\n          {L.triggerHint}\n        </span>\n        {hotkeys && (\n          <span\n            aria-hidden\n            className=\"hidden items-center gap-2.5 text-[13px] leading-none text-muted-foreground [@media(hover:hover)]:@min-[520px]:flex\"\n          >\n            <span className=\"inline-flex gap-1\">\n              <Kbd large>{mod}</Kbd>\n              <Kbd large>K</Kbd>\n            </span>\n            {shortcuts && (\n              <>\n                <span>{L.or}</span>\n                <Kbd large>/</Kbd>\n              </>\n            )}\n          </span>\n        )}\n      </form>\n\n      <dialog\n        ref={dialogRef}\n        data-slot=\"command-search-dialog\"\n        aria-label={L.dialog}\n        onCancel={(e) => {\n          e.preventDefault()\n          void close('key')\n        }}\n        onClose={onClosed}\n        onPointerDown={(e) => {\n          pressedOn.current = e.target\n        }}\n        onClick={(e) => {\n          // A text selection dragged out of the field ends on the scrim: that is not a click outside.\n          const outside = (t: EventTarget | null) => t === scrimRef.current || t === dialogRef.current\n          if (outside(e.target) && (e.detail === 0 || outside(pressedOn.current)))\n            void close(e.detail === 0 ? 'key' : 'pointer')\n        }}\n        className=\"fixed inset-0 m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground backdrop:bg-transparent\"\n      >\n        <div ref={scrimRef} className=\"absolute inset-0 bg-black/45 dark:bg-black/80\" />\n        <div\n          ref={boxRef}\n          className=\"absolute inset-x-0 top-[min(12vh,120px)] mx-auto flex w-[min(920px,calc(100vw-96px))] flex-col max-[720px]:inset-y-0 max-[720px]:w-full\"\n        >\n          <div\n            ref={rowRef}\n            className=\"relative z-[1] flex h-[68px] origin-top-left items-center gap-3.5 rounded-t-[20px] bg-[color-mix(in_oklab,var(--foreground)_3%,var(--card))] pr-3 pl-[22px] shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_6%,transparent),0_0_0_1px_color-mix(in_oklab,var(--foreground)_14%,transparent)] has-[input:focus-visible]:shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_6%,transparent),inset_0_-2px_0_var(--color-ring),0_0_0_1px_color-mix(in_oklab,var(--foreground)_14%,transparent)] max-[720px]:h-16 max-[720px]:rounded-none max-[720px]:pr-1.5 max-[720px]:pl-4\"\n          >\n            <Search className=\"size-5 flex-none text-muted-foreground\" strokeWidth={1.6} aria-hidden />\n            <input\n              ref={inputRef}\n              type=\"text\"\n              value={query}\n              onChange={(e) => type(e.target.value)}\n              onKeyDown={onInputKey}\n              role=\"combobox\"\n              aria-expanded={!none}\n              aria-controls={listId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={activeItem ? `${listId}-${current}` : undefined}\n              aria-label={L.inputPlaceholder}\n              placeholder={L.inputPlaceholder}\n              autoComplete=\"off\"\n              spellCheck={false}\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[19px] tracking-[-0.01em] text-ellipsis text-foreground outline-none placeholder:text-muted-foreground max-[720px]:text-[17px]\"\n            />\n            <button\n              type=\"button\"\n              aria-label={L.close}\n              onClick={(e) => void close(e.detail === 0 ? 'key' : 'pointer')}\n              className={cn(\n                'inline-flex size-11 flex-none items-center justify-center rounded-[10px] text-muted-foreground hover:bg-muted hover:text-foreground',\n                RING,\n              )}\n            >\n              <span className=\"max-[720px]:hidden\">\n                <Kbd>esc</Kbd>\n              </span>\n              <X className=\"hidden size-5 max-[720px]:block\" strokeWidth={1.75} aria-hidden />\n            </button>\n          </div>\n          <p role=\"status\" className=\"sr-only\">\n            {live}\n          </p>\n\n          <div\n            ref={sheetRef}\n            className=\"flex flex-col overflow-hidden rounded-b-[20px] bg-card shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_14%,transparent)] max-[720px]:min-h-0 max-[720px]:flex-1 max-[720px]:rounded-none max-[720px]:shadow-none\"\n          >\n            <div\n              className={cn(\n                'grid h-[min(460px,62vh)] border-t border-border max-[720px]:h-auto max-[720px]:min-h-0 max-[720px]:flex-1',\n                none ? 'grid-cols-1' : 'grid-cols-[minmax(0,1fr)_340px] max-[820px]:grid-cols-1',\n              )}\n            >\n              <div\n                ref={listRef}\n                id={listId}\n                role=\"listbox\"\n                aria-label={L.results}\n                hidden={none}\n                className=\"overflow-y-auto overscroll-contain p-2 [scrollbar-width:thin]\"\n              >\n                {groups.map((g, gi) => (\n                  <div key={g.label} role=\"group\" aria-labelledby={`${listId}-g${gi}`} className=\"pt-0.5 pb-2\">\n                    <div\n                      id={`${listId}-g${gi}`}\n                      className=\"flex items-baseline justify-between gap-3 px-3 pt-3 pb-2 text-[13px] leading-none text-muted-foreground\"\n                    >\n                      <b className=\"font-medium text-foreground/85\">{g.label}</b>\n                      <span className=\"tabular-nums\">\n                        {fillText(searching ? L.groupHits : L.groupCount, {\n                          shown: g.shown.length,\n                          total: searching ? g.matched : g.size,\n                        })}\n                      </span>\n                    </div>\n                    {g.shown.map((item) => {\n                      const i = index++\n                      const on = i === current\n                      return (\n                        <div\n                          key={item.id}\n                          id={`${listId}-${i}`}\n                          role=\"option\"\n                          aria-selected={on}\n                          onPointerMove={() => i !== current && setActive(i)}\n                          onClick={() => choose(item)}\n                          className={cn(\n                            'group/opt relative grid min-h-[52px] cursor-pointer scroll-m-2 grid-cols-[48px_minmax(0,1fr)_auto] items-center gap-3.5 rounded-[12px] py-1.5 pr-2.5 pl-3 max-[720px]:min-h-14',\n                            on && CAP,\n                          )}\n                        >\n                          <span\n                            aria-hidden\n                            className=\"absolute top-3 bottom-3 left-[3px] w-0.5 rounded-full bg-primary opacity-0 group-aria-selected/opt:opacity-100\"\n                          />\n                          {item.poster ? (\n                            <img\n                              src={item.poster}\n                              alt=\"\"\n                              loading=\"lazy\"\n                              decoding=\"async\"\n                              className=\"h-[30px] w-12 rounded-[6px] bg-background object-cover outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10\"\n                            />\n                          ) : (\n                            <span\n                              aria-hidden\n                              className={cn(\n                                'grid h-[30px] w-12 place-items-center rounded-[6px] bg-[length:6px_6px] font-mono text-[11px] leading-none font-medium tracking-[-0.02em] text-muted-foreground outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10',\n                                DOTS,\n                              )}\n                            >\n                              {initials(item.id)}\n                            </span>\n                          )}\n                          <span className=\"grid min-w-0 gap-[3px]\">\n                            <span className=\"truncate text-[15px] leading-[1.3] text-foreground\">{item.title}</span>\n                            <span className=\"truncate font-mono text-xs leading-[1.3] text-muted-foreground\">\n                              {marked(item.id, terms)}\n                            </span>\n                          </span>\n                          <span className=\"flex gap-1.5\">\n                            {item.category && <Badge className=\"max-[480px]:hidden\">{item.category}</Badge>}\n                            {item.tier && <Badge variant={item.tier}>{item.tier === 'pro' ? L.pro : L.free}</Badge>}\n                          </span>\n                        </div>\n                      )\n                    })}\n                  </div>\n                ))}\n              </div>\n\n              {none ? (\n                <div className=\"grid content-center justify-items-center gap-2.5 overflow-y-auto px-6 py-12 text-center\">\n                  {items.length === 0 ? (\n                    <p className=\"text-[15px] text-muted-foreground\">{L.empty}</p>\n                  ) : (\n                    <>\n                      <p className=\"max-w-full font-serif text-2xl leading-[1.2] tracking-[-0.015em] text-balance text-foreground [overflow-wrap:anywhere]\">\n                        {fill(L.noneTitle, { query: query.trim() })}\n                      </p>\n                      <p className=\"max-w-[46ch] text-[15px] leading-[1.55] text-pretty text-muted-foreground\">\n                        {suggested.length ? L.noneText : L.noneTextAlone}\n                      </p>\n                      {suggested.length > 0 && (\n                        <div\n                          role=\"group\"\n                          aria-label={L.suggestions}\n                          className=\"mt-1.5 flex flex-wrap justify-center gap-1.5\"\n                        >\n                          {suggested.map(([word, n]) => (\n                            <button\n                              key={word}\n                              type=\"button\"\n                              onClick={() => {\n                                type(word)\n                                inputRef.current?.focus()\n                              }}\n                              className={cn(\n                                'inline-flex h-11 items-center gap-[7px] rounded-[10px] px-3.5 font-mono text-[12.5px] leading-none font-medium text-foreground transition-transform duration-100 ease-out-quint active:translate-y-px motion-reduce:transition-none',\n                                CAP,\n                                RING,\n                              )}\n                            >\n                              {word}\n                              <span className=\"text-muted-foreground tabular-nums\">{n}</span>\n                            </button>\n                          ))}\n                        </div>\n                      )}\n                    </>\n                  )}\n                </div>\n              ) : (\n                <section\n                  aria-label={L.preview}\n                  className=\"flex min-h-0 flex-col gap-3.5 overflow-hidden border-l border-border p-4 max-[820px]:hidden\"\n                >\n                  {activeItem &&\n                    (renderPreview ? (\n                      renderPreview(activeItem)\n                    ) : (\n                      <>\n                        {activeItem.poster ? (\n                          <img\n                            src={activeItem.poster}\n                            alt=\"\"\n                            className=\"aspect-[16/10] min-h-0 w-full shrink rounded-[12px] bg-background object-cover outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10\"\n                          />\n                        ) : (\n                          <div\n                            aria-hidden\n                            className={cn(\n                              'flex aspect-[16/10] min-h-0 w-full shrink items-end rounded-[12px] bg-[length:12px_12px] p-3.5 outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10',\n                              DOTS,\n                            )}\n                          >\n                            <span className=\"font-mono text-2xl leading-[1.05] font-medium tracking-[-0.04em] break-words text-foreground/85\">\n                              {activeItem.id}\n                            </span>\n                          </div>\n                        )}\n                        <div className=\"min-w-0\">\n                          <p className=\"font-serif text-2xl leading-[1.15] tracking-[-0.015em] text-foreground\">\n                            {activeItem.title}\n                          </p>\n                          {activeItem.description && (\n                            <p className=\"mt-2 line-clamp-4 text-sm leading-[1.55] text-pretty text-muted-foreground\">\n                              {activeItem.description}\n                            </p>\n                          )}\n                        </div>\n                        <div className=\"flex flex-wrap gap-1.5\">\n                          {activeItem.category && <Badge>{activeItem.category}</Badge>}\n                          {activeItem.isNew && <Badge variant=\"new\">{L.new}</Badge>}\n                          {activeItem.tier && (\n                            <Badge variant={activeItem.tier}>{activeItem.tier === 'pro' ? L.pro : L.free}</Badge>\n                          )}\n                        </div>\n                        {activeItem.install && (\n                          <p\n                            className={cn(\n                              'mt-auto rounded-[10px] px-3 py-2.5 font-mono text-[11.5px] leading-normal text-foreground/85 [overflow-wrap:anywhere]',\n                              WELL,\n                            )}\n                          >\n                            <span className=\"text-muted-foreground\">\n                              {activeItem.install.slice(0, activeItem.install.lastIndexOf(' ') + 1)}\n                            </span>\n                            {breakable(activeItem.install.slice(activeItem.install.lastIndexOf(' ') + 1))}\n                          </p>\n                        )}\n                      </>\n                    ))}\n                </section>\n              )}\n            </div>\n\n            <div\n              aria-hidden\n              className=\"flex h-12 items-center gap-5 border-t border-border px-4 text-[13px] leading-none whitespace-nowrap text-muted-foreground max-[720px]:hidden\"\n            >\n              <span className={cn('inline-flex items-center gap-[7px]', none && 'opacity-40')}>\n                <span className=\"inline-flex gap-1\">\n                  <Kbd>↑</Kbd>\n                  <Kbd>↓</Kbd>\n                </span>\n                {L.keySelect}\n              </span>\n              <span className={cn('inline-flex items-center gap-[7px]', none && 'opacity-40')}>\n                <Kbd>↵</Kbd>\n                {L.keyOpen}\n              </span>\n              {anyInstall && (\n                <span className={cn('inline-flex items-center gap-[7px]', none && 'opacity-40')}>\n                  <span className=\"inline-flex gap-1\">\n                    <Kbd>{mod}</Kbd>\n                    <Kbd>↵</Kbd>\n                  </span>\n                  {L.keyCopy}\n                </span>\n              )}\n              <span className=\"inline-flex items-center gap-[7px]\">\n                <Kbd>esc</Kbd>\n                {L.keyClose}\n              </span>\n              <span className=\"ml-auto tabular-nums\">\n                {searching\n                  ? fillText(L.hitsOf, { count: found, total: items.length })\n                  : fillText(L.total, { count: items.length })}\n              </span>\n            </div>\n          </div>\n        </div>\n\n        <div\n          role=\"status\"\n          className={cn(\n            'pointer-events-none fixed bottom-7 left-1/2 z-10 flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-2.5 rounded-[12px] px-4 py-3 text-sm text-foreground transition-[opacity,translate] duration-200 ease-out-quint motion-reduce:transition-none',\n            CAP,\n            'bg-card',\n            toast.on ? 'translate-y-0 opacity-100' : 'translate-y-2.5 opacity-0',\n          )}\n        >\n          {toast.on && toast.text}\n        </div>\n      </dialog>\n    </>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "meta": {
    "tier": "free",
    "added": "2026-10-07",
    "usedIn": [],
    "props": [
      {
        "name": "items",
        "type": "CommandSearchItem[]",
        "default": null,
        "required": true,
        "description": "Everything that can be found. Counts in the palette are taken from this list."
      },
      {
        "name": "onSelect",
        "type": "(item: CommandSearchItem) => void",
        "default": null,
        "required": false,
        "description": "Called with the chosen item after the palette has closed. Without it, the item's `href` is followed."
      },
      {
        "name": "renderPreview",
        "type": "(item: CommandSearchItem) => ReactNode",
        "default": null,
        "required": false,
        "description": "Your own preview for the active item, in place of the poster, title, description, badges and install line."
      },
      {
        "name": "suggestions",
        "type": "string[] | ((query: string) => string[])",
        "default": null,
        "required": false,
        "description": "Searches to offer when nothing matches, shown with their hit counts (ones with no hits are left out, at most four). A function gets the query, for mapping words in another language. Default: the commonest categories."
      },
      {
        "name": "shortcuts",
        "type": "boolean",
        "default": "true",
        "required": false,
        "description": "Single-key shortcuts: \"/\" opens the search. Turn off for WCAG 2.1.4; Cmd/Ctrl+K keeps working. Default true."
      },
      {
        "name": "hotkeys",
        "type": "boolean",
        "default": "true",
        "required": false,
        "description": "Listen on the whole page for Cmd/Ctrl+K (and \"/\"). Turn off on a second search on the same page. Default true."
      },
      {
        "name": "limit",
        "type": "number",
        "default": "8",
        "required": false,
        "description": "Results per group while searching. Default 8."
      },
      {
        "name": "initialLimit",
        "type": "number",
        "default": "5",
        "required": false,
        "description": "Results per group with an empty field. Default 5."
      },
      {
        "name": "open",
        "type": "boolean",
        "default": null,
        "required": false,
        "description": "Controls whether the palette is open. Leave it out to let the component keep track."
      },
      {
        "name": "defaultOpen",
        "type": "boolean",
        "default": "false",
        "required": false,
        "description": null
      },
      {
        "name": "onOpenChange",
        "type": "(open: boolean) => void",
        "default": null,
        "required": false,
        "description": null
      },
      {
        "name": "query",
        "type": "string",
        "default": null,
        "required": false,
        "description": "Controls the text in the field (the trigger and the palette share it)."
      },
      {
        "name": "defaultQuery",
        "type": "string",
        "default": "''",
        "required": false,
        "description": "The starting text, such as `?q=` from the URL."
      },
      {
        "name": "onQueryChange",
        "type": "(query: string) => void",
        "default": null,
        "required": false,
        "description": null
      },
      {
        "name": "action",
        "type": "string",
        "default": null,
        "required": false,
        "description": "Where the trigger form sends `?q=` without JavaScript. Default: the current page."
      },
      {
        "name": "name",
        "type": "string",
        "default": "'q'",
        "required": false,
        "description": "The query parameter's name. Default \"q\"."
      },
      {
        "name": "labels",
        "type": "CommandSearchLabels",
        "default": "{}",
        "required": false,
        "description": "Words for other languages."
      },
      {
        "name": "className",
        "type": "string",
        "default": null,
        "required": false,
        "description": "Classes for the trigger field."
      }
    ],
    "a11y": [
      {
        "topic": "keyboard",
        "text": "Cmd or Ctrl+K toggles it, and / opens it unless you are typing in a field or shortcuts={false}. Typing in the trigger opens it with the text. Arrow keys wrap, Home and End jump, Enter opens, Cmd+Enter copies the install line, Escape closes and returns focus to the trigger.",
        "keys": [
          "Mod+K",
          "/",
          "ArrowUp",
          "ArrowDown",
          "Home",
          "End",
          "Enter",
          "Mod+Enter",
          "Escape"
        ]
      },
      {
        "topic": "screen-reader",
        "text": "A modal native dialog with a combobox and a listbox; aria-activedescendant is set only when an option is active. Each group is role=group. The hit count is announced politely after typing stops, and the copy toast is a status."
      },
      {
        "topic": "motion",
        "text": "Under reduced motion it opens and closes without animation; keyboard opens are always instant."
      }
    ]
  },
  "categories": [
    "overlays"
  ],
  "type": "registry:component"
}