{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "searchable-select",
  "title": "Searchable select",
  "description": "A single-choice dropdown with a search field above a grouped, scrollable list of options.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/searchable-select/searchable-select.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Check, ChevronDown, Search } from \"lucide-react\";\n\n/**\n * A one-choice picker with a search box inside it.\n *\n * ── Why not the native `<select>` ──\n *\n * A native select cannot be searched past its first letter, and it cannot show\n * a category above its options without `<optgroup>`'s fixed styling. The sector\n * list is 22 options under 6 headings — long enough that scrolling it is a\n * chore and short enough that typing three letters should end the job. This is\n * the listbox pattern from WAI-ARIA (a combobox that opens a listbox), built\n * here rather than pulled in: the project already carries a Radix select for\n * plain choices, and nothing in it searches.\n *\n * ── What it guarantees ──\n *\n *   * Keyboard first: ↑/↓ move, Enter picks, Escape closes and hands focus\n *     back to the trigger, Tab leaves. The active option is announced through\n *     `aria-activedescendant`, so the search field keeps focus while the\n *     listbox moves under it.\n *   * A value outside the option list still shows. Records hold values from\n *     before a list existed; the trigger reads what is actually on the record,\n *     and `unlistedLabel` says so without hiding it.\n *   * Choosing is the only thing it does. No free text, so a wrong answer is\n *     impossible rather than rejected — the rule the admin screens are held to\n *     (AGENTS.md, \"Prefer a choice over free text\").\n */\n\nexport type SearchableOption = {\n  value: string;\n  label: string;\n  /** Extra words this option should be findable by, never rendered. */\n  keywords?: readonly string[];\n};\n\nexport type SearchableOptionGroup = {\n  label: string;\n  options: readonly SearchableOption[];\n};\n\nexport function SearchableSelect({\n  id,\n  value,\n  onChange,\n  groups,\n  placeholder = \"Select…\",\n  searchPlaceholder = \"Search…\",\n  emptyMessage = \"Nothing matches that.\",\n  unlistedLabel,\n  allowCustom = false,\n  customGroupLabel = \"Not in the list\",\n  extraGroups,\n  onQueryChange,\n  searchHint,\n  disabled = false,\n  ariaLabel,\n  className = \"\",\n  popoverClassName = \"\",\n}: {\n  id: string;\n  value: string;\n  onChange: (next: string) => void;\n  groups: readonly SearchableOptionGroup[];\n  placeholder?: string;\n  searchPlaceholder?: string;\n  emptyMessage?: string;\n  /** Suffix for a value that is not in `groups` — e.g. \"not in the standard list\". */\n  unlistedLabel?: string;\n  /**\n   * Offers what the searcher typed as a last row when nothing matches it.\n   *\n   * Off by default, because a closed list is what makes a wrong answer\n   * impossible. Turn it on only where the list is genuinely a *good* list\n   * rather than a complete one — the local-authority places are 174 councils\n   * and a client can sit in a town that is not one of them.\n   */\n  allowCustom?: boolean;\n  customGroupLabel?: string;\n  /**\n   * Options worked out from the search text itself, offered above the list.\n   *\n   * The picker does not know where they come from — the place picker fills\n   * this by asking what council a typed postcode is in. They are not filtered\n   * again here: whoever produced them already answered the query.\n   */\n  extraGroups?: readonly SearchableOptionGroup[];\n  /** Told what is being searched for, so a consumer can go and find more. */\n  onQueryChange?: (query: string) => void;\n  /** A line under the search box — what else can be typed, or what is loading. */\n  searchHint?: React.ReactNode;\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n  popoverClassName?: string;\n}) {\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  // The highlight is held as a VALUE, not an index: the list moves under it as\n  // the query narrows, and an index would point at a different option — or off\n  // the end — the moment a letter is typed. Deriving the index each render is\n  // also what keeps this free of the setState-in-effect the React Compiler\n  // rules out.\n  const [activeValue, setActiveValue] = useState<string | null>(null);\n\n  // Whether a close should hand focus back to the trigger (Escape, or picking\n  // an option) rather than leave it where the pointer left it. State, not a\n  // ref, and acted on in the effect below: a `close` that touches a ref at all\n  // is a function the refs lint will not let us hand to an onClick.\n  const [focusTriggerOnClose, setFocusTriggerOnClose] = useState(false);\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const searchRef = useRef<HTMLInputElement | null>(null);\n  const listRef = useRef<HTMLDivElement | null>(null);\n\n  const reactId = useId();\n  const listboxId = `${id}-listbox`;\n  const optionId = (index: number) => `${id}-option-${index}-${reactId}`;\n\n  const allOptions = useMemo(\n    () => groups.flatMap((group) => group.options),\n    [groups],\n  );\n\n  const selected = allOptions.find((option) => option.value === value) ?? null;\n  const hasUnlistedValue = Boolean(value) && !selected;\n\n  // Matching is on the visible label plus any keywords, so \"homeless\" finds\n  // \"Housing & Homelessness\" and a category name finds everything under it.\n  const filteredGroups = useMemo(() => {\n    const needle = query.trim().toLowerCase();\n    if (!needle) return groups;\n    return groups\n      .map((group) => ({\n        label: group.label,\n        options: group.options.filter((option) => {\n          if (group.label.toLowerCase().includes(needle)) return true;\n          if (option.label.toLowerCase().includes(needle)) return true;\n          return (option.keywords ?? []).some((word) => word.toLowerCase().includes(needle));\n        }),\n      }))\n      .filter((group) => group.options.length > 0);\n  }, [groups, query]);\n\n  /**\n   * What the searcher typed, offered as its own row when the list has nothing\n   * like it. Rendered as an option rather than a separate button so the arrow\n   * keys and Enter reach it the same way they reach everything else.\n   */\n  const customOption = useMemo(() => {\n    if (!allowCustom) return null;\n    const typed = query.trim();\n    if (!typed) return null;\n    const alreadyListed = allOptions.some(\n      (option) => option.label.toLowerCase() === typed.toLowerCase(),\n    );\n    return alreadyListed ? null : { value: typed, label: `Use “${typed}”` };\n  }, [allowCustom, allOptions, query]);\n\n  /** Groups as rendered: what the query found elsewhere, the list, then the typed row. */\n  const renderedGroups = useMemo(() => {\n    const groupsToRender = [...(extraGroups ?? []), ...filteredGroups];\n    return customOption\n      ? [...groupsToRender, { label: customGroupLabel, options: [customOption] }]\n      : groupsToRender;\n  }, [extraGroups, filteredGroups, customOption, customGroupLabel]);\n\n  /** The filtered options in render order — what the arrow keys walk. */\n  const visibleOptions = useMemo(\n    () => renderedGroups.flatMap((group) => group.options),\n    [renderedGroups],\n  );\n\n  // Where the highlight actually is, now that the list has been filtered. A\n  // highlight whose option has just been typed out of view falls back to the\n  // first match, which is what a search box should do.\n  const activeIndex = Math.max(\n    visibleOptions.findIndex((option) => option.value === activeValue),\n    0,\n  );\n\n  // Focus follows the list: into the search box on opening, back to the trigger\n  // on a close that asked for it. No state is set here — see activeValue above.\n  useEffect(() => {\n    if (open) {\n      searchRef.current?.focus();\n      return;\n    }\n    if (focusTriggerOnClose) triggerRef.current?.focus();\n  }, [open, focusTriggerOnClose]);\n\n  // Keep the highlighted row in view when the arrows walk past the fold.\n  useEffect(() => {\n    if (!open) return;\n    const node = listRef.current?.querySelector<HTMLElement>('[data-active=\"true\"]');\n    node?.scrollIntoView({ block: \"nearest\" });\n  }, [open, activeIndex]);\n\n  // A click anywhere else closes it. Pointerdown, not click, so the close beats\n  // whatever the pointer landed on.\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open]);\n\n  /** Opening lands the highlight on the current value, never on whatever is first. */\n  const openList = () => {\n    setActiveValue(value || allOptions[0]?.value || null);\n    setQuery(\"\");\n    onQueryChange?.(\"\");\n    setOpen(true);\n  };\n\n  // useCallback, not a plain function: the click handlers below hand this to\n  // React, and the refs lint reads a bare closure over `triggerRef` as a ref\n  // touched during render.\n  const close = useCallback(\n    (returnFocus: boolean) => {\n      setFocusTriggerOnClose(returnFocus);\n      setOpen(false);\n      setQuery(\"\");\n      onQueryChange?.(\"\");\n    },\n    [onQueryChange],\n  );\n\n  /** Moves the highlight by walking the filtered list from where it sits now. */\n  const moveActive = (nextIndex: number) => {\n    if (visibleOptions.length === 0) return;\n    const wrapped = (nextIndex + visibleOptions.length) % visibleOptions.length;\n    setActiveValue(visibleOptions[wrapped]!.value);\n  };\n\n  const choose = useCallback(\n    (option: SearchableOption) => {\n      onChange(option.value);\n      close(true);\n    },\n    [onChange, close],\n  );\n\n  const onSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      moveActive(activeIndex + 1);\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      moveActive(activeIndex - 1);\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      moveActive(0);\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      moveActive(visibleOptions.length - 1);\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      const option = visibleOptions[activeIndex];\n      if (option) choose(option);\n    } else if (event.key === \"Escape\") {\n      event.preventDefault();\n      close(true);\n    } else if (event.key === \"Tab\") {\n      close(false);\n    }\n  };\n\n  const triggerLabel = selected\n    ? selected.label\n    : hasUnlistedValue\n      ? unlistedLabel\n        ? `${value} (${unlistedLabel})`\n        : value\n      : placeholder;\n\n  // Walked alongside the render so each option knows its own place in the\n  // flattened order the arrow keys use.\n  let flatIndex = -1;\n\n  return (\n    <div ref={rootRef} className={`relative w-full min-w-0 font-body ${className}`}>\n      <button\n        ref={triggerRef}\n        id={id}\n        type=\"button\"\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-controls={open ? listboxId : undefined}\n        aria-haspopup=\"listbox\"\n        aria-label={ariaLabel}\n        disabled={disabled}\n        onClick={() => (open ? close(false) : openList())}\n        onKeyDown={(event) => {\n          if (event.key === \"ArrowDown\" || event.key === \"Enter\" || event.key === \" \") {\n            event.preventDefault();\n            openList();\n          }\n        }}\n        className=\"flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-inset border border-rule bg-card px-3 text-left text-sm outline-none focus-visible:border-lead focus-visible:ring-2 focus-visible:ring-lead/20 disabled:pointer-events-none disabled:opacity-50\"\n      >\n        <span className={`truncate ${value ? \"text-ink\" : \"text-faint\"}`}>{triggerLabel}</span>\n        <ChevronDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-dim\" />\n      </button>\n\n      {open && (\n        <div className={`absolute z-30 mt-1 w-full overflow-hidden rounded-inset border border-rule bg-card shadow-lg ${popoverClassName}`}>\n          <div className=\"flex items-center gap-2 border-b border-rule-soft px-3\">\n            <Search aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-faint\" />\n            <input\n              ref={searchRef}\n              type=\"text\"\n              role=\"combobox\"\n              aria-expanded\n              aria-controls={listboxId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={\n                visibleOptions.length > 0 ? optionId(activeIndex) : undefined\n              }\n              aria-label={searchPlaceholder}\n              placeholder={searchPlaceholder}\n              value={query}\n              onChange={(event) => {\n                setQuery(event.target.value);\n                onQueryChange?.(event.target.value);\n              }}\n              onKeyDown={onSearchKeyDown}\n              className=\"h-9 w-full bg-transparent text-sm text-ink outline-none placeholder:text-faint\"\n            />\n          </div>\n\n          {searchHint && (\n            <p className=\"border-b border-rule-soft px-3 py-1.5 text-[12px] leading-[1.45] text-dim\">\n              {searchHint}\n            </p>\n          )}\n\n          <div\n            ref={listRef}\n            id={listboxId}\n            role=\"listbox\"\n            aria-label={ariaLabel ?? placeholder}\n            className=\"max-h-64 overflow-y-auto py-1\"\n          >\n            {visibleOptions.length === 0 ? (\n              <p className=\"px-3 py-3 text-[13px] text-dim\">{emptyMessage}</p>\n            ) : (\n              renderedGroups.map((group) => (\n                <div key={group.label} role=\"group\" aria-label={group.label}>\n                  <p className=\"px-3 pt-2 pb-1 text-[11.5px] font-semibold tracking-[0.02em] text-faint\">\n                    {group.label}\n                  </p>\n                  {group.options.map((option) => {\n                    flatIndex += 1;\n                    const index = flatIndex;\n                    const isActive = index === activeIndex;\n                    const isSelected = option.value === value;\n                    return (\n                      <div\n                        key={option.value}\n                        id={optionId(index)}\n                        role=\"option\"\n                        aria-selected={isSelected}\n                        data-active={isActive || undefined}\n                        onPointerEnter={() => setActiveValue(option.value)}\n                        onClick={() => choose(option)}\n                        className={`flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 text-sm ${\n                          isActive ? \"bg-paper text-ink\" : \"text-ink\"\n                        }`}\n                      >\n                        <span className=\"truncate\">{option.label}</span>\n                        {isSelected && (\n                          <Check aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-lead\" />\n                        )}\n                      </div>\n                    );\n                  })}\n                </div>\n              ))\n            )}\n          </div>\n\n          {hasUnlistedValue && (\n            <p className=\"border-t border-rule-soft px-3 py-2 text-[12.5px] leading-[1.5] text-dim\">\n              This record currently holds <span className=\"font-medium text-ink\">{value}</span>\n              {unlistedLabel ? `, which is ${unlistedLabel}.` : \".\"} Choosing above replaces it.\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default SearchableSelect;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "kind": "primitive",
    "whenToUse": "Use for choosing one client, place or category from a long list. Filters immediately by label, group or keywords; supports arrow keys, Enter and Escape.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/ui/searchable-select.tsx"
    },
    "credits": []
  },
  "type": "registry:ui"
}
