{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-menu",
  "type": "registry:ui",
  "title": "Command Menu",
  "description": "A searchable command palette with grouped commands, fuzzy matching and full keyboard control.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/command-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface CommandItem {\n  /** Stable, unique id. */\n  id: string;\n  label: string;\n  /** Secondary text shown after the label. */\n  description?: string;\n  icon?: React.ReactNode;\n  /** Keys shown on the right, e.g. [\"⌘\", \"N\"]. Display only. */\n  shortcut?: string[];\n  /** Extra search terms that match without being highlighted. */\n  keywords?: string[];\n  disabled?: boolean;\n  /** Runs when the command is chosen. */\n  onSelect?: () => void;\n}\n\nexport interface CommandGroup {\n  heading: string;\n  items: CommandItem[];\n}\n\nexport interface CommandMenuProps {\n  groups: CommandGroup[];\n  /** \"dialog\" opens a modal palette; \"inline\" renders the palette in place. */\n  variant?: \"dialog\" | \"inline\";\n  /** Open state (controlled, dialog variant). */\n  open?: boolean;\n  /** Initial open state (uncontrolled, dialog variant). */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Called after any command runs. */\n  onSelect?: (item: CommandItem) => void;\n  /** Key pressed with ⌘ / Ctrl to toggle the dialog (or focus the inline input). */\n  hotkey?: string;\n  disableHotkey?: boolean;\n  placeholder?: string;\n  emptyMessage?: string;\n  /** Accessible name of the palette. */\n  label?: string;\n  /** Accent colour for highlights, the active row marker and focus ring. */\n  accent?: string;\n  /** Highlight the matched characters in labels. */\n  highlightMatches?: boolean;\n  /** Close the dialog after a command runs. */\n  closeOnSelect?: boolean;\n  /** Show keyboard shortcut hints on the right of each row. */\n  showShortcuts?: boolean;\n  /** Maximum height of the results list in px. */\n  maxListHeight?: number;\n  className?: string;\n}\n\n/* ---------------------------------- search --------------------------------- */\n\ninterface Match {\n  score: number;\n  /** Indexes of matched characters in the label, for highlighting. */\n  indices: number[];\n}\n\nconst isBoundary = (text: string, i: number) => i === 0 || /[\\s\\-_/.:]/.test(text[i - 1]);\n\n/**\n * Fuzzy-ish matching: a contiguous substring wins (more so at a word start); otherwise every\n * query character must appear in order, rewarding consecutive runs and word starts.\n */\nfunction fuzzyMatch(text: string, query: string): Match | null {\n  const haystack = text.toLowerCase();\n  const needle = query.toLowerCase().replace(/\\s+/g, \" \").trim();\n  if (!needle) return { score: 0, indices: [] };\n\n  const at = haystack.indexOf(needle);\n  if (at !== -1) {\n    return {\n      score: 1000 - at + (isBoundary(haystack, at) ? 200 : 0),\n      indices: Array.from({ length: needle.length }, (_, i) => at + i),\n    };\n  }\n\n  const indices: number[] = [];\n  let from = 0;\n  let score = 0;\n  for (const char of needle) {\n    if (char === \" \") continue;\n    const found = haystack.indexOf(char, from);\n    if (found === -1) return null;\n    score += found === indices[indices.length - 1] + 1 ? 12 : 1;\n    if (isBoundary(haystack, found)) score += 8;\n    indices.push(found);\n    from = found + 1;\n  }\n  // Prefer tight matches over scattered ones.\n  return { score: score - (indices[indices.length - 1] - indices[0]) * 0.5, indices };\n}\n\nfunction matchItem(item: CommandItem, query: string): Match | null {\n  const label = fuzzyMatch(item.label, query);\n  if (label) return label;\n  const keywords = item.keywords?.join(\" \");\n  const keyword = keywords ? fuzzyMatch(keywords, query) : null;\n  return keyword ? { score: keyword.score * 0.5, indices: [] } : null;\n}\n\nfunction Highlight({ text, indices }: { text: string; indices: number[] }) {\n  if (!indices.length) return <>{text}</>;\n  const marked = new Set(indices);\n  const parts: { text: string; match: boolean }[] = [];\n  for (let i = 0; i < text.length; i++) {\n    const match = marked.has(i);\n    const last = parts[parts.length - 1];\n    if (last && last.match === match) last.text += text[i];\n    else parts.push({ text: text[i], match });\n  }\n  return (\n    <>\n      {parts.map((part, i) =>\n        part.match ? (\n          <mark\n            key={i}\n            className=\"rounded-[3px] bg-[color-mix(in_oklab,var(--cm-accent)_24%,transparent)] text-inherit\"\n          >\n            {part.text}\n          </mark>\n        ) : (\n          <React.Fragment key={i}>{part.text}</React.Fragment>\n        ),\n      )}\n    </>\n  );\n}\n\n/* -------------------------------- component -------------------------------- */\n\nconst domId = (value: string) => value.replace(/[^\\w-]/g, \"_\");\n\n/**\n * A searchable command palette with grouped commands, fuzzy filtering and match highlighting.\n * The dialog variant uses a native modal <dialog> (top layer, inert background, focus return).\n */\nexport function CommandMenu({\n  groups,\n  variant = \"dialog\",\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  onSelect,\n  hotkey = \"k\",\n  disableHotkey = false,\n  placeholder = \"Type a command or search…\",\n  emptyMessage = \"No matching commands.\",\n  label = \"Command menu\",\n  accent = \"#FF7448\",\n  highlightMatches = true,\n  closeOnSelect = true,\n  showShortcuts = true,\n  maxListHeight = 320,\n  className = \"\",\n}: CommandMenuProps) {\n  const id = React.useId();\n  const listId = `${id}-list`;\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const returnFocusRef = React.useRef<HTMLElement | null>(null);\n\n  const [query, setQuery] = React.useState(\"\");\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const isDialog = variant === \"dialog\";\n  const isOpen = isDialog ? (open ?? internalOpen) : true;\n\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n\n  /* ----------------------------- filtered results ---------------------------- */\n\n  const searching = query.trim() !== \"\";\n  const matchedGroups = groups\n    .map((group, g) => {\n      const entries = group.items\n        .map((item) => ({ item, match: matchItem(item, query) }))\n        .filter((entry): entry is { item: CommandItem; match: Match } => entry.match !== null);\n      if (searching) entries.sort((a, b) => b.match.score - a.match.score);\n      return { heading: group.heading, headingId: `${id}-group-${g}`, entries, best: entries[0]?.match.score ?? 0 };\n    })\n    .filter((group) => group.entries.length > 0);\n  // While searching, the group holding the best match comes first.\n  if (searching) matchedGroups.sort((a, b) => b.best - a.best);\n\n  const flat: CommandItem[] = [];\n  const results = matchedGroups.map((group) => ({\n    ...group,\n    entries: group.entries.map((entry) => ({ ...entry, index: flat.push(entry.item) - 1 })),\n  }));\n\n  const active = flat.length ? Math.min(activeIndex, flat.length - 1) : -1;\n  const activeItem = active >= 0 ? flat[active] : undefined;\n  const optionId = (item: CommandItem) => `${id}-option-${domId(item.id)}`;\n\n  /* --------------------------------- effects --------------------------------- */\n\n  // Open / close the native dialog in sync with state.\n  React.useEffect(() => {\n    const dialog = dialogRef.current;\n    if (!isDialog || !dialog) return;\n    if (isOpen && !dialog.open) {\n      returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n      dialog.showModal();\n      inputRef.current?.focus();\n    } else if (!isOpen && dialog.open) {\n      dialog.close();\n    }\n  }, [isOpen, isDialog]);\n\n  // Keep the active option visible by scrolling the list only (scrollIntoView could scroll the page).\n  React.useEffect(() => {\n    const list = listRef.current;\n    if (!activeItem || !list) return;\n    const option = list.querySelector<HTMLElement>(`[data-command-id=\"${CSS.escape(activeItem.id)}\"]`);\n    if (!option) return;\n    const listRect = list.getBoundingClientRect();\n    const optionRect = option.getBoundingClientRect();\n    // Convert from screen px to layout px in case an ancestor is scaled.\n    const scale = list.offsetHeight ? listRect.height / list.offsetHeight : 1;\n    const pad = 8 * scale;\n    if (optionRect.top < listRect.top + pad) list.scrollTop -= (listRect.top + pad - optionRect.top) / scale;\n    else if (optionRect.bottom > listRect.bottom - pad) list.scrollTop += (optionRect.bottom - listRect.bottom + pad) / scale;\n  }, [activeItem]);\n\n  const toggleFromHotkey = React.useEffectEvent(() => {\n    if (isDialog) setOpen(!isOpen);\n    else inputRef.current?.focus();\n  });\n\n  React.useEffect(() => {\n    if (disableHotkey || !hotkey) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;\n      if (event.key.toLowerCase() !== hotkey.toLowerCase()) return;\n      event.preventDefault();\n      toggleFromHotkey();\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [hotkey, disableHotkey]);\n\n  /* --------------------------------- actions --------------------------------- */\n\n  const run = (item: CommandItem | undefined) => {\n    if (!item || item.disabled) return;\n    item.onSelect?.();\n    onSelect?.(item);\n    if (isDialog && closeOnSelect) setOpen(false);\n  };\n\n  const move = (step: 1 | -1) => {\n    if (!flat.length) return;\n    let next = active;\n    // Walk past disabled commands, wrapping at both ends.\n    for (let i = 0; i < flat.length; i++) {\n      next = (next + step + flat.length) % flat.length;\n      if (!flat[next].disabled) break;\n    }\n    setActiveIndex(next);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.nativeEvent.isComposing) return;\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        move(1);\n        break;\n      case \"ArrowUp\":\n        event.preventDefault();\n        move(-1);\n        break;\n      case \"Enter\":\n        event.preventDefault();\n        run(activeItem);\n        break;\n      case \"Escape\":\n        // First Escape clears the query; the next one closes the dialog.\n        if (query) {\n          event.preventDefault();\n          event.stopPropagation();\n          setQuery(\"\");\n          setActiveIndex(0);\n        } else if (isDialog) {\n          event.preventDefault();\n          setOpen(false);\n        }\n        break;\n    }\n  };\n\n  /* --------------------------------- render --------------------------------- */\n\n  const panel = (\n    <div\n      className={`flex w-full max-w-[560px] flex-col overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-left text-zinc-900 shadow-[0_24px_64px_-24px_rgba(0,0,0,0.35),0_2px_6px_-2px_rgba(0,0,0,0.08)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_24px_64px_-24px_rgba(0,0,0,0.9)] ${\n        isDialog\n          ? \"transition-[opacity,translate,scale] duration-200 ease-out motion-reduce:transition-none starting:-translate-y-2 starting:scale-[0.98] starting:opacity-0\"\n          : \"\"\n      } ${className}`}\n      style={{ [\"--cm-accent\" as string]: accent }}\n    >\n      <div className=\"flex items-center gap-3 border-b border-zinc-900/[0.08] px-4 dark:border-white/[0.08]\">\n        <svg viewBox=\"0 0 16 16\" className=\"h-4 w-4 shrink-0 text-zinc-400\" aria-hidden=\"true\">\n          <circle cx=\"7\" cy=\"7\" r=\"4.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n          <path d=\"m10.5 10.5 3 3\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n        </svg>\n        <input\n          ref={inputRef}\n          type=\"text\"\n          role=\"combobox\"\n          aria-label={label}\n          aria-expanded={isOpen}\n          aria-controls={listId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={activeItem ? optionId(activeItem) : undefined}\n          autoComplete=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          value={query}\n          placeholder={placeholder}\n          onChange={(event) => {\n            setQuery(event.target.value);\n            setActiveIndex(0);\n          }}\n          onKeyDown={handleKeyDown}\n          className=\"h-12 min-w-0 flex-1 bg-transparent text-[15px] tracking-tight text-zinc-950 placeholder:text-zinc-400 focus:outline-none dark:text-zinc-50 dark:placeholder:text-zinc-500\"\n        />\n        {isDialog && (\n          <kbd className=\"rounded border border-zinc-900/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:border-white/10 dark:text-zinc-400\">\n            Esc\n          </kbd>\n        )}\n      </div>\n\n      <div\n        ref={listRef}\n        id={listId}\n        role=\"listbox\"\n        aria-label={label}\n        className=\"scroll-py-2 overflow-y-auto overscroll-contain p-2\"\n        style={{ maxHeight: maxListHeight }}\n      >\n        {results.map((group) => (\n          <div key={group.headingId} role=\"group\" aria-labelledby={group.headingId} className=\"pb-1\">\n            <div\n              id={group.headingId}\n              className=\"px-2.5 pb-1.5 pt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\"\n            >\n              {group.heading}\n            </div>\n            {group.entries.map(({ item, match, index }) => {\n              const isActive = index === active;\n              return (\n                <div\n                  key={item.id}\n                  id={optionId(item)}\n                  data-command-id={item.id}\n                  role=\"option\"\n                  aria-selected={isActive}\n                  aria-disabled={item.disabled || undefined}\n                  data-active={isActive || undefined}\n                  onPointerMove={() => {\n                    if (!item.disabled && index !== active) setActiveIndex(index);\n                  }}\n                  // Keep focus in the input when clicking an option.\n                  onMouseDown={(event) => event.preventDefault()}\n                  onClick={() => run(item)}\n                  className=\"group/option relative flex h-11 cursor-pointer select-none items-center gap-3 rounded-lg px-2.5 text-sm data-[active]:bg-zinc-900/[0.05] aria-disabled:cursor-not-allowed aria-disabled:opacity-40 dark:data-[active]:bg-white/[0.06]\"\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"absolute bottom-2.5 left-0 top-2.5 w-[2px] rounded-full bg-[var(--cm-accent)] opacity-0 transition-opacity group-data-[active]/option:opacity-100\"\n                  />\n                  {item.icon && (\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-zinc-900/10 bg-zinc-50 text-zinc-600 group-data-[active]/option:text-zinc-950 dark:border-white/10 dark:bg-white/[0.04] dark:text-zinc-400 dark:group-data-[active]/option:text-white [&_svg]:h-3.5 [&_svg]:w-3.5\"\n                    >\n                      {item.icon}\n                    </span>\n                  )}\n                  <span className=\"min-w-0 truncate\">\n                    <span className=\"font-medium tracking-tight\">\n                      {highlightMatches ? <Highlight text={item.label} indices={match.indices} /> : item.label}\n                    </span>\n                    {item.description && (\n                      <span className=\"ml-2 text-zinc-500 dark:text-zinc-400\">{item.description}</span>\n                    )}\n                  </span>\n                  {showShortcuts && item.shortcut && (\n                    <span aria-hidden=\"true\" className=\"ml-auto flex shrink-0 gap-1\">\n                      {item.shortcut.map((key, k) => (\n                        <kbd\n                          key={k}\n                          className=\"flex h-5 min-w-5 items-center justify-center rounded border border-zinc-900/10 bg-white px-1 font-mono text-[10px] text-zinc-500 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-400\"\n                        >\n                          {key}\n                        </kbd>\n                      ))}\n                    </span>\n                  )}\n                </div>\n              );\n            })}\n          </div>\n        ))}\n      </div>\n\n      {!flat.length && (\n        <p className=\"px-4 pb-8 pt-4 text-center text-sm text-zinc-500 dark:text-zinc-400\">{emptyMessage}</p>\n      )}\n      {/* Announces the number of results as the query changes. */}\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {query ? `${flat.length} ${flat.length === 1 ? \"result\" : \"results\"}` : \"\"}\n      </div>\n\n      <div className=\"flex items-center gap-4 border-t border-zinc-900/[0.08] px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:border-white/[0.08] dark:text-zinc-400\">\n        <span>↑↓ Navigate</span>\n        <span>↵ Run</span>\n        {isDialog && <span>Esc Close</span>}\n      </div>\n    </div>\n  );\n\n  if (!isDialog) return panel;\n\n  return (\n    <dialog\n      ref={dialogRef}\n      aria-label={label}\n      onCancel={(event) => {\n        // Escape outside the input: keep React state as the source of truth.\n        event.preventDefault();\n        setOpen(false);\n      }}\n      onClose={() => {\n        setQuery(\"\");\n        setActiveIndex(0);\n        returnFocusRef.current?.focus();\n        returnFocusRef.current = null;\n      }}\n      className=\"m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-zinc-950/40 backdrop:backdrop-blur-[2px]\"\n    >\n      <div\n        className=\"flex h-full w-full items-start justify-center px-4 pt-[12vh]\"\n        onClick={(event) => {\n          // Clicks on the overlay (outside the panel) close the palette.\n          if (event.target === event.currentTarget) setOpen(false);\n        }}\n      >\n        {panel}\n      </div>\n    </dialog>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/command-menu"
}
