{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "title": "Combobox",
  "description": "A searchable select that holds one choice or several.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/combobox/combobox.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronsUpDown, Plus, X } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type ComboboxOption = {\n  value: string\n  label: string\n  description?: string\n  /** Puts the option under a named heading in the list. */\n  group?: string\n  /** Extra words that should match, without being shown. */\n  keywords?: readonly string[]\n  disabled?: boolean\n}\n\ntype ComboboxBaseProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\" | \"defaultValue\"\n> & {\n  options: readonly ComboboxOption[]\n  label?: string\n  placeholder?: string\n  emptyMessage?: (query: string) => React.ReactNode\n  disabled?: boolean\n  /** Called as the query changes, for fetching the options yourself. */\n  onQueryChange?: (query: string) => void\n  /** Says options are on their way. Pair it with onQueryChange. */\n  loading?: boolean\n  loadingMessage?: React.ReactNode\n  /**\n   * Rank and filter the options here. Turn it off when they arrive already\n   * matched and ordered, so a server's ranking is not overruled.\n   */\n  filter?: boolean\n  /**\n   * Score an option against the query yourself: fuzzy matching, a field we do\n   * not know about, a weighting of your own. Lower is a better match, and\n   * false or nothing drops the option.\n   */\n  rank?: ComboboxRanker\n  /**\n   * Offers what was typed as a new option. You add it to options and to the\n   * value; this only reports the text.\n   */\n  onCreate?: (label: string) => void\n  createLabel?: (query: string) => React.ReactNode\n}\n\nexport type ComboboxRanker = (\n  option: ComboboxOption,\n  query: string\n) => number | false | null | undefined\n\nexport type ComboboxSingleProps = ComboboxBaseProps & {\n  multiple?: false\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  max?: never\n}\n\nexport type ComboboxMultipleProps = ComboboxBaseProps & {\n  multiple: true\n  value?: readonly string[]\n  defaultValue?: readonly string[]\n  onValueChange?: (value: string[]) => void\n  /** How many may be chosen. The rest go unavailable once it is reached. */\n  max?: number\n}\n\nexport type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps\n\n/**\n * Lower is a better match; Infinity, false, or nothing means no match. It is\n * exported so a ranker of your own can fall back to it rather than reproduce\n * it.\n */\nexport function rankComboboxOption(option: ComboboxOption, rawQuery: string) {\n  const query = rawQuery.trim().toLowerCase()\n  const label = option.label.toLowerCase()\n\n  if (label === query) return 0\n  if (label.startsWith(query)) return 1\n  if (label.includes(query)) return 2\n  if (option.group?.toLowerCase().includes(query)) return 3\n  if (option.keywords?.some((word) => word.toLowerCase().includes(query)))\n    return 4\n  if (option.description?.toLowerCase().includes(query)) return 5\n\n  return Number.POSITIVE_INFINITY\n}\n\nconst defaultEmptyMessage = (query: string) =>\n  query ? `Nothing matches “${query}”.` : \"No options.\"\n\nconst defaultCreateLabel = (query: string) => `Create “${query}”`\n\n/** The row that offers to make a new option, which is not one of them yet. */\nconst CREATE = \"combobox-create\"\n\ntype Row = ComboboxOption | typeof CREATE\n\nfunction toValues(value: string | readonly string[] | undefined) {\n  if (value === undefined) return undefined\n  if (typeof value === \"string\") return value === \"\" ? [] : [value]\n  return [...value]\n}\n\ntype Section = { group?: string; options: ComboboxOption[] }\n\n/** Same-named groups meet under one heading, in the order they first appear. */\nfunction toSections(results: readonly ComboboxOption[]) {\n  const sections: Section[] = []\n  const named = new Map<string, Section>()\n\n  for (const option of results) {\n    if (option.group === undefined) {\n      const last = sections[sections.length - 1]\n      if (last && last.group === undefined) last.options.push(option)\n      else sections.push({ options: [option] })\n      continue\n    }\n\n    const existing = named.get(option.group)\n    if (existing) {\n      existing.options.push(option)\n      continue\n    }\n\n    const section: Section = { group: option.group, options: [option] }\n    named.set(option.group, section)\n    sections.push(section)\n  }\n\n  return sections\n}\n\nexport function Combobox(props: ComboboxProps) {\n  const {\n    options,\n    label = \"Options\",\n    placeholder = \"Search\",\n    emptyMessage = defaultEmptyMessage,\n    disabled = false,\n    onQueryChange,\n    loading = false,\n    loadingMessage = \"Searching…\",\n    filter = true,\n    rank = rankComboboxOption,\n    onCreate,\n    createLabel = defaultCreateLabel,\n    className,\n    multiple,\n    value,\n    defaultValue,\n    onValueChange,\n    max,\n    ...rootProps\n  } = props\n\n  const id = React.useId()\n  const listId = `${id}-list`\n\n  const container = React.useRef<HTMLDivElement>(null)\n  const input = React.useRef<HTMLInputElement>(null)\n\n  const controlled = toValues(value)\n  const [own, setOwn] = React.useState(() => toValues(defaultValue) ?? [])\n  const selected = controlled ?? own\n\n  const selectedLabel = multiple\n    ? \"\"\n    : (options.find((option) => option.value === selected[0])?.label ?? \"\")\n\n  // What was typed, or nothing typed yet. Holding the draft rather than the\n  // field's text is what lets a single-value field fall back to the chosen\n  // label without an effect writing it there.\n  const [draft, setDraft] = React.useState<string | null>(null)\n  const [open, setOpen] = React.useState(false)\n  const [active, setActive] = React.useState(0)\n  const [message, setMessage] = React.useState(\"\")\n\n  const text = draft ?? selectedLabel\n  const search = draft === null ? \"\" : draft.trim()\n\n  React.useEffect(() => {\n    if (!open) return\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (container.current?.contains(event.target as Node)) return\n\n      setOpen(false)\n      if (!multiple) setDraft(null)\n    }\n\n    document.addEventListener(\"pointerdown\", onPointerDown)\n\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown)\n  }, [open, multiple])\n\n  const results = React.useMemo(() => {\n    // Already matched and ordered by whoever fetched them.\n    if (!filter || !search) return [...options]\n\n    return options\n      .map((option, index) => ({ option, index, score: rank(option, search) }))\n      .filter(\n        (\n          entry\n        ): entry is { option: ComboboxOption; index: number; score: number } =>\n          typeof entry.score === \"number\" && Number.isFinite(entry.score)\n      )\n      .sort((a, b) => a.score - b.score || a.index - b.index)\n      .map((entry) => entry.option)\n  }, [options, search, filter, rank])\n\n  const full = max !== undefined && selected.length >= max\n\n  const unavailable = (option: ComboboxOption) =>\n    option.disabled === true || (full && !selected.includes(option.value))\n\n  const choosable = results.filter((option) => !unavailable(option))\n\n  const creating =\n    onCreate !== undefined &&\n    search !== \"\" &&\n    !full &&\n    !options.some(\n      (option) => option.label.toLowerCase() === search.toLowerCase()\n    )\n\n  const rows: Row[] = creating ? [...choosable, CREATE] : choosable\n  const activeRow = rows[Math.min(active, rows.length - 1)]\n  const activeOption = activeRow === CREATE ? undefined : activeRow\n  const activeId =\n    activeRow === CREATE\n      ? `${id}-create`\n      : activeRow\n        ? `${id}-option-${activeRow.value}`\n        : undefined\n\n  function commit(next: string[]) {\n    if (controlled === undefined) setOwn(next)\n    if (multiple) onValueChange?.(next)\n    else onValueChange?.(next[0] ?? \"\")\n  }\n\n  function toggle(option: ComboboxOption) {\n    if (unavailable(option)) return\n\n    if (multiple) {\n      const dropping = selected.includes(option.value)\n      const next = dropping\n        ? selected.filter((one) => one !== option.value)\n        : [...selected, option.value]\n\n      commit(next)\n      setMessage(\n        dropping\n          ? `${option.label} removed.`\n          : next.length === max\n            ? `${option.label} added. That is the most you can choose.`\n            : `${option.label} added.`\n      )\n      setDraft(null)\n      setActive(0)\n    } else {\n      commit([option.value])\n      setMessage(`${option.label} chosen.`)\n      setDraft(null)\n      setOpen(false)\n    }\n\n    input.current?.focus()\n  }\n\n  function create() {\n    if (search === \"\") return\n\n    onCreate?.(search)\n    setMessage(`${search} created.`)\n    setDraft(null)\n    setActive(0)\n    input.current?.focus()\n  }\n\n  function remove(one: string) {\n    const option = options.find((entry) => entry.value === one)\n\n    commit(selected.filter((entry) => entry !== one))\n    setMessage(option ? `${option.label} removed.` : \"Removed.\")\n    input.current?.focus()\n  }\n\n  function dismiss() {\n    setOpen(false)\n    if (!multiple) setDraft(null)\n  }\n\n  function reveal() {\n    if (disabled) return\n\n    const index = choosable.findIndex((option) => option.value === selected[0])\n    setActive(multiple || index < 0 ? 0 : index)\n    setOpen(true)\n  }\n\n  function onKeyDown(event: React.KeyboardEvent) {\n    if (event.key === \"Escape\") {\n      event.preventDefault()\n\n      if (open) dismiss()\n      else setDraft(null)\n\n      return\n    }\n\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault()\n\n      if (!open) {\n        reveal()\n        return\n      }\n\n      const step = event.key === \"ArrowDown\" ? 1 : -1\n      setActive(\n        (current) => (current + step + rows.length) % Math.max(1, rows.length)\n      )\n      return\n    }\n\n    if (open && (event.key === \"Home\" || event.key === \"End\")) {\n      event.preventDefault()\n      setActive(event.key === \"Home\" ? 0 : rows.length - 1)\n      return\n    }\n\n    if (event.key === \"Enter\" && open) {\n      event.preventDefault()\n      if (activeRow === CREATE) create()\n      else if (activeRow) toggle(activeRow)\n      return\n    }\n\n    if (event.key === \"Backspace\" && multiple && search === \"\") {\n      const last = selected[selected.length - 1]\n      if (last !== undefined) {\n        event.preventDefault()\n        remove(last)\n      }\n    }\n  }\n\n  const chips = multiple\n    ? selected\n        .map((one) => options.find((option) => option.value === one))\n        .filter((option): option is ComboboxOption => option !== undefined)\n    : []\n\n  function renderOption(option: ComboboxOption) {\n    const blocked = unavailable(option)\n    const isSelected = selected.includes(option.value)\n\n    return (\n      <li\n        key={option.value}\n        id={`${id}-option-${option.value}`}\n        role=\"option\"\n        aria-selected={isSelected}\n        aria-disabled={blocked || undefined}\n        data-active={option === activeOption || undefined}\n        className={cn(\n          \"flex min-h-11 cursor-pointer items-start gap-2 rounded-md px-2.5 py-2\",\n          blocked && \"cursor-not-allowed opacity-50\",\n          option === activeOption && \"bg-muted\"\n        )}\n        onPointerEnter={() => {\n          const index = choosable.indexOf(option)\n          if (index >= 0) setActive(index)\n        }}\n        onClick={() => toggle(option)}\n      >\n        <Check\n          aria-hidden=\"true\"\n          size={14}\n          className={cn(\n            \"mt-1 shrink-0\",\n            isSelected ? \"text-primary\" : \"invisible\"\n          )}\n        />\n\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"text-foreground block text-sm\">{option.label}</span>\n          {option.description ? (\n            <span className=\"text-muted-foreground mt-0.5 block text-xs leading-snug\">\n              {option.description}\n            </span>\n          ) : null}\n        </span>\n      </li>\n    )\n  }\n\n  return (\n    <div\n      ref={container}\n      data-slot=\"combobox\"\n      className={cn(\"relative min-w-0\", className)}\n      onBlur={(event) => {\n        if (!container.current?.contains(event.relatedTarget)) dismiss()\n      }}\n      {...rootProps}\n    >\n      <div\n        data-slot=\"combobox-field\"\n        className={cn(\n          \"border-border bg-card focus-within:ring-ring flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-[calc(var(--radius)+0.15rem)] border px-2 py-1.5 transition-colors duration-150 focus-within:ring-2 motion-reduce:transition-none\",\n          disabled && \"opacity-50\"\n        )}\n        onClick={() => {\n          input.current?.focus()\n          if (!open) reveal()\n        }}\n      >\n        {chips.map((option) => (\n          <span\n            key={option.value}\n            data-slot=\"combobox-chip\"\n            className=\"bg-muted text-foreground flex items-center gap-1 rounded-md py-0.5 pr-1 pl-2 text-xs\"\n          >\n            {option.label}\n            <button\n              type=\"button\"\n              aria-label={`Remove ${option.label}`}\n              disabled={disabled}\n              className=\"text-muted-foreground hover:text-foreground relative flex size-4 items-center justify-center rounded-sm transition-colors duration-150 after:absolute after:-inset-2.5 motion-reduce:transition-none\"\n              onClick={(event) => {\n                event.stopPropagation()\n                remove(option.value)\n              }}\n            >\n              <X aria-hidden=\"true\" size={12} />\n            </button>\n          </span>\n        ))}\n\n        <input\n          ref={input}\n          id={`${id}-input`}\n          type=\"text\"\n          role=\"combobox\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          disabled={disabled}\n          aria-label={label}\n          aria-expanded={open}\n          aria-controls={open ? listId : undefined}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={open ? activeId : undefined}\n          placeholder={chips.length > 0 ? undefined : placeholder}\n          value={text}\n          className=\"text-foreground placeholder:text-muted-foreground min-w-24 flex-1 bg-transparent px-1 text-sm outline-none\"\n          onChange={(event) => {\n            setDraft(event.target.value)\n            setActive(0)\n            setOpen(true)\n            onQueryChange?.(event.target.value)\n          }}\n          onKeyDown={onKeyDown}\n        />\n\n        <button\n          type=\"button\"\n          tabIndex={-1}\n          aria-hidden=\"true\"\n          disabled={disabled}\n          className=\"text-muted-foreground shrink-0 px-1\"\n          onClick={(event) => {\n            event.stopPropagation()\n            input.current?.focus()\n            if (open) dismiss()\n            else reveal()\n          }}\n        >\n          <ChevronsUpDown size={14} />\n        </button>\n      </div>\n\n      {open ? (\n        <ul\n          id={listId}\n          role=\"listbox\"\n          aria-label={label}\n          aria-multiselectable={multiple || undefined}\n          aria-busy={loading || undefined}\n          className=\"border-border bg-card absolute z-50 mt-1 max-h-72 w-full overflow-auto rounded-[calc(var(--radius)+0.15rem)] border p-1 shadow-lg\"\n          onMouseDown={(event) => event.preventDefault()}\n        >\n          {loading && results.length === 0 ? (\n            <li\n              role=\"presentation\"\n              className=\"text-muted-foreground px-2.5 py-2 text-sm\"\n            >\n              {loadingMessage}\n            </li>\n          ) : results.length === 0 && !creating ? (\n            <li\n              role=\"presentation\"\n              className=\"text-muted-foreground px-2.5 py-2 text-sm\"\n            >\n              {emptyMessage(search)}\n            </li>\n          ) : (\n            toSections(results).map((section, index) => {\n              if (section.group === undefined) {\n                return (\n                  <React.Fragment key={`loose-${index}`}>\n                    {section.options.map(renderOption)}\n                  </React.Fragment>\n                )\n              }\n\n              const headingId = `${id}-group-${index}`\n\n              return (\n                <li key={section.group} role=\"presentation\">\n                  <div\n                    id={headingId}\n                    className=\"text-muted-foreground px-2.5 pt-2 pb-1 text-xs font-medium\"\n                  >\n                    {section.group}\n                  </div>\n                  <ul role=\"group\" aria-labelledby={headingId}>\n                    {section.options.map(renderOption)}\n                  </ul>\n                </li>\n              )\n            })\n          )}\n\n          {creating ? (\n            <li\n              id={`${id}-create`}\n              role=\"option\"\n              aria-selected={false}\n              data-active={activeRow === CREATE || undefined}\n              className={cn(\n                \"text-foreground flex min-h-11 cursor-pointer items-center gap-2 rounded-md px-2.5 py-2 text-sm\",\n                activeRow === CREATE && \"bg-muted\"\n              )}\n              onPointerEnter={() => setActive(rows.length - 1)}\n              onClick={create}\n            >\n              <Plus aria-hidden=\"true\" size={14} className=\"shrink-0\" />\n              {createLabel(search)}\n            </li>\n          ) : null}\n        </ul>\n      ) : null}\n\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </p>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "form",
    "input",
    "select",
    "combobox",
    "multiselect"
  ],
  "type": "registry:ui"
}