下拉选择。行为全部由 Base UI 承担:触发器是一个真 <button>,弹层走 Portal,
键盘打字定位、方向键导航、Escape 关闭、aria-controls / aria-activedescendant
互指都是它的事。组合就是全部 API。
默认在场是这个家族的家法:不写 children,Select 用 items 渲染完整
默认组合(触发器 + 回显 + 清除 ✕ + 弹层 + 选项);写了 children 就逐层接管,
SelectTrigger 不给 children 也自动含 SelectValue 与清除。
使用
import {
Field,
FieldError,
FieldLabel,
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from '@gedatou/cadenza-ui'const VOICES = { soprano: '女高音', alto: '女中音', tenor: '男高音' }
// 一行式:items 供数据,placeholder / aria-label 落在根上,其余全部默认(含清除 ✕)
<Select aria-label="声部" items={VOICES} placeholder="选一个声部" />
// 组合:要定制某一层时写 children,没写的层保持默认
<Select items={VOICES}>
<SelectTrigger>
<SelectValue placeholder="选一个声部" />
</SelectTrigger>
<SelectPopup>
{Object.entries(VOICES).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectPopup>
</Select>组成
组合时结构永远是:Select 里放一个 SelectTrigger(内含 SelectValue),后面跟一个
SelectPopup(内含 SelectItem):
Select
├── SelectTrigger
│ ├── SelectValue
│ └── SelectClear
└── SelectPopup
├── SelectEmpty
├── SelectGroup
│ ├── SelectLabel
│ ├── SelectItem
│ └── SelectItem
├── SelectSeparator
└── SelectGroup
├── SelectLabel
└── SelectItemSelectGroup 不必写——没写时 SelectPopup 自动包一层隐式分组(列表的内边距
住在组上);写了就完全归你,见分组。定制某一层时:SelectItem 的 value
就是它的值,SelectValue 回显当前选中(此时 placeholder 写在 SelectValue 上),
SelectTrigger 是真正的控件,aria-label 落在它身上。写了 children 的层完全归你,
没写的层保持默认——清除也一样,手写 trigger children 时要它就写
<SelectClear />,见可清除。
标签
触发器是真 <button>,所以一条通道全包了:原生 <label for> 既给它命名,
又由浏览器把点击转发过去打开弹层。id 落在 SelectTrigger 上;走一行式时 id
写在根上即可——Base UI 会把它路由给触发器,FieldLabel htmlFor 直接指它:
标签就是控件:弹层开着时再点标签是关掉它,一次干净的开合。这需要封装层补一刀——
Base UI 在弹层外按下就判定关闭(outside-press,同一次手势松手时还有 cancel-open),
而浏览器紧接着把标签的 click 转发给触发器、触发器再翻转一次,两下相加就是「关掉又打开」
的闪烁。指向自家触发器的 <label> 不算「外部」,封装层把这两个关闭 cancel() 掉。
四条标签通道的总表在 Field。
React Aria 那版这里要写两遍文案:它在触发器上挂了自己的
aria-labelledby, 在无障碍名计算里压过原生<label for>,所以必须另给一个aria-label; 而且usePress不认没有指针序列的裸 click,「点标签展开」还得封装层自己补。 这两笔债在 Base UI 上都不存在。
选中项对齐触发器
alignItemWithTrigger 决定弹层怎么出现:开着时选中项被对到光标正下方(原生
macOS select 的做法),关着时就是普通贴边下拉。本库把它默认关掉了——传
alignItemWithTrigger 即可换回 macOS 式对齐。
对齐开着时弹层在按下时就打开,如果某一项恰好落在光标下方,松手时指针已经压在 它上面了 —— 一次普通点击就会误选。Base UI 在上游挡掉了这件事,两条轴:
任一成立才允许那次松手提交。这套守卫是 alignItemWithTrigger 的配套设计:「移开
8px」等价于「离开了原本那一项」。默认关着时弹层不在指针底下打开,守卫处于待命状态,
开回对齐它随之生效(滚动锁也一样,上游 Positioner 的锁定条件是
alignItemWithTrigger || modal)。
前一版(React Aria 底座)这里是封装层自己实现的一套守卫 ——
一个挂在 <html> 上的属性加一条样式规则。现在整段删掉了,
PRESS_GRACE_ATTRIBUTE 这个导出也随之消失。
分组
SelectLabel 是分组的标题,不是控件的标签 —— 控件的标签在
Field 那边。SelectSeparator 分组之间、组内条目之间都能放:
多选
multiple 之后 value / onValueChange 整体换型:string[] 而不是
string | null。触发器上的多选显示由 SelectValue 拼,默认逗号分隔:
动态集合
数据驱动的选项就是一次 .map(),没有集合 API:
两处别混:
items 只管触发器怎么显示
这是从 React Aria 那版升上来最容易踩的一条:SelectValue 不看选项列表。
它只知道当前的值,要把值翻成人话就得给根组件一张 items 表:
<Select items={{ apple: '苹果', pear: '梨' }}> // 映射表
<Select items={fruits.map(f => ({ value: f.id, label: f.name }))}> // 数组
<Select items={[{ value: '弦乐', items: [{ value: 'violin', label: '小提琴' }] }]}>
// 分组形态(标题键是 value,Base UI 官方约定)
<SelectValue>{value => labels[value] ?? '—'}</SelectValue> // 或者函数不给的话触发器上印的是原始值本身(apple 而不是「苹果」)。
组合路径上 items 不渲染任何东西——选项还是你自己 .map() 出来的;
一行式(不写 children)由默认组合用它渲染选项。分组形态会被拍平渲染
(组标题不出现,DEV 下有警告)——这是 Base UI 的用法:分组形态只喂标签解析
(它内部就是 flatMap),渲染分组永远走组合路径(SelectGroup +
SelectLabel,连 Combobox 官方分组也是调用方自己 map)。值和标签本来就
一样时(比如每页条数 10 / 20 / 50)不用管这条。
空状态
SelectEmpty 与选项写在一起,列表里没有任何选项时它自动现身——纯 CSS
(:only-child),零接线:
走默认的隐式分组时这就是全部;只有手写 SelectGroup 时有一条约束:数据为空时
别渲染空的组壳,否则它不再是唯一子元素。空集合的 Select 照样能打开。
React Aria 那版这里有个坑:react-stately 在
open()里就把空集合挡住了, 不显式开allowsEmptyCollection根本看不到空态。Base UI 没有这道门。
可清除
清除默认在场:有值时一个 ✕ 站在 chevron 的位置,点击清空(单选 null、
多选 [],reason: 'clear-press')且不打开弹层;空值、disabled、
readOnly 时它不渲染,chevron 归位。clearable={false} 是总开关——必填
表单字段这类「可清除是语义错误」的场景用它,它也会让显式组合的
SelectClear 失效:
手写 trigger children 时清除不会隐式出现——组合路径完全归你,要它就写
<SelectClear />(可定制 aria-label / 图标)。它是标记部件:写在 trigger
里但渲染在 trigger 旁——HTML 禁止 button 套 button,封装层把它提升为
触发器的兄弟元素(真 <button>,在 Tab 序里:清除没有别的键盘路径,
鼠标专属的做法会把键盘用户锁在外面)。清除非受控 Select 之所以可行,
是因为封装层内部接管了值状态(对 Base UI 永远受控)。
进行中
改选后的保存 round-trip 用 pending 标记——动作面的词,与
Button 的进行中同一套规矩(loading
属于内容面)。
触发器保持可聚焦但弹层不再打开(底下走 readOnly,表单控件现成的
「能聚焦、动不了」通道——Button 没有它才需要用 disabled +
focusableWhenDisabled 拼装);Spinner 站进 chevron 的位置(与清除 ✕
同一个机位),清除让位;aria-busy 与 data-pending 由 pending
派生在触发器上,调用方无法只设一半。
禁用
两种粒度:disabled 落在 Select 上禁整个控件(弹层打不开),
落在 SelectItem 上只禁那一行(键盘导航会跳过它):
无效态
错误态是两个属性各管一头:Field 上挂 data-invalid 让整列文字变色,
SelectTrigger 上挂 aria-invalid 画出 destructive 边框与环、并告诉辅助技术:
<Field data-invalid>
<FieldLabel htmlFor="fruit">水果</FieldLabel>
<Select>
<SelectTrigger aria-invalid id="fruit">
<SelectValue placeholder="选一个" />
</SelectTrigger>
<SelectPopup>{/* … */}</SelectPopup>
</Select>
<FieldError>请选择一个水果。</FieldError>
</Field>错误消息交给同一个 Field 里的 FieldError,契约见
Field 的校验与错误。
什么时候用 Select
三个选择器,先挑对再看用法:
状态与 className
className 一律双形态:字符串,或 (state) => string 的函数(参数就是各自
状态表右列)。根组件本身不渲染 DOM,所以它没有 className,也没有状态属性。
SelectTrigger
SelectPopup
SelectItem
每个部件都带自己的 data-slot(select-trigger / select-trigger-container /
select-value / select-content / select-group / select-label /
select-item / select-separator / select-clear / select-empty /
select-pending),需要从外部定位时当选择器用。
其中 select-value 是接线契约,不是标记,别给它传自己的 data-slot ——
SelectTrigger 的布局规则靠 *:data-[slot=select-value]:… 命中它,换掉就静默失效。
select-trigger-container 也不是标记而是定位盒:清除可用时(clearable 为真,
且走默认组合、或手写 children 里写了 SelectClear),触发器外面会多包一层这个
<span> 来托住提升上去的 ✕,触发器本身此时是它的 flex-1 子项——写
> [data-slot=select-trigger] 这类子选择器、或给 Select 做外层布局时要算上它。
其余几个只是标记,改了不会弄坏什么(选中项的高亮走的是 focus:bg-accent,
不读 data-slot)。
键盘交互
禁用项在以上所有导航中都会被跳过。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
Select
根组件本身不渲染 DOM,所以它没有 className,也没有状态属性。
SelectTrigger
触发器自己会在末尾追加一个上下箭头图标,写在 children 后面。
SelectValue
SelectPopup
Portal + Positioner + Popup + List 一整套,滚动箭头也在里面。定位相关的 props 去 positioner,其余去 popup。
宽度默认跟随触发器(w-(--anchor-width)),高度上限是 --available-height,
超出就在弹层内滚动。
SelectGroup / SelectLabel / SelectSeparator
SelectItem
选中标记(对勾)由部件自己渲染在行尾。
SelectClear / SelectEmpty
十个部件的 props 类型也一并导出:SelectProps / SelectPopupProps /
SelectGroupProps / SelectItemProps / SelectLabelProps /
SelectSeparatorProps / SelectTriggerProps / SelectValueProps /
SelectClearProps / SelectEmptyProps;回调那一头另有
SelectChangeEventReason / SelectChangeEventDetails。