wui
组件

组合选择器 Combobox

融合文本输入搜索与下拉单选列表的复合选择控件,支持拼音模糊检索、别名过滤、一键清空与丰富的自定义选项渲染。

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

基础用法

最简单的组合选择器用法。点击展开下拉面板并在搜索框中键入关键词,列表将实时过滤匹配项:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/combobox
安装依赖组件与图标库
pnpm add radix-ui motion lucide-react class-variance-authority clsx tailwind-merge
确保已添加 Popover 与 Command 基础组件,并将源码复制到 components/ui/combobox.tsx
components/ui/combobox.tsx
"use client"

import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"
import {
  Command,
  CommandEmpty,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

const comboboxVariants = 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: "h-8 min-w-44 text-xs",
        default: "h-10 min-w-56 text-sm",
        lg: "h-12 min-w-64 text-base",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

export interface ComboboxOption {
  /** 表单提交和受控状态中使用的稳定值。 */
  value: string
  /** 展示给用户的文本或自定义内容。 */
  label: React.ReactNode
  /** 选项辅助描述信息。 */
  description?: React.ReactNode
  /** 参与模糊搜索的别名,例如拼音、缩写或关键词。 */
  keywords?: string[]
  /** 禁止选择此项。 */
  disabled?: boolean
}

export interface ComboboxProps
  extends Omit<
    React.ComponentProps<"button">,
    "value" | "defaultValue" | "onChange"
  > {
  /** 可搜索的选项集合。 */
  options: ComboboxOption[]
  /** 受控模式下的选中值。 */
  value?: string
  /** 非受控模式下的初始选中值。 */
  defaultValue?: string
  /** 选中值变化或清空时触发。 */
  onValueChange?: (value: string) => void
  /** 未选中时显示的文本。@default "请选择" */
  placeholder?: string
  /** 搜索框占位文本。@default "搜索选项" */
  searchPlaceholder?: string
  /** 没有匹配项时显示的文本。@default "没有匹配的选项" */
  emptyText?: React.ReactNode
  /** 是否显示清空按钮。@default true */
  clearable?: boolean
  /** 尺寸密度。@default "default" */
  size?: "sm" | "default" | "lg"
  /** 应用于浮层的额外类名。 */
  contentClassName?: string
}

/** 由 Popover 与 Command 组合而成的可搜索单选器。 */
function Combobox({
  className,
  options,
  value,
  defaultValue = "",
  onValueChange,
  placeholder = "请选择",
  searchPlaceholder = "搜索选项",
  emptyText = "没有匹配的选项",
  clearable = true,
  size = "default",
  contentClassName,
  disabled,
  ...props
}: ComboboxProps) {
  const reduceMotion = useReducedMotion()
  const [open, setOpen] = React.useState(false)
  const [internalValue, setInternalValue] = React.useState(defaultValue)
  const selectedValue = value ?? internalValue
  const selectedOption = options.find(
    (option) => option.value === selectedValue
  )

  function changeValue(nextValue: string) {
    if (value === undefined) setInternalValue(nextValue)
    onValueChange?.(nextValue)
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <div
        data-slot="combobox"
        data-disabled={disabled || undefined}
        className={cn(comboboxVariants({ size }), className)}
      >
        <PopoverTrigger asChild>
          <button
            type="button"
            data-slot="combobox-trigger"
            role="combobox"
            aria-expanded={open}
            disabled={disabled}
            className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-l-md px-3 text-left outline-none"
            {...props}
          >
            <span className="relative flex min-w-0 flex-1 overflow-hidden">
              <AnimatePresence initial={false} mode="popLayout">
                <motion.span
                  key={selectedOption?.value ?? "__placeholder"}
                  data-placeholder={!selectedOption || undefined}
                  className="data-[placeholder=true]:text-muted-foreground min-w-0 flex-1 truncate"
                  initial={reduceMotion ? false : { opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduceMotion ? undefined : { opacity: 0, y: -6 }}
                  transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                >
                  {selectedOption?.label ?? placeholder}
                </motion.span>
              </AnimatePresence>
            </span>
            <ChevronsUpDownIcon className="text-muted-foreground size-4 shrink-0" />
          </button>
        </PopoverTrigger>

        <AnimatePresence initial={false}>
          {clearable && selectedOption ? (
            <motion.button
              type="button"
              data-slot="combobox-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={(e) => {
                e.stopPropagation()
                changeValue("")
              }}
            >
              <XIcon className="size-3.5" />
            </motion.button>
          ) : null}
        </AnimatePresence>
      </div>

      <PopoverContent
        data-slot="combobox-content"
        align="start"
        className={cn(
          "w-[var(--radix-popover-trigger-width)] origin-(--radix-popover-content-transform-origin) p-0",
          contentClassName
        )}
      >
        <Command defaultActiveValue={selectedValue || undefined}>
          <CommandInput placeholder={searchPlaceholder} autoFocus />
          <CommandList>
            <CommandEmpty>{emptyText}</CommandEmpty>
            {options.map((option) => (
              <CommandItem
                key={option.value}
                value={option.value}
                keywords={[
                  typeof option.label === "string" ? option.label : "",
                  ...(typeof option.description === "string" ? [option.description] : []),
                  ...(option.keywords ?? []),
                ]}
                disabled={option.disabled}
                onSelect={() => {
                  changeValue(option.value)
                  setOpen(false)
                }}
              >
                <span className="flex size-4 shrink-0 items-center justify-center">
                  {selectedValue === option.value ? (
                    <motion.span
                      initial={reduceMotion ? false : { scale: 0.4, opacity: 0 }}
                      animate={{ scale: 1, opacity: 1 }}
                      transition={{ type: "spring", stiffness: 560, damping: 28, mass: 0.6 }}
                    >
                      <CheckIcon className="text-primary" />
                    </motion.span>
                  ) : null}
                </span>
                <div className="flex min-w-0 flex-1 flex-col">
                  <span className="truncate font-medium">{option.label}</span>
                  {option.description ? (
                    <span className="text-muted-foreground truncate text-xs">
                      {option.description}
                    </span>
                  ) : null}
                </div>
              </CommandItem>
            ))}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

export { Combobox, comboboxVariants }

属性 Props

Combobox (主组件)

属性类型默认值说明
optionsComboboxOption[]—可供检索与选择的选项数据集合(必填)。
valuestring—受控模式下的当前选中值,对应选项的 value 属性。
defaultValuestring""非受控模式下的初始选中值。
onValueChange(value: string) => void—选项发生改变或点击清空按钮时触发的回调函数,清空时返回空字符串。
placeholderstring"请选择"未选中任何选项时在触发器中显示的占位文案。
searchPlaceholderstring"搜索选项"下拉搜索输入框内的提示占位文案。
emptyTextReact.ReactNode"没有匹配的选项"输入关键词过滤后无任何匹配项时展示的空状态提示。
clearablebooleantrue在已有选中项时是否在触发器右侧显示快速清空图标按钮。
size"sm" | "default" | "lg""default"触发器的物理高度与内边距尺寸密度。
disabledbooleanfalse是否禁用整个选择器交互与弹层展开。
contentClassNamestring—应用于浮层容器的额外 CSS 类名。
classNamestring—应用于触发器外层容器的额外 CSS 类名。

ComboboxOption (选项对象结构)

属性类型默认值说明
valuestring—选项的唯一键值标识,在表单与状态中使用(必填)。
labelReact.ReactNode—在触发器和选项列表中呈现给用户的主要显示内容(必填)。
descriptionReact.ReactNode—在下拉列表中展示在标题下方的辅助说明文案。
keywordsstring[]—用于模糊检索的隐藏别名数组,如拼音全拼、首字母简拼或业务同义词。
disabledbooleanfalse是否禁止选择该特定选项。

事件 Events

属性类型默认值说明
onValueChange(value: string) => void—用户通过点击选项、键盘确认或点击清除图标时触发,传入最新选中的 value 字符串或空字符串。
onFocus(event: React.FocusEvent<HTMLButtonElement>) => void—触发按钮获得焦点时触发。
onBlur(event: React.FocusEvent<HTMLButtonElement>) => void—触发按钮失去焦点时触发。
onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) => void—焦点位于触发器时按下键盘按键触发。

使用场景与设计规范

Combobox 专为数据量较多、需要快速搜索定位的单选场景设计:

  • Combobox vs Select:当选项数量不多(如 10 项以内),且用户熟悉所有可能选项时,优先使用 Select;当选项数量超过 10–15 项,或者用户习惯直接敲击拼音/英文缩写快速找人或找资源时,优先使用 Combobox。
  • Combobox vs Command:Combobox 主要作为表单中的“带搜索单选输入框”,选中后浮层关闭并将值回填到触发器;Command 通常作为全屏全局命令面板(如 Cmd + K)或悬浮快捷操作菜单。
  • 提供拼音与别名(Keywords):中文环境下,强烈建议在 keywords 中注入中文姓名或专业术语的全拼与首字母缩写(例如 keywords: ["zhangsan", "zs"]),大幅提升重度键盘用户的录入效率。
  • 非受控 vs 受控:常规表单中可直接使用 defaultValue,组件内部自行管理选中状态与搜索输入;若需要通过外部按钮联动切换或重置,请传入 value 与 onValueChange。

场景示例

受控模式与外部联动

通过受控的 value 与 onValueChange 实现与外部状态的精确双向绑定,支持通过外部快捷按钮直接切换指派人:

Loading…
const [assignee, setAssignee] = React.useState("chen")

return (
  <Combobox
    options={members}
    value={assignee}
    onValueChange={setAssignee}
    placeholder="搜索团队成员…"
  />
)

尺寸规格

组件提供 sm(32px)、default(40px)和 lg(48px)三种物理尺寸密度:

Loading…
  • sm:紧凑尺寸,适合嵌入数据表格行筛选栏或紧凑工具栏。
  • default:标准尺寸,适用于常规业务表单与配置卡片。
  • lg:大尺寸,适用于突出型首屏筛选或触控移动端界面。

自定义选项渲染与副标题

选项支持配置图标和 description 副标题,在下拉面板中呈现层次分明的云资源或资产信息:

Loading…

清空与自定义空状态

通过 clearable 属性控制是否允许一键清空;通过 emptyText 自定义无匹配项时的提示文案:

Loading…

复杂表单集成

在云服务集群配置与生产环境部署等企业级表单中集成多个组合选择器:

Loading…

无障碍与交互 Accessibility

  • ARIA 规范:
    • 触发器元素声明 role="combobox"、aria-haspopup="dialog" 并实时同步 aria-expanded。
    • 浮层展开后内部搜索框自动聚焦(autoFocus),屏幕阅读器会实时朗读当前候选匹配项数量。
  • 键盘导航:
    • Space / Enter:在聚焦触发器时打开下拉搜索面板。
    • ↓ / ↑:在搜索过滤出的选项列表间上下移动高亮光标。
    • Enter:确认选中当前高亮的选项,自动关闭下拉面板并将焦点归还至触发器。
    • Escape:关闭下拉面板并不作选择。
  • 清空操作隔离:清空按钮内置事件冒泡拦截(stopPropagation),点击清空时不会误触打开下拉浮层。
  • 打开即定位:再次打开时高亮自动落在当前已选项并滚动到可见区域。
  • 动效:浮层从触发器方向展开;高亮背景在选项之间滑动;选中值在触发器中上移淡入刷新,勾选图标弹出;清空按钮缩放出现。均遵循 prefers-reduced-motion。