{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "searchable-select",
  "title": "Searchable Select",
  "author": "AniBT <https://anibt.net>",
  "description": "AniBT single-choice combobox: a 32px field that opens a searchable command list (groups, second lines, a pink tick on the chosen row). Radix popover + cmdk.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@anibt/theme",
    "@anibt/button",
    "@anibt/command",
    "@anibt/popover"
  ],
  "files": [
    {
      "path": "registry/anibt/ui/searchable-select.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, ChevronsUpDownIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/registry/anibt/ui/button\"\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@/registry/anibt/ui/command\"\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/registry/anibt/ui/popover\"\n\n/**\n * SearchableSelect — AniBT 「贴纸手账」 single-choice combobox for a long list\n * (a show, a fansub group, a category): type to filter instead of scrolling a\n * select.\n *\n * The closed control reads as a field, not a key: Button's 32px `field`\n * paint with the chosen label (or the placeholder in the muted ink) and an\n * up-down chevron. It opens a popover as wide as the field (at least 256)\n * holding a command list: a 44px search row over the stitch, optional group\n * heads, and rows with a second muted line. The row under the cursor takes\n * the pink wash; the chosen row carries a pink tick. Choosing closes the\n * popover. On a touch screen the search is not focused on open, so the\n * keyboard does not cover the list until the reader asks for it.\n *\n * Built on `@anibt/popover` + `@anibt/command` (Radix + cmdk), so it works\n * inside a Dialog, a Sheet or a Drawer.\n */\n\ntype SearchableSelectOption = {\n  value: string\n  label: string\n  /** A second, muted line under the label. */\n  description?: string\n  /** Extra words the search matches (romaji, an alias). */\n  keywords?: string\n  /** Drawn before the label: an icon, an avatar. */\n  icon?: React.ReactNode\n  disabled?: boolean\n}\n\ntype SearchableSelectGroup = {\n  heading?: string\n  options: readonly SearchableSelectOption[]\n}\n\ntype SearchableSelectProps = {\n  value: string | undefined\n  onValueChange: (value: string) => void\n  /** A flat list. Use `groups` instead for headed sections. */\n  options?: readonly SearchableSelectOption[]\n  groups?: readonly SearchableSelectGroup[]\n  /** The closed field's text while nothing is chosen. */\n  placeholder?: string\n  searchPlaceholder?: string\n  emptyText?: string\n  disabled?: boolean\n  id?: string\n  className?: string\n  /** Classes for the popover panel. */\n  contentClassName?: string\n  \"aria-label\"?: string\n  \"aria-labelledby\"?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n}\n\nfunction searchText(option: SearchableSelectOption) {\n  return [option.label, option.description, option.keywords, option.value].filter(Boolean).join(\" \")\n}\n\nfunction isCoarsePointer() {\n  return typeof window !== \"undefined\" && window.matchMedia?.(\"(pointer: coarse)\").matches\n}\n\nfunction SearchableSelect({\n  value,\n  onValueChange,\n  options,\n  groups,\n  placeholder = \"请选择\",\n  searchPlaceholder = \"搜索…\",\n  emptyText = \"没有匹配的结果\",\n  disabled = false,\n  id,\n  className,\n  contentClassName,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  \"aria-invalid\": ariaInvalid,\n}: SearchableSelectProps) {\n  const [open, setOpen] = React.useState(false)\n  const sections = React.useMemo<readonly SearchableSelectGroup[]>(\n    () => groups ?? (options ? [{ options }] : []),\n    [groups, options]\n  )\n  const selected = React.useMemo(\n    () => sections.flatMap((section) => section.options).find((option) => option.value === value) ?? null,\n    [sections, value]\n  )\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger {...{ asChild: true }} disabled={disabled}>\n        <Button\n          id={id}\n          type=\"button\"\n          variant=\"field\"\n          role=\"combobox\"\n          aria-expanded={open}\n          aria-haspopup=\"listbox\"\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          aria-invalid={ariaInvalid}\n          disabled={disabled}\n          data-slot=\"searchable-select-trigger\"\n          className={cn(\n            \"w-full min-w-0 justify-between gap-2 px-3 text-left text-[13px] has-[>svg]:px-3\",\n            !selected && \"text-muted-foreground\",\n            className\n          )}\n        >\n          {selected?.icon ? <span className=\"flex shrink-0 items-center\">{selected.icon}</span> : null}\n          <span className=\"anibt-ell min-w-0 flex-1\">{selected?.label ?? placeholder}</span>\n          <ChevronsUpDownIcon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        onOpenAutoFocus={(event) => {\n          if (isCoarsePointer()) event.preventDefault()\n        }}\n        className={cn(\n          \"w-(--radix-popover-trigger-width) max-w-[calc(100vw-2rem)] min-w-64 gap-0 overflow-hidden p-0\",\n          contentClassName\n        )}\n      >\n        <Command loop data-slot=\"searchable-select-content\">\n          <CommandInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} />\n          <CommandList className=\"max-h-[min(50dvh,18rem)]\">\n            <CommandEmpty>{emptyText}</CommandEmpty>\n            {sections.map((section, index) =>\n              section.options.length === 0 ? null : (\n                <React.Fragment key={section.heading ?? `section-${index}`}>\n                  {index > 0 && !section.heading ? <CommandSeparator /> : null}\n                  <CommandGroup heading={section.heading}>\n                    {section.options.map((option) => {\n                      const chosen = option.value === value\n                      return (\n                        <CommandItem\n                          key={option.value}\n                          value={searchText(option)}\n                          disabled={option.disabled}\n                          onSelect={() => {\n                            onValueChange(option.value)\n                            setOpen(false)\n                          }}\n                          className=\"min-h-9 items-start py-2\"\n                        >\n                          {option.icon ? <span className=\"flex shrink-0 items-center pt-px\">{option.icon}</span> : null}\n                          <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                            <span className={cn(\"anibt-ell\", chosen && \"font-semibold\")}>{option.label}</span>\n                            {option.description ? (\n                              <span className=\"anibt-ell text-[12px] text-muted-foreground\">{option.description}</span>\n                            ) : null}\n                          </span>\n                          {chosen ? (\n                            <CheckIcon className=\"mt-0.5 size-4 shrink-0 text-anibt-mark\" strokeWidth={2.75} aria-hidden=\"true\" />\n                          ) : (\n                            <span className=\"size-4 shrink-0\" aria-hidden=\"true\" />\n                          )}\n                        </CommandItem>\n                      )\n                    })}\n                  </CommandGroup>\n                </React.Fragment>\n              )\n            )}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport { SearchableSelect }\nexport type { SearchableSelectGroup, SearchableSelectOption, SearchableSelectProps }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms",
    "overlay"
  ],
  "type": "registry:ui"
}