一个会过滤列表的文本框。整份列表就在 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>,
Field 的 FieldLabel 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
└── ComboboxItemComboboxItem 的 value 就是选中后落进 combobox 值里的东西。
ComboboxEmpty 只在过滤结果为空时现身,它依赖根上的 items —— 上游是靠这份
数据判定「空」的,逐个手写 ComboboxItem 而不给 items 时它不会亮。它必须一直挂在
DOM 里(自身带 aria-live 播报),所以别条件渲染它本身,要换内容就换它的 children。
多选
multiple 之后值整体换型成数组,输入框那一行也换人:ComboboxChips 是带边框的那一行,
里面每个选中项是一个 ComboboxChip,末尾跟一个没有边框的 ComboboxChipsInput:
chips 那一行要自己交给弹层当锚点。 默认弹层贴着输入框,而这里输入框只是行里的一小
截,选多了还会换行把整行撑高。所以给 ComboboxChips 一个 ref,把同一个 ref 交给
ComboboxPopup 的 anchor —— 弹层跟着整行走。封装层认得这件事:给了 anchor 时弹层
上会多一个 data-chips(见 状态与 className),
要按这个分支调样式时它是个现成的选择器。
chip 的删除按下标走(selectedValue.filter((_, i) => i !== index),下标是它在
ComboboxChips 里的渲染顺序),所以chips 的顺序必须和值的顺序一致 —— 用
ComboboxValue 的函数 children 遍历当前值来渲染,顺序天然对齐。
ComboboxChip 默认自带那个 ✕(removable={false} 关掉,只留键盘删);chips 之间怎么
走见键盘交互。
可清除
清除按钮不是一直在:Base UI 只在「有东西可清」时才把它挂上 DOM,而「有东西」按 选择模式分三种:
第三种是上游 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 函数是两层:
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>),
只写第一个的话第二个会退回默认的 false,multiple 那行就报错了。
受控
两套通道,各管各的,别混:
弹层开合是第三套:open / defaultOpen / onOpenChange。
两个回调的第二参都是 ComboboxChangeEventDetails,reason 说明这一下从哪来 ——
'item-press'(选了一项)、'clear-press'(点了清除)、'chip-remove-press'
(拆了一个 chip)、'input-change'(打字)、'input-clear'(文字被清空)、
'escape-key' / 'outside-press' / 'focus-out'(关弹层)等;cancel() 拒绝这次变更。
一件容易撞上的事:单选时把输入框的文字删光,选中值会跟着被清成 null
(reason: 'input-clear')—— 上游认为「框里什么都没有」和「选中着某一项」不该同时成立。
不给 inputValue 也不给 defaultInputValue 时,单选的文本由选中值推导,
所以挂载时框里就印着当前选中项。
无效态
aria-invalid 和其余 props 一样进里面那个 <input>,整行跟着变红:InputGroup
用 has-[[data-slot][aria-invalid=true]]: 认它,而里面那个输入框把自己的环压掉了
(aria-invalid:ring-0),所以只有一圈。
<ComboboxInput aria-invalid aria-describedby="composer-error" />多选那一行同理:aria-invalid 给 ComboboxChipsInput,ComboboxChips 用
has-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 的 showClear 默认 false,封装层的 clearable
默认 true,取的是 Select「清除默认在场」那个意思。
但只取了一半:Select 的 clearable 住在根上、是连显式组合的清除一并关掉的总开关,
这里的 clearable 住在 ComboboxInput 上、只管它自己那套默认组合渲不渲染那颗按钮 ——
你手写的 <ComboboxClear /> 不受它影响。
除此之外全是 Base UI 的,原样透传。
什么时候用 Combobox
先挑对组件:
列表在服务端、要分页,就别在这里想办法把 items 灌满 —— 那是另一个组件的活。
不用打字过滤、选项固定的,用 Select。
状态与 className
ComboboxInput 是唯一的例外,先说清楚:它不是一个 <input>,它自己组装了一整行 ——
一个 InputGroup,里面装着真正的 input、箭头和清除按钮。
于是有一条要记住:
className落在那一行(Base UI 的Combobox.InputGroup,shadcn 的InputGroup只是它的 render 元素),所以 Base UI 的函数形态在这里也成立;- 其余 props 都进里面那个
<input>——placeholder、aria-label、onKeyDown、ref都是给它的。
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-*",需要从外部定位时当选择器用。其中两个是接线
契约、不只是标记:ComboboxClear 的 data-slot="combobox-clear" 是箭头那条隐藏规则
认的东西(见可清除),ComboboxChip 里那颗 ✕ 是
data-slot="combobox-chip-remove",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、也没有状态属性。
ComboboxInput
那一行文本框。className 之外的 props 全部进里面那个 <input>(Base UI Combobox.Input 的 props)。
状态(行和里面那个 <input> 各带一份,data-placeholder 除外)
ComboboxPopup
Portal + Positioner + Popup 一整套。定位那几个 props 是从 Positioner 借上来的, 其余进 Popup。
状态
宽度跟随锚点(inline-(--anchor-width)),高度上限是 --available-height。
锚点是整行,不是中间那截 input——输入行渲染成 Combobox.InputGroup,把自己
注册进 Base UI,于是 --anchor-width 量到的就是含前后 addon 的完整宽度,箭头和
清除不用额外补位。
ComboboxItem
状态
行尾那个对勾由部件自己渲染(ItemIndicator),没有可替换的 children。