组件
多选下拉 Multi Select
支持搜索、键盘导航、选中摘要与一键清空的多选下拉控件。
基础用法
适合标签、人员、渠道等扁平数据的多选场景。支持拼音关键词搜索,超过 maxDisplay 的选项折叠为数量摘要:
Loading…
安装与引入
pnpm dlx @wui-design/cli@latest add @wui/multi-select安装依赖
pnpm add radix-ui lucide-react motion class-variance-authority clsx tailwind-merge确保已添加 Command、Popover、Checkbox 与 SlidingNumber,并复制组件源码
"use client"
import * as React from "react"
import { ChevronDownIcon, XIcon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { SlidingNumber } from "@/components/ui/sliding-number"
const tagTransition = { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } as const
const multiSelectVariants = cva(
"border-input bg-background shadow-xs focus-within:border-ring focus-within:ring-ring/30 has-[button[aria-invalid=true]]:border-destructive has-[button[aria-invalid=true]]:ring-[3px] has-[button[aria-invalid=true]]:ring-destructive/20 flex w-full items-center rounded-md border transition-[border-color,box-shadow,background-color] duration-200 focus-within:ring-[3px] data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
{
variants: {
size: {
sm: "min-h-8 min-w-44 text-xs",
default: "min-h-10 min-w-56 text-sm",
lg: "min-h-12 min-w-64 text-base",
},
},
defaultVariants: { size: "default" },
}
)
export interface MultiSelectOption {
/** 表单状态中使用的稳定值。 */
value: string
/** 展示给用户的选项名称。 */
label: React.ReactNode
/** 参与本地搜索的别名,例如拼音或缩写。 */
keywords?: string[]
/** 禁止选择此项。 */
disabled?: boolean
}
export interface MultiSelectProps extends Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange" | "size"
> {
/** 可供选择的选项集合。 */
options: MultiSelectOption[]
/** 受控模式下的选中值。 */
value?: string[]
/** 非受控模式下的初始选中值。 */
defaultValue?: string[]
/** 选中值发生变化时触发。 */
onValueChange?: (value: string[]) => void
/** 未选择任何选项时显示的文本。@default "请选择" */
placeholder?: string
/** 搜索框占位文本。@default "搜索选项" */
searchPlaceholder?: string
/** 没有匹配项时显示的内容。@default "没有匹配的选项" */
emptyText?: React.ReactNode
/** 触发器内最多直接展示多少个已选项。@default 2 */
maxDisplay?: number
/** 是否显示一键清空按钮。@default true */
clearable?: boolean
/** 尺寸密度。@default "default" */
size?: "sm" | "default" | "lg"
/** 应用于浮层的额外类名。 */
contentClassName?: string
}
/** 支持搜索、键盘导航与批量清空的多选下拉组件。 */
function MultiSelect({
className,
options,
value,
defaultValue = [],
onValueChange,
placeholder = "请选择",
searchPlaceholder = "搜索选项",
emptyText = "没有匹配的选项",
maxDisplay = 2,
clearable = true,
size = "default",
contentClassName,
disabled,
...props
}: MultiSelectProps) {
const reduceMotion = useReducedMotion()
const [open, setOpen] = React.useState(false)
const [internalValue, setInternalValue] = React.useState(defaultValue)
const selectedValue = value ?? internalValue
const selectedSet = React.useMemo(() => new Set(selectedValue), [selectedValue])
const selectedOptions = options.filter((option) => selectedSet.has(option.value))
const visibleOptions = selectedOptions.slice(0, Math.max(0, maxDisplay))
const hiddenCount = Math.max(0, selectedOptions.length - visibleOptions.length)
function changeValue(nextValue: string[]) {
if (value === undefined) setInternalValue(nextValue)
onValueChange?.(nextValue)
}
function toggleValue(optionValue: string) {
changeValue(
selectedSet.has(optionValue)
? selectedValue.filter((current) => current !== optionValue)
: [...selectedValue, optionValue]
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<div
data-slot="multi-select"
data-disabled={disabled || undefined}
data-placeholder={!selectedValue.length || undefined}
className={cn(multiSelectVariants({ size }), className)}
>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
className="flex min-h-[inherit] min-w-0 flex-1 items-center gap-1.5 px-3 text-left outline-none disabled:cursor-not-allowed"
{...props}
>
<span className="relative flex min-w-0 flex-1 flex-wrap items-center gap-1.5 py-1">
<AnimatePresence initial={false} mode="popLayout">
{visibleOptions.length ? (
visibleOptions.map((option) => (
<motion.span
key={option.value}
layout={!reduceMotion}
data-slot="multi-select-tag"
className="bg-secondary text-secondary-foreground inline-flex max-w-40 items-center truncate rounded-sm px-1.5 py-0.5 text-[0.85em] font-medium"
initial={reduceMotion ? false : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? undefined : { opacity: 0, scale: 0.8 }}
transition={reduceMotion ? { duration: 0 } : tagTransition}
>
<span className="truncate">{option.label}</span>
</motion.span>
))
) : (
<motion.span
key="__placeholder"
className="text-muted-foreground truncate"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduceMotion ? undefined : { opacity: 0 }}
transition={{ duration: 0.15 }}
>
{placeholder}
</motion.span>
)}
{hiddenCount ? (
<motion.span
key="__overflow"
layout={!reduceMotion}
data-slot="multi-select-overflow"
aria-label={`另有 ${hiddenCount} 项`}
className="bg-muted text-muted-foreground inline-flex items-center rounded-sm px-1.5 py-0.5 text-[0.85em] font-medium tabular-nums"
initial={reduceMotion ? false : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? undefined : { opacity: 0, scale: 0.8 }}
transition={reduceMotion ? { duration: 0 } : tagTransition}
>
+<SlidingNumber value={hiddenCount} />
</motion.span>
) : null}
</AnimatePresence>
</span>
<ChevronDownIcon
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
open && "rotate-180"
)}
/>
</button>
</PopoverTrigger>
<AnimatePresence initial={false}>
{clearable && selectedValue.length ? (
<motion.button
type="button"
data-slot="multi-select-clear"
aria-label="清空选择"
disabled={disabled}
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 mr-2 flex size-6 shrink-0 items-center justify-center rounded-sm outline-none transition-colors focus-visible:ring-2"
initial={reduceMotion ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? undefined : { opacity: 0, scale: 0.6 }}
transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
onClick={() => changeValue([])}
>
<XIcon className="size-3.5" />
</motion.button>
) : null}
</AnimatePresence>
</div>
<PopoverContent
data-slot="multi-select-content"
align="start"
className={cn(
"w-[var(--radix-popover-trigger-width)] origin-(--radix-popover-content-transform-origin) p-0",
contentClassName
)}
>
<Command>
<CommandInput placeholder={searchPlaceholder} autoFocus />
<CommandList aria-multiselectable="true">
<CommandEmpty>{emptyText}</CommandEmpty>
{options.map((option) => {
const selected = selectedSet.has(option.value)
return (
<CommandItem
key={option.value}
value={option.value}
keywords={[
typeof option.label === "string" ? option.label : "",
...(option.keywords ?? []),
]}
disabled={option.disabled}
aria-selected={selected}
onSelect={() => toggleValue(option.value)}
>
<Checkbox
checked={selected}
size="sm"
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
/>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
</CommandItem>
)
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export { MultiSelect, multiSelectVariants }
场景示例
评审人指派
结合外部按钮批量追加选项,触发器中的标签会依次弹入并平滑重排,超出部分以滚动数字摘要展示:
Loading…
属性 Props
该组件没有自定义属性,支持透传底层元素的原生属性。
使用建议
- 选项较多时保留搜索框;选项较少时仍可使用相同组件保持表单一致性。
- 触发器只展示前
maxDisplay个选项,其余以数量摘要呈现,避免表单被大量标签撑高。 - 选择面板保持打开,便于连续选择;按 Esc 或点击外部关闭。
- 层级数据请使用
Cascader,单选数据请使用Select或Combobox。 - 标签增删带有弹入 / 弹出与重排动画,折叠数量逐位滚动;开启
prefers-reduced-motion时瞬间切换。