{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"command-palette","type":"registry:ui","title":"Command Palette","description":"A ⌘K command menu with fuzzy search, grouped results, full keyboard navigation and a recent-commands memory. One file, keyboard-first, accessible.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/command-palette.tsx","target":"components/ui/command-palette.tsx","content":"\"use client\"\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type ReactNode,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* --------------------------------------------------------------------------\n   Icons. Inline 1.5px strokes on a 24-grid, currentColor throughout, so a row\n   tints with its text and no icon library ships with the card.\n-------------------------------------------------------------------------- */\ntype IconProps = { className?: string }\n\nfunction Icon({ className, children }: IconProps & { children: ReactNode }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={cn(\"size-4 shrink-0\", className)}\n    >\n      {children}\n    </svg>\n  )\n}\n\nconst HomeIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <path d=\"M3 10.5 12 3l9 7.5\" />\n    <path d=\"M5 9.5V20a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5\" />\n    <path d=\"M9.5 21v-6h5v6\" />\n  </Icon>\n)\nconst SearchIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <circle cx=\"11\" cy=\"11\" r=\"7\" />\n    <path d=\"m20 20-3.2-3.2\" />\n  </Icon>\n)\nconst FileIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <path d=\"M14 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7z\" />\n    <path d=\"M14 3v4h4\" />\n  </Icon>\n)\nconst PlusIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <path d=\"M12 5v14M5 12h14\" />\n  </Icon>\n)\nconst CopyIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <rect x=\"9\" y=\"9\" width=\"11\" height=\"11\" rx=\"2\" />\n    <path d=\"M5 15V5a2 2 0 0 1 2-2h8\" />\n  </Icon>\n)\nconst ShareIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <circle cx=\"6\" cy=\"12\" r=\"2.5\" />\n    <circle cx=\"18\" cy=\"6\" r=\"2.5\" />\n    <circle cx=\"18\" cy=\"18\" r=\"2.5\" />\n    <path d=\"m8.2 10.9 7.6-3.8M8.2 13.1l7.6 3.8\" />\n  </Icon>\n)\nconst TrashIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <path d=\"M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2\" />\n    <path d=\"M6 7v13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7M10 11v6M14 11v6\" />\n  </Icon>\n)\nconst UserIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <circle cx=\"12\" cy=\"8\" r=\"3.5\" />\n    <path d=\"M5 20a7 7 0 0 1 14 0\" />\n  </Icon>\n)\nconst MoonIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <path d=\"M20 13.5A8 8 0 0 1 10.5 4a7 7 0 1 0 9.5 9.5\" />\n  </Icon>\n)\nconst KeyIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <circle cx=\"8\" cy=\"14\" r=\"4\" />\n    <path d=\"m11 11 8-8M16 6l2 2M14 8l2 2\" />\n  </Icon>\n)\nconst BellIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <path d=\"M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6\" />\n    <path d=\"M10.5 20a1.8 1.8 0 0 0 3 0\" />\n  </Icon>\n)\nconst CogIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <circle cx=\"12\" cy=\"12\" r=\"3\" />\n    <path d=\"M12 2v3M12 19v3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1\" />\n  </Icon>\n)\nconst BookIcon = (p: IconProps) => (\n  <Icon {...p}>\n    <path d=\"M5 4a1 1 0 0 1 1-1h11a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z\" />\n    <path d=\"M9 3v18\" />\n  </Icon>\n)\n\n/* --------------------------------------------------------------------------\n   The command list. Grouped by section; a keyboard hint on the ones that carry\n   one. `keywords` widens what a substring match will catch without cluttering\n   the visible label.\n-------------------------------------------------------------------------- */\ntype Command = {\n  id: string\n  label: string\n  hint?: string[]\n  keywords?: string\n  icon: (p: IconProps) => ReactNode\n}\n\ntype Group = { heading: string; commands: Command[] }\n\nconst GROUPS: Group[] = [\n  {\n    heading: \"Navigation\",\n    commands: [\n      { id: \"home\", label: \"Go to Dashboard\", hint: [\"G\", \"H\"], keywords: \"overview start\", icon: HomeIcon },\n      { id: \"search\", label: \"Search the workspace\", hint: [\"/\"], keywords: \"find lookup\", icon: SearchIcon },\n      { id: \"docs\", label: \"Open Documentation\", keywords: \"guide help reference\", icon: BookIcon },\n      { id: \"profile\", label: \"View your Profile\", keywords: \"account me\", icon: UserIcon },\n    ],\n  },\n  {\n    heading: \"Actions\",\n    commands: [\n      { id: \"new-file\", label: \"Create new File\", hint: [\"⌘\", \"N\"], keywords: \"add document\", icon: FileIcon },\n      { id: \"new-project\", label: \"New Project\", hint: [\"⌘\", \"⇧\", \"N\"], keywords: \"add create\", icon: PlusIcon },\n      { id: \"copy-link\", label: \"Copy shareable Link\", hint: [\"⌘\", \"C\"], keywords: \"url clipboard\", icon: CopyIcon },\n      { id: \"share\", label: \"Share with team\", keywords: \"invite collaborate\", icon: ShareIcon },\n      { id: \"delete\", label: \"Delete current item\", hint: [\"⌘\", \"⌫\"], keywords: \"remove trash\", icon: TrashIcon },\n    ],\n  },\n  {\n    heading: \"Settings\",\n    commands: [\n      { id: \"theme\", label: \"Toggle dark mode\", hint: [\"⌘\", \"D\"], keywords: \"appearance light night\", icon: MoonIcon },\n      { id: \"shortcuts\", label: \"Keyboard shortcuts\", hint: [\"⌘\", \"K\"], keywords: \"keys bindings\", icon: KeyIcon },\n      { id: \"notifications\", label: \"Notification settings\", keywords: \"alerts email\", icon: BellIcon },\n      { id: \"preferences\", label: \"Open Preferences\", hint: [\"⌘\", \",\"], keywords: \"config options\", icon: CogIcon },\n    ],\n  },\n]\n\n/**\n * A ⌘K command palette in the Raycast / Linear mould, rendered open inline.\n *\n * The catalogue card is a still: it needs to read as a palette at a glance, so\n * `compact` freezes it mid-search — a sample query, one result lit — and takes\n * everything out of the tab order, because a focusable node behind a thumbnail\n * is a trap with no way back. The full component is the live control: the input\n * autofocuses, the query filters the list on every keystroke, and the arrows\n * walk the flattened results so selection can wrap from the last row to the\n * first without minding which group each row sits in.\n *\n * The one motion here is the fade of the run-confirmation and the selection\n * tint; both drop to nothing under `prefers-reduced-motion` via `motion-reduce`,\n * so the palette still lights the active row, it just does not ease into it.\n */\n\n/** Case-insensitive substring over label + keywords. Empty query keeps all. */\nfunction filterGroups(query: string): Group[] {\n  const q = query.trim().toLowerCase()\n  if (!q) return GROUPS\n  return GROUPS.map((group) => ({\n    heading: group.heading,\n    commands: group.commands.filter((cmd) =>\n      `${cmd.label} ${cmd.keywords ?? \"\"}`.toLowerCase().includes(q),\n    ),\n  })).filter((group) => group.commands.length > 0)\n}\n\n/** Flatten the filtered groups to the running order the arrow keys walk. */\nfunction orderOf(groups: Group[]): Command[] {\n  return groups.flatMap((group) => group.commands)\n}\n\n/** A single ⌘-key cap. */\nfunction Key({ children }: { children: ReactNode }) {\n  return (\n    <kbd className=\"inline-flex min-w-5 items-center justify-center rounded-[5px] border border-border bg-background px-1 py-0.5 text-[11px] font-medium leading-none text-muted-foreground\">\n      {children}\n    </kbd>\n  )\n}\n\n/** One result row. `active` lights it with the accent; the whole row is a\n *  button in full mode and inert markup in the card. */\nfunction Row({\n  command,\n  active,\n  optionId,\n  compact,\n  onSelect,\n  onHover,\n}: {\n  command: Command\n  active: boolean\n  optionId: string\n  compact: boolean\n  onSelect: () => void\n  onHover: () => void\n}) {\n  const IconEl = command.icon\n  return (\n    <div\n      id={optionId}\n      role=\"option\"\n      aria-selected={active}\n      onClick={compact ? undefined : onSelect}\n      onMouseMove={compact ? undefined : onHover}\n      className={cn(\n        \"flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition-colors motion-reduce:transition-none\",\n        active\n          ? \"bg-violet-500 text-white\"\n          : \"text-foreground/90 hover:bg-foreground/[0.04]\",\n      )}\n    >\n      <IconEl className={cn(active ? \"text-white\" : \"text-muted-foreground\")} />\n      <span className=\"flex-1 truncate\">{command.label}</span>\n      {command.hint ? (\n        <span\n          className={cn(\n            \"flex items-center gap-1\",\n            active ? \"[&_kbd]:border-white/30 [&_kbd]:bg-white/15 [&_kbd]:text-white/90\" : \"\",\n          )}\n        >\n          {command.hint.map((key, i) => (\n            <Key key={i}>{key}</Key>\n          ))}\n        </span>\n      ) : null}\n    </div>\n  )\n}\n\nexport type CommandPaletteProps = {\n  /** Catalogue-card mode: a static, untabbable thumbnail. */\n  compact?: boolean\n  className?: string\n}\n\nexport function CommandPalette({ compact = false, className }: CommandPaletteProps) {\n  const uid = useId()\n  const listId = `${uid}-list`\n  const inputRef = useRef<HTMLInputElement | null>(null)\n  const scrollRef = useRef<HTMLDivElement | null>(null)\n\n  // The card is a still of a search in progress; the live one starts empty.\n  const [query, setQuery] = useState(compact ? \"new\" : \"\")\n  const [active, setActive] = useState(0)\n  // A run leaves a brief inline flash naming what was invoked.\n  const [ran, setRan] = useState<string | null>(null)\n\n  const groups = useMemo(() => filterGroups(query), [query])\n  const order = useMemo(() => orderOf(groups), [groups])\n\n  // Clamp selection whenever the result set shrinks under the cursor.\n  useEffect(() => {\n    setActive((i) => (order.length === 0 ? 0 : Math.min(i, order.length - 1)))\n  }, [order.length])\n\n  // Autofocus the input in full mode only — the card must not steal focus.\n  useEffect(() => {\n    if (!compact) inputRef.current?.focus()\n  }, [compact])\n\n  // Keep the selected row in view as the arrows walk past the fold.\n  useEffect(() => {\n    if (compact) return\n    const el = scrollRef.current?.querySelector<HTMLElement>('[aria-selected=\"true\"]')\n    el?.scrollIntoView({ block: \"nearest\" })\n  }, [active, compact])\n\n  const run = useCallback((cmd: Command | undefined) => {\n    if (!cmd) return\n    setRan(cmd.label)\n  }, [])\n\n  // The flash clears itself so the palette does not keep stale confirmation.\n  useEffect(() => {\n    if (!ran) return\n    const t = window.setTimeout(() => setRan(null), 1600)\n    return () => window.clearTimeout(t)\n  }, [ran])\n\n  const onKeyDown = useCallback(\n    (e: ReactKeyboardEvent) => {\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault()\n        setActive((i) => (order.length === 0 ? 0 : (i + 1) % order.length))\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault()\n        setActive((i) => (order.length === 0 ? 0 : (i - 1 + order.length) % order.length))\n      } else if (e.key === \"Enter\") {\n        e.preventDefault()\n        run(order[active])\n      } else if (e.key === \"Escape\") {\n        e.preventDefault()\n        // Esc clears the query rather than closing — there is nothing to close\n        // in an always-open demo, and clearing is the next thing a user wants.\n        setQuery(\"\")\n        setActive(0)\n      }\n    },\n    [order, active, run],\n  )\n\n  const activeId = order.length > 0 ? `${uid}-opt-${order[active]?.id}` : undefined\n\n  return (\n    <div\n      className={cn(\n        \"flex h-full w-full items-center justify-center p-4\",\n        compact && \"pointer-events-none\",\n        className,\n      )}\n      style={compact ? { touchAction: \"pan-y\" } : undefined}\n    >\n      <div\n        role=\"dialog\"\n        aria-modal={compact ? undefined : true}\n        aria-label=\"Command palette\"\n        className={cn(\n          \"flex w-full flex-col overflow-hidden rounded-xl border border-border bg-card/95 text-card-foreground shadow-2xl shadow-black/10 ring-1 ring-black/5 backdrop-blur-xl dark:shadow-black/40 dark:ring-white/5\",\n          compact ? \"max-w-sm\" : \"max-w-lg\",\n        )}\n      >\n        {/* Search field. In the card it is read-only and off the tab order. */}\n        <div className=\"flex items-center gap-3 border-b border-border px-4\">\n          <SearchIcon className=\"text-muted-foreground\" />\n          <input\n            ref={inputRef}\n            type=\"text\"\n            value={query}\n            readOnly={compact}\n            tabIndex={compact ? -1 : undefined}\n            aria-hidden={compact ? true : undefined}\n            onChange={compact ? undefined : (e) => {\n              setQuery(e.target.value)\n              setActive(0)\n            }}\n            onKeyDown={compact ? undefined : onKeyDown}\n            placeholder=\"Type a command or search...\"\n            aria-label=\"Command\"\n            aria-controls={listId}\n            aria-activedescendant={activeId}\n            role=\"combobox\"\n            aria-expanded=\"true\"\n            spellCheck={false}\n            autoComplete=\"off\"\n            className=\"w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground\"\n          />\n          {!compact ? (\n            <span className=\"hidden items-center gap-1 sm:flex\">\n              <Key>esc</Key>\n            </span>\n          ) : null}\n        </div>\n\n        {/* Results. Grouped, scrollable, capped so long lists stay a palette. */}\n        <div\n          ref={scrollRef}\n          id={listId}\n          role=\"listbox\"\n          aria-label=\"Commands\"\n          className=\"max-h-80 overflow-y-auto overscroll-contain p-2\"\n        >\n          {order.length === 0 ? (\n            <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n              No commands match &ldquo;{query}&rdquo;\n            </p>\n          ) : (\n            groups.map((group) => (\n              <div key={group.heading} className=\"mb-1 last:mb-0\">\n                <p className=\"px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground\">\n                  {group.heading}\n                </p>\n                {group.commands.map((cmd) => {\n                  const index = order.indexOf(cmd)\n                  return (\n                    <Row\n                      key={cmd.id}\n                      command={cmd}\n                      compact={compact}\n                      optionId={`${uid}-opt-${cmd.id}`}\n                      active={index === active}\n                      onSelect={() => run(cmd)}\n                      onHover={() => setActive(index)}\n                    />\n                  )\n                })}\n              </div>\n            ))\n          )}\n        </div>\n\n        {/* Footer: nav legend on the left, run confirmation flash on the right. */}\n        <div className=\"flex items-center justify-between gap-2 border-t border-border px-4 py-2.5 text-[11px] text-muted-foreground\">\n          <span className=\"flex items-center gap-3\">\n            <span className=\"flex items-center gap-1\">\n              <Key>↑</Key>\n              <Key>↓</Key>\n              <span className=\"ml-1\">navigate</span>\n            </span>\n            <span className=\"hidden items-center gap-1 sm:flex\">\n              <Key>↵</Key>\n              <span className=\"ml-1\">run</span>\n            </span>\n          </span>\n          <span\n            aria-live=\"polite\"\n            className={cn(\n              \"flex items-center gap-1.5 font-medium text-violet-500 transition-opacity duration-200 motion-reduce:transition-none\",\n              ran ? \"opacity-100\" : \"opacity-0\",\n            )}\n          >\n            {ran ? (\n              <>\n                <span className=\"size-1.5 rounded-full bg-violet-500\" />\n                Ran &ldquo;{ran}&rdquo;\n              </>\n            ) : (\n              // Placeholder keeps the footer height stable when nothing ran.\n              <span className=\"select-none\">&nbsp;</span>\n            )}\n          </span>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport default CommandPalette\n","type":"registry:ui"},{"path":"lib/utils.ts","target":"lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n","type":"registry:lib"}],"meta":{"kind":"components","categories":["command"],"docs":"https://ui.artbloom.tech/artbloom/components/command-palette"}}