数据表格家族。底下是原生 <table>:排序按钮、行复选框都是可聚焦的原生控件,
aria-sort 说明当前排序方向,<th scope="row"> 让读屏按行名播报。你只提供一个
列定义数组和数据,不写 JSX 表格结构。
配套的 DataPagination 负责 offset 分页;游标分页走无限滚动;大数据集可用
virtualized 开启 TanStack Virtual 行虚拟化,见下文。
每个 demo 下方都附完整源码(View Code 展开、右上角复制);异步接口用 Promise
模拟(300–500ms 随机延时),mock 数据源在 demos/lib 里。
使用
import {
DataPagination,
DataTable,
DataTableColumnsSelect,
DataTableEmpty,
DataTableError,
DataTableLoadingMore,
DataTableLoadingOverlay,
DataTableRetry,
DataTableStatus,
} from '@gedatou/cadenza-ui'<DataTable
aria-label="作曲家"
columns={[
{ id: 'name', header: '姓名', cell: person => person.name, rowHeader: true },
{ id: 'role', header: '角色', cell: person => person.role },
]}
items={people}
/>列定义是普通对象:id 是列标识,header 是表头内容,cell 收到行数据和行号、返回单元格内容。
aria-label 必填 —— 表格是一整块区域,没有 FieldLabel htmlFor 那样的落点,可访问名只能从这里给。
两个约定:
rowHeader标记「代表这一行」的列(读屏播报行时念它),不标时默认第一列。- 行的 key 默认取
item.id;数据没有id字段时传getRowId。
组成
DataTable 自己就是完整的表格;其余部件都是可选的状态插槽,经 children
通道组合进去,只负责出文案(见插槽):
DataTable
├── DataTableEmpty 空态(没有行)
├── DataTableColumnsEmpty 空列(列全被藏掉)
├── DataTableError 错误
│ └── DataTableRetry 重试按钮(自己接基座的 onRetry)
├── DataTableLoadingOverlay 定制加载磨砂(标记部件,原地不渲染)
└── DataTableLoadingMore 「加载更多」文案(标记部件,原地不渲染)
DataTableStatus 空 / 错误共用的 role="status" 容器,单独导出有两个部件不在这棵树里,都是放在表格外面由你组合的独立组件:
DataPagination 管翻页(见分页),
DataTableColumnsSelect 管显示哪些列(见列选择器)。
表头固定
表头默认是 sticky 的,滚动行区时它留在原地。这需要行区自己是个会滚动的容器,
所以 maxHeight 默认就是 480 —— 它是上限不是高度:比它矮的表格照常按内容
高度渲染,不会被撑出空白,也不会多出滚动条。
要让整张表跟着页面滚(表头也一起滚走),传 maxHeight={Infinity}。
这里有个容易踩的坑,本库替你踩过了:
position: sticky认的是最近的滚动祖先。 shadcn 的Table会自己套一层overflow-x-auto的容器,如果表格渲染在那里面, 表头就粘在那个永远不滚动的容器上 —— 看起来一切正常,实际完全失效。所以这里直接 渲染裸<table>,横向滚动和纵向滚动都归同一个 ScrollArea。
排序
列上开 sortable,表头变为可按压并显示排序指示。组件只维护排序意图
(sortDescriptor 受控 + onSortChange),真正的排序是数据层的事 ——
本地数据自己 sort,服务端数据把 descriptor 转成请求参数:
SortDescriptor 类型({ column, direction })从 @gedatou/cadenza-ui 导出 ——
是本库自己的词汇,不来自任何底座。表头的排序入口是一个真 <button>,
所在的 <th> 带 aria-sort。
行选择与行动作
选择推荐走便利层:selectionMode(single / multiple)+
value / defaultValue / onValueChange,与 InfiniteSelect 同一套契约。
开 selectionColumn 会在最前面合成一列复选框:行内勾选、表头全选/半选全部自动接线。
复选框的无障碍名走 selectAllLabel / selectRowLabel(英文兜底,业务层传译文)。
需要原始语义(selectedKeys / onSelectionChange / 'all' 哨兵)的高级场景仍可用
透传层。两层的关系:便利层激活时 selectedKeys 被忽略,但 onSelectionChange
仍会收到原始 Selection(含 'all' 哨兵),可以当观察钩子用。
单选
value 是单个 id,onValueChange 直接收行对象(取消选中时收 null);
表头不渲染全选框。下面的 demo 开了 selectionColumn,所以选择走复选框——不开它
的时候,点击行才是选中手势(见下方的路由表):
多选
value 是 id 数组;onValueChange(items, ids) 里 ids 是权威全集,
items 只回显已加载页的对象——与 InfiniteSelect 的口径一致,持久化请存 ids。
onRowAction 是行激活,回调直接拿到行对象而不是 key。点击行这一个手势
只能属于一件事,所以路由规则是一条:
复选框永远是明确的选择入口 —— 这正是 selectionColumn 的用武之地。
行可点时它也进 Tab 序,Enter / Space 与点击走同一条路径:不开
selectionColumn 时点行是唯一的选中手势,只认鼠标等于把键盘用户挡在门外。
两个细节:
- 合成列永远不做行头(读屏播报行名仍取你的数据列),有
pinned: 'start'数据列时它会跟着钉住; - 便利层把表头全选归一为「已加载行并入选择集」、反选(含 Esc 清除)归一为
「已加载行剔除」——真正的「服务端全选」(所有匹配项,含未加载页)组件表达
不了,那是业务层与后端的契约(如
select_all=true+ 排除名单)。
跨页存档
选择集按 id 存档,天然跨页:翻页只是换 items,value 里的 id 不动,
翻回来 checkbox 自动恢复;勾选第 2 页的行不会丢第 1 页的存档;跨页的行对象
由组件缓存,onValueChange 的 items 参数里能拿到。把选择 state 放在翻页 state
外面即可。下面的 demo 每页 5 条——跨几页勾选,名单一直完整(跨页的名字
来自组件的对象缓存,不是当前页数据):
横向滚动与固定列
横向滚动由列宽驱动:给列设数字 width 后它就是固定宽度(不会被表格布局
压缩),各列宽度加起来超出容器时自动出现横向滚动——滚动条、左右边缘渐隐
都不用配置:
不给 width 的列是弹性的:总宽不足容器时由它们吸收剩余空间,此时不会出现
横向滚动。
需要某些列在横向滚动时钉住,在列定义上加 pinned。同一侧可以固定多列,
sticky 偏移按数组顺序累加——下面的 demo 左侧钉了姓名、角色两列,右侧钉了
操作列:
三个约定:
pinned: 'start'的列排在数组头部、'end'的列排在尾部——sticky 偏移按数组顺序累加,夹在中间的固定列没有意义。- 固定列必须给数字
width,偏移量由它算出。 - 固定列的单元格自带不透明底色(并跟随行的 hover / 选中态),滚过去的内容 不会从下面透出来;边缘渐隐会自动跳过固定列的区域,只作用于会动的部分。
插槽
基座零文案。空 / 加载 / 错误都是 context 驱动的插槽组件,经 children
通道传入、渲染在表头下方的空态区域,按状态自渲染、互斥,文案由你注入 ——
i18n 只存在于你的业务层。
DataTableEmpty:空
items 为空(且不在加载 / 错误中)时自渲染:
加载中:统一的磨砂覆盖层
加载没有文案插槽——无论首屏还是刷新,视觉统一是内建的 LoadingOverlay,一种加载长相。
首屏(isLoading 且还没有行):卡片给最低高度(min-block-32)防坍缩,磨砂盖在空白上:
刷新(isLoading 且行还在屏上——业务层配 react-query 的 placeholderData
保住上一页数据):旧行原地保留、被磨砂盖住。含表头一起盖,刷新中重复点排序
只会把请求打成串,挡住是保护:
DataTableLoadingOverlay:定制磨砂
在插槽通道里组合它即可定制上面那层磨砂。它是 TabsIndicator 式的标记部件——
写在哪里都不渲染(绝对定位的覆盖层没法住在空态区的文档流里),由卡片提升到
覆盖层的位置渲染;children 替换居中的 Spinner(品牌动画、文案都行),
className 调磨砂浓度,loading 是基座的接线、不在它的 props 里。直接子级或
Fragment 内有效,包进自定义组件会找不到(与 TabsIndicator 同一限制):
(旧版的 DataTableLoading 文案插槽已移除,定制走 DataTableLoadingOverlay。)
DataTableLoadingMore:加载更多
翻下一页时,滚动流末尾会出现一个加载行,默认是一个 Spinner —— 基座零文案, 和磨砂覆盖层同一条规矩。要文字就在插槽通道里组合它:
<DataTable {/* … */}>
<DataTableLoadingMore>加载更多…</DataTableLoadingMore>
</DataTable>同样是 DataTableLoadingOverlay 那种标记部件:原地不渲染,由基座提升到滚动流
末尾;定制入口不是开关,不写也会渲染那个 Spinner。它落在一个 <tr> 上
(<tbody> 里只能放行),所以 props 是 ComponentProps<'tr'> 而不是 'div'。
DataTableError / DataTableRetry:错误与重试
isError 时自渲染;DataTableRetry 自己接好基座的 onRetry(没有则不渲染),
你只出文案。这个 demo 首次加载必定失败,点重试恢复:
DataTableStatus:状态容器
空 / 错误两个状态插槽最终都渲染成同一个 role="status" 容器(状态切换会被读屏
播报;加载没有文案插槽,走磨砂覆盖层),
它也单独导出——自定义状态视觉时给各插槽传 className 微调,或复用这个容器
保持一致的排版。
列选择器
DataTableColumnsSelect 是让用户挑「显示哪些列」的控件:选项是表格的列,
值是可见的那些。
const [visible, setVisible] = useState<string[]>(allColumns.map(column => column.id))
<DataTableColumnsSelect columns={allColumns} value={visible} onValueChange={setVisible} />
<DataTable
aria-label="作曲家"
columns={allColumns.filter(column => visible.includes(column.id))}
items={items}
/>它自己不藏列,藏列是调用方那一行 filter —— 表格永远只渲染你交给它的列。
把过滤摊在明面上是有意的:同一个控件因此也能驱动状态存在别处的表格——
外部数据层的列可见性状态、URL 参数、或者一份保存下来的视图。
hideable: false 把某一列钉死:选择器里那一项置灰,而且它永远出现在
onValueChange 的结果里,哪怕你传进去的 value 漏了它。行名列通常要这么标——
读屏靠它播报行,藏掉整张表就没了名字。
const allColumns: DataTableColumn<Person>[] = [
{ id: 'name', header: '姓名', cell: p => p.name, rowHeader: true, hideable: false },
{ id: 'role', header: '角色', cell: p => p.role },
]columns 收的是结构化的一小片(id / header / hideable),不是完整的
DataTableColumn —— DataTableColumn 天然满足它,而列定义长在别处的调用方
一行 .map() 就能喂进来,不用编造假的 cell 函数。
一列不剩时
不锁任何列,用户就能把列全部藏掉。行没有任何单元格可渲染时,表格不会塌成
一叠边框线:表头行不再渲染,行区退回状态区,文案从新的 DataTableColumnsEmpty
插槽进来 —— 基座零文案的家法不变,而且这正是放「显示全部列」恢复动作的地方:
<DataTable aria-label="作曲家" columns={ordered.filter(…)} items={items}>
<DataTableColumnsEmpty className="flex flex-col items-center gap-2">
所有列都被隐藏了
<Button variant="outline" onClick={() => setVisible(allIds)}>显示全部列</Button>
</DataTableColumnsEmpty>
</DataTable>它和 DataTableEmpty 是两个状态,故意分开:数据在、列不在时说「暂无数据」是
说谎。两者同时成立(零列 + 零行)时列插槽优先。触发器那侧用 placeholder
兜底(「没有可见的列」之类),不给就是空白。
要根本不允许走到这一步,把行名列标成 hideable: false 就够了 ——
这也是上面推荐的默认做法。
传给它的应当是全部列(含已隐藏的),毕竟那是这个菜单该有的内容, 不是表格当前在渲染的那些。
拖拽排序
给了重排回调,每个选项左侧就长出一个拖拽把手。不给就不长 —— 缺席胜过布尔开关, 一个没处上报的把手是死 UI。
和 Slider 一样,重排是一对回调:
驱动表格的是 onOrderCommitted,不是 onOrderChange。
const [order, setOrder] = useState<string[]>(allColumns.map(column => column.id))
const ordered = order.map(id => allColumns.find(column => column.id === id)!)
<DataTableColumnsSelect columns={ordered} onOrderCommitted={setOrder} … />
<DataTable columns={ordered.filter(column => visible.includes(column.id))} … />拖动期间顺序只活在选择器内部
这是这对回调存在的理由。拖动中的顺序是选择器自己的一份草稿:
- 选择器列表实时让位 —— 草稿在动,这就是放置位置的预览
- 外面的表格纹丝不动 —— 它只听
onOrderCommitted,而那要等松手 - 松手 —— 草稿作为最终结果上报一次,表格一次性重排
把表格接到 onOrderChange 上会让它在你手还没松的时候就跟着重排,而且每越过一项
就把所有列重渲染一遍。想要实时联动的场景才用它。
草稿在落定后清空,重新跟随 columns:调用方应用了这次变更,两者本就相同、看不到
中间帧;调用方忽略了,列表弹回原位 —— 那正是「没生效」该有的样子。
底层是 Motion 的 Reorder。它白送三样东西:spring 重排动画、
拖起时的 whileDrag 抬升,以及其余项实时让位——那个「让位」就是放置位置的预览,
不用另画指示线。
在 Base UI 的 listbox 里跑通它有三个关键点,都是实测撞出来的 —— 全部封在部件内部,写在这里只是讲原理,组合形态也不需要碰:
- option 和可拖项是同一个元素,不是嵌套两层。靠 Base UI 的
renderprop 把Reorder.Item合并进SelectItem——这样role="option"、选中、typeahead 全部原样保留, 而 Motion 只接管位置。 dragListener={false}+useDragControls():只有把手能发起拖拽,按住整行仍然是切显隐。- 捕获阶段的守卫要加在组上,不是把手上。松手时指针早已移到别的 option 头上, Base UI 会把那一下读成按压、顺手把那一列关掉。守把手守不住,因为把手早就不在光标下面了。
<SelectItem
render={<Reorder.Item as="div" dragControls={controls} dragListener={false} />}
value={column.id}
>拖拽只走指针,没有键盘路径。 listbox 里方向键归 Base UI 的选项导航,让不出来。 显隐(功能性的那部分)的键盘操作完全不受影响 —— 排序是便利,不是唯一出路。
hideable: false的列也拖不动:Base UI 给禁用选项加了pointer-events: none。
组合形态
不写 children 就是上面那个默认组合;写了 children,结构整个归你 —— 和 Select
家族同一条家法。触发器、弹层用的就是 Select 的现有词汇(SelectTrigger /
SelectValue / SelectPopup / SelectSeparator…),新部件只有三个:
DataTableColumnsSelectList / Item / Grip。
<DataTableColumnsSelect columns={ordered} value={visible}
onValueChange={setVisible} onOrderCommitted={setOrder}>
<SelectTrigger aria-label="显示列">
<IconColumns />
<SelectValue>{() => `${visible.length} / ${ordered.length} 列`}</SelectValue>
</SelectTrigger>
<SelectPopup>
<DataTableColumnsSelectList>
{column => (
<DataTableColumnsSelectItem column={column}>
<DataTableColumnsSelectGrip />
{column.header}
</DataTableColumnsSelectItem>
)}
</DataTableColumnsSelectList>
<SelectSeparator />
<Button variant="ghost" onClick={() => setOrder(defaultOrder)}>恢复默认顺序</Button>
</SelectPopup>
</DataTableColumnsSelect>组合形态解锁的正是闭合形态给不了的:自定义触发器(图标、「N / M 列」计数)、
弹层尾部的动作(恢复默认)、每项的自定义内容,以及走
Field + FieldLabel htmlFor 挂可见标签。
SelectValue 的标签解析照常工作 —— 根始终把 columns 的 id→header 映射喂给
Select 根,组合出来的触发器不用自己再建一份。
组合时的四条规矩:
- List 的 children 是函数,不是静态 JSX —— 这是有意为之的硬性契约:拖动中的
渲染顺序由内部草稿决定,Motion 按位置把
values和子元素对起来,静态 JSX 的 顺序写死了就会错乱。列表拥有映射,你只提供「一列长什么样」。 - Item 的 children 会进 option 的可访问名(
ItemText机制),图标、计数这类 装饰内容会被读屏一起念出来、也会进 typeahead 匹配。给 Item 传aria-label可整体覆盖播报的名字。 - Grip 在根上没有重排回调时渲染 null —— 和
DataTableRetry同一条规矩, 缺席胜过死 UI。所以组合里可以无条件写它。 - 踩出来的守卫全在部件内部(捕获阶段吞按压、松手晚一拍放行、把手
aria-hidden)——组合开放的是结构,不是这些陷阱。
顺带一提:组合也解锁了键盘排序的路 —— 在弹层里放自己的「上移/下移」按钮,
调 onOrderChange / onOrderCommitted 即可,不必依赖拖拽。
分页
DataPagination 是独立组件,
放在表格下方由你组合 —— 分页状态在外面,表格只收当页 items:
props、内建守卫(total=0 不钳位、limit 非法兜底)与自定义文案
见 DataPagination 页。
换一批行就回到行区顶部,不用你穿线:否则点「下一页」会把上一页的滚动偏移
套在新数据上 —— 停在半空、新一页开头的那些行被跳过。表格只看得到 items、
看不到页码,所以判据是首行 id:
只归零纵向;横向偏移留着——列没有变。删掉第一行也会被判成新的一批,那是刚做完
删除动作时的一次视觉重置,代价小于每次翻页都落在半空。InfiniteSelect 的列表
同规则(搜索换批回顶,加载下一页不动)。
无限滚动
游标分页不用 DataPagination,用列表状态 props:滚动到底部附近自动触发
onLoadMore(一个 IntersectionObserver 哨兵行,提前预取)。这组 props 的形状与
InfiniteSelectAdapterProps 完全一致,适配器对象直接展开 {...list}:
行区默认就有 480px 的高度上限(见表头固定),无限滚动需要的那个会滚动的容器
因此天然存在;要更高更矮就传 maxHeight。加载更多的指示渲染在滚动流末尾(默认一个 Spinner,用
DataTableLoadingMore 换成自己的文案):预取提前触发,
停在顶部的用户根本看不到它。
demo 同时开了 virtualized,这不是巧合:无限滚动的已加载集是无界累积的
——每翻一页 DOM 净增一页的行,翻几十页表格就自己长成了性能陷阱。虚拟化把
任意页数的 DOM 压在窗口大小;加载哨兵位于已加载内容的真实末尾(底部填充行
之后),虚拟化下照样提前触发。总量有限、页数可控时才可以不开。
「加载更多」是预取失败时的兜底,不是功能——理想状态下用户感知不到分页。
loadMoreScrollOffset 控制预取距离(默认提前 1 个视口高度触发),接口延迟高
就调大它;预取距离本质是成本权衡(每个用户都提前拉一页 = 服务端 QPS 翻倍),
所以留给业务层定。更激进的做法在数据层:第 N 页一到就预取第 N+1 页
(react-query 的 prefetchQuery),滚动只消费缓存,指示器几乎绝迹。
滚动条默认 scrollbars="hover":平时隐藏,指针悬停或滚动进行时淡入
(另外两个值:'always' 常显、'hidden' 常隐)。
虚拟化
开启 virtualized 后行渲染交给 TanStack Virtual:
任何时刻只有视口内可见的行、加上少量预渲染的行(overscan)存在于 DOM,
上下各一条不可交互的填充行撑住完整滚动高度,表格保持原生 <table>——
sticky 表头、边框、列布局与非虚拟模式完全一致。下面的 demo 一次拉满 10000 条:
开启前要知道的取舍:
- 行高固定为
rowHeight(默认 40px)——虚拟化靠固定行高换取零测量、零跳动; 单元格内容更高时必须同步调大它。内容真正不定高时开dynamicRowHeight, 见动态行高。 - 读屏的作用域变窄:DOM 里只有那几十行,读屏播报的行数以窗口为准; 浏览器的页内查找、全选同样只够得着可见的那些行。
- 因此几百行以内不建议开——不开时整张表都在 DOM 里,而几百行根本不构成 性能问题。
虚拟化用的是 TanStack Virtual,和 InfiniteSelect
同一个引擎;行不改绝对定位,靠上下两条 filler <tr> 撑起滚动高度,所以原生
<table> 的 sticky 表头、列固定和边框全部照常。
给列设置 width 可以避免滚动时列宽随窗口内容轻微抖动(自动布局的表格列宽
由可见内容决定)。
虚拟化与无限滚动可以同时开,而且页数无上限时应该同时开——见无限滚动 一节的说明。
动态行高
非虚拟模式下行高本来就由内容决定,什么都不用做。虚拟化默认用固定行高换
零测量、零跳动;内容不定高(多行文本、标签组)时开 dynamicRowHeight:
rowHeight 降级为初始估算,行渲染后按实际高度测量(ResizeObserver)并自动
校正布局。单元格默认不换行,需要换行的列自己加 className: 'whitespace-normal'。
下面的 demo 一次载入 10000 行,简介列长短不一:
代价:滚动中实测值替换估算值时滚动条会有轻微校正跳动,且每行多一份测量
开销——行高均匀时用固定行高,把 dynamicRowHeight 留给真正不定高的表。
状态与 className
这个家族底下没有 Base UI 的 state:className 处处是普通字符串,属性是封装层
手动挂上去的。外层卡片回显三个列表状态,行与表头回显各自的语义:
行的选中态不是
data-state="selected"。那是 Radix 的词汇,本库不写data-state;行上是data-selected,和 Base UI、库里其余部分一个口径。 从 shadcn 的表格样式抄类名过来时会踩这一脚。
封装层自渲染的部件都带 data-slot,需要从外部定位时当选择器用:
键盘交互
表格是原生 <table>,键盘走的是 Tab 遍历里面的可聚焦控件:
0.2(React Aria 底座)这里有方向键在单元格间移动的网格导航和
role="grid"。 换到原生<table>之后没有了 —— 这是这次迁移里唯一一处明确的无障碍退步, 换来的是和 shadcn / Radix 生态一致的表格语义。
导出的类型
Props
DataTable
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
DataTableLoadingOverlay
定制那层磨砂。标记部件:写在插槽通道里但原地不渲染,由卡片提升到覆盖层的位置 渲染,所以只在直接子级或 Fragment 里有效,包进自定义组件就找不到了。行为见 加载中。
<DataTable {/* … */}>
<DataTableLoadingOverlay className="backdrop-blur-sm">
正在同步…
</DataTableLoadingOverlay>
</DataTable>DataTableLoadingMore
翻下一页时滚动流末尾那一行。同样是标记部件,原地不渲染、由基座提升到流末尾。 它不是开关:不写也会渲染一个 Spinner,写了只是替换内容。
<DataTable {/* … */}>
<DataTableLoadingMore>加载更多…</DataTableLoadingMore>
</DataTable>DataTableEmpty / DataTableError / DataTableRetry / DataTableStatus
这些状态插槽都是纯 DOM 部件,除了各自的出现时机没有别的契约:className 是字符串,
其余 props 透传到宿主元素。行为见插槽。
DataTableColumnsSelect
行为上只有三条:它自己不藏列、也不排序(两者都是调用方对 columns 的操作);
hideable: false 的列既置灰、也永远在 onValueChange 的结果里;把手只在
给了两个重排回调之一时出现。见列选择器。
组合部件
DataPagination 的 props 见它自己的页面。