Cadenza
EN

级联选择器 —— 树形选项逐级子菜单展开,值是从根到叶的一条路径,Select 同款触发器

级联选择器。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-disableddata-disabled

什么时候用 Cascader

  • 选项天然成树、路径本身就是值(省/市/区、类目/子类目)→ Cascader。
  • 选项是扁平列表 → Select。层级只是分组视觉时用 SelectGroup,值仍是单段。
  • 输入搜索InfiniteSelect / InfiniteCombobox。 本组件不做搜索;异步、分页与虚拟化见上面两节,但选项仍须成树。

状态与 className

data-* 全部是空串存在型(出现即为真,反面缺席或有互补词):

属性落点出现时机
data-placeholder触发器空值(显示 placeholder)时
data-size触发器恒在,值 sm / default
data-popup-open触发器、分支项对应弹层开着时(Base UI)
data-disabled触发器、任一项禁用时(Base UI)
data-highlighted任一项键盘/悬停高亮时(Base UI)
data-checked / data-unchecked叶子项互补对:是否当前选中(Base UI)
data-selected分支项该分支在选中路径上(seam 写入)
data-open / data-closed弹层互补对,进出场动画钩子(Base UI)
data-loading面板懒面板首页在途时(seam 写入)
data-error面板loadItems 拒绝后(seam 写入,重开重试)
data-empty面板已加载但该层为空(seam 写入)

data-slot(seam 自渲染的部件):

data-slot是什么
cascader-trigger触发器 <button>
cascader-trigger-container组合了清除时包住触发器的定位容器
cascader-value回显 <span>
cascader-clear提升出来的 ✕ <button>
cascader-popup第一级弹层
cascader-submenu-trigger分支项
cascader-submenu-popup子级弹层
cascader-item叶子项
cascader-item-indicator叶子项行尾对勾的定位容器
cascader-panel每层内容的定位壳(loading / error / empty 落点,LoadingOverlay 的锚)
cascader-list面板行的列表容器(虚拟化时切 p-0,padding 移到行壳)
cascader-load-more追加下一页时的 Spinner 行
cascader-load-more-sentinel分页面板尾部的加载哨兵(aria-hidden
cascader-no-more / cascader-no-more-rule分页层的终止行与渐隐横线
cascader-virtual-list虚拟化面板的总高撑杆容器

面板内部还会出现 scroll-area-*(滚动容器)与 loading-overlay (磨砂加载层)家族的 slot——它们是被复用的公共部件,不属于本家族词汇。

className 两条通道:CascaderTrigger / CascaderPopup 落在 Base UI 槽位上,支持函数形态 (state) => stringCascaderValue / CascaderClear 落在纯 DOM 元素上,只收 string

键盘交互

按键效果
Enter / Space / (触发器上)开弹层并高亮第一项
在当前面板内移动(循环)
分支上:展开子菜单并进入第一项
收起当前子菜单,退回上一级
Enter叶子上:提交整条路径并关闭
Esc关闭整个弹层(不只当前子级)
打字typeahead:跳到当前面板中匹配的项(虚拟化下只见已挂载窗口)

Props

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

Cascader

根。不渲染 DOM;显式词汇表,不透传 Menu.Root 的菜单机械(handle / payload 等不进入公开面)。

Prop类型默认值说明
itemsCascaderNode[]选项树。节点:{ value, label?, disabled?, leaf?, items? }value 同级唯一。有 loadItems 时可省略(首层转由 loader 提供)
defaultValuestring[] | nullnull非受控初始路径
valuestring[] | null受控路径;null 为空
defaultOpenbooleanfalse非受控初始开合
openboolean受控开合
onValueChange(value, eventDetails) => void选中 / 清除时;cancel() 可拒绝
loadItems(path, { page }) => Promise<CascaderNode[] | CascaderPage>懒分支子级的加载器,见异步加载
onOpenChange(open, eventDetails) => void开合变化时
onOpenChangeComplete(open) => void开合动画结束后
actionsRefRefObject<Menu.Root.Actions>命令式 close / unmount
clearablebooleantrue清除总开关
modalbooleanfalse锁滚动、挡外部交互(Base UI 默认 true,seam 翻转)
virtualizedbooleanfalse面板走固定行高虚拟窗口,见虚拟化
rowHeightnumber32虚拟窗口的固定行高(px),仅 virtualized 下读取
maxListHeightnumber256每个面板滚动 viewport 的最大高度(px),与 InfiniteSelect 同名同默认
disabledbooleanfalse整体禁用
namestring表单字段名,见表单
placeholderstring默认组合回显的占位文案
idstring转发给默认组合触发器,供 FieldLabel htmlFor
aria-labelstring默认组合触发器的无障碍名(无可见标签时)
childrenReactNode组合通道:触发器一侧,见组成

CascaderTrigger

<button>。不写 children 自动含 CascaderValue 与清除。

Prop类型默认值说明
size'sm' | 'default''default'触发器高度档
其余Base UI Menu.Trigger 的 props(原生 button 属性 + ref;不含 handle / payload透传

CascaderValue

回显 <span>。内容由状态派生,children 是定制函数而非插槽。

Prop类型默认值说明
placeholderstring根的 placeholder空值时显示
children(labels, value) => ReactNode自定义回显,仅有值时调用
其余span 原生属性(classNamestring透传

CascaderClear

标记部件:写在 CascaderTrigger 里,自身渲染 null,由触发器提升为 兄弟 <button>。触发器的直接子元素或 Fragment 内才检测得到。

Prop类型默认值说明
childrenReactNode✕ 图标自定义图标
其余button 原生属性(不含 typearia-label 默认 "Clear selection"透传到提升后的按钮

CascaderPopup

Portal + Positioner + Popup 合一;无 children 通道,内容来自根的 items

Prop类型默认值说明
sideMenu.Positionerside'bottom'弹出方向
sideOffsetnumber4与触发器的间距
alignMenu.Positioneralign'start'对齐
alignOffsetnumber0对齐偏移
其余Base UI Menu.Popup 的 props(不含 children透传到第一级弹层

9 个类型一并导出:CascaderProps / CascaderNode / CascaderPage / CascaderTriggerProps / CascaderValueProps / CascaderClearProps / CascaderPopupProps / CascaderChangeEventReason / CascaderChangeEventDetails