Searchable Select
长列表的单选组合框:看起来是一个 32 高的输入框,展开是可搜索的命令列表,选中的行带粉色勾。
searchable-select-demo.tsx
"use client"
import { useState } from "react"
import { SearchableSelect, type SearchableSelectGroup } from "@/components/ui/searchable-select"
const SHOWS: SearchableSelectGroup[] = [
{
heading: "2026 年 10 月新番",
options: [
{ value: "kusuriya-3", label: "药屋少女的呢喃 第三季", description: "Kusuriya no Hitorigoto S3", keywords: "药屋" },
{ value: "frieren-2", label: "葬送的芙莉莲 第二季", description: "Sousou no Frieren S2", keywords: "芙莉莲" },
{ value: "spy-3", label: "间谍过家家 第三季", description: "SPY×FAMILY Season 3", keywords: "间谍" },
{ value: "dandadan-3", label: "胆大党 第三季", description: "Dandadan S3" },
],
},
{
heading: "继续放送",
options: [
{ value: "onepiece", label: "海贼王", description: "One Piece · 第 1147 集起" },
{ value: "conan", label: "名侦探柯南", description: "Detective Conan · 第 1180 集起" },
{ value: "precure", label: "光之美少女", description: "Precure · 本季已完结", disabled: true },
],
},
]
export default function SearchableSelectDemo() {
const [show, setShow] = useState<string>("frieren-2")
return (
<div className="w-full max-w-xs">
<label id="show-label" htmlFor="show-picker" className="anibt-label">
番剧
</label>
<SearchableSelect
id="show-picker"
aria-labelledby="show-label"
value={show}
onValueChange={setShow}
groups={SHOWS}
placeholder="选择番剧"
searchPlaceholder="搜索番剧名或罗马字…"
/>
</div>
)
}安装
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. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 安装依赖
npm install lucide-react - 3. 它还用到
npx shadcn@latest add @anibt/button @anibt/command @anibt/popover - 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(多选)。
