文档/概览

数据表格

一个包含 2,000 个任务的 Nuxt UI 表格,虚拟化、可搜索,通过抓手拖动行来排序。

编辑此页

本页由 AI 翻译,可能存在不准确之处。 阅读英文原文

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import TaskTable from './TaskTable.vue';
</script>

<template>
  <!-- The row in hand: a table row can't have a shadow, so its overlay content has it. -->
  <DnDProvider overlay-class="overflow-hidden rounded-md bg-default shadow-xl ring ring-accented">
    <TaskTable />
  </DnDProvider>
</template>

Nuxt UI 的 UTable 渲染表格、它的行和它的虚拟化器:只有屏幕上的行在 DOM 中。Vue DnD Kit 负责给它们排序。表格就是这个 UI 库原样提供的表格,带着它的插槽和 ui 类;没有任何部分被重写。

看点

  • TaskTable.vue:可排序列表是表格的 <tbody>,通过 UTable 暴露的 tableRef 找到。表格把列表的草稿作为它的 data,所以拖拽时行会移动,get-row-id 按任务给行设置 key,与列表一致。axis: 'y' 让拖拽留在它的列中,restrictToFirstScrollableAncestor 让它留在表格内部:表格的滚动容器是行外面第一个可滚动的元素。展开演示:表格会占满整个屏幕。
  • RowGrip.vue:行由 UTable 渲染,所以没有一个行是你自己的组件。第一个单元格中的抓手(#grip-cell 插槽)把它外面的行变成项,它本身就是这一项的手柄。
  • 搜索就是列表的 filter:表格显示匹配的任务,放置时把整个数组写回,隐藏的任务留在原处。
  • tasks.ts:放在 shallowRef 中的 2,000 个任务:库从不查看它们的内部。

由表格渲染的行

抓手调用 makeSortableItem 时传入一个返回行的 getter:也就是它外面的 <tr>(在它渲染出来之后)。作为手柄,抓手获得焦点和 ARIA 属性,行的其余部分仍可自由用于点击和选择文本。

RowGrip.vue
const grip = useTemplateRef<HTMLElement>('grip');
makeSortableItem(() => grip.value?.closest('tr') ?? null, {
  id: () => props.task.id,
  handle: grip,
  label: () => props.task.title,
});
TaskTable.vue
<UTable ref="table" :data="items" :get-row-id="(task: Task) => task.id" …>
  <template #grip-cell="{ row }">
    <RowGrip :task="row.original" />
  </template>
</UTable>

浮层是库对这一行的复制,放在一个单独的表格里,单元格宽度保持不变。表格行不绘制阴影,所以由 provider 的 overlay-class 给浮层的内容加上阴影,以及表格的背景和一圈边框(ring)。行移动期间,行的 ui 类给它的位置着色(data-[dnd-placeholder]:bg-primary/5)。

index.vue
<DnDProvider overlay-class="overflow-hidden rounded-md bg-default shadow-xl ring ring-accented">
  <TaskTable />
</DnDProvider>

虚拟化的行

virtualize 给表格加上 TanStack Virtual:渲染屏幕上的行以及它们周围另外 8 行(overscan),位于两个间隔行之间。

TaskTable.vue
<UTable
  :data="items"
  :watch-options="{ deep: false }"
  :virtualize="{ estimateSize: 45, overscan: 8, onChange: () => captureLayout() }"
  class="min-h-0 flex-1"
/>
  • estimateSize 是初始估计:虚拟化器会在每一行渲染时测量它。一次测量会移动它下面的行,onChange 调用列表的 captureLayout(),这样在拖拽期间库会为这次移动添加动画,而不是让它跳过去。
  • 表格就是滚动容器,手中的行留在它内部:靠近它的上边缘或下边缘时,拖拽会让它滚动,新的行在进入视口时渲染。
  • 每次移动后草稿都是一个新数组,所以表格不需要监听它的内部(deep: false)。
  • 滚动到离你拿着的行很远的地方,它就会离开渲染范围并被卸载。拖拽继续进行:浮层仍然显示它,它会落到草稿给它安排的位置。你自己的虚拟列表可以用 rangeExtractor 让它保持挂载(参见虚拟列表)。表格把行布置在间隔行之间,在那里一个远离其他行渲染的行会把它们挤开,所以这个表格让它卸载。

先搜索,再拖动

TaskTable.vue
const { items, captureLayout } = makeSortableList(() => table.value?.tableRef?.tBodies[0] ?? null, {
  items: tasks,
  filter: (task) => {
    const text = query.value.trim().toLowerCase();
    return !text || `${task.id} ${task.title} ${task.assignee}`.toLowerCase().includes(text);
  },
  axis: 'y',
  modifiers: [restrictToFirstScrollableAncestor],
  label: 'Tasks',
});

搜索“Mia”,把她的一个任务移到另一个任务上方,然后清空搜索:她的任务按新的顺序排列,其他每个任务都还在原处。放置事件的 e.to.index 按显示出来的行计数;e.result 包含整个数组。

其他 UI 库。Nuxt UI 渲染普通元素,并把你的类和属性传递下去,它所基于的 Reka UI 原语也是如此:只需要一个 ref、一个插槽和一个类。在其他库中,表格同样自己渲染行,同样的方法也适用:
  • 行。渲染整行的插槽(Vuetify 的 #item、Quasar 的 #body)会给你行的元素:把它作为项。如果只能拿到单元格(PrimeVue Column 的 #body、Element Plus 列的默认插槽、Naive UI 的 render),就在其中一个单元格里放一个抓手,把它的 closest('tr') 作为项,就像这里一样。
  • 列表。表格根元素内部的 <tbody>:表格上的模板引用给你组件,它的 $el 给你元素。
  • key 和数据。按你的 id 给行设置 key(item-value、row-key、dataKey),并把列表的草稿作为表格的数据。
  • 关闭它自己的排序。不要启用表格自带的行重新排序和列排序:顺序由列表决定。
  • 虚拟表格把行放在间隔元素之间,或放在绝对定位的盒子里:两种都可以,而且拖拽在它的行被卸载后依然继续。
StackBlitz 按钮会在一个按 Nuxt UI 文档中 Vue 用法配置好的 Vite 项目里打开这些文件:@nuxt/ui/vite 插件、Tailwind CSS,以及包裹示例的 UApp。在带有 @nuxt/ui 模块的 Nuxt 应用中,复制文件夹并在某个页面上渲染 index.vue 即可。

键盘和屏幕阅读器

用 Tab 移到一个任务的抓手上,按 Space 拿起这一行。:kbd{value="ArrowUp"} 和 ArrowDown 移动它,:kbd{value="Space"} 放下它,:kbd{value="Escape"} 把它放回原处。行有 label,列表也有:屏幕阅读器听到的是“Fix the login form dropped at position 3 of 2000 in Tasks.”,而不是 key。

延伸阅读:虚拟列表 · 只显示部分项 · 样式:Tailwind 和 UI 库 · 无障碍