{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:component",
  "description": "A single-select combobox with inline search, async loading support, and full controlled/uncontrolled API",
  "dependencies": [
    "cmdk",
    "lucide-react"
  ],
  "registryDependencies": [
    "popover",
    "command",
    "input",
    "skeleton",
    "utils",
    "https://kaui-shadcn-registry.vercel.app/r/use-controlled-state.json"
  ],
  "files": [
    {
      "type": "registry:component",
      "target": "components/ui/combobox.tsx",
      "path": "src/registry/base/combobox/components/combobox.tsx",
      "content": "import {\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n  type ComponentProps,\n  type ReactNode,\n} from \"react\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { Check, X } from \"lucide-react\";\n\nimport {\n  Popover,\n  PopoverAnchor,\n  PopoverContent,\n} from \"@/components/ui/popover\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { cn } from \"@/lib/utils\";\n\nimport { useControlledState } from \"@/hooks/use-controlled-state\";\nimport type { SelectionOption } from \"@/hooks/use-filtered-options\";\nimport { useOptionMap } from \"@/hooks/use-option-map\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupInput,\n} from \"@/components/ui/input-group\";\n\nexport type ComboboxGroup<T extends string> = {\n  /** Optional heading rendered above this group of options. */\n  label?: string;\n  items: SelectionOption<T>[];\n};\n\nconst FOOTER_ITEM_VALUE = \"__kaui_combobox_footer__\";\n\nfunction isGrouped<T extends string>(\n  items: SelectionOption<T>[] | ComboboxGroup<T>[],\n): items is ComboboxGroup<T>[] {\n  return items.length > 0 && \"items\" in (items[0] as object);\n}\n\ntype ComboboxProps<T extends string> = {\n  /** Currently selected option, or `null` when nothing is selected. */\n  selected: SelectionOption<T> | null;\n  /** Called when the selection changes. Receives `null` on deselect. */\n  onSelectedChange: (value: SelectionOption<T> | null) => void;\n\n  /**\n   * Options to display. Accepts a flat `SelectionOption<T>[]` or a\n   * `ComboboxGroup<T>[]` to render items under labeled headings.\n   */\n  items: SelectionOption<T>[] | ComboboxGroup<T>[];\n  /**\n   * Custom renderer for each list item.\n   * Receives the option and a boolean indicating whether it is currently selected.\n   * When omitted a default label + checkmark layout is used.\n   */\n  renderOption?: (option: SelectionOption<T>, selected: boolean) => ReactNode;\n\n  /** Controlled search query. Omit to let the component manage query state internally. */\n  query?: string;\n  /** Called whenever the search query changes. */\n  onQueryChange?: (query: string) => void;\n\n  /** Controlled open state of the dropdown. Omit to let the component manage it internally. */\n  open?: boolean;\n  /** Called whenever the open state changes. */\n  onOpenChange?: (open: boolean) => void;\n\n  /** Skip client-side filtering. Use when items are already filtered server-side. */\n  disableLocalFilter?: boolean;\n  /** Custom filter predicate. Replaces the default case-insensitive label match. */\n  filterFn?: (item: SelectionOption<T>, query: string) => boolean;\n\n  /** Placeholder shown in the search input when empty. @default \"Search...\" */\n  placeholder?: string;\n  /** Content shown when no options match the current query. @default \"No results found.\" */\n  emptyContent?: ReactNode;\n  /** Content shown in place of the option list while `isLoading` is `true`. Defaults to a skeleton bar. */\n  loadingContent?: ReactNode;\n\n  /** Show the loading state in place of the option list. @default false */\n  isLoading?: boolean;\n  /** Close the dropdown immediately after an option is selected. @default false */\n  closeAfterSelect?: boolean;\n  /** Disable the entire combobox. @default false */\n  disabled?: boolean;\n  /** Show a built-in clear button when an option is selected. @default false */\n  clearable?: boolean;\n\n  /** Content rendered as a leading addon inside the input (e.g. a search icon or label). */\n  startAddon?: ReactNode;\n  /** Content rendered as a trailing addon inside the input (e.g. a status indicator). */\n  endAddon?: ReactNode;\n  /**\n   * A persistent CommandItem pinned to the bottom of the list.\n   * Fully keyboard-navigable (arrow keys + Enter) unlike emptyContent.\n   * Shown whenever the dropdown is open, regardless of whether items exist.\n   */\n  footerItem?: { label: ReactNode; onSelect: () => void };\n} & ComponentProps<typeof PopoverContent>;\n\n/**\n * Single-select combobox with an inline search input and a dropdown option list.\n *\n * **Required:** `selected`, `onSelectedChange`, `items`\n *\n * Supports controlled `open` and `query` state, async loading, grouped options,\n * custom option rendering, and custom filtering.\n */\nexport function Combobox<T extends string>({\n  selected,\n  onSelectedChange,\n  items,\n  renderOption,\n  query: queryProp,\n  onQueryChange,\n  open: openProp,\n  onOpenChange,\n  disableLocalFilter,\n  filterFn,\n  placeholder = \"Search...\",\n  emptyContent = \"No results found.\",\n  loadingContent,\n  isLoading = false,\n  closeAfterSelect = false,\n  disabled = false,\n  clearable = false,\n  startAddon,\n  endAddon,\n  footerItem,\n  ...props\n}: ComboboxProps<T>) {\n  const [query, setQuery] = useControlledState({\n    value: queryProp,\n    defaultValue: \"\",\n    onChange: onQueryChange,\n  });\n\n  const [open, setOpen] = useControlledState({\n    value: openProp,\n    defaultValue: false,\n    onChange: onOpenChange,\n  });\n\n  // Controlled highlighted item — starts empty so the first item is not\n  // auto-focused when the dropdown opens. Resets on close so the next open\n  // is also clean.\n  const [highlightedValue, setHighlightedValue] = useState(\"\");\n  useEffect(() => {\n    if (!open) setHighlightedValue(\"\");\n  }, [open]);\n\n  const groups = useMemo(\n    (): ComboboxGroup<T>[] =>\n      !items.length\n        ? []\n        : isGrouped(items)\n          ? items\n          : [{ items: items as SelectionOption<T>[] }],\n    [items],\n  );\n\n  const allOptions = useMemo(() => groups.flatMap((g) => g.items), [groups]);\n  const optionMap = useOptionMap(allOptions);\n\n  const filteredGroups = useMemo((): ComboboxGroup<T>[] => {\n    if (disableLocalFilter || !query.trim()) return groups;\n    const pred =\n      filterFn ??\n      ((item: SelectionOption<T>, q: string) =>\n        item.label.toLowerCase().includes(q.toLowerCase()));\n    return groups\n      .map((g) => ({\n        ...g,\n        items: g.items.filter((item) => pred(item, query)),\n      }))\n      .filter((g) => g.items.length > 0);\n  }, [groups, query, disableLocalFilter, filterFn]);\n\n  const hasItems = filteredGroups.length > 0;\n\n  const handleSelect = useCallback(\n    (selectedValue: string) => {\n      const option = optionMap.get(selectedValue as T);\n      if (!option) return;\n\n      if (selected?.value === option.value) {\n        onSelectedChange(null);\n        setQuery(\"\");\n      } else {\n        onSelectedChange(option);\n        setQuery(option.label);\n        if (closeAfterSelect) setOpen(false);\n      }\n    },\n    [\n      optionMap,\n      selected?.value,\n      onSelectedChange,\n      setQuery,\n      closeAfterSelect,\n      setOpen,\n    ],\n  );\n\n  const handleClear = useCallback(() => {\n    onSelectedChange(null);\n    setQuery(\"\");\n  }, [onSelectedChange, setQuery]);\n\n  const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {\n    if (e.relatedTarget?.hasAttribute(\"cmdk-list\")) return;\n    if (selected) setQuery(selected.label);\n  };\n\n  const renderItem = (item: SelectionOption<T>) => (\n    <CommandItem\n      key={item.value}\n      value={item.value}\n      disabled={item.disabled}\n      onSelect={handleSelect}\n      onMouseDown={(e) => e.preventDefault()}\n    >\n      {renderOption ? (\n        renderOption(item, selected?.value === item.value)\n      ) : (\n        <>\n          <Check\n            className={cn(\n              \"mr-2 h-4 w-4\",\n              selected?.value === item.value ? \"opacity-100\" : \"opacity-0\",\n            )}\n          />\n          {item.label}\n        </>\n      )}\n    </CommandItem>\n  );\n\n  const footerCommandItem = footerItem ? (\n    <CommandItem\n      value={FOOTER_ITEM_VALUE}\n      onSelect={footerItem.onSelect}\n      onMouseDown={(e) => e.preventDefault()}\n    >\n      {footerItem.label}\n    </CommandItem>\n  ) : null;\n\n  return (\n    <div data-slot=\"combobox-content\" className=\"relative w-full\">\n      <Popover open={open} onOpenChange={setOpen}>\n        <Command\n          shouldFilter={false}\n          value={highlightedValue}\n          onValueChange={setHighlightedValue}\n        >\n          <PopoverAnchor asChild>\n            <InputGroup>\n              {startAddon && (\n                <InputGroupAddon align=\"inline-start\">\n                  {startAddon}\n                </InputGroupAddon>\n              )}\n              <CommandPrimitive.Input\n                asChild\n                value={query}\n                onValueChange={setQuery}\n                onMouseDown={() => !disabled && setOpen(true)}\n                onKeyDown={(e) => {\n                  if (disabled) return;\n                  if (e.key === \"Escape\") {\n                    if (open) {\n                      setOpen(false);\n                    } else {\n                      setQuery(\"\");\n                    }\n                    return;\n                  }\n                  setOpen(true);\n                }}\n                onBlur={handleBlur}\n              >\n                <InputGroupInput\n                  placeholder={placeholder}\n                  disabled={disabled}\n                  onFocus={() => !disabled && setOpen(true)}\n                />\n              </CommandPrimitive.Input>\n\n              {((clearable && !!selected) || !!endAddon) && (\n                <InputGroupAddon align=\"inline-end\">\n                  {clearable && selected && (\n                    <InputGroupButton\n                      size=\"icon-xs\"\n                      onMouseDown={(e) => {\n                        e.preventDefault();\n                        handleClear();\n                      }}\n                    >\n                      <X className=\"size-3\" />\n                    </InputGroupButton>\n                  )}\n                  {endAddon}\n                </InputGroupAddon>\n              )}\n            </InputGroup>\n          </PopoverAnchor>\n\n          {!open && <CommandList aria-hidden=\"true\" className=\"hidden\" />}\n\n          <PopoverContent\n            asChild\n            className=\"w-(--radix-popover-trigger-width) p-0\"\n            onOpenAutoFocus={(e) => e.preventDefault()}\n            onInteractOutside={(e) => {\n              if (\n                e.target instanceof Element &&\n                e.target.hasAttribute(\"cmdk-input\")\n              ) {\n                e.preventDefault();\n              }\n            }}\n            {...props}\n          >\n            <CommandList>\n              {isLoading ? (\n                <CommandPrimitive.Loading>\n                  <div className=\"p-2\">\n                    {loadingContent ?? <Skeleton className=\"h-6 w-full\" />}\n                  </div>\n                </CommandPrimitive.Loading>\n              ) : hasItems ? (\n                <>\n                  {filteredGroups.map((group, index) => (\n                    <CommandGroup key={index} heading={group.label}>\n                      {group.items.map(renderItem)}\n                    </CommandGroup>\n                  ))}\n                  {footerCommandItem && (\n                    <CommandGroup>{footerCommandItem}</CommandGroup>\n                  )}\n                </>\n              ) : footerItem ? (\n                <>\n                  <p className=\"py-6 text-center text-sm\">{emptyContent}</p>\n                  <CommandGroup>{footerCommandItem}</CommandGroup>\n                </>\n              ) : (\n                <CommandEmpty>{emptyContent}</CommandEmpty>\n              )}\n            </CommandList>\n          </PopoverContent>\n        </Command>\n      </Popover>\n    </div>\n  );\n}\n"
    },
    {
      "type": "registry:hook",
      "target": "hooks/use-option-map.ts",
      "path": "src/registry/base/combobox/hooks/use-option-map.ts",
      "content": "import { useMemo } from \"react\";\nimport type { SelectionOption } from \"@/hooks/use-filtered-options\";\n\n/** Memoised `Map<value, SelectionOption>` for O(1) option lookup after a cmdk selection. */\nexport function useOptionMap<T extends string>(items: SelectionOption<T>[]) {\n  return useMemo(\n    () => new Map(items.map((item) => [item.value, item])),\n    [items],\n  );\n}\n"
    },
    {
      "type": "registry:hook",
      "target": "hooks/use-filtered-options.ts",
      "path": "src/registry/base/use-filtered-options/hooks/use-filtered-options.ts",
      "content": "import { useMemo } from \"react\";\n\nexport type SelectionOption<T extends string> = {\n  /** Unique identifier for this option, used as the cmdk `value`. */\n  value: T;\n  /** Display string shown in the list and the search input. */\n  label: string;\n  /** When `true`, the item renders but cannot be selected. @default false */\n  disabled?: boolean;\n};\n\ntype UseFilteredOptionsProps<T extends string> = {\n  items: SelectionOption<T>[];\n  query: string;\n  disableLocalFilter?: boolean;\n  filterFn?: (item: SelectionOption<T>, query: string) => boolean;\n};\n\n/**\n * Filters a `SelectionOption` list against a search query.\n *\n * Returns the full list when `disableLocalFilter` is `true` or `query` is blank.\n * Applies `filterFn` when provided; otherwise uses a case-insensitive substring\n * match on `label`.\n */\nexport function useFilteredOptions<T extends string>({\n  items,\n  query,\n  disableLocalFilter = false,\n  filterFn,\n}: UseFilteredOptionsProps<T>) {\n  return useMemo(() => {\n    if (disableLocalFilter || !query.trim()) {\n      return items;\n    }\n    if (filterFn) {\n      return items.filter((item) => filterFn(item, query));\n    }\n    const normalizedQuery = query.toLowerCase();\n    return items.filter((item) =>\n      item.label.toLowerCase().includes(normalizedQuery),\n    );\n  }, [items, query, disableLocalFilter, filterFn]);\n}\n"
    }
  ]
}