正在加载演示…
<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 属性,行的其余部分仍可自由用于点击和选择文本。
const grip = useTemplateRef<HTMLElement>('grip');
makeSortableItem(() => grip.value?.closest('tr') ?? null, {
id: () => props.task.id,
handle: grip,
label: () => props.task.title,
});
<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)。
<DnDProvider overlay-class="overflow-hidden rounded-md bg-default shadow-xl ring ring-accented">
<TaskTable />
</DnDProvider>
虚拟化的行
virtualize 给表格加上 TanStack Virtual:渲染屏幕上的行以及它们周围另外 8 行(overscan),位于两个间隔行之间。
<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让它保持挂载(参见虚拟列表)。表格把行布置在间隔行之间,在那里一个远离其他行渲染的行会把它们挤开,所以这个表格让它卸载。
先搜索,再拖动
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 包含整个数组。
- 行。渲染整行的插槽(Vuetify 的
#item、Quasar 的#body)会给你行的元素:把它作为项。如果只能拿到单元格(PrimeVueColumn的#body、Element Plus 列的默认插槽、Naive UI 的render),就在其中一个单元格里放一个抓手,把它的closest('tr')作为项,就像这里一样。 - 列表。表格根元素内部的
<tbody>:表格上的模板引用给你组件,它的$el给你元素。 - key 和数据。按你的 id 给行设置 key(
item-value、row-key、dataKey),并把列表的草稿作为表格的数据。 - 关闭它自己的排序。不要启用表格自带的行重新排序和列排序:顺序由列表决定。
- 虚拟表格把行放在间隔元素之间,或放在绝对定位的盒子里:两种都可以,而且拖拽在它的行被卸载后依然继续。
@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 库 · 无障碍