Cadenza
EN

Base UI Select 的封装 —— 组合式下拉选择,不写 children 就是完整默认组合,清除 ✕ 默认在场

下拉选择。行为全部由 Base UI 承担:触发器是一个真 <button>,弹层走 Portal, 键盘打字定位、方向键导航、Escape 关闭、aria-controls / aria-activedescendant 互指都是它的事。组合就是全部 API。

默认在场是这个家族的家法:不写 children,Selectitems 渲染完整 默认组合(触发器 + 回显 + 清除 ✕ + 弹层 + 选项);写了 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
        └── SelectItem

SelectGroup 不必写——没写时 SelectPopup 自动包一层隐式分组(列表的内边距 住在组上);写了就完全归你,见分组。定制某一层时:SelectItemvalue 就是它的值,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 在上游挡掉了这件事,两条轴:

阈值含义
时间按住 400ms按住不放再松开,是有意的「按下—拖—松开」手势
位移拖出 8px指针从某一项上移开过,说明在拖选

任一成立才允许那次松手提交。这套守卫是 alignItemWithTrigger 的配套设计:「移开 8px」等价于「离开了原本那一项」。默认关着时弹层不在指针底下打开,守卫处于待命状态, 开回对齐它随之生效(滚动锁也一样,上游 Positioner 的锁定条件是 alignItemWithTrigger || modal)。

前一版(React Aria 底座)这里是封装层自己实现的一套守卫 —— 一个挂在 <html> 上的属性加一条样式规则。现在整段删掉了, PRESS_GRACE_ATTRIBUTE 这个导出也随之消失。

分组

SelectLabel 是分组的标题,不是控件的标签 —— 控件的标签在 Field 那边。SelectSeparator 分组之间、组内条目之间都能放:

多选

multiple 之后 value / onValueChange 整体换型:string[] 而不是 string | null。触发器上的多选显示由 SelectValue 拼,默认逗号分隔:

动态集合

数据驱动的选项就是一次 .map(),没有集合 API:

两处别混:

传给谁管什么
根组件的 items触发器上印什么
SelectItemlabel键盘打字定位检索什么。children 不是纯字符串时必须自己给

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')且不打开弹层;空值、disabledreadOnly 时它不渲染,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-busydata-pendingpending 派生在触发器上,调用方无法只设一半。

禁用

两种粒度: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

三个选择器,先挑对再看用法:

组件什么时候
Select选项固定,不用打字找
Combobox打字过滤,而整份列表已经在手上 —— 过滤在浏览器
InfiniteCombobox要打字过滤,但列表在服务端、要分页取

状态与 className

className 一律双形态:字符串,或 (state) => string 的函数(参数就是各自 状态表右列)。根组件本身不渲染 DOM,所以它没有 className,也没有状态属性。

SelectTrigger

状态出现时机
data-popup-open弹层打开着
data-placeholder还没有选中值
data-disableddisabled
data-size始终有值:sm / default(我们加的)
data-pending根的 pending(seam 派生,配套 aria-busy

SelectPopup

状态出现时机
data-open / data-closed进出场动画期间
data-side实际贴的那一边(可能被翻转)
data-align-triggeralignItemWithTrigger 的回显(我们加的)

SelectItem

状态出现时机函数 className 里的名字
data-selected是当前选中项selected
data-highlighted键盘或指针高亮到它highlighted
data-disableddisableddisabled

每个部件都带自己的 data-slotselect-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)。

键盘交互

按键行为
Space / Enter / / 打开弹层
/ 打开后在选项间移动
Home / End跳到第一项 / 最后一项
直接打字label(或文本内容)跳到匹配项
Enter选中当前高亮项并关闭
Esc关闭,不改选中
Tab关闭并把焦点交给下一个控件

禁用项在以上所有导航中都会被跳过。

Props

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

Select

Prop类型默认值说明
defaultValueValue | Value[] | nullnull非受控初始值
valueValue | Value[] | null受控值;multiple 时是数组
onValueChange(value, eventDetails) => void选中变化回调
itemsRecord<string, ReactNode> | Array<{ value, label }> | Array<{ value, items }>写了 children 时只喂 SelectValue;不写 children 的一行式用法里,它同时是选项数据源(分组形态会被拍平——渲染分组要自己写 SelectGroup),见 items 只管触发器怎么显示
multiplebooleanfalse多选;开了之后值的类型整体变成数组
pendingboolean改选保存中:可聚焦但弹层不开,Spinner 站 chevron 位,见进行中
disabledbooleanfalse禁用整个控件
readOnlybooleanfalse只读
requiredbooleanfalse必填(表单校验用)
namestring表单字段名,会渲染一个隐藏 input
modalbooleanfalse打开时是否锁住页面滚动与外部交互。封装层把 Base UI 的默认 true 翻成了 false——页面照常滚动
open / defaultOpen / onOpenChange弹层开合的受控 / 非受控通道
clearablebooleantrue清除的总开关false 连你显式写的 SelectClear 一起关掉
placeholderstring只在不写 children 的默认组合里用,喂给自动渲染的 SelectValue;自己写 trigger 后被忽略
aria-labelstring默认组合触发器的无障碍名(没有可见标签时才需要);自己写 SelectTrigger 时改挂在它上面
idstringBase UI 把它路由给触发器,所以 FieldLabel htmlFor 指根组件即可
其余Base UI Select.Root 的 props透传

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

SelectTrigger

Prop类型默认值说明
aria-label / idstring控件的无障碍名落在这里,不在根组件上
size'sm' | 'default''default'高度档位,回显为 data-size
nativeButtonbooleantrue关掉才能用 render 换成别的元素
classNamestring | (state) => string类名,双形态见状态与 className
其余Base UI Select.Trigger 的 props(button 的原生属性 + ref透传 —— aria-invalid 就是从这里进去的

触发器自己会在末尾追加一个上下箭头图标,写在 children 后面。

SelectValue

Prop类型默认值说明
placeholderReactNode没有选中值时显示的内容
childrenReactNode | (value) => ReactNode函数形态拿到当前值,自己决定印什么
classNamestring | (state) => string类名
其余Base UI Select.Value 的 props透传(data-slot 除外,见状态与 className

SelectPopup

Portal + Positioner + Popup + List 一整套,滚动箭头也在里面。定位相关的 props 去 positioner,其余去 popup。

Prop类型默认值说明
alignItemWithTriggerbooleanfalse把选中项对到触发器(光标)位置(macOS 式)。封装层默认关——普通贴边弹出;它开着时滚动锁也会随之生效(上游 Positioner 的锁定条件是 alignItemWithTrigger || modal),见选中项对齐触发器
side'top' | 'bottom' | 'left' | 'right' | 'inline-start' | 'inline-end''bottom'贴哪一边
sideOffsetnumber4与触发器的间距
align'start' | 'center' | 'end''center'交叉轴对齐
alignOffsetnumber0交叉轴偏移
classNamestring | (state) => string弹层类名
其余Base UI Select.Positioner / Select.Popup 的 props透传

宽度默认跟随触发器(w-(--anchor-width)),高度上限是 --available-height, 超出就在弹层内滚动。

SelectGroup / SelectLabel / SelectSeparator

部件元素说明
SelectGroupdivrole="group"一组选项。不写时 SelectPopup 自动包一层隐式分组;写了就完全归你
SelectLabeldiv组标题,自动关联到所在的组
SelectSeparatordiv分隔线,不可交互

SelectItem

Prop类型默认值说明
valueanynull这一项的值
labelstring文本内容键盘打字定位用;children 不是纯字符串时必须给
disabledbooleanfalse禁用这一项
classNamestring | (state) => string类名
其余Base UI Select.Item 的 props透传(data-slot 除外,见状态与 className

选中标记(对勾)由部件自己渲染在行尾。

SelectClear / SelectEmpty

部件元素说明
SelectClearbutton清除标记部件,写在 SelectTrigger 里、渲染在它旁边;接 button 的原生 props(type 除外),默认 aria-label="Clear selection"、默认 children 是一个 ✕ 图标
SelectEmptydivrole="status"空态插槽,接原生 div props

十个部件的 props 类型也一并导出:SelectProps / SelectPopupProps / SelectGroupProps / SelectItemProps / SelectLabelProps / SelectSeparatorProps / SelectTriggerProps / SelectValueProps / SelectClearProps / SelectEmptyProps;回调那一头另有 SelectChangeEventReason / SelectChangeEventDetails