{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-search",
  "title": "Command Search",
  "description": "A command palette that types queries and live-filters grouped results, with keyboard navigation.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "@spectrumui/use-typewriter"
  ],
  "files": [
    {
      "path": "app/registry/command-search/command-search.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useInView } from \"motion/react\";\nimport {\n  ArrowDown,\n  ArrowRight,\n  ArrowUp,\n  CornerDownLeft,\n  Search,\n  SearchX,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { useTypewriter } from \"@/components/spectrumui/use-typewriter\";\n\nexport interface CommandSearchItem {\n  label: string;\n  icon?: React.ReactNode;\n}\n\nexport interface CommandSearchGroup {\n  label: string;\n  items: CommandSearchItem[];\n}\n\nexport interface CommandSearchProps {\n  /** Static text for the search field (used when `autoType` is false). */\n  query?: string;\n  /** Queries the palette types out and live-filters by. */\n  queries?: string[];\n  /** Type the queries automatically and filter results as they type. */\n  autoType?: boolean;\n  placeholder?: string;\n  /** Grouped results rendered below the search field. */\n  groups?: CommandSearchGroup[];\n  /** Called with the item when a row is clicked. */\n  onSelect?: (item: CommandSearchItem) => void;\n  /** Fixed height of the palette. Content overflows are clipped like a real palette. */\n  height?: number;\n  className?: string;\n}\n\nconst DEFAULT_QUERIES = [\"anim\", \"chart\", \"dialog\", \"side\"];\n\nconst DEFAULT_GROUPS: CommandSearchGroup[] = [\n  {\n    label: \"Pages\",\n    items: [\n      { label: \"Animated Drawer\" },\n      { label: \"Docs\" },\n      { label: \"Components\" },\n      { label: \"Blocks\" },\n      { label: \"Charts\" },\n      { label: \"Directory\" },\n      { label: \"Create\" },\n    ],\n  },\n  {\n    label: \"Components\",\n    items: [\n      { label: \"Accordion\" },\n      { label: \"Alert\" },\n      { label: \"Alert Dialog\" },\n      { label: \"Animated Beam\" },\n      { label: \"Avatar\" },\n      { label: \"Badge\" },\n      { label: \"Breadcrumb\" },\n      { label: \"Button\" },\n      { label: \"Calendar\" },\n      { label: \"Card\" },\n      { label: \"Carousel\" },\n      { label: \"Chart\" },\n      { label: \"Checkbox\" },\n      { label: \"Combobox\" },\n      { label: \"Command\" },\n      { label: \"Dialog\" },\n      { label: \"Drawer\" },\n      { label: \"Dropdown Menu\" },\n      { label: \"Input\" },\n      { label: \"Navigation Menu\" },\n      { label: \"Popover\" },\n      { label: \"Select\" },\n      { label: \"Sidebar\" },\n      { label: \"Table\" },\n      { label: \"Tabs\" },\n      { label: \"Tooltip\" },\n    ],\n  },\n];\n\nconst ROW_SPRING = { type: \"spring\", stiffness: 420, damping: 34 } as const;\n\nfunction Caret() {\n  return (\n    <motion.span\n      aria-hidden\n      className=\"ml-[2px] h-[18px] w-[1.5px] shrink-0 rounded-full bg-foreground\"\n      animate={{ opacity: [1, 1, 0, 0] }}\n      transition={{ duration: 1.1, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}\n    />\n  );\n}\n\nexport function CommandSearch({\n  query = \"anim\",\n  queries,\n  autoType = true,\n  placeholder = \"Search…\",\n  groups = DEFAULT_GROUPS,\n  onSelect,\n  height = 408,\n  className,\n}: CommandSearchProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const inView = useInView(rootRef, { margin: \"-10% 0px\" });\n\n  const { text: typed } = useTypewriter(queries ?? DEFAULT_QUERIES, {\n    typeMs: 170,\n    deleteMs: 80,\n    holdMs: 2800,\n    gapMs: 1000,\n    enabled: autoType && inView,\n  });\n\n  const displayQuery = autoType ? typed : query;\n  const needle = displayQuery.trim().toLowerCase();\n\n  const filteredGroups = React.useMemo(\n    () =>\n      groups\n        .map((group) => ({\n          ...group,\n          items: needle\n            ? group.items.filter((item) =>\n                item.label.toLowerCase().includes(needle),\n              )\n            : group.items,\n        }))\n        .filter((group) => group.items.length > 0),\n    [groups, needle],\n  );\n\n  // The highlight tracks item identity (label), never a numeric index, so it\n  // stays correct while rows animate in and out. When the hovered row is\n  // filtered away it falls back to the first visible row — no state resets.\n  const [hoveredLabel, setHoveredLabel] = React.useState<string | null>(null);\n  const visibleLabels = React.useMemo(\n    () => new Set(filteredGroups.flatMap((g) => g.items.map((i) => i.label))),\n    [filteredGroups],\n  );\n  const activeLabel =\n    hoveredLabel && visibleLabels.has(hoveredLabel)\n      ? hoveredLabel\n      : filteredGroups[0]?.items[0]?.label ?? null;\n\n  // The highlight is ONE persistent element positioned in the list container,\n  // re-measured from the DOM whenever the active row or the filter changes.\n  // (A layoutId pill breaks here: the list reflows instantly under the\n  // animation and the projected position goes stale between re-renders.)\n  const rowRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const [pillY, setPillY] = React.useState<number | null>(null);\n\n  // Remeasure when the active row or filtered list changes so the pill stays\n  // aligned while auto-typing reflows results. setState bails out when unchanged.\n  React.useLayoutEffect(() => {\n    const row = activeLabel ? rowRefs.current.get(activeLabel) : undefined;\n    const next = row ? row.offsetTop : null;\n    setPillY((prev) => (prev === next ? prev : next));\n  }, [activeLabel, filteredGroups]);\n\n  const flatItems = React.useMemo(\n    () => filteredGroups.flatMap((group) => group.items),\n    [filteredGroups],\n  );\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (flatItems.length === 0) return;\n\n      const currentIndex = Math.max(\n        0,\n        flatItems.findIndex((item) => item.label === activeLabel),\n      );\n\n      if (event.key === \"ArrowDown\") {\n        event.preventDefault();\n        const next = flatItems[(currentIndex + 1) % flatItems.length];\n        setHoveredLabel(next.label);\n        rowRefs.current.get(next.label)?.scrollIntoView({ block: \"nearest\" });\n        return;\n      }\n\n      if (event.key === \"ArrowUp\") {\n        event.preventDefault();\n        const prev =\n          flatItems[(currentIndex - 1 + flatItems.length) % flatItems.length];\n        setHoveredLabel(prev.label);\n        rowRefs.current.get(prev.label)?.scrollIntoView({ block: \"nearest\" });\n        return;\n      }\n\n      if (event.key === \"Enter\") {\n        event.preventDefault();\n        const selected = flatItems[currentIndex] ?? flatItems[0];\n        if (selected) onSelect?.(selected);\n      }\n    },\n    [activeLabel, flatItems, onSelect],\n  );\n\n  const activeOptionId = activeLabel\n    ? `command-search-option-${activeLabel.replace(/\\s+/g, \"-\").toLowerCase()}`\n    : undefined;\n\n  return (\n    <div\n      ref={rootRef}\n      tabIndex={0}\n      role=\"listbox\"\n      aria-label=\"Command search\"\n      aria-activedescendant={activeOptionId}\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"relative flex w-full flex-col overflow-hidden rounded-[14px] bg-white p-2 pb-11 outline-hidden\",\n        \"shadow-[0_1px_3px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(10,10,10,0.05)] drop-shadow-[0_4px_14.2px_rgba(108,108,108,0.25)]\",\n        \"focus-visible:ring-2 focus-visible:ring-neutral-400/40 dark:focus-visible:ring-neutral-500/40\",\n        \"dark:bg-neutral-950 dark:shadow-[0_1px_3px_0_rgba(0,0,0,0.5),0_0_0_1px_rgba(255,255,255,0.12)]\",\n        className,\n      )}\n      style={{ height }}\n    >\n      {/* Search field */}\n      <div className=\"flex h-9 shrink-0 items-center gap-3 rounded-lg border border-border bg-neutral-200/50 px-3 dark:bg-neutral-800/50\">\n        <Search className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n        <div className=\"flex min-w-0 flex-1 items-center font-inter text-base text-[#020202] dark:text-neutral-50\">\n          <span className=\"whitespace-pre\">{displayQuery}</span>\n          <Caret />\n          {!displayQuery ? (\n            <span className=\"ml-1 truncate text-muted-foreground/60\">\n              {placeholder}\n            </span>\n          ) : null}\n        </div>\n      </div>\n\n      {/* Results — filtering is instant, like a native palette; the animated\n          piece is the highlight pill gliding between rows. */}\n      <div\n        className=\"relative flex-1 overflow-y-auto overflow-x-hidden\"\n        onMouseLeave={() => setHoveredLabel(null)}\n      >\n        <motion.span\n          aria-hidden\n          initial={false}\n          animate={{\n            y: pillY ?? 0,\n            opacity: pillY === null ? 0 : 1,\n          }}\n          transition={{ type: \"spring\", stiffness: 420, damping: 36 }}\n          className=\"pointer-events-none absolute inset-x-0 top-0 h-9 rounded-lg bg-neutral-200/50 dark:bg-neutral-800/60\"\n        />\n\n        {filteredGroups.map((group) => (\n          <div key={group.label}>\n            <p className=\"px-3 pb-1 pt-4 text-xs font-medium leading-4 text-muted-foreground\">\n              {group.label}\n            </p>\n            {group.items.map((item) => {\n              const active = item.label === activeLabel;\n              return (\n                <motion.button\n                  key={item.label}\n                  id={`command-search-option-${item.label.replace(/\\s+/g, \"-\").toLowerCase()}`}\n                  ref={(el) => {\n                    if (el) rowRefs.current.set(item.label, el);\n                    else rowRefs.current.delete(item.label);\n                  }}\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={active}\n                  tabIndex={-1}\n                  onClick={() => onSelect?.(item)}\n                  onMouseEnter={() => setHoveredLabel(item.label)}\n                  onFocus={() => setHoveredLabel(item.label)}\n                  whileTap={{ scale: 0.985 }}\n                  className=\"relative flex h-9 w-full items-center gap-3 rounded-lg px-3 text-left outline-hidden\"\n                >\n                  <span className=\"flex min-w-0 items-center gap-3\">\n                    <motion.span\n                      animate={{ x: active ? 2 : 0 }}\n                      transition={ROW_SPRING}\n                      className=\"shrink-0 text-muted-foreground [&_svg]:h-4 [&_svg]:w-4\"\n                    >\n                      {item.icon ?? <ArrowRight />}\n                    </motion.span>\n                    <span\n                      className={cn(\n                        \"truncate text-sm font-medium leading-5 transition-colors duration-150\",\n                        active\n                          ? \"text-neutral-900 dark:text-neutral-100\"\n                          : \"text-neutral-900/80 dark:text-neutral-100/80\",\n                      )}\n                    >\n                      <Highlighted label={item.label} needle={needle} />\n                    </span>\n                  </span>\n                </motion.button>\n              );\n            })}\n          </div>\n        ))}\n\n          <AnimatePresence>\n            {filteredGroups.length === 0 ? (\n              <motion.div\n                initial={{ opacity: 0, y: 6 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0 }}\n                transition={{ duration: 0.2, ease: \"easeOut\" }}\n                className=\"flex flex-col items-center gap-2 pt-10 text-center\"\n              >\n                <SearchX className=\"h-5 w-5 text-muted-foreground/60\" />\n                <p className=\"text-sm text-muted-foreground\">\n                  No results for&nbsp;\n                  <span className=\"font-medium text-foreground\">\n                    “{displayQuery}”\n                  </span>\n                </p>\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n      </div>\n\n      {/* Footer hints */}\n      <div className=\"absolute inset-x-0 bottom-0 z-10 flex h-11 items-center gap-2 rounded-b-[14px] border-t border-border bg-neutral-50 px-4 dark:bg-neutral-900/60\">\n        <kbd className=\"flex h-[22px] w-[22px] items-center justify-center rounded-md border border-border bg-white dark:bg-neutral-900\">\n          <ArrowUp className=\"h-3 w-3 text-muted-foreground\" />\n        </kbd>\n        <kbd className=\"flex h-[22px] w-[22px] items-center justify-center rounded-md border border-border bg-white dark:bg-neutral-900\">\n          <ArrowDown className=\"h-3 w-3 text-muted-foreground\" />\n        </kbd>\n        <span className=\"font-inter text-xs font-medium text-[#71717a] dark:text-neutral-400\">\n          Navigate\n        </span>\n        <kbd className=\"ml-2 flex h-[22px] w-[22px] items-center justify-center rounded-md border border-border bg-white dark:bg-neutral-900\">\n          <CornerDownLeft className=\"h-3 w-3 text-muted-foreground\" />\n        </kbd>\n        <span className=\"font-inter text-xs font-medium text-[#71717a] dark:text-neutral-400\">\n          Select\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction Highlighted({ label, needle }: { label: string; needle: string }) {\n  if (!needle) return <>{label}</>;\n  const start = label.toLowerCase().indexOf(needle);\n  if (start === -1) return <>{label}</>;\n  const end = start + needle.length;\n  return (\n    <>\n      {label.slice(0, start)}\n      <span className=\"rounded-[3px] bg-amber-200/40 dark:bg-amber-400/15\">\n        {label.slice(start, end)}\n      </span>\n      {label.slice(end)}\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/command-search.tsx"
    }
  ],
  "type": "registry:component"
}
