Multi Select
订阅选择器:已选的值是框里可移除的标签,展开是可搜索的勾选清单,勾选时不关闭。
字幕组已选 2 项
喵萌奶茶屋LoliHouse
不选就是全部字幕组。
multi-select-demo.tsx
"use client"
import { UsersIcon } from "lucide-react"
import { useState } from "react"
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"
const GROUPS = ["喵萌奶茶屋", "LoliHouse", "北宇治字幕组", "桜都字幕组", "千夏字幕组", "ANi", "Sakurato", "黒ネズミたち"]
/** A group's tile: its first character on the pink wash. */
const OPTIONS: MultiSelectOption[] = GROUPS.map((name) => ({
value: name,
label: name,
icon: (
<span className="anibt-avatar size-5 text-[12px]" aria-hidden="true">
{name.slice(0, 1)}
</span>
),
}))
export default function MultiSelectDemo() {
const [groups, setGroups] = useState<string[]>(["喵萌奶茶屋", "LoliHouse"])
return (
<div className="w-full max-w-sm">
<div className="mb-1.5 flex items-baseline justify-between">
<span id="groups-label" className="text-[13px] font-semibold text-anibt-ink2">
字幕组
</span>
{groups.length ? <span className="anibt-num text-[12px] text-muted-foreground">已选 {groups.length} 项</span> : null}
</div>
<MultiSelect
aria-labelledby="groups-label"
icon={<UsersIcon />}
value={groups}
onValueChange={setGroups}
options={OPTIONS}
placeholder="全部字幕组"
searchPlaceholder="搜索字幕组…"
/>
<p className="mt-1.5 text-[12px] text-muted-foreground">不选就是全部字幕组。</p>
</div>
)
}安装
npx shadcn@latest add @anibt/multi-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/multi-select.tsx
"use client" import * as React from "react" import { CheckIcon, ChevronsUpDownIcon, XIcon } 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, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover" /** * MultiSelect — AniBT 「贴纸手账」 many-choice combobox for an open-ended list * (the subscription picker: 字幕组, 标签). * * The chosen values sit inside a 32px field as removable tags; the rest of * the field is the button that opens a searchable checklist. Nothing chosen * means "all", and the field says so in the muted ink (`placeholder`, e.g. * 「全部字幕组」). The list leads with that 「全部」 row, then one row per * option with A's 18px candy tick box. It stays open while you tick, so three * groups are three clicks, not three openings; the foot shows the count and a * 完成 key. * * In a checklist pink means chosen, so the keyboard cursor here is the * neutral sunk press, not the menu's pink wash. A click on a row ticks it * without taking focus from the search, so the next keystroke still filters. * * Built on `@anibt/popover` + `@anibt/command` (Radix + cmdk). The popover is * modal, so it scrolls and takes clicks inside a Dialog or a Drawer. */ type MultiSelectOption = { value: string label: string /** Extra words the search matches (romaji, an alias). */ keywords?: string /** Drawn before the label in the list: an avatar, an icon. */ icon?: React.ReactNode disabled?: boolean } type MultiSelectProps = { /** The chosen values; empty means "all". */ value: readonly string[] onValueChange: (value: string[]) => void options: readonly MultiSelectOption[] /** What an empty choice means: 「全部字幕组」. Also the list's first row. */ placeholder?: string searchPlaceholder?: string emptyText?: string /** Shown instead of the list when there are no options at all. */ noOptionsText?: string /** The 「全部」 row at the top of the list. */ showAll?: boolean /** The icon at the start of the field. */ icon?: React.ReactNode doneLabel?: React.ReactNode /** The count in the foot: `已选 3 项`. */ countLabel?: (count: number) => string /** The accessible name of a tag's remove button. */ removeLabel?: (label: string) => string disabled?: boolean id?: string className?: string contentClassName?: string "aria-label"?: string "aria-labelledby"?: string } const ROW = "min-h-9 gap-2.5 data-[selected=true]:bg-muted data-[selected=true]:text-foreground" const FOCUS_RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid" /** The 18px candy tick box a row leads with: drawn, not a control. */ function RowCheck({ checked }: { checked: boolean }) { return ( <span aria-hidden="true" className={cn("anibt-check", checked && "anibt-check-on")}> {checked ? <CheckIcon className="size-3 animate-anibt-pop text-anibt-candy-ink" strokeWidth={3} /> : null} </span> ) } /** Ticks a row without moving focus out of the search. */ function keepSearchFocus(event: React.MouseEvent<HTMLDivElement>) { event.preventDefault() } function isCoarsePointer() { return typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches } function MultiSelect({ value, onValueChange, options, placeholder = "全部", searchPlaceholder = "搜索…", emptyText = "没有匹配的结果", noOptionsText = "暂无选项", showAll = true, icon, doneLabel = "完成", countLabel = (count) => `已选 ${count} 项`, removeLabel = (label) => `移除 ${label}`, disabled = false, id, className, contentClassName, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, }: MultiSelectProps) { const [open, setOpen] = React.useState(false) const summaryId = React.useId() const isAll = value.length === 0 const labelOf = (item: string) => options.find((option) => option.value === item)?.label ?? item const toggle = (item: string) => { const next = value.includes(item) ? value.filter((entry) => entry !== item) : [...value, item] onValueChange(next) } const summary = isAll ? placeholder : countLabel(value.length) return ( <Popover modal open={open} onOpenChange={setOpen}> <PopoverAnchor {...{ asChild: true }}> <div role="group" aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} data-slot="multi-select" data-state={open ? "open" : "closed"} data-disabled={disabled || undefined} className={cn( "anibt-field h-auto min-h-8 flex-wrap gap-1 py-1 pr-1 pl-2.5", open && "anibt-field-on", disabled && "pointer-events-none bg-muted", className )} > {icon ? <span className="flex shrink-0 items-center text-muted-foreground [&>svg]:size-4">{icon}</span> : null} {value.map((item) => { const name = labelOf(item) return ( <span key={item} className="anibt-tag max-w-full gap-0.5 pr-0.5" title={name}> <span className="anibt-ell">{name}</span> <button type="button" aria-label={removeLabel(name)} disabled={disabled} onClick={() => toggle(item)} className={cn( "relative grid size-4 shrink-0 place-items-center rounded-[4px] text-muted-foreground transition-colors after:absolute after:-inset-1 hover:bg-accent hover:text-accent-foreground", FOCUS_RING )} > <XIcon className="size-3" strokeWidth={2.5} aria-hidden="true" /> </button> </span> ) })} <PopoverTrigger {...{ asChild: true }}> <button type="button" id={id} disabled={disabled} aria-haspopup="listbox" // Named by the field's label plus what is chosen: 「字幕组 已选 2 项」. aria-label={ariaLabelledBy ? undefined : ariaLabel ? `${ariaLabel} · ${summary}` : summary} aria-labelledby={ariaLabelledBy ? `${ariaLabelledBy} ${summaryId}` : undefined} className="flex h-6 min-w-12 flex-1 items-center justify-between gap-2 rounded-[6px] pl-0.5 text-left text-[13px] text-muted-foreground outline-none" > <span className="anibt-ell">{isAll ? placeholder : null}</span> <span id={summaryId} className="sr-only"> {summary} </span> <ChevronsUpDownIcon className="size-4 shrink-0" aria-hidden="true" /> </button> </PopoverTrigger> </div> </PopoverAnchor> <PopoverContent align="start" onOpenAutoFocus={(event) => { if (isCoarsePointer()) event.preventDefault() }} data-vaul-no-drag="" 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="multi-select-content"> <CommandInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} /> <CommandList className="max-h-[min(45dvh,18rem)] px-1"> <CommandEmpty>{emptyText}</CommandEmpty> {showAll ? ( <CommandGroup className="p-0"> <CommandItem value={`* ${placeholder}`} aria-checked={isAll} onMouseDown={keepSearchFocus} onSelect={() => onValueChange([])} className={ROW} > <RowCheck checked={isAll} /> <span className="anibt-ell min-w-0 flex-1 font-semibold">{placeholder}</span> </CommandItem> </CommandGroup> ) : null} {showAll ? <CommandSeparator /> : null} {options.length === 0 ? ( <p className="px-2.5 py-2 text-[12px] text-muted-foreground">{noOptionsText}</p> ) : ( <CommandGroup className="p-0"> {options.map((option) => { const checked = value.includes(option.value) return ( <CommandItem key={option.value} value={[option.label, option.keywords, option.value].filter(Boolean).join(" ")} disabled={option.disabled} aria-checked={checked} onMouseDown={keepSearchFocus} onSelect={() => toggle(option.value)} className={ROW} > <RowCheck checked={checked} /> {option.icon ? <span className="flex shrink-0 items-center">{option.icon}</span> : null} <span className="anibt-ell min-w-0 flex-1" title={option.label}> {option.label} </span> </CommandItem> ) })} </CommandGroup> )} </CommandList> <div className="anibt-stitch-over flex h-11 shrink-0 items-center justify-between gap-2 px-3"> <span className="anibt-num text-[12px] text-muted-foreground">{summary}</span> <Button type="button" size="sm" variant="secondary" onClick={() => setOpen(false)}> {doneLabel} </Button> </div> </Command> </PopoverContent> </Popover> ) } export { MultiSelect } export type { MultiSelectOption, MultiSelectProps }
用法
components/group-filter.tsx
import { UsersIcon } from "lucide-react"
import { useState } from "react"
import { MultiSelect } from "@/components/ui/multi-select"
export function GroupFilter({ groups }: { groups: { value: string; label: string }[] }) {
const [chosen, setChosen] = useState<string[]>([])
return (
<MultiSelect
aria-label="字幕组"
icon={<UsersIcon />}
value={chosen}
onValueChange={setChosen}
options={groups}
placeholder="全部字幕组"
searchPlaceholder="搜索字幕组…"
/>
)
}什么时候用
开放的长列表(字幕组、标签)用它;几个固定的选项(字幕语言、分辨率、格式)直接在行内放 chip,不用展开。
样子
- 框是 32 高的输入框材质(
anibt-field),已选的值是 20 高的标签,每个带一个移除键;剩下的地方是展开清单的按钮。展开时框是粉色边加粉色光晕。 - 没有选就是「全部」:框里用弱色写
placeholder(「全部字幕组」),清单第一行也是它。 - 每行前面是 18px 的糖果勾选框,勾上时勾会弹一下。在勾选清单里粉色代表「已选」,所以键盘所在行用中性的下沉灰,不用菜单的粉色。
- 勾选不会关闭清单,点行也不会把焦点从搜索框拿走,下一个字照样能筛选。底部是已选数量和「完成」键。
- 浮层是模态的:放在 Dialog 或 Drawer 里时,清单自己能滚动、能点击。
在弹窗里
multi-select-dialog.tsx
"use client"
import { BellPlusIcon, TagIcon, UsersIcon } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { MultiSelect } from "@/components/ui/multi-select"
import { SearchableSelect } from "@/components/ui/searchable-select"
const GROUPS = ["喵萌奶茶屋", "LoliHouse", "北宇治字幕组", "桜都字幕组", "千夏字幕组", "ANi"].map((name) => ({
value: name,
label: name,
}))
const TAGS = ["合集", "BDRip", "WebRip", "无修", "繁中", "外挂字幕"].map((name) => ({ value: name, label: name }))
const RESOLUTIONS = [
{ value: "any", label: "不限分辨率" },
{ value: "2160p", label: "2160p", description: "4K,文件最大" },
{ value: "1080p", label: "1080p", description: "大多数字幕组的首发" },
{ value: "720p", label: "720p", description: "适合手机" },
]
export default function MultiSelectDialog() {
const [groups, setGroups] = useState<string[]>(["LoliHouse"])
const [tags, setTags] = useState<string[]>([])
const [resolution, setResolution] = useState("1080p")
return (
<Dialog>
<DialogTrigger asChild>
<Button>
<BellPlusIcon />
订阅设置
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>订阅 葬送的芙莉莲 第二季</DialogTitle>
<DialogDescription>符合条件的新资源会出现在你的 RSS 里。</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div>
<span id="sub-groups" className="anibt-label">
字幕组
</span>
<MultiSelect
aria-labelledby="sub-groups"
icon={<UsersIcon />}
value={groups}
onValueChange={setGroups}
options={GROUPS}
placeholder="全部字幕组"
searchPlaceholder="搜索字幕组…"
/>
</div>
<div>
<span id="sub-tags" className="anibt-label">
标签
</span>
<MultiSelect
aria-labelledby="sub-tags"
icon={<TagIcon />}
value={tags}
onValueChange={setTags}
options={TAGS}
placeholder="全部标签"
searchPlaceholder="搜索标签…"
/>
</div>
<div>
<span id="sub-res" className="anibt-label">
画质
</span>
<SearchableSelect
aria-labelledby="sub-res"
value={resolution}
onValueChange={setResolution}
options={RESOLUTIONS}
searchPlaceholder="搜索…"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">取消</Button>
</DialogClose>
<DialogClose asChild>
<Button>保存订阅</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}订阅设置弹窗:两个多选组合框(字幕组、标签)加一个 Searchable Select。
属性
MultiSelect
valuestring[]默认 —已选的值;空数组表示全部
onValueChange(value: string[]) => void默认 —每次勾选、移除或选「全部」时调用
options{ value; label; keywords?; icon?; disabled? }[]默认 —选项;icon 画在清单行里(头像、图标)
placeholderstring默认 "全部"空选时的含义,也是清单第一行
showAllboolean默认 true清单顶部的「全部」行
iconReactNode默认 —框最前面的图标
searchPlaceholderstring默认 "搜索…"搜索框占位
emptyTextstring默认 "没有匹配的结果"搜不到时的提示
noOptionsTextstring默认 "暂无选项"完全没有选项时的提示
doneLabelReactNode默认 "完成"底部关闭键的文字
countLabel(count: number) => string默认 已选 n 项底部的数量文字
removeLabel(label: string) => string默认 移除 …标签移除键的读屏名称
disabledboolean默认 false禁用
aria-label / aria-labelledbystring默认 —整组的名称
