{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "An accessible React combobox for Next.js and Tailwind CSS: type in the field to filter and pick one option, or several as chips, with a gliding highlight.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "lucide-react@^1.52.0",
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json",
    "https://ui.fewtone.com/r/command.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/combobox.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  CommandEmpty,\n  type CommandFilter,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n  CommandProvider,\n  useCommandInput,\n  useGlidingHeight,\n} from \"@/components/ui/command\";\nimport { blur, duration, ease, spring } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { ChevronDownIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Popover as PopoverPrimitive } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  Fragment,\n  type KeyboardEvent,\n  type ReactNode,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n/*\n * Combobox: type to filter and pick, right in the field (roadmap, 2026-10-16). The field is the text input,\n * styled like Select's trigger; the options open in a list below it. The list is Command (filtering and\n * ranking, groups, the gliding highlight and height, the keyboard) inside a Radix Popover (placement, flipping,\n * Escape and clicks outside).\n * - One value, or several (`multiple`), shown as chips in the field.\n * - The keyboard stays in the field (WAI-ARIA's editable combobox with list autocomplete): typing or ArrowDown\n *   opens the list, the arrows move, Enter picks, Escape closes, and Backspace in an empty field removes the\n *   last chip. Home and End stay with the text.\n * - Motion (.agents/rules/motion.md):\n *   - the chosen option's check draws itself in and erases the way it came (Checkbox's stroke)\n *   - chips come and go through the blur while the others glide into place, and the field's height glides\n *     when they wrap onto another line (the height pattern, never a spring)\n *   - the list opens out of the field and closes back into it through the blur (popover-in/out)\n *   Nothing animates on first paint. With reduced motion everything jumps or only fades.\n * - One value: the field shows the chosen label. Typing searches from it; leaving without picking puts the\n *   label back, and clearing the text clears the choice.\n */\n\nexport type ComboboxOption = {\n  value: string;\n  /** What the field and the list show, and what the search matches */\n  label: string;\n  /** More words the search matches */\n  keywords?: string[];\n  disabled?: boolean;\n  /** Options with the same group are listed together under it as a heading */\n  group?: string;\n};\n\ntype ComboboxBaseProps = Omit<\n  ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"size\" | \"type\" | \"multiple\" | \"children\"\n> & {\n  options: ComboboxOption[];\n  /** Shown when nothing matches */\n  emptyText?: ReactNode;\n  size?: \"sm\" | \"md\";\n  /** Custom content for an option in the list (the field still shows its label) */\n  renderOption?: (option: ComboboxOption) => ReactNode;\n  /** Scores an option's label against the search; 0 hides it */\n  filter?: CommandFilter;\n  /** Called with the search as it changes */\n  onSearchChange?: (search: string) => void;\n};\n\ntype ComboboxSingleProps = {\n  multiple?: false;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n};\n\ntype ComboboxMultipleProps = {\n  multiple: true;\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n};\n\nexport type ComboboxProps = ComboboxBaseProps & (ComboboxSingleProps | ComboboxMultipleProps);\n\nconst sizes = {\n  // Rows are the input's height; chips sit centred in them, so wrapped lines keep the same rhythm\n  sm: { field: \"min-h-(--size-control-sm) rounded-md\", row: \"h-7\", chip: \"my-0.5\" },\n  md: { field: \"min-h-(--size-control-md)\", row: \"h-8\", chip: \"my-1\" },\n} as const;\n\nconst asList = (value?: string | string[]) =>\n  value === undefined ? undefined : Array.isArray(value) ? value : value ? [value] : [];\n\n/** The same check as Checkbox: it draws itself in, and erases the way it came */\nconst CHECK = \"M5 12.5l4.5 4.5L19 7.5\";\n\nfunction Check({ shown, reduce }: { shown: boolean; reduce: boolean }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden className=\"size-(--size-icon-sm) text-current\">\n      <motion.path\n        d={CHECK}\n        stroke=\"currentColor\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={false}\n        animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0 }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : shown\n              ? { pathLength: { duration: duration.base, ease: ease.out }, opacity: { duration: 0 } }\n              : { duration: duration.fast, ease: ease.in }\n        }\n      />\n    </svg>\n  );\n}\n\n/** The text input, wired to the list by Command (it has to sit inside CommandProvider) */\nfunction FieldInput({ open, onKeyDown, className, ...props }: ComponentProps<\"input\"> & { open: boolean }) {\n  const field = useCommandInput({ onKeyDown, homeEnd: false });\n  return (\n    <input\n      data-slot=\"combobox-input\"\n      type=\"text\"\n      autoComplete=\"off\"\n      autoCorrect=\"off\"\n      spellCheck={false}\n      {...field}\n      role=\"combobox\"\n      aria-expanded={open}\n      className={cn(\n        \"w-full min-w-0 bg-transparent text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/**\n * A text field that filters a list of options as you type, for one value or several (`multiple`, shown as\n * chips). Give it a visible label (`<label htmlFor>` with its `id`) or an aria-label.\n */\nfunction Combobox({\n  multiple = false,\n  value,\n  defaultValue,\n  onValueChange,\n  options,\n  placeholder,\n  emptyText = \"No results found.\",\n  size = \"md\",\n  renderOption,\n  filter,\n  onSearchChange,\n  className,\n  name,\n  disabled = false,\n  onKeyDown,\n  onBlur,\n  ref,\n  \"aria-label\": ariaLabel,\n  \"aria-invalid\": ariaInvalid,\n  ...props\n}: ComboboxProps) {\n  const [own, setOwn] = useState<string[]>(() => asList(defaultValue) ?? []);\n  const values = asList(value) ?? own;\n  const [open, setOpen] = useState(false);\n  // The text typed in the field. With one value it only counts while editing; otherwise the label shows.\n  const [text, setText] = useState(\"\");\n  const [editing, setEditing] = useState(false);\n  const [active, setActive] = useState(\"\");\n  const field = useRef<HTMLDivElement>(null);\n  const inner = useRef<HTMLDivElement>(null);\n  const input = useRef<HTMLInputElement | null>(null);\n  const content = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion() ?? false;\n  const look = sizes[size];\n  useGlidingHeight(field, inner);\n\n  const labels = useMemo(() => new Map(options.map((option) => [option.value, option.label])), [options]);\n  const labelOf = (option: string) => labels.get(option) ?? option;\n  // Options in groups of the same `group`, in the order each group first appears\n  const sections = useMemo(() => {\n    const out: { heading?: string; options: ComboboxOption[] }[] = [];\n    for (const option of options) {\n      const section = out.find((entry) => entry.heading === option.group);\n      if (section) section.options.push(option);\n      else out.push({ heading: option.group, options: [option] });\n    }\n    return out;\n  }, [options]);\n\n  const shownText = multiple || editing ? text : values[0] ? labelOf(values[0]) : \"\";\n  const search = multiple || editing ? text : \"\";\n  const invalid = ariaInvalid === true || ariaInvalid === \"true\";\n\n  function commit(next: string[]) {\n    setOwn(next);\n    (onValueChange as ((value: string | string[]) => void) | undefined)?.(multiple ? next : (next[0] ?? \"\"));\n  }\n\n  function show() {\n    if (disabled || open) return;\n    // One value opens on the chosen option (Command centres it); several start at the top\n    setActive(multiple ? \"\" : (values[0] ?? \"\"));\n    setOpen(true);\n  }\n\n  function close() {\n    // Clearing the text and leaving clears a single choice; otherwise its label comes back\n    if (!multiple && editing && !text.trim() && values.length) commit([]);\n    setEditing(false);\n    setText(\"\");\n    setOpen(false);\n  }\n\n  function pick(option: string) {\n    if (multiple) {\n      // The list stays open, so the check can be seen drawing in or erasing\n      commit(values.includes(option) ? values.filter((entry) => entry !== option) : [...values, option]);\n      return;\n    }\n    commit([option]);\n    setEditing(false);\n    setText(\"\");\n    setOpen(false);\n  }\n\n  function onFieldKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    if ((event.key === \"ArrowDown\" || event.key === \"ArrowUp\") && !open) {\n      event.preventDefault();\n      show();\n    } else if (event.key === \"Backspace\" && multiple && !text && values.length) {\n      commit(values.slice(0, -1));\n    }\n    // Escape closes through the popover; Enter, the arrows and the rest go on to Command\n  }\n\n  const item = (option: ComboboxOption) => {\n    const selected = values.includes(option.value);\n    return (\n      <CommandItem\n        key={option.value}\n        value={option.value}\n        textValue={option.label}\n        keywords={option.keywords}\n        disabled={option.disabled}\n        onSelect={pick}\n        data-checked={selected}\n        // Several: aria-selected says which are chosen. One: it follows the highlight, and the field shows the choice.\n        {...(multiple ? { \"aria-selected\": selected } : {})}\n        className=\"ps-2 pe-8\"\n      >\n        {renderOption ? renderOption(option) : option.label}\n        <span className=\"absolute end-2 flex size-(--size-icon-md) items-center justify-center\">\n          <Check shown={selected} reduce={reduce} />\n        </span>\n      </CommandItem>\n    );\n  };\n\n  const hidden = reduce ? blur.shown : blur.hidden;\n\n  return (\n    <CommandProvider search={search} value={active} onValueChange={setActive} filter={filter}>\n      <PopoverPrimitive.Root open={open} onOpenChange={(next) => (next ? show() : close())}>\n        <PopoverPrimitive.Anchor asChild>\n          {/* The input has the keyboard; a press anywhere else in the field focuses it and opens the list,\n              and the chevron closes an open list */}\n          {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer convenience only; the input inside handles the keyboard */}\n          <div\n            ref={field}\n            data-slot=\"combobox\"\n            data-state={open ? \"open\" : \"closed\"}\n            data-size={size}\n            data-invalid={invalid || undefined}\n            data-disabled={disabled || undefined}\n            onMouseDown={(event) => {\n              const target = event.target as HTMLElement;\n              const node = input.current;\n              if (disabled || !node || target.closest(\"[data-slot=combobox-chip-remove]\")) return;\n              // With one value, the first press selects its label, so typing replaces it\n              const selectLabel =\n                !multiple && !editing && values.length > 0 && document.activeElement !== node;\n              if (target !== node || selectLabel) {\n                event.preventDefault();\n                node.focus();\n                if (selectLabel) node.select();\n              }\n              if (open && target.closest(\"[data-slot=combobox-chevron]\")) close();\n              else show();\n            }}\n            className={cn(\n              \"group relative w-full min-w-0 cursor-text rounded-lg border border-input bg-transparent text-sm shadow-xs\",\n              \"transition duration-(--duration-fast) ease-out\",\n              \"outline-ring has-focus-visible:border-ring has-focus-visible:outline-(length:--focus-ring-width) has-focus-visible:outline-offset-(--focus-ring-offset)\",\n              \"data-invalid:border-destructive data-invalid:outline-destructive\",\n              \"data-disabled:cursor-not-allowed data-disabled:opacity-(--opacity-disabled)\",\n              look.field,\n              className,\n            )}\n          >\n            <div ref={inner} className=\"relative flex flex-wrap items-center gap-x-1 py-px ps-1 pe-8\">\n              {multiple ? (\n                // A chip that leaves pops out of the flow and fades through the blur where it was, while\n                // the rest glide into place\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  {values.map((entry) => (\n                    <motion.span\n                      key={entry}\n                      layout={reduce ? false : \"position\"}\n                      data-slot=\"combobox-chip\"\n                      initial={{ opacity: 0, filter: hidden, scale: reduce ? 1 : 0.96 }}\n                      animate={{\n                        opacity: 1,\n                        filter: blur.shown,\n                        scale: 1,\n                        transitionEnd: { filter: \"none\" },\n                      }}\n                      exit={{\n                        opacity: 0,\n                        filter: hidden,\n                        scale: reduce ? 1 : 0.96,\n                        transition: { duration: duration.fast, ease: ease.in },\n                      }}\n                      transition={{ duration: duration.base, ease: ease.out, layout: spring.snappy }}\n                      className={cn(\n                        \"inline-flex h-6 max-w-full min-w-0 items-center gap-1 rounded-md bg-secondary ps-2 pe-1 text-xs font-medium text-secondary-foreground\",\n                        look.chip,\n                      )}\n                    >\n                      <span className=\"truncate\">{labelOf(entry)}</span>\n                      {/* Out of the tab order: Backspace removes the last chip, and the list unpicks any */}\n                      <button\n                        type=\"button\"\n                        tabIndex={-1}\n                        data-slot=\"combobox-chip-remove\"\n                        aria-label={`Remove ${labelOf(entry)}`}\n                        disabled={disabled}\n                        onMouseDown={(event) => event.preventDefault()}\n                        onClick={() => commit(values.filter((other) => other !== entry))}\n                        className=\"inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors duration-(--duration-fast) ease-out hover:text-foreground\"\n                      >\n                        <XIcon aria-hidden className=\"size-3\" />\n                      </button>\n                    </motion.span>\n                  ))}\n                </AnimatePresence>\n              ) : null}\n              <motion.span\n                layout={multiple && !reduce ? \"position\" : false}\n                transition={spring.snappy}\n                className=\"flex min-w-16 flex-1\"\n              >\n                <FieldInput\n                  {...props}\n                  ref={(node) => {\n                    input.current = node;\n                    if (typeof ref === \"function\") ref(node);\n                    else if (ref) ref.current = node;\n                  }}\n                  open={open}\n                  disabled={disabled}\n                  aria-label={ariaLabel}\n                  aria-invalid={ariaInvalid}\n                  placeholder={multiple && values.length ? undefined : placeholder}\n                  value={shownText}\n                  onChange={(event) => {\n                    setText(event.target.value);\n                    if (!multiple) setEditing(true);\n                    onSearchChange?.(event.target.value);\n                    show();\n                  }}\n                  onKeyDown={onFieldKeyDown}\n                  onBlur={(event) => {\n                    onBlur?.(event);\n                    const next = event.relatedTarget;\n                    if (\n                      next instanceof Node &&\n                      (field.current?.contains(next) || content.current?.contains(next))\n                    )\n                      return;\n                    close();\n                  }}\n                  className={cn(look.row, multiple && values.length ? \"ps-1\" : \"ps-2\")}\n                />\n              </motion.span>\n            </div>\n            <span\n              data-slot=\"combobox-chevron\"\n              aria-hidden\n              className={cn(\n                \"absolute end-3 top-px flex cursor-pointer items-center text-muted-foreground\",\n                look.row,\n              )}\n            >\n              <ChevronDownIcon className=\"size-(--size-icon-md) shrink-0 transition-transform duration-(--duration-base) ease-out group-data-[state=open]:rotate-180\" />\n            </span>\n          </div>\n        </PopoverPrimitive.Anchor>\n        <PopoverPrimitive.Portal>\n          {/* Not a dialog: it only holds the listbox, and focus stays in the field */}\n          {/* biome-ignore lint/a11y/useValidAriaRole: removes Radix's role=\"dialog\" rather than setting one */}\n          <PopoverPrimitive.Content\n            ref={content}\n            data-slot=\"combobox-content\"\n            role={undefined}\n            align=\"start\"\n            sideOffset={4}\n            onOpenAutoFocus={(event) => event.preventDefault()}\n            onCloseAutoFocus={(event) => event.preventDefault()}\n            onInteractOutside={(event) => {\n              if (event.target instanceof Node && field.current?.contains(event.target))\n                event.preventDefault();\n            }}\n            className={cn(\n              \"z-(--z-popover) flex max-h-(--radix-popover-content-available-height) w-(--radix-popover-trigger-width) flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md outline-hidden\",\n              \"origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out\",\n            )}\n          >\n            <CommandList label={ariaLabel ?? \"Options\"} aria-multiselectable={multiple || undefined}>\n              <CommandEmpty>{emptyText}</CommandEmpty>\n              {sections.map((section) =>\n                section.heading ? (\n                  <CommandGroup key={section.heading} heading={section.heading}>\n                    {section.options.map(item)}\n                  </CommandGroup>\n                ) : (\n                  <Fragment key=\"\">{section.options.map(item)}</Fragment>\n                ),\n              )}\n            </CommandList>\n          </PopoverPrimitive.Content>\n        </PopoverPrimitive.Portal>\n      </PopoverPrimitive.Root>\n      {name\n        ? (multiple ? values : [values[0] ?? \"\"]).map((entry) => (\n            <input key={entry} type=\"hidden\" name={name} value={entry} disabled={disabled} />\n          ))\n        : null}\n    </CommandProvider>\n  );\n}\n\nexport { Combobox };\n"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/combobox"
}
