AniBTUI
✦ 组件@anibt/searchable-selectregistry JSON

Searchable Select

长列表的单选组合框:看起来是一个 32 高的输入框,展开是可搜索的命令列表,选中的行带粉色勾。

安装

npx shadcn@latest add @anibt/searchable-select

第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/button、@anibt/command、@anibt/popover。

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

用法

components/show-picker.tsx
import { useState } from "react"

import { SearchableSelect } from "@/components/ui/searchable-select"

export function ShowPicker() {
  const [show, setShow] = useState<string>()
  return (
    <SearchableSelect
      aria-label="番剧"
      value={show}
      onValueChange={setShow}
      options={[
        { value: "frieren-2", label: "葬送的芙莉莲 第二季", description: "Sousou no Frieren S2" },
        { value: "spy-3", label: "间谍过家家 第三季", keywords: "SPY×FAMILY" },
      ]}
      placeholder="选择番剧"
      searchPlaceholder="搜索番剧名或罗马字…"
    />
  )
}

什么时候用

选项多到要翻页(番剧、字幕组、分类)时,用它代替下拉选择:打字筛选,不用来回滚。只有几个固定选项时,用行内的 chip 或普通的选择框。

样子

  • 关着时是 Button 的 field 材质:32 高、纸面、控件边;没有选中时显示弱色的占位文字。展开时边变成 1.5px 粉色。
  • 浮层和输入框一样宽(至少 256),里面是 Command:搜索行、可选的组名、带第二行说明的选项。
  • 键盘所在的行是粉色淡底;已选中的行文字加粗,行尾是粉色的勾。选了就关。
  • 触屏设备上展开时不自动聚焦搜索框,键盘不会挡住列表;想搜的时候再点搜索框。
  • keywords 是额外参与搜索的词(罗马字、别名),不会显示出来。

在弹窗里

Multi Select 的第二个例子把它和多选组合框一起放在 Dialog 里。两者都建在 Radix Popover + cmdk 上,和 @anibt/dialog、@anibt/sheet、@anibt/drawer 是同一套浮层,在里面能正常滚动和点击。

属性

SearchableSelect
valuestring | undefined默认 —选中的值
onValueChange(value: string) => void默认 —选择后调用
optionsSearchableSelectOption[]默认 —平铺的选项
groups{ heading?: string; options: SearchableSelectOption[] }[]默认 —分组的选项,代替 options
placeholderstring默认 "请选择"没选时框里的文字
searchPlaceholderstring默认 "搜索…"搜索框占位,也是它的读屏名称
emptyTextstring默认 "没有匹配的结果"搜不到时的提示
disabledboolean默认 false禁用
aria-label / aria-labelledbystring默认 —触发器的名称:没有可见标签时必填
aria-invalidboolean默认 —校验失败时画红色轮廓
contentClassNamestring默认 —浮层的 class
SearchableSelectOption
value / labelstring默认 —值和显示文字
descriptionstring默认 —第二行弱色说明
keywordsstring默认 —额外的搜索词
iconReactNode默认 —文字前的图标或头像
disabledboolean默认 —不可选

为什么不叫 combobox

shadcn 现在的 components/ui/combobox.tsx 是基于 Base UI 的另一套组合 API。为了不覆盖它,AniBT 的两个组合框分别叫 searchable-select(单选)和 multi-select(多选)。