Cadenza
EN

原生 table 语义的数据表格 —— 列定义驱动 + 组合式插槽 + 分页 / 无限滚动 / 虚拟化

数据表格家族。底下是原生 <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

行选择与行动作

选择推荐走便利层selectionModesingle / 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。点击行这一个手势 只能属于一件事,所以路由规则是一条

情形点击行做什么
onRowAction触发动作。选择走复选框
onRowAction、无 selectionColumn、开了 selectionMode切换该行的选中
其余什么都不做

复选框永远是明确的选择入口 —— 这正是 selectionColumn 的用武之地。

行可点时它也进 Tab 序,Enter / Space 与点击走同一条路径:不开 selectionColumn 时点行是唯一的选中手势,只认鼠标等于把键盘用户挡在门外。

两个细节:

  • 合成列永远不做行头(读屏播报行名仍取你的数据列),有 pinned: 'start' 数据列时它会跟着钉住;
  • 便利层把表头全选归一为「已加载行并入选择集」、反选(含 Esc 清除)归一为 「已加载行剔除」——真正的「服务端全选」(所有匹配项,含未加载页)组件表达 不了,那是业务层与后端的契约(如 select_all=true + 排除名单)。

跨页存档

选择集按 id 存档,天然跨页:翻页只是换 itemsvalue 里的 id 不动, 翻回来 checkbox 自动恢复;勾选第 2 页的行不会丢第 1 页的存档;跨页的行对象 由组件缓存,onValueChangeitems 参数里能拿到。把选择 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 一样,重排是一对回调:

回调什么时候响第二参能不能拦
onOrderChange拖动中每越过一项就响一次DataTableColumnsSelectChangeEventDetails能。cancel() 一调,这次变更不落到选择器内部那份草稿上
onOrderCommitted松手、这一次拖拽落定后,响一次DataTableColumnsSelectOrderEventDetails不能 —— 它是通知,没有 cancel()

驱动表格的是 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:调用方应用了这次变更,两者本就相同、看不到 中间帧;调用方忽略了,列表弹回原位 —— 那正是「没生效」该有的样子。

底层是 MotionReorder。它白送三样东西:spring 重排动画、 拖起时的 whileDrag 抬升,以及其余项实时让位——那个「让位」就是放置位置的预览, 不用另画指示线。

在 Base UI 的 listbox 里跑通它有三个关键点,都是实测撞出来的 —— 全部封在部件内部,写在这里只是讲原理,组合形态也不需要碰:

  • option 和可拖项是同一个元素,不是嵌套两层。靠 Base UI 的 render prop 把 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

items 怎么变首行 id行为
翻页 / 换每页条数 / 排序 / 搜索回到顶部
无限滚动追加下一页不变保持不动(否则每加载一页就被弹回顶部)
原地刷新(placeholderData不变保持不动

只归零纵向;横向偏移留着——列没有变。删掉第一行也会被判成新的一批,那是刚做完 删除动作时的一次视觉重置,代价小于每次翻页都落在半空。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 条:

开启前要知道的取舍:

  1. 行高固定rowHeight(默认 40px)——虚拟化靠固定行高换取零测量、零跳动; 单元格内容更高时必须同步调大它。内容真正不定高时开 dynamicRowHeight, 见动态行高
  2. 读屏的作用域变窄:DOM 里只有那几十行,读屏播报的行数以窗口为准; 浏览器的页内查找、全选同样只够得着可见的那些行。
  3. 因此几百行以内不建议开——不开时整张表都在 DOM 里,而几百行根本不构成 性能问题。

虚拟化用的是 TanStack Virtual,和 InfiniteSelect 同一个引擎;行不改绝对定位,靠上下两条 filler <tr> 撑起滚动高度,所以原生 <table> 的 sticky 表头、列固定和边框全部照常。

给列设置 width 可以避免滚动时列宽随窗口内容轻微抖动(自动布局的表格列宽 由可见内容决定)。

虚拟化与无限滚动可以同时开,而且页数无上限时应该同时开——见无限滚动 一节的说明。

动态行高

非虚拟模式下行高本来就由内容决定,什么都不用做。虚拟化默认用固定行高换 零测量、零跳动;内容不定高(多行文本、标签组)时开 dynamicRowHeightrowHeight 降级为初始估算,行渲染后按实际高度测量(ResizeObserver)并自动 校正布局。单元格默认不换行,需要换行的列自己加 className: 'whitespace-normal'。 下面的 demo 一次载入 10000 行,简介列长短不一:

代价:滚动中实测值替换估算值时滚动条会有轻微校正跳动,且每行多一份测量 开销——行高均匀时用固定行高,把 dynamicRowHeight 留给真正不定高的表。

状态与 className

这个家族底下没有 Base UI 的 state:className 处处是普通字符串,属性是封装层 手动挂上去的。外层卡片回显三个列表状态,行与表头回显各自的语义:

属性挂在效果
data-loading外层卡片isLoading,磨砂覆盖层同时出现
data-empty外层卡片没有行(且不在加载 / 错误中),DataTableEmpty 同时出现
data-columns-empty外层卡片没有列(全被藏掉),DataTableColumnsEmpty 同时出现
data-error外层卡片isErrorDataTableError 同时出现
data-selected<tr>该行在选择集里,背景为 muted
aria-sort可排序列的 <th>当前排序方向(ascending / descending / none

行的选中态不是 data-state="selected"。那是 Radix 的词汇,本库不写 data-state;行上是 data-selected,和 Base UI、库里其余部分一个口径。 从 shadcn 的表格样式抄类名过来时会踩这一脚。

封装层自渲染的部件都带 data-slot,需要从外部定位时当选择器用:

data-slot是什么
data-table外层卡片容器(className 落在它身上)
data-table-grid真正的 <table>
data-table-row<tr>
data-table-sort-button表头里的排序按钮
data-table-status空 / 错误共用的 role="status" 容器
data-table-retryDataTableRetry 的按钮
data-table-load-more滚动流末尾的加载行
data-table-spacer虚拟化的上下填充行

键盘交互

表格是原生 <table>,键盘走的是 Tab 遍历里面的可聚焦控件:

按键行为
Tab依次落到排序按钮、行复选框、可激活的行本身(有 onRowAction、或行点击即选中时),以及单元格里你自己放的按钮/链接
Space / Enter激活当前聚焦的那个控件

0.2(React Aria 底座)这里有方向键在单元格间移动的网格导航和 role="grid"。 换到原生 <table> 之后没有了 —— 这是这次迁移里唯一一处明确的无障碍退步, 换来的是和 shadcn / Radix 生态一致的表格语义。

导出的类型

类型说明
DataTableColumn<T>列定义:{ id, header, cell, rowHeader?, sortable?, hideable?, pinned?, width?, minWidth?, maxWidth?, className? }
DataTableColumnWidth列宽:px 数字或百分比字符串(如 '50%'
DataTableColumnOption列选择器吃的那一小片:{ id, header, hideable? }
DataTableColumnsSelectProps列选择器根的完整 props
DataTableColumnsSelectListProps / ItemProps / GripProps三个组合部件各自的 props
DataTableColumnsSelectChangeEventReason / ChangeEventDetailsonValueChange / onOrderChange 的第二参:'item-press' | 'drag' | 'none',可 cancel()
DataTableColumnsSelectOrderEventDetailsonOrderCommitted 的第二参 —— 通知语义,无 cancel()
DataTableChangeEventReason / DataTableChangeEventDetails行选择 onValueChange / onSelectionChange 的第二参:'item-press' | 'select-all-press' | 'none',可 cancel()
DataTableSortEventDetailsonSortChange 的第二参,reason 恒为 'sort-press' —— 通知语义,无 cancel()sortDescriptor 完全受控,没有内部状态可跳过
DataTableProps<T>DataTable 完整 props,业务层包一层时直接复用
DataTableSelectionProps<T>便利层选择的判别联合(单选 value?: string | nullnull 是受控空值、undefined 表示非受控 / 多选 value?: string[]onValueChange 签名随之切换)
DataPaginationProps / DataPaginationState分页器 props 与 summary / pageIndicator 收到的状态
SortDescriptor / Selection / Key本库自己的集合词汇,排序与选择回调的类型

Props

DataTable

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

Prop类型默认值说明
aria-labelstring表格的可访问名(必填)
columnsDataTableColumn<T>[]列定义
itemsT[]行数据
getRowId(item: T) => stringitem.id行 key 提取
isLoading / isError / onRetryisLoading 统一渲染内建磨砂覆盖层(首屏给最低高度);isError / onRetry 驱动错误插槽,见插槽
hasNextPage / isFetchingNextPage / onLoadMore无限滚动,见无限滚动
loadMoreScrollOffsetnumber1预取距离:提前多少个视口高度触发 onLoadMore
selectionMode'none' | 'single' | 'multiple''none'行选择模式
value / defaultValue / onValueChangestring | string[]便利层选择(跨页存档),签名随 selectionMode 切换,见行选择与行动作
selectedKeys / onSelectionChange原始选择透传。便利层激活时前者被忽略;onSelectionChange 仍触发,仅作观察 —— 但它的 eventDetails.cancel() 会连便利层的写入一起挡下
selectionColumnbooleanfalse合成复选框列(行勾选 + 表头全选),见行选择与行动作
selectAllLabel / selectRowLabelstring'Select all rows' / 'Select row'复选框的无障碍名,英文兜底,业务层传译文
sortDescriptor / onSortChangeSortDescriptor排序意图(受控)
onRowAction(item: T) => void行激活,回调收行对象
maxHeightnumber480行区高度上限(不是高度)。见表头固定;传 Infinity 取消上限
scrollbars'always' | 'hover' | 'hidden''hover'滚动条可见性:常显 / hover 与滚动时显示 / 常隐
virtualizedbooleanfalseTanStack Virtual 行虚拟化,大数据集用
rowHeightnumber40虚拟化行的固定高度;dynamicRowHeight 下为初始估算
dynamicRowHeightbooleanfalse虚拟化行按内容实测高度(估算 + 校正),见动态行高
classNamestring外层卡片容器的附加类(落在普通 div,不支持函数形式)
childrenReactNode状态插槽通道

DataTableLoadingOverlay

定制那层磨砂。标记部件:写在插槽通道里但原地不渲染,由卡片提升到覆盖层的位置 渲染,所以只在直接子级或 Fragment 里有效,包进自定义组件就找不到了。行为见 加载中

Prop类型默认值说明
childrenReactNode居中的 Spinner替换覆盖层内容(品牌动画、文案都行)
classNamestring调磨砂浓度等
其余LoadingOverlayProps 去掉 loading透传。loading 是基座的接线,不在它的 props 里
<DataTable {/* … */}>
  <DataTableLoadingOverlay className="backdrop-blur-sm">
    正在同步…
  </DataTableLoadingOverlay>
</DataTable>

DataTableLoadingMore

翻下一页时滚动流末尾那一行。同样是标记部件,原地不渲染、由基座提升到流末尾。 它不是开关:不写也会渲染一个 Spinner,写了只是替换内容。

Prop类型默认值说明
childrenReactNodeSpinner替换那一行的内容
其余ComponentProps<'tr'>透传。是 'tr' 不是 'div' —— 它落在 <tbody> 里,那儿只能放行
<DataTable {/* … */}>
  <DataTableLoadingMore>加载更多…</DataTableLoadingMore>
</DataTable>

DataTableEmpty / DataTableError / DataTableRetry / DataTableStatus

这些状态插槽都是纯 DOM 部件,除了各自的出现时机没有别的契约:className 是字符串, 其余 props 透传到宿主元素。行为见插槽

部件props什么时候渲染
DataTableEmptyComponentProps<'div'>items 为空,且不在加载 / 错误中(列全被藏掉时让位给下面那个)
DataTableColumnsEmptyComponentProps<'div'>columns 为空(列全被藏掉),见一列不剩时
DataTableErrorComponentProps<'div'>isError
DataTableRetryButtonPropsisError 且给了 onRetry(它自己接好,没有就不渲染)
DataTableStatusComponentProps<'div'>上面几个最终都渲染进这个 role="status" 容器;单独导出供你复用排版

DataTableColumnsSelect

Prop类型默认说明
columnsDataTableColumnOption[]全部列,含已隐藏的 —— 这是选择器的菜单,不是表格当前渲染的那些。DataTableColumn 天然满足
valuestring[]受控:可见列的 id
defaultValuestring[]全部列非受控初值
onValueChange(ids, eventDetails) => void显隐变化。eventDetails.cancel() 拒绝这次变更;reason'item-press' / 'none'
onOrderChange(ids, eventDetails) => void拖动每越过一项响一次,可 cancel()reason'drag'
onOrderCommitted(ids, eventDetails) => void拖动落定后响一次 —— 驱动表格的是这个。通知语义,无 cancel()
aria-labelstring'Columns'默认组合触发器的可访问名。英文默认是家法,翻译归业务层;写了 children 就没用了
placeholderstring默认组合里一列不剩时触发器显示什么;写了 children 就没用了
classNamestring落在默认组合的触发器上;写了 children 就没用了
childrenReactNode组合通道:写了它,默认组合整个让位,见组合形态

行为上只有三条:它自己不藏列、也不排序(两者都是调用方对 columns 的操作); hideable: false 的列既置灰、也永远在 onValueChange 的结果里;把手只在 给了两个重排回调之一时出现。见列选择器

组合部件

部件props说明
DataTableColumnsSelectListchildren: (column) => ReactNodeclassNamechildren 必须是函数——列表拥有草稿顺序的映射,见组合形态
DataTableColumnsSelectItemcolumnaria-labelclassNamechildrenchildren 默认 column.headeraria-label 覆盖 option 的可访问名,装饰内容不再进播报
DataTableColumnsSelectGripclassNamechildrenchildren 换默认的抓握图标;根上没有重排回调时渲染 null

DataPagination 的 props 见它自己的页面