级联选择器。Base UI 没有 cascader 原语,这是 seam 直接建在 @base-ui/react
Menu 上的自建组合(Slider / Combobox 先例):子菜单承担逐级面板 ——
悬停展开、方向键钻取、键盘打字定位、焦点管理全部由 Menu 承担;seam 补上
Menu 没有的那一半 —— 值协议、Select 同款触发器、路径标签回显与表单序列化。
值是路径(string[],从根到叶),只有叶子(没有 items 的节点)可选,
选中即提交整条路径并关闭弹层。items 是唯一数据源:弹层内容与触发器
回显都从它解析,弹层没有 JSX 词汇;组合只覆盖触发器一侧,不写 children
就是完整默认组合(触发器 + 回显 + 清除 ✕ + 弹层)。
使用
import { Cascader } from '@gedatou/cadenza-ui'const REGIONS = [
{
value: 'zhejiang',
label: '浙江',
items: [
{ value: 'hangzhou', label: '杭州', items: [{ value: 'xihu', label: '西湖区' }] },
],
},
{ value: 'beijing', label: '北京' },
]
// 一行式:items 供数据,placeholder / aria-label 落在根上,其余全部默认(含清除 ✕)
<Cascader aria-label="地区" items={REGIONS} placeholder="选择地区" />
// onValueChange 收到整条路径:['zhejiang', 'hangzhou', 'xihu']
<Cascader items={REGIONS} onValueChange={path => console.log(path)} />标签走常规通道:触发器是真 <button>,FieldLabel htmlFor 指向根的
id(默认组合会把它转发给触发器),点标签即聚焦并可开弹层 ——
见表单的示例与 Field。
组成
Cascader # 根:值协议、items、隐藏 input。不渲染 DOM
├─ CascaderTrigger # 真 <button>,Select 同款外观;不写 children 自动含回显与清除
│ ├─ CascaderValue # 路径回显:各段 label 以 / 相连,空时显示 placeholder
│ └─ CascaderClear # 标记部件:✕ 被触发器提升为兄弟按钮,站进 chevron 的位置
└─ CascaderPopup # Portal + Positioner + Popup 合一;内容永远来自根的 items逐层接管:写了 children 的层完全归你(比如只写 CascaderTrigger 定制
id / size),没写的层保持默认 —— 弹层会自动补上。CascaderPopup
没有 children 通道,写它只为定制位置 props 与 className。
选中路径
只有叶子提交值。分支节点是子菜单触发器:悬停或 ArrowRight 展开下一级,
点它不改值、不关弹层。选中后重新打开,选中路径上的子菜单自动展开,
一眼落回值所在的位置;路径上的分支触发器带 data-selected,选中的叶子
带 data-checked 与行尾对勾。
回显按段解析:items 里找得到就用节点的 label,找不到的段原样打印
自己的 value(与 SelectValue 同一回退行为;懒加载的段命中缓存后同样解析)。
安全三角形
从分支斜向移入子面板会掠过下方的兄弟项,Cascader 用悬停意图判定兜住 这条斜线——demo 实时画出这块宽限区,光标动起来就能看到。
这是亚马逊导航菜单出名的交互(Ben Kamens 的 Breaking down Amazon's mega dropdown): 以光标位置与子面板近边两角围成三角形,光标在三角形内移动被判定为 「正在奔向子面板」,掠过的兄弟项不触发切换;方向一偏出三角形,立即切到 新分支——既没有全局延时的迟钝,也不会斜线走到一半子面板被换掉。
Cascader 不为此做任何额外工作:它建在 Base UI Menu 上,子菜单触发器内置
Floating UI 的 safePolygon——三角形的泛化(随光标移动动态重算、停顿
超时就收拢的多边形,宽限期内子面板之外的指针事件被临时屏蔽)。demo 画的
三角形是教学近似,真实宽限区以 safePolygon 为准。
异步加载
一个 loadItems 全包按需与分页:子级不再需要提前铺满,面板打开时拉取;
太多的层返回分页形态,滚到底自动追加。demo 里 State、City 两层逐级懒加载,
District 一层每页 20 条共 3 页。
契约:
loadItems(path, { page })在某个懒面板挂载且未缓存时被调用,path是分支路径(首层是[]——因此有 loader 时items可省略)。返回裸数组 表示该层一次加载完;返回{ items, hasNextPage: true }则该层分页—— 面板尾部的哨兵滚进视口就带着递增的page再次调用、结果追加。- 有 loader 时,无
items的节点默认是懒分支;leaf: true标叶子; 写了静态items的节点从不询问 loader(优先级:items>leaf)。 - 加载视觉与本库其他组件一致:首页在途时面板带
data-loading,由磨砂 LoadingOverlay 覆盖(InfiniteSelect 同款处理,覆盖可视区、不随行滚动);分页尾巴也照搬 InfiniteSelect—— 哨兵是aria-hidden的空元素,位于菜单项语义之外、提前一个视口高度触发 (IntersectionObserver),追加在途时尾部出现 Spinner 行,全部页到齐后 渲染渐隐横线的终止行(只有分过页的层才有,静态层与一次加载完的层直接停)。 - 面板的滚动容器是本库统一的
ScrollArea(悬停浮现的滚动条 + 上下
渐隐遮罩),viewport 高度上限
maxListHeight(默认 256px)。 - 结果按路径缓存整个组件生命周期,关闭重开不重复请求;重开自动展开
选中路径时也逐级走同一条加载链,标签随缓存命中从原始值换成
label。 - 回显不用打开弹层:有
value时组件沿选中路径把各未加载层的第 0 页 预载进同一缓存,标签就位即换——demo 打开页面就显示「State 2 / City 3 / District 3」。分页层的目标段不在第 0 页、或段已失效时,该段停在原始值—— 此时用CascaderValue的 children 函数自定义回显。 - loader 拒绝时面板标
data-error(dev 下 console 警告),下次打开 重试;要更丰富的错误 UI,在 loader 内部自理。
虚拟化
万级子节点的层开 virtualized:每个面板经固定行高(rowHeight,默认
32px)的虚拟窗口渲染,DOM 里只挂当前可视窗口附近的行。
重开时自动滚到选中项所在位置(子菜单自动展开的虚拟化对应物)。 键盘天花板要知道:上游 Menu 没有虚拟化支持(Base UI 只给 Combobox 做了),typeahead 与 Home/End 只见已挂载的窗口; ↑ ↓ 逐行走不受影响——焦点会拖着窗口前进。 虚拟化面板的行绝对定位、无法撑开宽度,子级面板最小宽从 96px 提到 192px。
受控
受控三件套 value / defaultValue / onValueChange,受控空值是 null。
回调第二参永远存在:onValueChange(value, eventDetails),
eventDetails.reason 是选中时的 'item-press' 或清除时的 'clear-press';
eventDetails.cancel() 拒绝这次变更,内部状态不动。受控性首渲染锁定 ——
undefined 专属非受控,中途切换在 DEV 会收到警告。
可清除
清除默认在场:选中后 ✕ 站进 chevron 的位置,点击清空(onValueChange
收到 null,reason 'clear-press')且不开弹层。clearable={false}
是总开关 —— 关掉后默认组合与显式写的 CascaderClear 都不渲染。
提升出来的 ✕ 是真 <button>(Tab 停靠点),键盘用户同样能清除。
表单
给 name,每个路径段渲染一个同名隐藏 input,提交顺序即路径顺序
(Base UI multiple Select 的每值一 input 模式);空值不渲染任何 input。
隐藏 input 是普通 type="hidden",渲染在弹层外(弹层关闭即卸载)。
原生 required 校验看不见 hidden input —— 必填校验在表单层做。
禁用
两个层级:根的 disabled 禁掉整个控件(触发器灰显、隐藏 input 带
disabled 不参与提交);节点的 disabled 只禁那一项 —— 叶子不可选中,
分支不再展开(整棵子树不可达),项上写 aria-disabled 与 data-disabled。
什么时候用 Cascader
- 选项天然成树、路径本身就是值(省/市/区、类目/子类目)→ Cascader。
- 选项是扁平列表 → Select。层级只是分组视觉时用
SelectGroup,值仍是单段。 - 要输入搜索 → InfiniteSelect / InfiniteCombobox。 本组件不做搜索;异步、分页与虚拟化见上面两节,但选项仍须成树。
状态与 className
data-* 全部是空串存在型(出现即为真,反面缺席或有互补词):
data-slot(seam 自渲染的部件):
面板内部还会出现 scroll-area-*(滚动容器)与 loading-overlay
(磨砂加载层)家族的 slot——它们是被复用的公共部件,不属于本家族词汇。
className 两条通道:CascaderTrigger / CascaderPopup 落在 Base UI
槽位上,支持函数形态 (state) => string;CascaderValue / CascaderClear
落在纯 DOM 元素上,只收 string。
键盘交互
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
Cascader
根。不渲染 DOM;显式词汇表,不透传 Menu.Root 的菜单机械(handle /
payload 等不进入公开面)。
CascaderTrigger
真 <button>。不写 children 自动含 CascaderValue 与清除。
CascaderValue
回显 <span>。内容由状态派生,children 是定制函数而非插槽。
CascaderClear
标记部件:写在 CascaderTrigger 里,自身渲染 null,由触发器提升为
兄弟 <button>。触发器的直接子元素或 Fragment 内才检测得到。
CascaderPopup
Portal + Positioner + Popup 合一;无 children 通道,内容来自根的 items。
9 个类型一并导出:CascaderProps / CascaderNode / CascaderPage /
CascaderTriggerProps / CascaderValueProps / CascaderClearProps /
CascaderPopupProps / CascaderChangeEventReason / CascaderChangeEventDetails。