{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "Search and select from a list without leaving the field.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/combobox/combobox.tsx",
      "content": "\"use client\";\n\nimport { animate, AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, ChevronDown, Search, X } from \"lucide-react\";\nimport { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from \"react\";\nimport type { InputHTMLAttributes, KeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from \"react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface ComboboxOption {\n  value: string;\n  label: string;\n  disabled?: boolean;\n  keywords?: string[];\n}\n\nexport interface ComboboxProps extends Omit<\n  InputHTMLAttributes<HTMLInputElement>,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"placeholder\"\n> {\n  label: string;\n  options: ComboboxOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  description?: string;\n  placeholder?: string;\n  emptyMessage?: string;\n  className?: string;\n}\n\n/* Arrow keys move the highlight often, so it changes almost instantly. */\nconst optionClass =\n  \"flex min-h-9 cursor-pointer items-center justify-between gap-3 rounded-[calc(var(--radius-panel)-5px)] px-3 text-(length:--text-sm) text-ink outline-none select-none [transition:background-color_80ms_var(--ease-standard),color_80ms_var(--ease-standard)] data-[active=true]:bg-hover data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-[.42] motion-reduce:transition-none [&>span:first-child]:min-w-0 [&>span:first-child]:truncate\";\n\n/** Follows the listbox height with a critically damped spring, so filtering never snaps the menu. */\nfunction AutoHeight({ children, reduceMotion }: { children: ReactNode; reduceMotion: boolean | null }) {\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const inner = innerRef.current;\n    if (!inner) return;\n    const observer = new ResizeObserver(() => setHeight(inner.offsetHeight));\n    observer.observe(inner);\n    return () => observer.disconnect();\n  }, []);\n  return (\n    <motion.div\n      className=\"overflow-hidden\"\n      initial={false}\n      animate={{ height }}\n      transition={reduceMotion ? { duration: 0 } : motionTokens.spring.smooth}\n    >\n      <div ref={innerRef}>{children}</div>\n    </motion.div>\n  );\n}\n\nexport const Combobox = forwardRef<HTMLInputElement, ComboboxProps>(function Combobox(\n  {\n    label,\n    options,\n    value: controlledValue,\n    defaultValue = \"\",\n    onValueChange,\n    description,\n    placeholder = \"Search or select…\",\n    emptyMessage = \"No matches found\",\n    id,\n    className,\n    disabled,\n    onFocus,\n    ...inputProps\n  },\n  forwardedRef,\n) {\n  const generatedId = useId();\n  const controlId = id ?? generatedId;\n  const listboxId = `${controlId}-listbox`;\n  const hintId = description ? `${controlId}-description` : undefined;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const optionRefs = useRef<Record<string, HTMLDivElement | null>>({});\n  const reduceMotion = useReducedMotion();\n  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const selectedValue = controlledValue ?? uncontrolledValue;\n  const selectedOption = options.find((option) => option.value === selectedValue);\n\n  useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);\n\n  // A chosen label settles into the field: it rises in from below with a soft blur. Clearing fades the\n  // placeholder in instead of snapping from the old label.\n  const settledValue = useRef(selectedValue);\n  useEffect(() => {\n    if (settledValue.current === selectedValue) return;\n    settledValue.current = selectedValue;\n    const input = inputRef.current;\n    if (!input || reduceMotion || (selectedValue && open)) return;\n    const controls = selectedValue\n      ? animate(\n          input,\n          { opacity: [0, 1], y: [\"0.35em\", \"0em\"], filter: [`blur(${motionTokens.blur.soft}px)`, \"blur(0px)\"] },\n          { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n        )\n      : animate(\n          input,\n          { opacity: [0, 1] },\n          { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n        );\n    return () => controls.complete();\n  }, [selectedValue, reduceMotion, open]);\n\n  const filteredOptions = useMemo(() => {\n    const normalizedQuery = query.trim().toLocaleLowerCase();\n    if (!normalizedQuery) return options;\n    return options.filter((option) =>\n      [option.label, ...(option.keywords ?? [])].some((term) => term.toLocaleLowerCase().includes(normalizedQuery)),\n    );\n  }, [options, query]);\n\n  const enabledIndices = filteredOptions.reduce<number[]>((indices, option, index) => {\n    if (!option.disabled) indices.push(index);\n    return indices;\n  }, []);\n\n  useEffect(() => {\n    if (!open) return;\n    const activeOption = activeIndex >= 0 ? filteredOptions[activeIndex] : undefined;\n    const option = activeOption ? optionRefs.current[activeOption.value] : null;\n    const listbox = option?.parentElement;\n    if (option && listbox) {\n      const top = option.offsetTop;\n      const bottom = top + option.offsetHeight;\n      if (top < listbox.scrollTop) listbox.scrollTop = top;\n      else if (bottom > listbox.scrollTop + listbox.clientHeight) listbox.scrollTop = bottom - listbox.clientHeight;\n    }\n  }, [activeIndex, filteredOptions, open]);\n\n  useEffect(() => {\n    const handlePointerDown = (event: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(event.target as Node)) {\n        setOpen(false);\n        setQuery(\"\");\n      }\n    };\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, []);\n\n  const choose = (option: ComboboxOption) => {\n    if (option.disabled) return;\n    setUncontrolledValue(option.value);\n    onValueChange?.(option.value);\n    setQuery(\"\");\n    setOpen(false);\n    inputRef.current?.focus();\n  };\n\n  const clear = (event: ReactMouseEvent<HTMLButtonElement>) => {\n    event.preventDefault();\n    setUncontrolledValue(\"\");\n    onValueChange?.(\"\");\n    setQuery(\"\");\n    setOpen(true);\n    inputRef.current?.focus();\n  };\n\n  const openMenu = () => {\n    if (disabled) return;\n    setOpen(true);\n    setQuery(\"\");\n    setActiveIndex(-1);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!open) {\n        openMenu();\n        return;\n      }\n      if (!enabledIndices.length) return;\n      const currentPosition = enabledIndices.indexOf(activeIndex);\n      const nextPosition =\n        event.key === \"ArrowDown\"\n          ? (currentPosition + 1) % enabledIndices.length\n          : (currentPosition - 1 + enabledIndices.length) % enabledIndices.length;\n      setActiveIndex(enabledIndices[nextPosition]!);\n      return;\n    }\n    if (event.key === \"Enter\" && open && activeIndex >= 0) {\n      event.preventDefault();\n      const option = filteredOptions[activeIndex];\n      if (option) choose(option);\n      return;\n    }\n    if (event.key === \"Escape\" && open) {\n      event.preventDefault();\n      setOpen(false);\n      setQuery(\"\");\n      return;\n    }\n  };\n\n  const inputValue = open ? query : (selectedOption?.label ?? \"\");\n  const activeOption = activeIndex >= 0 ? filteredOptions[activeIndex] : undefined;\n\n  return (\n    <div ref={rootRef} className=\"relative grid min-w-0 gap-2\">\n      <label htmlFor={controlId} className=\"text-(length:--text-sm) font-medium\">\n        {label}\n      </label>\n      {/* Open keeps the strong border and fill over the focus border, as long as the menu shows. */}\n      <div\n        className={cn(\n          \"relative z-2 flex min-h-control-md items-center gap-2 rounded-control border border-line bg-surface px-3 text-ink [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-fast)_var(--ease-standard)] focus-within:shadow-[0_0_0_3px_var(--focus-ring)] motion-reduce:transition-none\",\n          open ? \"border-line-strong bg-hover\" : \"focus-within:border-accent\",\n          !disabled && \"hover:border-line-strong hover:bg-hover\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className,\n        )}\n      >\n        <Search className=\"flex-[0_0_auto] text-ink-3\" size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        <input\n          {...inputProps}\n          ref={inputRef}\n          id={controlId}\n          type=\"text\"\n          className=\"w-full min-w-0 border-0 bg-transparent p-0 text-(length:--text-sm) text-ink outline-none placeholder:text-ink-3\"\n          role=\"combobox\"\n          value={inputValue}\n          // While searching, the chosen label stays in place as muted placeholder copy instead of vanishing.\n          placeholder={selectedOption?.label ?? placeholder}\n          disabled={disabled}\n          aria-describedby={hintId}\n          aria-expanded={open}\n          aria-controls={open ? listboxId : undefined}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={open && activeOption ? `${controlId}-option-${activeOption.value}` : undefined}\n          onFocus={(event) => {\n            onFocus?.(event);\n            openMenu();\n          }}\n          onClick={openMenu}\n          onChange={(event) => {\n            setQuery(event.target.value);\n            setOpen(true);\n            setActiveIndex(-1);\n          }}\n          onKeyDown={handleKeyDown}\n        />\n        <AnimatePresence initial={false}>\n          {selectedOption && !disabled && (\n            <motion.button\n              type=\"button\"\n              className=\"grid size-[22px] flex-[0_0_auto] place-items-center rounded-button border-0 bg-transparent text-ink-3 [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] hover:bg-hover hover:text-ink focus-visible:[--focus-outline-offset:1px] motion-reduce:transition-none\"\n              aria-label=\"Clear selection\"\n              onMouseDown={(event) => event.preventDefault()}\n              onClick={clear}\n              initial={\n                reduceMotion\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }\n              }\n              animate={{\n                opacity: 1,\n                scale: 1,\n                filter: \"blur(0px)\",\n                transition: reduceMotion\n                  ? { duration: motionTokens.duration.instant }\n                  : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } },\n              }}\n              exit={{\n                opacity: 0,\n                ...(reduceMotion ? {} : { scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` }),\n                transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n              }}\n              whileTap={{\n                scale: reduceMotion ? 1 : 0.96,\n                transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n              }}\n            >\n              <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n            </motion.button>\n          )}\n        </AnimatePresence>\n        <ChevronDown\n          className={cn(\n            \"flex-[0_0_auto] text-ink-3 transition-transform duration-spring ease-spring motion-reduce:transition-none\",\n            open && \"rotate-180\",\n          )}\n          size={16}\n          strokeWidth={1.75}\n          aria-hidden=\"true\"\n        />\n      </div>\n      {description && (\n        <span id={hintId} className=\"text-(length:--text-xs) text-ink-3\">\n          {description}\n        </span>\n      )}\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            className=\"absolute top-[calc(100%+8px)] right-0 left-0 z-80 origin-top overflow-hidden rounded-panel border border-line bg-surface p-[5px] text-ink shadow-floating will-change-[transform,opacity]\"\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}\n            animate={{\n              opacity: 1,\n              y: 0,\n              scale: 1,\n              transition: reduceMotion\n                ? { duration: motionTokens.duration.instant }\n                : {\n                    ...motionTokens.spring.snappy,\n                    opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n                  },\n            }}\n            exit={{\n              opacity: 0,\n              ...(reduceMotion ? {} : { y: -4, scale: 0.98 }),\n              transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n            }}\n            role=\"presentation\"\n          >\n            <AutoHeight reduceMotion={reduceMotion}>\n              <div\n                id={listboxId}\n                className=\"max-h-[min(300px,40vh)] overflow-y-auto overscroll-contain\"\n                role=\"listbox\"\n                aria-label={`${label} options`}\n              >\n                {filteredOptions.length ? (\n                  filteredOptions.map((option, index) => (\n                    // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus -- options are reached through aria-activedescendant; the input handles the keys\n                    <div\n                      key={option.value}\n                      ref={(element) => {\n                        optionRefs.current[option.value] = element;\n                      }}\n                      id={`${controlId}-option-${option.value}`}\n                      className={optionClass}\n                      data-active={index === activeIndex ? \"true\" : undefined}\n                      data-disabled={option.disabled ? \"true\" : undefined}\n                      role=\"option\"\n                      aria-selected={option.value === selectedValue}\n                      aria-disabled={option.disabled || undefined}\n                      onMouseDown={(event) => event.preventDefault()}\n                      onMouseEnter={() => !option.disabled && setActiveIndex(index)}\n                      onClick={() => choose(option)}\n                    >\n                      <span>{option.label}</span>\n                      {option.value === selectedValue && (\n                        <Check className=\"flex-[0_0_auto] text-ink\" size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                      )}\n                    </div>\n                  ))\n                ) : (\n                  <motion.div\n                    className=\"p-3 text-(length:--text-sm) text-ink-3\"\n                    role=\"status\"\n                    initial={reduceMotion ? false : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` }}\n                    animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                    transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n                  >\n                    {emptyMessage}\n                  </motion.div>\n                )}\n              </div>\n            </AutoHeight>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n});\n\nCombobox.displayName = \"Combobox\";\n",
      "type": "registry:component",
      "target": "@components/iris/combobox/combobox.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/combobox",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "Combobox",
    "tags": [
      "field",
      "search"
    ],
    "docs": "https://irisui.dev/components/combobox"
  }
}
