{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-palette",
  "title": "Command Palette",
  "description": "Global search and jump on Cmd or Ctrl K: grouped results, prefix filters and keyboard navigation.",
  "dependencies": [
    "@base-ui/react@^1.9.0"
  ],
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/cn.json",
    "https://sproutboat.com/ikada/r/icons.json",
    "https://sproutboat.com/ikada/r/kbd.json",
    "https://sproutboat.com/ikada/r/portal.json",
    "https://sproutboat.com/ikada/r/spinner.json"
  ],
  "files": [
    {
      "path": "registry/ikada/ui/command-palette.tsx",
      "content": "\"use client\";\n\nimport { Dialog as BaseDialog } from \"@base-ui/react/dialog\";\nimport { useEffect, useEffectEvent, useId, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { cn, focusRing } from \"@/components/ikada/lib/cn\";\nimport { SearchIcon } from \"@/components/ikada/lib/icons\";\nimport { usePortalContainer } from \"@/components/ikada/lib/portal\";\nimport { Kbd } from \"@/components/ikada/kbd\";\nimport { Spinner } from \"@/components/ikada/spinner\";\n\nexport interface PaletteItem {\n  id: string;\n  label: string;\n  /** Muted text at the end of the row: \"sproutd · production\". */\n  hint?: string;\n  icon?: ReactNode;\n  /** Extra search terms. */\n  keywords?: string;\n  /** Typed-prefix scope this item answers to (\"d1\" for `d1:`). Unscoped items show in every search. */\n  scope?: string;\n}\n\nexport interface PaletteGroup {\n  id: string;\n  label: string;\n  items: readonly PaletteItem[];\n}\n\nexport interface PalettePrefix {\n  /** Without the colon: \"d1\". Typed as `d1:`. */\n  scope: string;\n  /** What it searches, for the chip and the footer hint: \"D1 databases\". */\n  label: string;\n}\n\nexport interface CommandPaletteProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  /** Results to show, in order: each group a heading and its items. */\n  groups: readonly PaletteGroup[];\n  /** Shown first while the query is empty. */\n  recents?: readonly PaletteItem[];\n  /** Scopes typed as `scope:` that narrow the search, shown as a chip. */\n  prefixes?: readonly PalettePrefix[];\n  /** Called with the chosen item; the palette closes after it. */\n  onSelect: (item: PaletteItem) => void;\n  /** Shows a spinner while results are fetched. */\n  loading?: boolean;\n  /** Placeholder of the search input. */\n  placeholder?: string;\n  /** Name for screen readers. */\n  label?: string;\n}\n\ninterface Row {\n  item: PaletteItem;\n  group: string;\n}\n\nconst norm = (s: string): string => s.toLowerCase();\n\nconst contains = (haystack: string, needle: string): boolean => haystack.includes(needle);\n\nfunction matches(item: PaletteItem, query: string): boolean {\n  if (!query) return true;\n  const hay = norm(`${item.label} ${item.hint ?? \"\"} ${item.keywords ?? \"\"}`);\n  return query.split(/\\s+/).every((word) => contains(hay, word));\n}\n\n/** Splits `d1: notes` into scope `d1` and rest `notes`, when `d1` is a known prefix. */\nexport function parsePrefix(\n  query: string,\n  prefixes: readonly PalettePrefix[],\n): { prefix?: PalettePrefix; rest: string } {\n  const m = /^([a-z0-9]+):\\s*(.*)$/i.exec(query);\n  const prefix = m ? prefixes.find((p) => p.scope === norm(m[1] ?? \"\")) : undefined;\n  return prefix ? { prefix, rest: m?.[2] ?? \"\" } : { rest: query };\n}\n\n/**\n * Global search and jump (Cmd or Ctrl K). It is opened with a key, so it never animates\n * in or out. Combobox pattern: focus stays in the input, arrows move `aria-activedescendant`,\n * Enter runs the row, Escape closes, and focus is trapped inside while open.\n */\nexport function CommandPalette({\n  open,\n  onOpenChange,\n  groups,\n  recents = [],\n  prefixes = [],\n  onSelect,\n  loading = false,\n  placeholder = \"Search projects, versions, storage…\",\n  label = \"Command palette\",\n}: CommandPaletteProps) {\n  const container = usePortalContainer();\n  // Whoever had focus when the palette opened gets it back on close. The initial-focus\n  // callback runs before focus moves, so that is the moment to remember them.\n  const opener = useRef<HTMLElement | null>(null);\n  const input = useRef<HTMLInputElement>(null);\n  return (\n    <BaseDialog.Root open={open} onOpenChange={onOpenChange} modal>\n      <BaseDialog.Portal container={container}>\n        <BaseDialog.Backdrop className={cn(container ? \"absolute\" : \"fixed\", \"inset-0 z-(--sb-z-overlay) bg-scrim\")} />\n        <BaseDialog.Viewport\n          className={cn(\n            container ? \"absolute\" : \"fixed\",\n            \"inset-0 z-(--sb-z-modal) grid place-items-start justify-items-center overflow-y-auto p-3 pt-[min(12vh,6rem)] sm:p-4 sm:pt-[min(14vh,7rem)]\",\n          )}\n        >\n          <BaseDialog.Popup\n            data-slot=\"command-palette-content\"\n            aria-label={label}\n            initialFocus={() => {\n              const active = document.activeElement;\n              opener.current = active instanceof HTMLElement ? active : null;\n              return input.current;\n            }}\n            finalFocus={() => opener.current}\n            className=\"flex max-h-[min(32rem,calc(100%-1rem))] w-full max-w-xl flex-col overflow-hidden rounded-2xl bg-raised text-fg shadow-dialog ring-1 ring-(--sb-hairline) outline-hidden\"\n          >\n            <PaletteBody\n              inputRef={input}\n              groups={groups}\n              recents={recents}\n              prefixes={prefixes}\n              onSelect={(item) => {\n                onSelect(item);\n                onOpenChange(false);\n              }}\n              loading={loading}\n              placeholder={placeholder}\n              label={label}\n            />\n          </BaseDialog.Popup>\n        </BaseDialog.Viewport>\n      </BaseDialog.Portal>\n    </BaseDialog.Root>\n  );\n}\n\nfunction PaletteBody({\n  inputRef,\n  groups,\n  recents,\n  prefixes,\n  onSelect,\n  loading,\n  placeholder,\n  label,\n}: Required<\n  Pick<CommandPaletteProps, \"groups\" | \"recents\" | \"prefixes\" | \"onSelect\" | \"loading\" | \"placeholder\" | \"label\">\n> & { inputRef: RefObject<HTMLInputElement | null> }) {\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const listId = useId();\n  const listRef = useRef<HTMLDivElement>(null);\n\n  const { prefix, rest } = parsePrefix(query, prefixes);\n  const term = norm(rest.trim());\n\n  const sections = useMemo(() => {\n    const out: { id: string; label: string; items: PaletteItem[] }[] = [];\n    const inScope = (item: PaletteItem) => (prefix ? item.scope === prefix.scope : true);\n    if (!query && recents.length) out.push({ id: \"recent\", label: \"Recent\", items: [...recents] });\n    for (const g of groups) {\n      const items = g.items.filter((i) => inScope(i) && matches(i, term));\n      if (items.length) out.push({ id: g.id, label: g.label, items });\n    }\n    return out;\n  }, [groups, recents, prefix, query, term]);\n\n  const rows: Row[] = sections.flatMap((s) => s.items.map((item) => ({ item, group: s.id })));\n  const activeIndex = Math.min(active, Math.max(0, rows.length - 1));\n  const optionId = (index: number): string => `${listId}-opt-${index}`;\n  const activeId = `${listId}-opt-${activeIndex}`;\n\n  useEffect(() => {\n    document.getElementById(activeId)?.scrollIntoView?.({ block: \"nearest\" });\n  }, [activeId]);\n\n  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!rows.length) return;\n      const step = event.key === \"ArrowDown\" ? 1 : -1;\n      setActive((activeIndex + step + rows.length) % rows.length);\n    } else if (event.key === \"Home\" || event.key === \"End\") {\n      if (event.ctrlKey || event.metaKey || !rows.length) return;\n      event.preventDefault();\n      setActive(event.key === \"Home\" ? 0 : rows.length - 1);\n    } else if (event.key === \"Enter\") {\n      const row = rows[activeIndex];\n      if (row && !event.nativeEvent.isComposing) {\n        event.preventDefault();\n        onSelect(row.item);\n      }\n    }\n  }\n\n  let index = -1;\n  return (\n    <>\n      <BaseDialog.Title className=\"sr-only\">{label}</BaseDialog.Title>\n      <div className=\"flex items-center gap-3 px-4 shadow-[inset_0_-1px_0_var(--sb-hairline)]\">\n        <SearchIcon className=\"text-fg-muted\" />\n        {prefix ? (\n          <span className=\"inline-flex h-6 shrink-0 items-center rounded-sm bg-neutral-soft px-2 font-mono text-sb-xs font-medium text-neutral-fg\">\n            {prefix.scope}:\n          </span>\n        ) : null}\n        <input\n          ref={inputRef}\n          role=\"combobox\"\n          aria-label={label}\n          aria-expanded=\"true\"\n          aria-controls={listId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={rows.length ? activeId : undefined}\n          value={prefix ? rest : query}\n          placeholder={prefix ? `Search ${prefix.label.toLowerCase()}` : placeholder}\n          onChange={(e) => {\n            setQuery(prefix ? `${prefix.scope}:${e.target.value}` : e.target.value);\n            setActive(0);\n          }}\n          onKeyDown={(e) => {\n            if (e.key === \"Backspace\" && prefix && !rest) setQuery(\"\");\n            onKeyDown(e);\n          }}\n          autoComplete=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          className=\"h-13 min-w-0 flex-1 bg-transparent text-sb-md font-normal text-fg outline-hidden placeholder:text-fg-muted\"\n        />\n        {loading ? <Spinner size=\"sm\" label=\"Searching\" /> : null}\n      </div>\n\n      <div\n        ref={listRef}\n        className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5 [scrollbar-color:var(--sb-hairline-strong)_transparent] [scrollbar-gutter:stable] [scrollbar-width:thin]\"\n      >\n        <div id={listId} role=\"listbox\" aria-label=\"Results\" className=\"flex flex-col gap-0.5\">\n          {sections.map((section) => (\n            <div key={section.id} role=\"group\" aria-labelledby={`${listId}-${section.id}`} className=\"flex flex-col\">\n              <div\n                id={`${listId}-${section.id}`}\n                className=\"px-2.5 pt-3 pb-1 text-sb-xs font-medium tracking-wide text-fg-muted first:pt-1.5\"\n              >\n                {section.label}\n              </div>\n              {section.items.map((item) => {\n                index += 1;\n                const i = index;\n                const isActive = i === activeIndex;\n                return (\n                  // oxlint-disable-next-line jsx-a11y/click-events-have-key-events -- keys are handled by the combobox input\n                  <div\n                    key={`${section.id}:${item.id}`}\n                    id={optionId(i)}\n                    role=\"option\"\n                    aria-selected={isActive}\n                    data-active={isActive || undefined}\n                    onMouseMove={() => i !== activeIndex && setActive(i)}\n                    onClick={() => onSelect(item)}\n                    className={cn(\n                      \"flex min-h-9 cursor-default items-center gap-2.5 rounded-lg px-2.5 py-1 text-sb-base\",\n                      \"data-active:bg-[color-mix(in_oklch,var(--sb-fg)_7%,transparent)]\",\n                      \"data-active:after:ms-auto data-active:after:text-sb-xs data-active:after:text-fg-muted data-active:after:content-['↵']\",\n                      \"pointer-coarse:min-h-11\",\n                    )}\n                  >\n                    {item.icon ? <span className=\"text-fg-muted [&>svg]:size-4\">{item.icon}</span> : null}\n                    <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n                    {item.hint ? (\n                      <span className=\"hidden shrink-0 font-mono text-sb-xs text-fg-muted sm:inline\">{item.hint}</span>\n                    ) : null}\n                  </div>\n                );\n              })}\n            </div>\n          ))}\n        </div>\n        {rows.length === 0 && !loading ? (\n          <div role=\"status\" className=\"px-4 py-10 text-center\">\n            <p className=\"font-semibold\">No results{term ? ` for “${rest.trim()}”` : \"\"}</p>\n            <p className=\"mt-1 text-fg-muted\">\n              {prefix\n                ? `Nothing in ${prefix.label.toLowerCase()} matches.`\n                : \"Check the spelling, or try a prefix such as d1:.\"}\n            </p>\n          </div>\n        ) : null}\n      </div>\n\n      <div className=\"flex flex-wrap items-center gap-x-4 gap-y-1.5 px-4 py-2 text-sb-xs text-fg-muted shadow-[inset_0_1px_0_var(--sb-hairline)]\">\n        <span className=\"inline-flex items-center gap-1.5\">\n          <Kbd>↑</Kbd>\n          <Kbd>↓</Kbd> Navigate\n        </span>\n        <span className=\"inline-flex items-center gap-1.5\">\n          <Kbd>↵</Kbd> Open\n        </span>\n        {prefixes.length ? (\n          <span className=\"inline-flex flex-wrap items-center gap-1.5\">\n            Prefixes\n            {prefixes.map((p) => (\n              <Kbd key={p.scope} title={p.label}>\n                {p.scope}:\n              </Kbd>\n            ))}\n          </span>\n        ) : null}\n        <BaseDialog.Close\n          className={cn(\"ms-auto inline-flex items-center gap-1.5 rounded-xs hover:text-fg\", focusRing)}\n        >\n          <Kbd aria-hidden=\"true\">Esc</Kbd> Close\n        </BaseDialog.Close>\n      </div>\n    </>\n  );\n}\n\n/** Opens on Cmd+K (macOS) or Ctrl+K elsewhere. Call it once, near the app root. */\nexport function useCommandPaletteShortcut(onToggle: () => void): void {\n  const toggle = useEffectEvent(onToggle);\n  useEffect(() => {\n    function onKey(event: globalThis.KeyboardEvent) {\n      if (event.key.toLowerCase() === \"k\" && (event.metaKey || event.ctrlKey) && !event.altKey) {\n        event.preventDefault();\n        toggle();\n      }\n    }\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, []);\n}\n\n/** \"⌘K\" on Apple platforms, \"Ctrl K\" elsewhere. */\nexport function shortcutLabel(): string {\n  const apple = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? \"\");\n  return apple ? \"⌘K\" : \"Ctrl K\";\n}\n",
      "type": "registry:ui",
      "target": "@components/ikada/command-palette.tsx"
    }
  ],
  "docs": "Mount it once in your layout and open it with useCommandPaletteShortcut().",
  "categories": [
    "navigation",
    "overlays"
  ],
  "type": "registry:ui"
}