Cadenza
EN

在浏览器里过滤的组合框 —— 整份列表住在 items 里,Base UI 随输入收窄

一个会过滤列表的文本框。整份列表就在 items,你打字,Base UI 拿这份数组 在浏览器里收窄,ComboboxList 的 render 函数收到的永远是收窄之后的那一份。

列表在服务端、要分页,就别在这里想办法把 items 灌满 —— 那是另一个组件的活, 见什么时候用 Combobox

使用

import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
  InputGroupAddon,
} from '@gedatou/cadenza-ui'
<Combobox<string> id="composer" items={composers}>
  <ComboboxInput placeholder="搜索作曲家" />
  <ComboboxPopup>
    <ComboboxEmpty>没有匹配的作曲家</ComboboxEmpty>
    <ComboboxList>
      {(composer: string) => (
        <ComboboxItem key={composer} value={composer}>{composer}</ComboboxItem>
      )}
    </ComboboxList>
  </ComboboxPopup>
</Combobox>

id 写在根上就够了:Base UI 把它路由给 ComboboxInput 里那个 <input>FieldFieldLabel htmlFor 直接指过去。

组成

ComboboxInput 是那一行文本框,ComboboxPopup 是浮层,ComboboxList 的 children 写成 render 函数就把每一项渲染成 ComboboxItem。三种形态:

单行

一行文本框配一份平列表,见页首示例与使用

Combobox
├── ComboboxInput
└── ComboboxPopup
    ├── ComboboxEmpty
    └── ComboboxList
        ├── ComboboxItem
        └── ComboboxItem

带 chips

multiple 之后输入框那一行换成 chips 行,见多选

Combobox
├── ComboboxChips
│   └── ComboboxValue
│       ├── ComboboxChip
│       └── ComboboxChipsInput
└── ComboboxPopup
    ├── ComboboxEmpty
    └── ComboboxList
        ├── ComboboxItem
        └── ComboboxItem

分组与 Collection

每一组的条目由 ComboboxGroup 里的 ComboboxCollection 渲染,组之间可以插一条 ComboboxSeparator,见分组

Combobox
├── ComboboxInput
└── ComboboxPopup
    ├── ComboboxEmpty
    └── ComboboxList
        ├── ComboboxGroup
        │   ├── ComboboxGroupLabel
        │   └── ComboboxCollection
        │       ├── ComboboxItem
        │       └── ComboboxItem
        ├── ComboboxSeparator
        └── ComboboxGroup
            ├── ComboboxGroupLabel
            └── ComboboxCollection
                ├── ComboboxItem
                └── ComboboxItem

ComboboxItemvalue 就是选中后落进 combobox 值里的东西。

ComboboxEmpty 只在过滤结果为空时现身,它依赖根上的 items —— 上游是靠这份 数据判定「空」的,逐个手写 ComboboxItem 而不给 items 时它不会亮。它必须一直挂在 DOM 里(自身带 aria-live 播报),所以别条件渲染它本身,要换内容就换它的 children。

多选

multiple 之后值整体换型成数组,输入框那一行也换人:ComboboxChips 是带边框的那一行, 里面每个选中项是一个 ComboboxChip,末尾跟一个没有边框的 ComboboxChipsInput

chips 那一行要自己交给弹层当锚点。 默认弹层贴着输入框,而这里输入框只是行里的一小 截,选多了还会换行把整行撑高。所以给 ComboboxChips 一个 ref,把同一个 ref 交给 ComboboxPopupanchor —— 弹层跟着整行走。封装层认得这件事:给了 anchor 时弹层 上会多一个 data-chips(见 状态与 className), 要按这个分支调样式时它是个现成的选择器。

chip 的删除按下标走(selectedValue.filter((_, i) => i !== index),下标是它在 ComboboxChips 里的渲染顺序),所以chips 的顺序必须和值的顺序一致 —— 用 ComboboxValue 的函数 children 遍历当前值来渲染,顺序天然对齐。

ComboboxChip 默认自带那个 ✕(removable={false} 关掉,只留键盘删);chips 之间怎么 走见键盘交互

可清除

清除按钮不是一直在:Base UI 只在「有东西可清」时才把它挂上 DOM,而「有东西」按 选择模式分三种:

选择模式「有东西可清」指什么
单选(默认)选中了值selectedValue != null
multiple有 chip —— 选中数组非空
selectionMode="none"输入框里有文字

第三种是上游 Autocomplete 那个根的模式,本库的 Combobox 只把 multiple 翻成 'multiple' / 'single',没有第三档。

要点在第一行:单选时你只打了字、还没从列表里选,清除不出现,箭头仍在。 正是这个 条件挂载让箭头和清除共用一个位置 —— ComboboxInput 给箭头挂了一条 group-has-data-[slot=combobox-clear]/input-group:hidden:行里一旦出现了清除按钮, 箭头就自己让开。两个都要关掉就 trigger={false} / clearable={false}

点清除会把文本清空、把选中值清成 null(多选是 []), reason'clear-press',然后焦点回到输入框。

分组

分组数据是 items 的第二种形态:一个数组,每个元素是一个组

唯一必需的键是 items —— 上游就是靠「第一个元素身上有没有 items」认出这是分组的 (isGroupedItems)。组名叫什么随你(demo 里用 value,是 Base UI 官方例子的写法), 那个键封装层和 Base UI 都不读,你自己在 ComboboxGroupLabel 里印出来。

于是 render 函数是两层:

拿到什么干什么
ComboboxList 的 children每一个渲染 ComboboxGroup,把 group.items 交给它
ComboboxCollection 的 children组内的每一渲染 ComboboxItem

ComboboxGroup items 不是装饰:它开一个 context,里面的 ComboboxCollection 认这个 context,才知道该遍历哪一组;没有它 ComboboxCollection 会去遍历整份过滤结果。

过滤按组进行:命中的项留在原组里(组对象的其余键原样带过去),一项都没命中的组整个 消失,不会留下一个空标题。

ComboboxGroupLabel组的标题,不是控件的标签 —— 控件的标签在 Field 那边。这也是移植时改名的原因,见名字这一节

自定义条目

条目是对象时,给根一对 itemToString* 把它翻成字符串 —— 一份给人看,一份给表单提交:

interface Composer { id: string, name: string }
 
const COMPOSERS: Composer[] = [
  { id: 'bach', name: '巴赫' },
  { id: 'mozart', name: '莫扎特' },
]
 
<Combobox<Composer>
  items={COMPOSERS}
  itemToStringLabel={composer => composer.name}
  itemToStringValue={composer => composer.id}
>
  <ComboboxInput placeholder="搜索作曲家" />
  <ComboboxPopup>
    <ComboboxEmpty>没有匹配的作曲家</ComboboxEmpty>
    <ComboboxList>
      {(composer: Composer) => (
        <ComboboxItem key={composer.id} value={composer}>{composer.name}</ComboboxItem>
      )}
    </ComboboxList>
  </ComboboxPopup>
</Combobox>

形状恰好是 { value, label } 的话上游自动认,两个都可以不写;比较不按引用时给 isItemEqualToValue

泛型不退化。 Combobox<Value, Multiple> 两个类型参数一路走到回调上:

<Combobox<string> onValueChange={value =>}>          // value: string | null
<Combobox<string, true> multiple onValueChange={}>    // value: string[]

Multiple 是第二个参数,单选时省略即可。注意 TS 不做部分类型参数推断 —— 想写 multiple 就得把两个都写出来(<Combobox<string, true> multiple>), 只写第一个的话第二个会退回默认的 falsemultiple 那行就报错了。

受控

两套通道,各管各的,别混:

通道管什么三件套
选中值用户选中了哪一项(多选时是数组)value / defaultValue / onValueChange
文本输入框里显示的字inputValue / defaultInputValue / onInputValueChange

弹层开合是第三套:open / defaultOpen / onOpenChange

两个回调的第二参都是 ComboboxChangeEventDetailsreason 说明这一下从哪来 —— 'item-press'(选了一项)、'clear-press'(点了清除)、'chip-remove-press' (拆了一个 chip)、'input-change'(打字)、'input-clear'(文字被清空)、 'escape-key' / 'outside-press' / 'focus-out'(关弹层)等;cancel() 拒绝这次变更。

一件容易撞上的事:单选时把输入框的文字删光,选中值会跟着被清成 nullreason: 'input-clear')—— 上游认为「框里什么都没有」和「选中着某一项」不该同时成立。

不给 inputValue 也不给 defaultInputValue 时,单选的文本由选中值推导, 所以挂载时框里就印着当前选中项。

无效态

aria-invalid 和其余 props 一样进里面那个 <input>整行跟着变红InputGrouphas-[[data-slot][aria-invalid=true]]: 认它,而里面那个输入框把自己的环压掉了 (aria-invalid:ring-0),所以只有一圈。

<ComboboxInput aria-invalid aria-describedby="composer-error" />

多选那一行同理:aria-invalidComboboxChipsInputComboboxChipshas-aria-invalid: 变红。要让整列文字(标签、描述)也进错误态,在 Field 上挂 data-invalid —— 本库的 Field 是纯 DOM 的, 不是 Base UI 那个 Field.Root,所以控件身上那批 data-valid / data-invalid 在这里不会动,见状态与 className

禁用

整控件禁用写在根上<Combobox disabled>

封装层不再往下转发它 —— Base UI 的 Input 自己就把 fieldDisabled || comboboxDisabled || disabledProp 解析好、写在它渲染的元素上, 而 render 元素自己的 props 会赢下这次合并:vendored 那版转发了一个默认 disabled = false, 正好把根算出来的那份顶掉,于是根上写了 disabled、框还能打字、行也不变灰。箭头和清除 各自从根的状态里读(useStore(store, selectors.disabled)),你自己传的 disabled 照旧从展开里过去。

ComboboxInput 上的 disabled 只禁这个输入框ComboboxItem 上的 disabled 只禁那一行,键盘导航跳过它。

Input Group

ComboboxInput 组装出来的那一行就是一个 InputGroup, 所以往它的 children 里塞 InputGroupAddon 就能加前置图标、单位后缀:

<ComboboxInput placeholder="搜索作曲家">
  <InputGroupAddon>
    <IconSearch />
  </InputGroupAddon>
</ComboboxInput>

children 在 DOM 上排在箭头/清除那个 addon 之后,但那个 addon 是 align="inline-end" (带 order-last),所以画出来仍在最右;addon 默认的 align="inline-start"order-first,图标落在行首。

这份是封装层自己的移植

不是转出。vendored 的 src/primitives/combobox.tsx 是 typecheck exclude 名单里 的两个文件之一(另一个是 accordion):它的 ComboboxInput 声明了 Base UI 的函数 className,转手又把它路由给 InputGroup —— 一个纯 DOM 的 div,只收字符串。 类型承诺了元素兑现不了的契约,tsc 说了实话。byte-pinned 的 vendored 源码改不得, 于是封装层自己重写了一份,把路由改对 —— 那一行直接渲染 Base UI 的 Combobox.InputGroup, 函数 className 这才有人兑现 —— 顺手把名字定了形:

vendored封装层为什么
ComboboxContentComboboxPopup浮层就叫 Popup;Content 留给「搬进浮层里的内容」(navigation-menu 那种)
ComboboxLabelComboboxGroupLabel它本来就是 ComboboxGroup 的标题,不是控件的标签
showTrigger / showCleartrigger / clearable全库没有 show* 布尔
useComboboxAnchor删掉它的全部实现是 useRef(null)。公开一个 hook 是一份公共承诺,这个没内容 —— 传普通 ref 给 ComboboxPopupanchor 就行

顺带一个默认值也变了:vendored 的 showClear 默认 false,封装层的 clearable 默认 true,取的是 Select「清除默认在场」那个意思。 但只取了一半:Select 的 clearable 住在根上、是连显式组合的清除一并关掉的总开关, 这里的 clearable 住在 ComboboxInput 上、只管它自己那套默认组合渲不渲染那颗按钮 —— 你手写的 <ComboboxClear /> 不受它影响。

除此之外全是 Base UI 的,原样透传。

什么时候用 Combobox

先挑对组件:

ComboboxInfiniteCombobox
列表在哪内存里,一整份 items服务端,一页一页来
谁过滤Base UI(Intl.Collator 的包含匹配)你的接口 —— 关键词发过去,它决定返回什么
合适的量级几十到几百条,一次给全上不封顶,滚到底再要下一页
要接的线没有,给数组就行取数适配器、分页、loading / error 槽位

列表在服务端、要分页,就别在这里想办法把 items 灌满 —— 那是另一个组件的活。 不用打字过滤、选项固定的,用 Select

状态与 className

ComboboxInput 是唯一的例外,先说清楚:它不是一个 <input>,它自己组装了一整行 —— 一个 InputGroup,里面装着真正的 input、箭头和清除按钮。 于是有一条要记住:

  • className 落在那一行(Base UI 的 Combobox.InputGroup,shadcn 的 InputGroup 只是它的 render 元素),所以 Base UI 的函数形态在这里也成立;
  • 其余 props 都进里面那个 <input> —— placeholderaria-labelonKeyDownref 都是给它的。

Base UI 写的那些 data-*data-popup-open / data-disabled / data-list-empty…), 行和里面那个 <input> 各带一份,想按状态给整行上色直接写在行上就行 (data-[popup-open]:border-ring);has-* 选择器(比如 has-data-[popup-open]:border-ring) 一样能用,认里面 input 独有的状态时用它。 上游 vendored 的那版仍是错的 —— 它声明了 Base UI 的函数 className 却路由到纯 DOM 的 InputGroup;封装层是靠改路由、不是靠窄类型把两边对上的,见上一节

其余部件都通到 Base UI 的 slot,所以函数形态成立:(state) => string | undefined, state 的字段就是Props 里各部件那张 data-* 表。这些 state 的类型名住在上游 (Combobox.Item.State 之类),封装层只转出根的 ComboboxState

每个部件都带 data-slot="combobox-*",需要从外部定位时当选择器用。其中两个是接线 契约、不只是标记ComboboxCleardata-slot="combobox-clear" 是箭头那条隐藏规则 认的东西(见可清除),ComboboxChip 里那颗 ✕ 是 data-slot="combobox-chip-remove",chip 靠它把右内边距收掉。传一个同名属性进去会把 契约值顶掉:不报错,只是规则不再命中。

键盘交互

按键效果
/ 在列表里移动高亮;走到头是否绕回输入框看 loopFocus(默认绕)。grid 时方向键按行列走
Enter选中当前高亮项
Escape关闭弹层(reason: 'escape-key'
(光标在输入框最左端)跳到最后一个 chip
/ (在 chip 上)在 chips 之间移动(RTL 下左右对调)
Backspace / Delete(在 chip 上)删掉当前这个 chip
任意字符(在 chip 上)跳回输入框继续打字

后四行只在 multiple + ComboboxChips 的那一行里成立,见多选

导出的类型

ComboboxProps / ComboboxState / ComboboxChangeEventDetails / ComboboxValueProps / ComboboxTriggerProps / ComboboxClearProps / ComboboxInputProps / ComboboxPopupProps / ComboboxListProps / ComboboxItemProps / ComboboxGroupProps / ComboboxGroupLabelProps / ComboboxCollectionProps / ComboboxEmptyProps / ComboboxSeparatorProps / ComboboxChipsProps / ComboboxChipProps / ComboboxChipsInputProps

ComboboxState 是根的 state —— 而根不渲染 DOM,那个类型是空对象,别指望从它 读到什么;部件各自的 state 类型住在上游(Combobox.Item.State 之类)。

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className

Combobox

根组件不渲染任何 DOM,所以它没有 className、也没有状态属性。

Prop类型默认值说明
itemsreadonly Value[] | readonly Group[]整份列表。过滤、ComboboxEmpty 的空判定、ComboboxValue 的标签解析都靠它。分组形态见分组
defaultValueValue | Value[] | null多选 [],单选无非受控初始选中值
valueValue | Value[] | null受控选中值;multiple 时是数组
onValueChange(value, eventDetails: ComboboxChangeEventDetails) => void选中变化。单选可能给 null
defaultInputValuestring | number | readonly string[]单选由选中值推导非受控初始文本
inputValuestring | number | readonly string[]受控文本
onInputValueChange(inputValue: string, eventDetails: ComboboxChangeEventDetails) => void文本变化
defaultOpenbooleanfalse非受控初始开合
openboolean受控开合
onOpenChange(open: boolean, eventDetails: ComboboxChangeEventDetails) => void开合变化
onOpenChangeComplete(open: boolean) => void进出场动画结束后
onItemHighlighted(value: Value | undefined, eventDetails) => void高亮项变化;reason'keyboard' / 'pointer' / 'none'
multiplebooleanfalse多选;开了之后值整体变成数组,见多选
disabledbooleanfalse忽略用户交互,见禁用
readOnlybooleanfalse不能改选中
requiredbooleanfalse提交前必须有值
filternull | ((itemValue, query, itemToString?) => boolean)Intl.Collator 的包含匹配换掉过滤规则;给 null 就是不过滤
filteredItemsreadonly Value[] | readonly Group[]自己在外面过滤好的结果;给了就不再走内部过滤(配上游的 useFilter()
limitnumber-1最多渲染多少项,-1 不限
localeIntl.LocalesArgument运行时区域过滤用的字符串比较区域
itemToStringLabel(itemValue: Value) => string对象值 → 显示用的字符串({ value, label } 形状自动认),见自定义条目
itemToStringValue(itemValue: Value) => string对象值 → 提交用的字符串
isItemEqualToValue(itemValue: Value, value: Value) => booleanObject.is对象值不按引用相等时给它
openOnInputClickbooleantrue点输入框就展开
autoHighlightbooleanfalse过滤后自动高亮第一项
highlightItemOnHoverbooleantrue指针划过就高亮。关掉后 CSS :hover 才能和 data-highlighted 区分开
loopFocusbooleantrue方向键走到头是否绕回输入框
modalbooleanfalse打开时是否锁页面滚动与外部交互
inlinebooleanfalse列表不用自带浮层、直接铺在页面里(要同时无条件给 open
gridbooleanfalse列表是网格布局,方向键按行列走
virtualizedbooleanfalse列表由外部虚拟化
idstring落到 ComboboxInput 那个 <input> 上,FieldLabel htmlFor 指这里
namestring表单字段名
formstring拥有隐藏 input 的表单 id,控件渲染在表单之外时用
autoCompletestring浏览器自动填充提示,落到隐藏 input 上
inputRefRef<HTMLInputElement>拿到隐藏的那个 <input>
actionsRefRefObject<{ unmount: () => void } | null>命令式动作;外部控制的关闭动画播完后调 unmount()
其余Base UI Combobox.Root 的 props透传

ComboboxInput

那一行文本框。className 之外的 props 全部进里面那个 <input>(Base UI Combobox.Input 的 props)。

Prop类型默认值说明
triggerbooleantrue渲染那个下拉箭头。输入框住在弹层里时关掉
clearablebooleantrue渲染清除按钮。它只在「有东西可清」时才挂上,见可清除
disabledboolean只禁这个输入框。整控件禁用写在根上,见禁用
childrenReactNode追加到行里,见 Input Group
renderReactElement | (props, state) => ReactElement排在封装层那个 InputGroupInput 之后:传了就把它换掉
classNamestring | ((state) => string | undefined)那一行(Base UI 的 Combobox.InputGroup)的类名,见状态与 className
其余Base UI Combobox.Input 的 props(input 的原生属性 + ref透传给里面那个 <input>

状态(行和里面那个 <input> 各带一份,data-placeholder 除外)

data-*出现时机
data-popup-open弹层开着
data-pressed正被按下
data-disabled / data-readonly对应的 prop
data-popup-side弹层实际贴的那一边
data-list-empty过滤结果为空
data-placeholder还没有选中值 —— 只在行上,里面那个 <input> 没有这一个。想让占位态影响整行(比如 data-placeholder:text-muted-foreground),选择器就得落在行上
data-valid / data-invalid / data-touched / data-dirty / data-filled / data-focused只在 Base UI 的 Field.Root 里才会动 —— 本库的 Field 是纯 DOM 布局,不是它

ComboboxPopup

Portal + Positioner + Popup 一整套。定位那几个 props 是从 Positioner 借上来的, 其余进 Popup。

Prop类型默认值说明
anchorElement | null | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null)输入框贴谁。多选时给 ComboboxChips 的 ref
side'top' | 'bottom' | 'left' | 'right' | 'inline-start' | 'inline-end''bottom'贴哪一边(可能因碰撞翻转)
sideOffsetnumber | ((data) => number)6与锚点的间距(封装层的默认,上游是 0
align'start' | 'center' | 'end''start'交叉轴对齐(封装层的默认,上游是 'center'
alignOffsetnumber | ((data) => number)0交叉轴偏移
initialFocusboolean | RefObject<HTMLElement | null> | ((openType) => …)打开时焦点给谁
finalFocusboolean | RefObject<HTMLElement | null> | ((closeType) => …)关闭时焦点还给谁
classNamestring | ((state) => string | undefined)弹层类名
其余Base UI Combobox.Popup 的 props(div 的原生属性 + ref透传

状态

data-*出现时机
data-open / data-closed开 / 关(进出场动画挂在这上面)
data-starting-style / data-ending-style进场开始 / 出场进行中
data-side / data-align实际贴的那一边、实际对齐
data-empty过滤结果为空
data-instant这一次不要动画
data-chips封装层加的:给了 anchor 就挂上(空串值),没给就不挂

宽度跟随锚点(inline-(--anchor-width)),高度上限是 --available-height。 锚点是整行,不是中间那截 input——输入行渲染成 Combobox.InputGroup,把自己 注册进 Base UI,于是 --anchor-width 量到的就是含前后 addon 的完整宽度,箭头和 清除不用额外补位。

ComboboxItem

Prop类型默认值说明
valueanynull这一项的值,选中后就是它落进 combobox 的值里
disabledbooleanfalse禁这一行,键盘导航跳过
indexnumber自动从 DOM 推显式给可以省掉推算,长列表更快
onClick(event) => void选中时触发(指针点击,或高亮着按 Enter
nativeButtonbooleanfalserender 换成真 <button> 时打开
classNamestring | ((state) => string | undefined)类名
其余Base UI Combobox.Item 的 props(div 的原生属性 + ref,不含 id透传

状态

data-*state 里的名字出现时机
data-selectedselected是当前选中项
data-highlightedhighlighted键盘或指针高亮到它
data-disableddisableddisabled

行尾那个对勾由部件自己渲染(ItemIndicator),没有可替换的 children。

其余部件

部件元素关键 props说明
ComboboxListdivrole="listbox"children: ReactNode | ((item, index) => ReactNode)滚动的列表。children 是函数时隐式包一层 ComboboxCollection。状态 data-empty
ComboboxCollection不渲染元素children: (item, index) => ReactNode(必填)遍历「当前该遍历的那一份」:在 ComboboxGroup items 里就是那一组,否则是整份过滤结果
ComboboxGroupdivrole="group"items一组选项。items 开 context 给里面的 ComboboxCollection
ComboboxGroupLabeldiv组的标题,自动关联到所在的组(aria-labelledby
ComboboxEmptydiv过滤结果为空时现身,靠根上的 items 判定。自带播报,别条件渲染它本身
ComboboxSeparatordivorientation分隔线
ComboboxTriggerbuttondisabled那个下拉箭头。ComboboxInput 默认已经渲染了一个,单独用才写它。状态 data-popup-open / data-pressed / data-disabled / data-readonly / data-popup-side / data-list-empty / data-placeholder
ComboboxClearbuttondisabledkeepMounted清除。条件挂载规则见可清除keepMounted 让它一直在 DOM 里。状态 data-visible / data-popup-open / data-disabled
ComboboxValue不渲染元素placeholderchildren: ReactNode | ((value) => ReactNode)当前值。函数 children 拿到选中值 —— 多选渲染 chips 就靠它
ComboboxChipsdiv(有 chip 时 role="toolbar"多选那一行。给它 ref 并交给 ComboboxPopupanchor
ComboboxChipdivremovable(默认 true一个选中项。removable 关掉就只剩键盘删。状态 data-disabled
ComboboxChipsInputinputchips 之间那个没有边框的输入框