{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"command-palette","type":"registry:ui","title":"Command Palette","description":"Results reorder as you type.","dependencies":["motion"],"categories":["overlay"],"docs":"https://www.interior.dev/docs/command-palette","files":[{"path":"registry/interior/command-palette.tsx","type":"registry:ui","target":"components/interior/command-palette.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst BOUNDARY = /[\\s\\-_/.:]/;\nconst ROW = 36;\nconst GAP = 2;\nconst PAD = 5;\n\nexport type CommandItem = {\n  id: string;\n  label: string;\n  hint?: string;\n  keywords?: string;\n  shortcut?: string[];\n};\n\nexport type UseCommandPaletteOptions = {\n  items: CommandItem[];\n  onSelect: (item: CommandItem) => void;\n  onDismiss?: () => void;\n};\n\nfunction scoreOne(text: string, query: string): number {\n  const t = text.toLowerCase();\n  let cursor = 0;\n  let total = 0;\n  let streak = 0;\n\n  for (let i = 0; i < query.length; i++) {\n    const at = t.indexOf(query[i], cursor);\n    if (at < 0) return -1;\n    streak = at === cursor && i > 0 ? streak + 1 : 0;\n    total += 2 + streak * 4;\n    if (at === 0) total += 12;\n    else if (BOUNDARY.test(t[at - 1])) total += 8;\n    cursor = at + 1;\n  }\n\n  return total;\n}\n\nfunction rank(items: CommandItem[], query: string): CommandItem[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return items;\n\n  const scored: { item: CommandItem; score: number; order: number }[] = [];\n\n  for (let i = 0; i < items.length; i++) {\n    const item = items[i];\n    const direct = scoreOne(item.label, q);\n    const aliased = item.keywords ? scoreOne(item.keywords, q) - 3 : -1;\n    const best = Math.max(direct, item.keywords ? aliased : -1);\n    if (best < 0) continue;\n    scored.push({ item, score: best - item.label.length * 0.05, order: i });\n  }\n\n  scored.sort((a, b) => b.score - a.score || a.order - b.order);\n  return scored.map((s) => s.item);\n}\n\nexport function useCommandPalette({\n  items,\n  onSelect,\n  onDismiss,\n}: UseCommandPaletteOptions) {\n  const [query, setQuery] = useState(\"\");\n  const [pinned, setPinned] = useState<string | null>(null);\n\n  const listRef = useRef<HTMLUListElement>(null);\n  const pointer = useRef({ x: -1, y: -1 });\n\n  const select = useRef(onSelect);\n  select.current = onSelect;\n  const dismiss = useRef(onDismiss);\n  dismiss.current = onDismiss;\n\n  const results = useMemo(() => rank(items, query), [items, query]);\n\n  const activeId = results.some((r) => r.id === pinned)\n    ? pinned\n    : (results[0]?.id ?? null);\n  const activeIndex = results.findIndex((r) => r.id === activeId);\n\n  useEffect(() => {\n    if (listRef.current) listRef.current.scrollTop = 0;\n  }, [query]);\n\n  const reveal = (index: number) => {\n    const list = listRef.current;\n    const row = list?.children[index];\n    if (!list || !(row instanceof HTMLElement)) return;\n    const top = row.offsetTop - PAD;\n    const bottom = row.offsetTop + row.offsetHeight + PAD;\n    if (top < list.scrollTop) list.scrollTop = top;\n    else if (bottom > list.scrollTop + list.clientHeight) {\n      list.scrollTop = bottom - list.clientHeight;\n    }\n  };\n\n  const jump = (index: number) => {\n    if (results.length === 0) return;\n    const next = Math.max(0, Math.min(results.length - 1, index));\n    setPinned(results[next].id);\n    reveal(next);\n  };\n\n  const move = (delta: number) => {\n    if (results.length === 0) return;\n    const from = activeIndex < 0 ? 0 : activeIndex;\n    jump((from + delta + results.length) % results.length);\n  };\n\n  const run = (item?: CommandItem) => {\n    const target = item ?? results.find((r) => r.id === activeId);\n    if (target) select.current(target);\n  };\n\n  const pointerActivate = (id: string, event: React.PointerEvent) => {\n    const { x, y } = pointer.current;\n    if (event.clientX === x && event.clientY === y) return;\n    pointer.current = { x: event.clientX, y: event.clientY };\n    if (id !== activeId) setPinned(id);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      move(1);\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      move(-1);\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      jump(0);\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      jump(results.length - 1);\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      run();\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      dismiss.current?.();\n    }\n  };\n\n  return {\n    query,\n    setQuery,\n    results,\n    activeId,\n    activeIndex,\n    listRef,\n    onKeyDown,\n    pointerActivate,\n    jump,\n    move,\n    run,\n  };\n}\n\nexport type CommandPaletteProps = {\n  items: CommandItem[];\n  onSelect: (item: CommandItem) => void;\n  onDismiss?: () => void;\n\n  open?: boolean;\n  placeholder?: string;\n  emptyLabel?: string;\n  label?: string;\n  maxRows?: number;\n  autoFocus?: boolean;\n  className?: string;\n};\n\nexport function CommandPalette({\n  items,\n  onSelect,\n  onDismiss,\n  open,\n  placeholder = \"Search commands\",\n  emptyLabel = \"No command matches\",\n  label = \"Command palette\",\n  maxRows = 6,\n  autoFocus = false,\n  className = \"\",\n}: CommandPaletteProps) {\n  const uid = useId();\n  const reduced = useReducedMotion();\n  const panelRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const liveRef = useRef<HTMLSpanElement>(null);\n\n  const {\n    query,\n    setQuery,\n    results,\n    activeId,\n    listRef,\n    onKeyDown,\n    pointerActivate,\n    run,\n  } = useCommandPalette({ items, onSelect, onDismiss });\n\n  const rows = Math.max(1, Math.min(maxRows, items.length));\n  const height = PAD * 2 + rows * ROW + (rows - 1) * GAP;\n  const count = results.length;\n\n  useEffect(() => {\n    if (autoFocus) inputRef.current?.focus({ preventScroll: true });\n  }, [autoFocus]);\n\n  useEffect(() => {\n    if (open) setQuery(\"\");\n  }, [open, setQuery]);\n\n  useEffect(() => {\n    const id = setTimeout(() => {\n      if (!liveRef.current) return;\n      liveRef.current.textContent =\n        count === 0\n          ? emptyLabel\n          : `${count} ${count === 1 ? \"command\" : \"commands\"} available`;\n    }, 400);\n    return () => clearTimeout(id);\n  }, [count, emptyLabel]);\n\n  const spring = reduced ? { duration: 0 } : CELL;\n\n  const overlaid = open !== undefined;\n\n  const surface = (\n    <div\n      ref={panelRef}\n      className={`overflow-hidden rounded-[14px] border border-stone-200 bg-white dark:border-white/[0.16] dark:bg-[#1D1D1A] ${\n        overlaid\n          ? \"w-full max-w-[520px] shadow-[0_1px_2px_rgba(28,25,23,0.07),0_28px_56px_-24px_rgba(24,22,20,0.5)] dark:shadow-[0_3px_16px_rgba(0,0,0,0.65)]\"\n          : \"\"\n      } ${className}`}\n    >\n      <div className=\"flex h-11 items-center gap-2.5 border-b border-stone-200 px-3 dark:border-white/[0.16]\">\n        <svg\n          viewBox=\"0 0 16 16\"\n          className=\"size-[14px] shrink-0 text-stone-500 dark:text-stone-400\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.4\"\n          strokeLinecap=\"round\"\n          aria-hidden\n        >\n          <circle cx=\"7\" cy=\"7\" r=\"4.25\" />\n          <path d=\"M10.2 10.2 13.5 13.5\" />\n        </svg>\n        <input\n          ref={inputRef}\n          type=\"text\"\n          role=\"combobox\"\n          aria-label={label}\n          aria-expanded\n          aria-controls={`${uid}-list`}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={activeId ? `${uid}-${activeId}` : undefined}\n          autoComplete=\"off\"\n          spellCheck={false}\n          value={query}\n          placeholder={placeholder}\n          onChange={(e) => setQuery(e.target.value)}\n          onKeyDown={onKeyDown}\n          className=\"h-full min-w-0 flex-1 bg-transparent text-[13.5px] text-stone-700 outline-none placeholder:text-stone-400 dark:text-stone-200 dark:placeholder:text-stone-500\"\n        />\n        <span className=\"min-w-[3ch] shrink-0 text-right font-mono text-[9.5px] tabular-nums text-stone-500 dark:text-stone-400\">\n          {count}\n        </span>\n      </div>\n      <div className=\"relative\" style={{ height }}>\n        <ul\n          ref={listRef}\n          id={`${uid}-list`}\n          // eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role\n          role=\"listbox\"\n          aria-label={label}\n          onMouseDown={(e) => e.preventDefault()}\n          className=\"absolute inset-0 flex flex-col gap-[2px] overflow-y-auto overscroll-contain p-[5px] [scrollbar-gutter:stable]\"\n        >\n          {results.map((item) => {\n            const active = item.id === activeId;\n            return (\n              /* eslint-disable-next-line jsx-a11y/interactive-supports-focus */\n              <motion.li\n                key={item.id}\n                id={`${uid}-${item.id}`}\n                role=\"option\"\n                aria-selected={active}\n                layout={reduced ? false : \"position\"}\n                transition={spring}\n                onPointerMove={(e) => pointerActivate(item.id, e)}\n                onClick={() => run(item)}\n                className=\"relative flex h-9 shrink-0 cursor-default items-center rounded-[9px] px-2.5\"\n              >\n                <motion.span\n                  aria-hidden\n                  initial={false}\n                  animate={{ opacity: active ? 1 : 0 }}\n                  transition={reduced ? { duration: 0 } : CROSSFADE}\n                  className=\"absolute inset-0 rounded-[9px] bg-stone-100 dark:bg-white/10\"\n                />\n                <span className=\"relative flex min-w-0 flex-1 items-center gap-2.5\">\n                  <span className=\"truncate text-[13px] font-medium text-stone-700 dark:text-stone-200\">\n                    {item.label}\n                  </span>\n\n                  {item.hint ? (\n                    <span className=\"hidden shrink-0 text-[11.5px] text-stone-500 sm:inline dark:text-stone-400\">\n                      {item.hint}\n                    </span>\n                  ) : null}\n\n                  {item.shortcut ? (\n                    <span className=\"ml-auto flex shrink-0 items-center gap-1\">\n                      {item.shortcut.map((key) => (\n                        <span\n                          key={key}\n                          className=\"flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-stone-200 px-1 font-mono text-[9.5px] tabular-nums text-stone-500 dark:border-white/[0.16] dark:text-stone-400\"\n                        >\n                          {key}\n                        </span>\n                      ))}\n                    </span>\n                  ) : null}\n                </span>\n              </motion.li>\n            );\n          })}\n        </ul>\n\n        {count === 0 ? (\n          <motion.p\n            initial={reduced ? false : { opacity: 0 }}\n            animate={{ opacity: 1 }}\n            transition={reduced ? { duration: 0 } : CROSSFADE}\n            className=\"pointer-events-none absolute inset-0 flex items-center justify-center px-3 text-center text-[12.5px] text-stone-500 dark:text-stone-400\"\n          >\n            {emptyLabel}\n          </motion.p>\n        ) : null}\n      </div>\n      <span ref={liveRef} role=\"status\" aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n\n  if (!overlaid) return surface;\n  return (\n    <PaletteLayer\n      open={open}\n      onDismiss={onDismiss}\n      reduced={Boolean(reduced)}\n      panelRef={panelRef}\n    >\n      {surface}\n    </PaletteLayer>\n  );\n}\n\nconst LAYER_EASE = [0.23, 1, 0.32, 1] as const;\nconst LAYER_OUT = [0.4, 0, 1, 1] as const;\nconst PANEL = { type: \"spring\", stiffness: 420, damping: 36, mass: 0.9 } as const;\n\nfunction PaletteLayer({\n  open,\n  onDismiss,\n  reduced,\n  panelRef,\n  children,\n}: {\n  open: boolean;\n  onDismiss?: () => void;\n  reduced: boolean;\n  panelRef: React.RefObject<HTMLDivElement | null>;\n  children: React.ReactNode;\n}) {\n  const [host, setHost] = useState<HTMLElement | null>(null);\n  const downedOutside = useRef(false);\n  const leave = useRef(onDismiss);\n  leave.current = onDismiss;\n\n  useEffect(() => setHost(document.body), []);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      event.stopPropagation();\n      leave.current?.();\n    };\n    document.addEventListener(\"keydown\", onKeyDown, true);\n    return () => document.removeEventListener(\"keydown\", onKeyDown, true);\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const root = document.documentElement;\n    const overflow = root.style.overflow;\n    const padding = root.style.paddingRight;\n    const gutter = window.innerWidth - root.clientWidth;\n    root.style.overflow = \"hidden\";\n    if (gutter > 0) root.style.paddingRight = `${gutter}px`;\n    return () => {\n      root.style.overflow = overflow;\n      root.style.paddingRight = padding;\n    };\n  }, [open]);\n\n  if (!host) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {open ? (\n        <motion.div\n          key=\"palette-layer\"\n          className=\"fixed inset-0 z-50 flex items-center justify-center p-4\"\n          initial=\"closed\"\n          animate=\"open\"\n          exit=\"gone\"\n          variants={{ closed: {}, open: {}, gone: {} }}\n          onPointerDown={(event) => {\n            const panel = panelRef.current;\n            downedOutside.current = !panel?.contains(event.target as Node);\n          }}\n          onClick={(event) => {\n            const panel = panelRef.current;\n            if (panel?.contains(event.target as Node)) return;\n            if (!downedOutside.current) return;\n            downedOutside.current = false;\n            leave.current?.();\n          }}\n        >\n          <motion.div\n            aria-hidden\n            className=\"absolute inset-0 bg-stone-900/40 dark:bg-black/65\"\n            variants={{\n              closed: { opacity: 0 },\n              open: {\n                opacity: 1,\n                transition: reduced\n                  ? { duration: 0 }\n                  : { duration: 0.2, ease: LAYER_EASE },\n              },\n              gone: {\n                opacity: 0,\n                transition: reduced\n                  ? { duration: 0 }\n                  : { duration: 0.15, ease: LAYER_OUT },\n              },\n            }}\n          />\n          <motion.div\n            className=\"relative flex w-full justify-center\"\n            variants={{\n              closed: reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 },\n              open: {\n                opacity: 1,\n                scale: 1,\n                y: 0,\n                transition: reduced\n                  ? { duration: 0 }\n                  : { ...PANEL, opacity: { duration: 0.16, ease: LAYER_EASE } },\n              },\n              gone: reduced\n                ? { opacity: 0, transition: { duration: 0 } }\n                : {\n                    opacity: 0,\n                    scale: 0.98,\n                    y: 6,\n                    transition: { duration: 0.15, ease: LAYER_OUT },\n                  },\n            }}\n          >\n            {children}\n          </motion.div>\n        </motion.div>\n      ) : null}\n    </AnimatePresence>,\n    host,\n  );\n}\n"}]}