wui
组件

多选下拉 Multi Select

支持搜索、键盘导航、选中摘要与一键清空的多选下拉控件。

第三方依赖 · lucide-react第三方依赖 · class-variance-authority第三方依赖 · motion

基础用法

适合标签、人员、渠道等扁平数据的多选场景。支持拼音关键词搜索,超过 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,并复制组件源码
components/ui/multi-select.tsx
"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 时瞬间切换。