{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["@base-ui/react", "motion", "lucide-react"],
  "devDependencies": [],
  "files": [
    {
      "path": "combobox.tsx",
      "content": "\"use client\";\n\nimport { ScrollArea as ScrollAreaPrimitive } from \"@base-ui/react/scroll-area\";\nimport { Check, ChevronsUpDown, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/utils\";\n\nconst controlCornerClassName =\n  \"rounded-lg supports-[corner-shape:squircle]:corner-squircle supports-[corner-shape:squircle]:rounded-[11px]\";\n\nconst controlCornerInheritClassName =\n  \"rounded-[inherit] supports-[corner-shape:squircle]:[corner-shape:inherit]\";\n\nconst componentThemeClassName =\n  \"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]\";\n\nconst comboboxListScrollbarClassName =\n  \"z-10 my-1.5 mr-0.5 w-1 shrink-0 touch-none select-none opacity-0 transition-opacity duration-150 before:absolute before:left-1/2 before:h-full before:w-5 before:-translate-x-1/2 before:content-[''] data-hovering:pointer-events-auto data-hovering:opacity-100 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-0\";\n\nconst comboboxListThumbClassName =\n  \"relative rounded-full bg-muted-foreground/50 bg-[color:color-mix(in_oklch,var(--ic-muted-foreground),transparent_35%)]\";\n\nexport type ComboboxOption = {\n  value: string;\n  label: string;\n  description?: string;\n};\n\nexport interface ComboboxProps {\n  options: ComboboxOption[];\n  value?: string;\n  onChange?: (value: string) => void;\n  placeholder?: string;\n  emptyMessage?: string;\n  className?: string;\n  disabled?: boolean;\n  clearable?: boolean;\n  openOnFocus?: boolean;\n}\n\ntype SearchParts = {\n  normalized: string;\n  compact: string;\n  tokens: string[];\n};\n\nfunction getSearchParts(value: string): SearchParts {\n  const normalized = value\n    .normalize(\"NFKD\")\n    .replace(/[\\u0300-\\u036f]/g, \"\")\n    .toLowerCase()\n    .replace(/[_-]+/g, \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n\n  return {\n    normalized,\n    compact: normalized.replace(/\\s+/g, \"\"),\n    tokens: normalized.split(\" \").filter(Boolean),\n  };\n}\n\nfunction matchesSearch(\n  candidate: string | undefined,\n  { normalized, compact, tokens }: SearchParts\n) {\n  if (!candidate) return false;\n\n  const candidateParts = getSearchParts(candidate);\n\n  if (candidateParts.normalized.includes(normalized)) return true;\n  if (compact && candidateParts.compact.includes(compact)) return true;\n\n  return (\n    tokens.length > 0 &&\n    tokens.every(\n      (token) =>\n        candidateParts.normalized.includes(token) ||\n        candidateParts.compact.includes(token)\n    )\n  );\n}\n\nexport function Combobox({\n  options,\n  value,\n  onChange,\n  placeholder = \"Select an option...\",\n  emptyMessage = \"No results found.\",\n  className,\n  disabled = false,\n  clearable = true,\n  openOnFocus = false,\n}: ComboboxProps) {\n  const [mounted, setMounted] = React.useState(false);\n  const [open, setOpen] = React.useState(false);\n  const [query, setQuery] = React.useState(\"\");\n  const [isEditingQuery, setIsEditingQuery] = React.useState(false);\n  const [activeIndex, setActiveIndex] = React.useState(0);\n  const [isSmallScreen, setIsSmallScreen] = React.useState(false);\n  const [menuStyle, setMenuStyle] = React.useState<React.CSSProperties>({\n    top: 0,\n    left: 0,\n    width: 0,\n  });\n\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const menuRef = React.useRef<HTMLDivElement>(null);\n  const listboxId = React.useId();\n\n  const selected = options.find((o) => o.value === value);\n\n  const filtered = React.useMemo(() => {\n    const search = getSearchParts(query);\n    if (!search.normalized) return options;\n\n    return options.filter(\n      (o) =>\n        matchesSearch(o.label, search) ||\n        matchesSearch(o.value, search) ||\n        matchesSearch(o.description, search)\n    );\n  }, [options, query]);\n\n  // Close on outside click\n  React.useEffect(() => {\n    if (!open) return;\n    const handler = (e: MouseEvent) => {\n      const target = e.target as Node;\n      if (containerRef.current?.contains(target)) return;\n      if (menuRef.current?.contains(target)) return;\n      setOpen(false);\n    };\n    document.addEventListener(\"mousedown\", handler);\n    return () => document.removeEventListener(\"mousedown\", handler);\n  }, [open]);\n\n  // Reset query when closing\n  React.useEffect(() => {\n    if (open) {\n      const idx = filtered.findIndex((o) => o.value === value);\n      setActiveIndex(idx >= 0 ? idx : 0);\n    } else {\n      setQuery(\"\");\n      setIsEditingQuery(false);\n    }\n  }, [open, filtered, value]);\n\n  React.useEffect(() => {\n    if (activeIndex >= filtered.length) setActiveIndex(0);\n  }, [filtered.length, activeIndex]);\n\n  React.useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\") return;\n\n    const mediaQuery = window.matchMedia(\"(max-width: 640px)\");\n    const updateViewportMode = () => setIsSmallScreen(mediaQuery.matches);\n\n    updateViewportMode();\n    mediaQuery.addEventListener(\"change\", updateViewportMode);\n\n    return () => mediaQuery.removeEventListener(\"change\", updateViewportMode);\n  }, []);\n\n  const updateMenuPosition = React.useCallback(() => {\n    const container = containerRef.current;\n    if (!container) return;\n    const rect = container.getBoundingClientRect();\n    setMenuStyle({\n      top: rect.bottom + 4,\n      left: rect.left,\n      width: rect.width,\n    });\n  }, []);\n\n  React.useEffect(() => {\n    if (!open) return;\n    if (isSmallScreen) return;\n    updateMenuPosition();\n    const onScrollOrResize = () => updateMenuPosition();\n    window.addEventListener(\"resize\", onScrollOrResize);\n    window.addEventListener(\"scroll\", onScrollOrResize, true);\n    return () => {\n      window.removeEventListener(\"resize\", onScrollOrResize);\n      window.removeEventListener(\"scroll\", onScrollOrResize, true);\n    };\n  }, [open, updateMenuPosition, isSmallScreen]);\n\n  React.useEffect(() => {\n    if (!open) return;\n    const el = listRef.current?.querySelector<HTMLElement>(\n      `[data-index=\"${activeIndex}\"]`\n    );\n    el?.scrollIntoView({ block: \"nearest\" });\n  }, [activeIndex, open]);\n\n  const selectDisplayedValue = React.useCallback(() => {\n    if (!selected?.label || query || isEditingQuery) return;\n\n    requestAnimationFrame(() => {\n      const input = inputRef.current;\n      if (input && document.activeElement === input) {\n        input.select();\n      }\n    });\n  }, [selected?.label, query, isEditingQuery]);\n\n  const select = (opt: ComboboxOption) => {\n    onChange?.(opt.value);\n    setOpen(false);\n    inputRef.current?.blur();\n  };\n\n  const handleArrowKey = (key: \"ArrowDown\" | \"ArrowUp\") => {\n    if (!open) {\n      setOpen(true);\n      selectDisplayedValue();\n      return;\n    }\n    setActiveIndex((i) => {\n      if (!filtered.length) return 0;\n      if (key === \"ArrowDown\") return (i + 1) % filtered.length;\n      return (i - 1 + filtered.length) % filtered.length;\n    });\n  };\n\n  const handleOpenListKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (!open) return;\n    if (e.key === \"Home\") {\n      e.preventDefault();\n      setActiveIndex(0);\n      return;\n    }\n    if (e.key === \"End\") {\n      e.preventDefault();\n      setActiveIndex(Math.max(0, filtered.length - 1));\n      return;\n    }\n    if (e.key === \"Enter\" && filtered[activeIndex]) {\n      e.preventDefault();\n      select(filtered[activeIndex]);\n    }\n  };\n\n  const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    switch (e.key) {\n      case \"Tab\":\n        setOpen(false);\n        break;\n      case \"Escape\":\n        if (open) {\n          e.preventDefault();\n          setOpen(false);\n        }\n        break;\n      case \"ArrowDown\":\n      case \"ArrowUp\":\n        e.preventDefault();\n        handleArrowKey(e.key);\n        break;\n      case \"Home\":\n      case \"End\":\n      case \"Enter\":\n        handleOpenListKey(e);\n        break;\n      default:\n        break;\n    }\n  };\n\n  const displayValue = open\n    ? isEditingQuery\n      ? query\n      : (selected?.label ?? query)\n    : (selected?.label ?? \"\");\n\n  const menuContent = (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          animate={{ opacity: 1, y: 0 }}\n          className={cn(\n            componentThemeClassName,\n            controlCornerClassName,\n            \"z-[9999] overflow-hidden border border-neutral-200 bg-white text-neutral-900 shadow-lg dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100\"\n          )}\n          exit={{ opacity: 0, y: -4 }}\n          initial={{ opacity: 0, y: -6 }}\n          key=\"popover\"\n          ref={menuRef}\n          role=\"presentation\"\n          style={\n            isSmallScreen\n              ? undefined\n              : {\n                  position: \"fixed\",\n                  ...menuStyle,\n                  transformOrigin: \"top center\",\n                }\n          }\n          transition={{\n            duration: 0.16,\n            ease: [0.22, 1, 0.36, 1],\n          }}\n        >\n          <ScrollAreaPrimitive.Root className=\"relative max-h-64 overflow-hidden\">\n            <ScrollAreaPrimitive.Viewport\n              className=\"max-h-64 min-h-0 overscroll-contain p-1 outline-none\"\n              id={listboxId}\n              ref={listRef}\n              role=\"listbox\"\n            >\n              {filtered.length === 0 ? (\n                <motion.div\n                  animate={{ opacity: 1 }}\n                  className=\"px-3 py-6 text-center text-muted-foreground text-sm\"\n                  initial={{ opacity: 0 }}\n                >\n                  {emptyMessage}\n                </motion.div>\n              ) : (\n                filtered.map((opt, index) => {\n                  const isSelected = opt.value === value;\n                  const isActive = index === activeIndex;\n                  return (\n                    <motion.div\n                      animate={{ opacity: 1, y: 0 }}\n                      aria-selected={isSelected}\n                      className={cn(\n                        \"relative flex cursor-pointer select-none items-start gap-2 px-2.5 py-2 text-sm transition-colors\",\n                        controlCornerClassName,\n                        isActive ? \"text-accent-foreground\" : \"text-foreground\"\n                      )}\n                      data-index={index}\n                      id={`${listboxId}-opt-${opt.value}`}\n                      initial={{ opacity: 0, y: 2 }}\n                      key={opt.value}\n                      onClick={() => select(opt)}\n                      onMouseDown={(e) => {\n                        e.preventDefault();\n                      }}\n                      onMouseEnter={() => setActiveIndex(index)}\n                      role=\"option\"\n                      transition={{\n                        duration: 0.12,\n                        ease: \"easeOut\",\n                      }}\n                    >\n                      {isActive && (\n                        <motion.div\n                          className={cn(\n                            \"absolute inset-0 -z-10 bg-accent\",\n                            controlCornerInheritClassName\n                          )}\n                          layoutId=\"combobox-active\"\n                          transition={{\n                            type: \"spring\",\n                            stiffness: 500,\n                            damping: 35,\n                          }}\n                        />\n                      )}\n                      <span className=\"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center\">\n                        <AnimatePresence initial={false}>\n                          {isSelected && (\n                            <motion.span\n                              animate={{ scale: 1, rotate: 0, opacity: 1 }}\n                              className=\"text-primary\"\n                              exit={{ scale: 0.95, rotate: 90, opacity: 0 }}\n                              initial={{ scale: 0.95, rotate: -90, opacity: 0 }}\n                              transition={{\n                                type: \"spring\",\n                                stiffness: 500,\n                                damping: 22,\n                              }}\n                            >\n                              <Check className=\"h-4 w-4\" strokeWidth={3} />\n                            </motion.span>\n                          )}\n                        </AnimatePresence>\n                      </span>\n                      <span className=\"flex flex-col\">\n                        <span className=\"font-medium leading-tight\">\n                          {opt.label}\n                        </span>\n                        {opt.description && (\n                          <span className=\"text-muted-foreground text-xs\">\n                            {opt.description}\n                          </span>\n                        )}\n                      </span>\n                    </motion.div>\n                  );\n                })\n              )}\n            </ScrollAreaPrimitive.Viewport>\n            <ScrollAreaPrimitive.Scrollbar\n              className={comboboxListScrollbarClassName}\n              orientation=\"vertical\"\n            >\n              <ScrollAreaPrimitive.Thumb\n                className={comboboxListThumbClassName}\n              />\n            </ScrollAreaPrimitive.Scrollbar>\n          </ScrollAreaPrimitive.Root>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n\n  return (\n    <div\n      className={cn(componentThemeClassName, \"relative w-full\", className)}\n      ref={containerRef}\n    >\n      <div\n        className={cn(\n          \"group flex h-11 w-full items-center gap-2 border border-input bg-background px-3.5 text-base shadow-sm transition-[border-color,box-shadow] sm:text-sm\",\n          controlCornerClassName,\n          \"hover:border-ring/40 hover:shadow\",\n          \"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          open && \"border-ring ring-2 ring-ring/30\"\n        )}\n        onClick={() => {\n          if (!disabled) {\n            setOpen(true);\n            inputRef.current?.focus();\n            selectDisplayedValue();\n          }\n        }}\n        onKeyDown={(e) => {\n          if (disabled || e.target !== e.currentTarget) return;\n          if (e.key === \"Enter\" || e.key === \" \") {\n            e.preventDefault();\n            setOpen(true);\n            inputRef.current?.focus();\n            selectDisplayedValue();\n          }\n        }}\n      >\n        <input\n          aria-activedescendant={\n            open && filtered[activeIndex]\n              ? `${listboxId}-opt-${filtered[activeIndex].value}`\n              : undefined\n          }\n          aria-autocomplete=\"list\"\n          aria-controls={listboxId}\n          aria-expanded={open}\n          className=\"h-full w-full flex-1 bg-transparent text-[16px] text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed sm:text-sm\"\n          disabled={disabled}\n          onChange={(e) => {\n            if (!open) setOpen(true);\n            setIsEditingQuery(true);\n            setQuery(e.target.value);\n            setActiveIndex(0);\n          }}\n          onFocus={() => {\n            selectDisplayedValue();\n            if (openOnFocus) setOpen(true);\n          }}\n          onKeyDown={onInputKeyDown}\n          placeholder={placeholder}\n          ref={inputRef}\n          role=\"combobox\"\n          type=\"text\"\n          value={displayValue}\n        />\n\n        {clearable && selected && !disabled && (\n          <button\n            aria-label=\"Clear selection\"\n            className={cn(\n              \"flex h-10 w-10 shrink-0 items-center justify-center text-muted-foreground opacity-70 transition-colors hover:bg-accent/60 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30\",\n              controlCornerClassName\n            )}\n            onClick={(e) => {\n              e.stopPropagation();\n              onChange?.(\"\");\n              setQuery(\"\");\n              setIsEditingQuery(open);\n              inputRef.current?.focus();\n            }}\n            type=\"button\"\n          >\n            <X className=\"h-3.5 w-3.5\" />\n          </button>\n        )}\n\n        <motion.span\n          animate={{ rotate: open ? 180 : 0 }}\n          className=\"text-muted-foreground\"\n          transition={{ type: \"spring\", stiffness: 300, damping: 22 }}\n        >\n          <ChevronsUpDown className=\"h-4 w-4\" />\n        </motion.span>\n      </div>\n\n      {isSmallScreen ? (\n        <div className=\"absolute inset-x-0 top-full z-[9999] mt-1\">\n          {menuContent}\n        </div>\n      ) : mounted ? (\n        createPortal(menuContent, document.body)\n      ) : null}\n    </div>\n  );\n}\n\nexport { Combobox as combobox };\n",
      "type": "registry:ui"
    }
  ],
  "title": "Combobox",
  "description": "Searchable single-select input with inline filtering, arrow-key navigation, animated dropdown motion, and an optional clear action."
}
