{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "searchable-select",
  "title": "Searchable Select",
  "description": "Single-select typeahead on Base UI Combobox: case- and diacritic-insensitive filtering over label and keywords, keyboard support, 16px controls on mobile. Cruiser original (not in shadcn/ui).",
  "dependencies": [
    "@base-ui/react",
    "cn",
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/sav/ui/searchable-select.tsx",
      "content": "// @sav-registry: searchable-select\n\"use client\"\n\nimport * as React from \"react\"\nimport { Combobox } from \"@base-ui/react/combobox\"\nimport { CheckIcon, ChevronDownIcon, SearchIcon } from \"lucide-react\"\n\nimport { cn } from \"cn\"\n\nexport interface SearchableSelectOption {\n  value: string\n  label: string\n  /** Extra terms matched by the search box (e.g. an ICAO code and the name). */\n  keywords?: string[]\n}\n\n/** Lowercase + strip diacritics, so \"hadean\" matches \"Hădean\". */\nfunction fold(value: string): string {\n  return value\n    .normalize(\"NFD\")\n    .replace(/\\p{Diacritic}/gu, \"\")\n    .toLowerCase()\n}\n\n/**\n * Case- and diacritic-insensitive substring match over the option's label\n * plus any keywords. Lets users type an ICAO code (\"LROP\"), part of the name\n * (\"Cluj\"), or an unaccented form (\"plescan\" → \"Pleșcan\").\n */\nfunction matchesQuery(option: SearchableSelectOption, query: string): boolean {\n  const q = fold(query.trim())\n  if (q === \"\") return true\n  const haystacks = [option.label, ...(option.keywords ?? [])]\n  return haystacks.some((h) => fold(h).includes(q))\n}\n\n/**\n * A searchable single-select (typeahead). Closed, it shows the selected label;\n * opening reveals a search input that filters a long list. Built on base-ui's\n * Combobox, so keyboard support (type to filter, arrow keys, Enter to select,\n * Esc to close) and ARIA wiring come for free.\n */\nexport function SearchableSelect({\n  id,\n  value,\n  onChange,\n  options,\n  placeholder,\n  searchPlaceholder = \"Search…\",\n  emptyText = \"No results found.\",\n  disabled,\n  className,\n}: {\n  id?: string\n  value: string\n  onChange: (value: string) => void\n  options: SearchableSelectOption[]\n  placeholder?: string\n  searchPlaceholder?: string\n  emptyText?: string\n  disabled?: boolean\n  className?: string\n}) {\n  const selected = React.useMemo(\n    () => options.find((option) => option.value === value) ?? null,\n    [options, value],\n  )\n\n  return (\n    <Combobox.Root<SearchableSelectOption>\n      items={options}\n      value={selected}\n      onValueChange={(option) => onChange(option?.value ?? \"\")}\n      isItemEqualToValue={(a, b) => a.value === b.value}\n      filter={matchesQuery}\n      disabled={disabled}\n    >\n      <Combobox.Trigger\n        id={id}\n        className={cn(\n          \"flex h-10 w-full items-center justify-between gap-1.5 rounded-none border border-transparent border-b-input bg-transparent px-0 py-2 text-left text-base whitespace-nowrap transition-[color,border-color] outline-none focus-visible:border-b-ring disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground md:text-sm\",\n          className,\n        )}\n      >\n        <span className=\"line-clamp-1 flex-1 text-left\">\n          <Combobox.Value placeholder={placeholder} />\n        </span>\n        <Combobox.Icon\n          render={\n            <ChevronDownIcon className=\"pointer-events-none size-3.5 shrink-0 text-muted-foreground\" />\n          }\n        />\n      </Combobox.Trigger>\n\n      <Combobox.Portal>\n        <Combobox.Positioner\n          className=\"isolate z-50 w-(--anchor-width)\"\n          side=\"bottom\"\n          sideOffset={4}\n          align=\"start\"\n        >\n          <Combobox.Popup\n            className=\"relative isolate z-50 max-h-(--available-height) w-full max-w-(--available-width) min-w-(--anchor-width) origin-(--transform-origin) overflow-hidden rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\"\n          >\n            <div className=\"flex items-center gap-2 border-b border-b-input px-3\">\n              <SearchIcon className=\"size-3.5 shrink-0 text-muted-foreground\" />\n              <Combobox.Input\n                placeholder={searchPlaceholder}\n                className=\"h-10 w-full min-w-0 bg-transparent py-1 text-base outline-none placeholder:text-muted-foreground md:text-sm\"\n              />\n            </div>\n            <Combobox.Empty className=\"px-3 py-6 text-center text-sm text-muted-foreground\">\n              {emptyText}\n            </Combobox.Empty>\n            <Combobox.List className=\"max-h-72 overflow-y-auto overscroll-contain p-1 data-empty:p-0\">\n              {(option: SearchableSelectOption) => (\n                <Combobox.Item\n                  key={option.value}\n                  value={option}\n                  className=\"relative flex w-full cursor-default items-center gap-2.5 rounded-none py-2 pr-8 pl-3 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground\"\n                >\n                  <span className=\"line-clamp-1 flex-1\">{option.label}</span>\n                  <Combobox.ItemIndicator className=\"absolute right-2 flex size-4 items-center justify-center\">\n                    <CheckIcon className=\"pointer-events-none size-3.5\" />\n                  </Combobox.ItemIndicator>\n                </Combobox.Item>\n              )}\n            </Combobox.List>\n          </Combobox.Popup>\n        </Combobox.Positioner>\n      </Combobox.Portal>\n    </Combobox.Root>\n  )\n}\n",
      "type": "registry:ui",
      "target": "@ui/searchable-select.tsx"
    }
  ],
  "categories": [
    "ui"
  ],
  "type": "registry:ui"
}