{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-palette",
  "type": "registry:ui",
  "title": "Command Palette",
  "description": "⌘K. A modal surface holding one filterable list of actions.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "overlay",
    "primitive"
  ],
  "dependencies": [
    "@base-ui/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/dialog.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/command-palette.tsx",
      "target": "components/ui/command-palette.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/command-palette v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/command-palette\n// What changed since: https://ds.interlace.tools/c/command-palette#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — CommandPalette\n *\n * ⌘K. A modal surface holding one filterable list of actions.\n *\n * This is a COMPOSITION, not a new primitive: `Dialog` supplies the modal\n * (backdrop, focus trap, page inert, Escape, focus restore to the trigger),\n * and `@base-ui/react/combobox` in `inline` mode supplies the field and the\n * list (filtering, listbox ARIA, virtual focus, highlight). Neither half is\n * reimplemented here — this file is the ~200 lines of surface that sit\n * between them.\n * Mirrors: https://ui.shadcn.com/docs/components/command (`CommandDialog`)\n *\n * ## Why `inline` is load-bearing\n *\n * `inline` is not a styling flag. It is the prop that makes a combobox\n * legal inside a dialog, and Base UI's own source says so\n * (`combobox/root/AriaCombobox.js`: \"Support composing the Dialog component\n * around an inline combobox\"). Three things change:\n *\n *   1. No portal, no positioner. `ComboboxList` registers ITSELF as the\n *      positioner element, so the list renders in the dialog's flow instead\n *      of floating over it in a second portal — which is what makes the\n *      panel one surface and one scroll container.\n *   2. Base UI walks `positionerElement.closest('[role=\"dialog\"]')` to find\n *      the animated element. Our panel is a `Dialog.Popup`, so it matches.\n *   3. `useDismiss` is DISABLED and Escape is allowed to bubble\n *      (`bubbles: inline ? true : undefined`). Without `inline`, the\n *      combobox swallows Escape to close its own popup and the dialog stays\n *      open — one Escape does nothing, and the user is in a surface they\n *      cannot leave from the keyboard (WCAG 2.1.2). This is the failure the\n *      keyboard lock exists to catch.\n *\n * The combobox root is held `open`, controlled, forever. In `inline` mode\n * Base UI already treats the list as open internally, but the input's\n * `aria-controls` is still gated on the root's own `open` state — so leaving\n * it `false` ships a `role=\"combobox\"` that points at nothing.\n *\n * ## Anatomy\n *\n *   CommandPalette (Root)                    → Dialog.Root (open state)\n *     ├─ CommandPaletteTrigger               → Dialog.Trigger (optional)\n *     └─ CommandPaletteContent               → Dialog portal + backdrop +\n *         │                                    popup, wrapping an inline\n *         │                                    Combobox.Root\n *         ├─ CommandPaletteInput             (the search field — no border;\n *         │                                    the panel is the field's box)\n *         ├─ CommandPaletteEmpty\n *         └─ CommandPaletteList\n *             ├─ CommandPaletteGroup → CommandPaletteGroupLabel\n *             ├─ CommandPaletteItem → CommandPaletteShortcut\n *             └─ CommandPaletteSeparator\n *\n * ## Keyboard contract\n *\n * A command palette IS its keyboard model; axe cannot press a key, so every\n * row below is asserted in `CommandPalette.stories.tsx` (`KeyboardFlow`) and\n * the story's existence is locked by `overlay-nav-keyboard-lock.test.ts`.\n *\n * | Key            | Action                                                    |\n * | -------------- | --------------------------------------------------------- |\n * | ⌘K / Ctrl+K    | Open. Not bound here — `useCommandPaletteHotkey` binds it, |\n * |                | opt-in, because a DS may not seize a global chord          |\n * | (on open)      | Focus lands in the input. Dialog's default initial focus   |\n * |                | is the first tabbable element, and that is the input       |\n * | a–z            | Filter. No \"search\" button exists or should               |\n * | ↓ / ↑          | Move the highlight, wrapping at both ends                  |\n * | Enter          | Run the highlighted command                                |\n * | Esc            | Close the palette and restore focus to whatever opened it  |\n * | Tab            | Trapped inside the panel — the page behind is inert        |\n *\n * FOCUS STAYS IN THE INPUT. Base UI runs list navigation with\n * `virtual: true`: the highlighted row is published via\n * `aria-activedescendant` and `document.activeElement` never becomes an\n * option. Asserting `activeElement.role === 'option'` — correct for `Select`\n * — asserts a bug here.\n *\n * HOME / END MOVE THE CARET, not the highlight. Base UI stops those keys on\n * the input. There is no keyboard route to \"last item\" other than ↑ from the\n * top, which the wrap makes one keystroke.\n *\n * ## MIN_VIEWPORT — 320\n *\n * The panel is `w-full max-w-[calc(100%-2rem)]` with a `40rem` cap above the\n * `sm` breakpoint, so at 320px it is a full-bleed card with the 16px gutter\n * intact. The list is capped at `max-h-80` and scrolls; the input stays\n * pinned above it, so the field is never pushed off a short viewport by a\n * long result set.\n *\n * ## Contrast (verified by token math)\n *\n * | Composite                                            | Light   | Dark    | Floor           |\n * | ---------------------------------------------------- | ------- | ------- | --------------- |\n * | `text-foreground` on `--background` (panel)          | 19.65:1 | 15.71:1 | 4.5:1 (SC 1.4.3)|\n * | `placeholder:text-muted-foreground` on `--background`| 9.41:1  | 8.74:1  | 4.5:1 (SC 1.4.3)|\n * | highlighted `text-accent-foreground` on `--accent`   | 8.98:1  | 10.46:1 | 4.5:1 (SC 1.4.3)|\n * | group label `text-muted-foreground` on `--background`| 9.41:1  | 8.74:1  | 4.5:1 (SC 1.4.3)|\n * | `focus-visible:ring-ring/60` on `--background`       | 3.23:1  | 4.73:1  | 3:1 (SC 2.4.13) |\n *\n * The input adds no ring of its own — it inherits the DS-wide\n * `:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px }`\n * from `styles/preflight.css`. It is deliberately NOT suppressed even\n * though the input is the only tab stop and holds focus for the whole life\n * of the panel: SC 2.4.7 asks for a visible indicator on a focusable\n * element, and \"there is nowhere else for focus to be\" is not one of the\n * exceptions. What the palette must not do is stack a SECOND indicator on\n * top, so no `ring-*` is added here.\n *\n * The indicator that carries the navigation state is the ROW highlight —\n * `bg-accent` at 8.98:1 / 10.46:1, well over the 3:1 of SC 2.4.13, and\n * paired with `aria-activedescendant` rather than left to colour alone.\n *\n * `bg-black/50` on the backdrop is inherited from `DialogOverlay` and is\n * intentionally not a token — a scrim must darken the page in BOTH themes.\n *\n * | Rule | Concept                          | Where in this file                                          |\n * | ---- | -------------------------------- | ----------------------------------------------------------- |\n * | R4   | Extends Base UI part props       | each wrapper extends its Base UI part's props               |\n * | R6   | data-slot per part               | `command-palette-content` / `-input` / `-item` / …          |\n * | R7   | className merged + ...rest       | `cn(BASE, className)` + `{...props}` on every part          |\n * | R11  | Composition over prop-drilling   | commands are children; only `items` is a prop, because      |\n * |      |                                  | Base UI needs the array to filter                           |\n * | R12  | Reuse over wrap                  | `Dialog` for the modal, Base UI for the list — no fork      |\n * | R13  | Build with the ecosystem         | `@base-ui/react/combobox` + our own `dialog.tsx`            |\n * | R14  | Controlled + uncontrolled        | `open` / `onOpenChange` / `defaultOpen` from `Dialog.Root`  |\n * | R17  | API parity with shadcn           | mirrors `CommandDialog` part names                          |\n * | R18  | Tailwind only                    | zero inline `style`                                         |\n * | R19  | Tokens only                      | semantic colour tokens throughout — no raw hex              |\n * | R20  | AA contrast                      | table above — every composite measured                      |\n * | R25  | Client component                 | required — Dialog and Combobox both ship client hooks       |\n * | R26  | A11y from upstream               | dialog ARIA + combobox/listbox ARIA + keyboard model        |\n */\n\nimport {\n  Combobox as BaseCombobox,\n  type ComboboxRootProps,\n} from '@base-ui/react/combobox';\nimport { SearchIcon } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog';\n\n/** Smallest viable viewport (CSS px) for this primitive. */\nexport const MIN_VIEWPORT = 320 as const;\n\n/** Root — `Dialog.Root`, holding only the open state. Renders no DOM. */\nconst CommandPalette = Dialog;\nconst CommandPaletteTrigger = DialogTrigger;\n/** Required for ARIA labelling of the modal. Usually visually hidden. */\nconst CommandPaletteTitle = DialogTitle;\nconst CommandPaletteDescription = DialogDescription;\n\ninterface CommandPaletteContentProps<Value>\n  extends Omit<\n    ComboboxRootProps<Value, false>,\n    'inline' | 'open' | 'defaultOpen' | 'onOpenChange' | 'modal'\n  > {\n  /** Override the panel className (width, offset, padding). */\n  className?: string;\n  children?: React.ReactNode;\n  /**\n   * Close the palette once a command is chosen. Default `true` — it is what\n   * every ⌘K surface does, and leaving it to the call site breaks the\n   * uncontrolled case entirely (there is no `setOpen` to call).\n   *\n   * Set `false` for a palette that stays open across several commands, e.g.\n   * a filter builder.\n   */\n  closeOnSelect?: boolean;\n}\n\n/**\n * The panel: dialog portal → backdrop → popup, wrapping the inline combobox.\n *\n * Built on `DialogContent` rather than on `Dialog.Popup` so the palette\n * inherits one set of overlay decisions (portal target, backdrop opacity,\n * enter/exit transition) instead of a second copy that drifts. The className\n * overrides are deliberate and few — `top`/`translate-y` to dock the panel\n * near the top of the viewport where a palette belongs, `p-0` because the\n * input and the list own their own padding, and the width cap. Everything\n * else, including `role=\"dialog\"` (which the inline combobox looks for),\n * comes from `DialogContent` unchanged.\n */\nfunction CommandPaletteContent<Value>({\n  className,\n  children,\n  closeOnSelect = true,\n  onValueChange,\n  ...props\n}: CommandPaletteContentProps<Value>) {\n  /**\n   * Closing goes through a real `DialogClose` that we click, not through a\n   * `setOpen` we own.\n   *\n   * The alternative — lifting the open state into a wrapper root so this part\n   * could call `setOpen(false)` — means synthesising Base UI's\n   * `eventDetails`, which is internal, and re-implementing the\n   * controlled/uncontrolled fork that `Dialog.Root` already gets right. A\n   * hidden close button routes through Base UI's own close path instead, so\n   * `onOpenChange` fires with a real reason and focus returns to the trigger\n   * exactly as it does for Escape.\n   */\n  const closeRef = React.useRef<HTMLButtonElement>(null);\n\n  type ValueChange = NonNullable<ComboboxRootProps<Value, false>['onValueChange']>;\n  const handleValueChange: ValueChange = (value, eventDetails) => {\n    onValueChange?.(value, eventDetails);\n    if (closeOnSelect) closeRef.current?.click();\n  };\n\n  return (\n    <DialogContent\n      showCloseButton={false}\n      data-slot=\"command-palette-content\"\n      data-min-viewport={String(MIN_VIEWPORT)}\n      className={cn(\n        'top-[12vh] block translate-y-0 gap-0 overflow-hidden p-0 sm:max-w-[40rem]',\n        className,\n      )}\n    >\n      {/*\n        `open` is pinned true and uncontrollable. See the header: in `inline`\n        mode Base UI treats the list as open regardless, but the input's\n        `aria-controls` is gated on this flag — leaving it false ships a\n        `role=\"combobox\"` that references no listbox.\n      */}\n      <BaseCombobox.Root\n        inline\n        open\n        onValueChange={handleValueChange}\n        {...(props as ComboboxRootProps<Value, false>)}\n      >\n        {children}\n      </BaseCombobox.Root>\n      {/*\n        Never focusable, never announced — it exists only so `handleValueChange`\n        has a Base UI close path to invoke. `hidden` keeps it out of layout\n        while leaving `.click()` working.\n      */}\n      <DialogClose ref={closeRef} hidden aria-hidden=\"true\" tabIndex={-1} />\n    </DialogContent>\n  );\n}\n\n/**\n * The search field. Borderless on purpose: the dialog panel is the field's\n * visual box, and a second border inside it reads as a nested control.\n * The separator under it is the affordance that says \"type here, results\n * below\".\n */\nfunction CommandPaletteInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseCombobox.Input>) {\n  return (\n    <div\n      data-slot=\"command-palette-input-wrapper\"\n      className=\"border-border flex h-12 items-center gap-2 border-b px-3\"\n    >\n      <SearchIcon\n        aria-hidden=\"true\"\n        className=\"text-muted-foreground size-4 shrink-0\"\n      />\n      <BaseCombobox.Input\n        data-slot=\"command-palette-input\"\n        className={cn(\n          // `h-10` inside the `h-12` row, not `h-full`: the DS focus ring is\n          // `outline: 2px` at `outline-offset: 2px`, so it needs 4px of\n          // clearance on each side. At `h-full` the panel's `overflow-hidden`\n          // shears the top of the ring off — a 2px clip that reads as a\n          // rendering glitch and cannot be seen in jsdom, where every box is\n          // 0×0. Measured in Chromium.\n          'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground h-10 w-full min-w-0 bg-transparent text-base outline-none md:text-sm',\n          'disabled:cursor-not-allowed disabled:opacity-50',\n          className,\n        )}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction CommandPaletteList({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseCombobox.List>) {\n  return (\n    <BaseCombobox.List\n      data-slot=\"command-palette-list\"\n      className={cn(\n        'flex max-h-80 flex-col overflow-x-hidden overflow-y-auto p-1 outline-none',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandPaletteItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseCombobox.Item>) {\n  return (\n    <BaseCombobox.Item\n      data-slot=\"command-palette-item\"\n      className={cn(\n        // 40px tall — over the 24px floor of SC 2.5.8, and comfortable for a\n        // surface the user drives at speed.\n        'text-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2.5 text-sm transition-colors duration-150 outline-hidden select-none',\n        'data-highlighted:bg-accent data-highlighted:text-accent-foreground',\n        'data-disabled:pointer-events-none data-disabled:opacity-50',\n        \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandPaletteGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseCombobox.Group>) {\n  return (\n    <BaseCombobox.Group\n      data-slot=\"command-palette-group\"\n      className={cn('flex flex-col', className)}\n      {...props}\n    />\n  );\n}\n\nfunction CommandPaletteGroupLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseCombobox.GroupLabel>) {\n  return (\n    <BaseCombobox.GroupLabel\n      data-slot=\"command-palette-group-label\"\n      className={cn(\n        'text-muted-foreground px-3 pt-3 pb-1.5 text-xs font-medium',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/**\n * `empty:py-0` for the reason spelled out in `combobox.tsx`: Base UI keeps\n * this `role=\"status\"` live region mounted and only drops its children, so\n * `py-10` otherwise reserves an 80px dead band between the search field and\n * the first result. Collapsing the padding rather than hiding the element\n * keeps the region in the accessibility tree, which is what makes the\n * \"no matching command\" announcement land.\n */\nfunction CommandPaletteEmpty({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseCombobox.Empty>) {\n  return (\n    <BaseCombobox.Empty\n      data-slot=\"command-palette-empty\"\n      className={cn(\n        'text-muted-foreground px-3 py-10 text-center text-sm empty:py-0',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandPaletteSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseCombobox.Separator>) {\n  return (\n    <BaseCombobox.Separator\n      data-slot=\"command-palette-separator\"\n      className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}\n      {...props}\n    />\n  );\n}\n\n/** The trailing ⌘K-style hint on a row. Decorative — never the only cue. */\nfunction CommandPaletteShortcut({\n  className,\n  ...props\n}: React.ComponentProps<'kbd'>) {\n  return (\n    <kbd\n      data-slot=\"command-palette-shortcut\"\n      className={cn(\n        'text-muted-foreground ms-auto font-sans text-xs tracking-widest',\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/**\n * Renders filtered rows. Re-exported under the palette's own name so a\n * consumer composing groups never has to import from `combobox` as well.\n */\nconst CommandPaletteCollection = BaseCombobox.Collection;\n\n/* ─────────────────────────────────────────────────────────────────\n * useCommandPaletteHotkey — the ⌘K binding, opt-in.\n *\n * Not wired into `CommandPalette` itself. A global chord is an application\n * decision: ⌘K is already \"focus the search box\" in a dozen apps, and a\n * design system that seizes it on mount takes that choice away from every\n * page that renders a palette. One line at the call site restores it:\n *\n *   useCommandPaletteHotkey(() => setOpen(true));\n *\n * Binds on `document` with the platform-correct modifier (⌘ on Apple, Ctrl\n * elsewhere) and calls `preventDefault()` so the browser's own ⌘K does not\n * also fire.\n * ──────────────────────────────────────────────────────────────── */\ninterface CommandPaletteHotkeyOptions {\n  /** The letter, lower-case. Default `'k'`. */\n  key?: string;\n  /** Set false to unbind without unmounting the component. Default true. */\n  enabled?: boolean;\n}\n\nfunction useCommandPaletteHotkey(\n  onTrigger: () => void,\n  options: CommandPaletteHotkeyOptions = {},\n): void {\n  const { key = 'k', enabled = true } = options;\n  // Kept in a ref so a call-site arrow function doesn't rebind the listener\n  // on every render — the listener would otherwise be removed and re-added\n  // between a keydown and its default being prevented.\n  const handlerRef = React.useRef(onTrigger);\n  React.useEffect(() => {\n    handlerRef.current = onTrigger;\n  }, [onTrigger]);\n\n  React.useEffect(() => {\n    if (!enabled) return undefined;\n    function onKeyDown(event: KeyboardEvent) {\n      // Either modifier: `metaKey` is ⌘ on Apple platforms, `ctrlKey`\n      // everywhere else. Testing both avoids a `navigator.platform` sniff,\n      // which is deprecated and wrong under emulation anyway.\n      if (event.key.toLowerCase() !== key || !(event.metaKey || event.ctrlKey)) {\n        return;\n      }\n      event.preventDefault();\n      handlerRef.current();\n    }\n    document.addEventListener('keydown', onKeyDown);\n    return () => document.removeEventListener('keydown', onKeyDown);\n  }, [key, enabled]);\n}\n\nexport {\n  CommandPalette,\n  CommandPaletteCollection,\n  CommandPaletteContent,\n  CommandPaletteDescription,\n  CommandPaletteEmpty,\n  CommandPaletteGroup,\n  CommandPaletteGroupLabel,\n  CommandPaletteInput,\n  CommandPaletteItem,\n  CommandPaletteList,\n  CommandPaletteSeparator,\n  CommandPaletteShortcut,\n  CommandPaletteTitle,\n  CommandPaletteTrigger,\n  useCommandPaletteHotkey,\n};\nexport type { CommandPaletteContentProps, CommandPaletteHotkeyOptions };\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": true,
    "minViewport": 320,
    "loading": false,
    "version": "1.0.0",
    "since": "1.1.0"
  },
  "docs": "## @interlace/command-palette\n\nInstalled to `components/ui/command-palette.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/command-palette';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/command-palette\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
