文档/指南

可排序列表

拖拽时重新排序的列表、标签栏和网格——makeSortableList 渲染草稿,makeSortableItem 创建行,放置时新顺序写入你的 ref。另有手柄、插入线和交换。

编辑此页

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

Live

正在加载演示…

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

// Front to back, as a design tool lists them.
const layers = ref([
  { id: 'title', name: 'Title', kind: 'text' as const, visible: true, locked: false },
  { id: 'badge', name: 'Badge', kind: 'shape' as const, visible: true, locked: false },
  { id: 'photo', name: 'Photo', kind: 'image' as const, visible: true, locked: false },
  { id: 'card', name: 'Card', kind: 'shape' as const, visible: true, locked: true },
]);
</script>

<template>
  <DnDProvider>
    <div class="editor">
      <!-- The artboard reads `layers`: it restacks when a drop writes the new order. -->
      <div class="artboard" aria-hidden="true">
        <template v-for="(layer, index) in layers" :key="layer.id">
          <div v-show="layer.visible" class="shape" :class="layer.id" :style="{ zIndex: layers.length - index }">
            <template v-if="layer.id === 'title'">Summer sale</template>
            <template v-if="layer.id === 'badge'">-30%</template>
          </div>
        </template>
      </div>
      <LayersPanel v-model="layers" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.editor {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 24px;
}

.artboard {
  position: relative;
  overflow: hidden;
  width: 240px;
  height: 170px;
  border-radius: 4px;
  background: #fff;
  box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5);
}

.shape {
  position: absolute;
}

.card {
  inset: 18px;
  border-radius: 12px;
  background: #1e3a8a;
}

.photo {
  top: 34px;
  left: 66px;
  width: 120px;
  height: 84px;
  border-radius: 8px;
  background:
    radial-gradient(circle at 75% 30%, #fde68a 0 12px, transparent 13px),
    linear-gradient(170deg, #67e8f9, #0e7490);
}

.badge {
  top: 20px;
  left: 160px;
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: #facc15;
  color: #1e3a8a;
  font: 800 13px/1 system-ui, sans-serif;
}

.title {
  top: 104px;
  left: 30px;
  color: #fff;
  font: 800 24px/1 system-ui, sans-serif;
  letter-spacing: -0.02em;
  text-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
}
</style>

列表和它的行

可排序列表需要两个组件:列表,以及它的行。

LayersPanel.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import LayerRow from './LayerRow.vue';

const layers = defineModel<Layer[]>({ required: true });
const list = useTemplateRef<HTMLElement>('list');
const { items } = makeSortableList(list, { items: layers });
</script>

<template>
  <ul ref="list">
    <LayerRow v-for="layer in items" :key="layer.id" :layer="layer" />
  </ul>
</template>
LayerRow.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';

const props = defineProps<{ layer: Layer }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.layer });
</script>

<template>
  <li ref="el">{{ layer.name }}</li>
</template>

行会注册到渲染它的那个列表中。一个组件可以创建多个列表(两列、列表中的列表):每一行加入元素包含它的那个列表,列表嵌套时加入最内层的列表。

草稿

makeSortableList 返回 items,它是你的数组的一份草稿。拖拽时,草稿实时重新排序:被拖拽的行占据新的位置,其他行滑开让路。在放置之前,你的数组不会改变。放置时,库把新顺序写入其中。

上面的图层面板同时展示了两者:面板渲染 items,拖拽时随之移动;画板读取 layers,也就是数组本身,只有在你松开时才重新叠放。

  • 渲染 items,而不是你的数组。如果渲染你的数组,放置之前什么都不会动。
  • 写入发生在列表的 onDrop 之后。在 onDrop 中,你的数组仍是旧顺序,e.result 是新顺序。
  • layers 是普通的 ref(这里是 defineModel,写入时会触发 update:modelValue),所以应用的其他部分会像看到任何其他变化一样看到它。
makeSortableList(list, {
  items: layers,
  onDrop: (e) => {
    console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
  },
});
使用 store、服务端状态或不可变数据?传入 getter 或 computed,而不是 ref。库不会写入只读的数据源:调用 e.preventDefault(),然后自己应用 e.result。见受控列表。

Key

行按它们的数据作为 key,从不按索引或自动生成的 id。默认 key 是 item.id,对于字符串和数字则是项本身。

// LayersPanel.vue: your items have another key
makeSortableList(list, { items: layers, getKey: (layer) => layer.uuid });

// LayerRow.vue: `item` is keyed with the list's getKey…
makeSortableItem(el, { item: () => props.layer });
// …or pass the key yourself
makeSortableItem(el, { id: () => props.layer.uuid });
  • 在 v-for 中使用相同的 key::key="layer.uuid"。使用 :key="index" 时,会有错误的行产生动画,行的状态也会在行之间跳动。
  • key 在所有相互交换项的列表中必须唯一。
  • 以 getter 形式传入 item 和 id,这样行的 props 变化时它也能保持同步。

手柄

默认情况下,整行都能开始拖拽,但其中的按钮、输入框和其他控件除外:图层的眼睛和锁仍是普通按钮。传入 handle,就只能通过某一部分拖拽:

LayerRow.vue
<script setup lang="ts">
makeSortableItem(el, { item: () => props.layer, handle: '.grip' });
</script>

<template>
  <li ref="el">
    <button type="button" class="grip" :aria-label="`Move ${layer.name}`">⠿</button>
    {{ layer.name }}
  </li>
</template>
  • handle 是项内部的一个选择器,或一个元素 ref。
  • 鼠标拖拽从手柄立即开始,无需通常的 4 px。按下后不移动仍然是点击:手柄可以在 click 时打开菜单。
  • 手柄获得拖拽的属性(role、tabindex、aria-*),键盘拖拽也从它开始。

把手柄做成带 aria-label 的 <button type="button">:它本身就可以获得焦点,屏幕阅读器会播报它的标签。只带图标的 <span> 也能用(库会给它加上 tabindex="0" 和 role="button"),但它没有有意义的名称。

触摸拖拽在 200 ms 长按之后才开始,这样页面仍能滚动。要从手柄立即开始拖拽,给手柄加上 touch-action: none,并给项设置 activation: { touch: { distance: 4 } }。

策略

strategy 说明列表的布局方式,这样各行能以正确的方式让出空间:

strategy适用于
'vertical'(默认)一列行。
'horizontal'单独一行:标签页、标签、时间线。
'grid'CSS 网格和换行的 flex 行,项的尺寸也可以各不相同。

一排标签页就是一个横向列表:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <Browser />
  </DnDProvider>
</template>
Browser.vue
const { items } = makeSortableList(strip, {
  items: tabs,
  strategy: 'horizontal',
  axis: 'x',        // the tabs stay on their row
  animateChanges: true, // the buttons open and close tabs: slide the others then too
});

标签页的关闭按钮在可拖拽的标签页内部照常工作,结束拖拽的那次点击不会选中标签页。拖拽时各行会自己滑动;animateChanges 为非拖拽引起的变化添加同样的效果,这里就是按钮引起的变化(见动画)。

网格在两个维度上排序,图块可以是任意尺寸:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <PhotoGrid />
  </DnDProvider>
</template>
PhotoGrid.vue
const { items } = makeSortableList(grid, { items: photos, strategy: 'grid' });
.grid { display: grid; grid-template-columns: repeat(4, 76px); grid-auto-rows: 76px; grid-auto-flow: dense; }
.wide { grid-column: span 2; }
.tall { grid-row: span 2; }

grid-auto-flow: dense 让大图块移动时网格保持紧凑。没有它,网格会在放不下图块的地方留下空洞,并多出一行。

锁定轴

makeSortableList(list, { items: layers, axis: 'y' });

axis: 'y' 让拖拽保持在竖直轴上,'x' 则保持在水平轴上:上面的图层和标签页就用了它。项自己的 axis 优先于它所在列表的。要同时把拖拽限制在列表内,加上 restrictToParent 修饰器。更多内容见约束。

为空位设置样式

被拖拽的行留在列表中,作为它将落入的位置,并获得 data-dnd-placeholder。默认样式把它淡化到 40%,优先级为零,所以你的任何规则都会胜出:

LayerRow.vue
.layer[data-dnd-placeholder] {
  opacity: 1;
  box-shadow: inset 0 0 0 1.5px #42b883;
}

.layer[data-dnd-placeholder] > * {
  visibility: hidden;   /* an empty slot of the row's exact size */
}

makeSortableItem 以 ref 的形式返回同样的状态,外加几个:

返回值是什么
isPlaceholder这一行是被拖拽项将落入的位置。
isDragging这一行正被拖拽(多项拖拽时,每个被拖拽的行都是)。
index它在草稿中的索引:拖拽时会变化。
selected可写,绑定到 provider 的选中集合。
indicator在指示器模式下,插入线的位置。

列表返回 isOver 和 isAccepting 用于自身的高亮,还返回 activeKey 和 activeIndex,即被拖拽的项及其在本列表草稿中的位置。所有状态及其实时演示见样式。

关闭功能

选项阻止什么
项上的 disabled此项不能被拖拽。其他项仍能越过它移动:锁定上面的一个图层,然后把另一个图层拖过它。
列表上的 disabled不能从中拖出,也不能放入其中。
列表上的 sort: false它的项不能在内部重新排序,但仍可以离开它:面板,见共享项的列表。

它们都接受 getter 或 ref,并在下一次拖拽时生效:

LayerRow.vue
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });

无论其他行怎样,都必须固定在最前或最后的行根本不是项:把它渲染在列表之外。面板的 Background 图层就位于 <ul> 之下。

只显示部分项

搜索框、标签:filter 只显示它返回 true 的项,拖拽在这些项之间排序。其余项留在你的数组中,位置不变:

Live

正在加载演示…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Backlog from './Backlog.vue';
import type { Task } from './TaskRow.vue';

// In priority order: the array is the backlog.
const tasks = ref<Task[]>([
  { id: 1, title: 'Login fails on Safari', tag: 'bug' },
  { id: 2, title: 'Dark mode for settings', tag: 'feature' },
  { id: 3, title: 'Document the export API', tag: 'docs' },
  { id: 4, title: 'Crash when the list is empty', tag: 'bug' },
  { id: 5, title: 'Keyboard shortcuts', tag: 'feature' },
  { id: 6, title: 'Typo in the pricing page', tag: 'docs' },
  { id: 7, title: 'Slow search with 10k rows', tag: 'bug' },
  { id: 8, title: 'Share a board by link', tag: 'feature' },
]);
</script>

<template>
  <DnDProvider>
    <Backlog v-model="tasks" />
  </DnDProvider>
</template>
Backlog.vue
const query = ref('');
const tag = ref<Tag | null>(null);

const { items } = makeSortableList(list, {
  items: tasks,
  filter: (task) => (!tag.value || task.tag === tag.value) && task.title.includes(query.value),
});
  • items 包含匹配的项:照常渲染它。过滤是响应式的:在搜索框中输入会立刻更新列表。
  • 放置会写入整个数组。每个隐藏项紧跟在它原先跟随的那个显示项之后;如果那一项正是被移动的项,它就跟在之前最近的、没有移动的项之后。
  • e.to.index 和列表的 activeIndex 按显示的项计数。e.result 包含整个数组,包括隐藏项。
过滤数组的 computed 作为 items 传入也可以,但它是只读的:你得自己应用 e.result,并算出隐藏项该放在哪里。filter 把这两件事都做了。

插入线

使用 placeholder: 'indicator' 时,拖拽期间列表保持不动,由一条线显示放置的位置。这条线由你来画:

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <IssueTable />
  </DnDProvider>
</template>
IssueTable.vue
const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
IssueRow.vue
<script setup lang="ts">
const { indicator } = makeSortableItem(el, { item: () => props.issue });
</script>

<template>
  <div ref="el" class="row" :class="indicator && `drop-${indicator.side}`">…</div>
</template>

<style scoped>
.row.drop-before::before { content: ''; position: absolute; top: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
.row.drop-after::before { content: ''; position: absolute; bottom: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
</style>
indicator 字段内容
list放置进入的列表。
index在渲染出的列表中的插入索引。
key插入线所依附的项;在空列表中为 null(此时在列表上画线)。
side在该项的 'before' 或 'after';在交换的列表中为 'swap'。树还会加上 'inside'。
depth、parentKey仅限树:放置落到哪里。见树。
  • 插入线位于某个列表中时,该列表的 indicator 有值(表格会把它打印出来);插入线依附于某个项时,该项的 indicator 有值。
  • 被拖拽的行留在原处,被标记为占位:把它淡化。
  • 键盘也会移动插入线。
  • 在此模式下,放到所有目标之外会取消(dropOutside: 'cancel'),因为列表从未显示过别的顺序。
  • 浮层会遮住它下方那一行的一部分。让它稍微透明一点,插入线就能透出来:[data-dnd-overlay] .row { opacity: 0.85 }。

交换而非平移

使用 behavior: 'swap' 时,被拖拽的项与它下方的项交换位置,其他项保持不动:

Live

正在加载演示…

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

const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const columns = reactive([
  {
    id: 'primary',
    title: 'Primary',
    people: [
      { id: 'ava', name: 'Ava', hue: 160 },
      { id: 'ben', name: 'Ben', hue: 20 },
      { id: 'cleo', name: 'Cleo', hue: 280 },
      { id: 'dev', name: 'Dev', hue: 210 },
      { id: 'eli', name: 'Eli', hue: 45 },
    ],
  },
  {
    id: 'backup',
    title: 'Backup',
    people: [
      { id: 'fay', name: 'Fay', hue: 330 },
      { id: 'gus', name: 'Gus', hue: 100 },
      { id: 'hana', name: 'Hana', hue: 190 },
      { id: 'ivo', name: 'Ivo', hue: 0 },
      { id: 'jun', name: 'Jun', hue: 250 },
    ],
  },
]);

/** Live: the swap shows while you drag. On drop: nothing moves, the partner is marked. */
const placeholder = ref<'item' | 'indicator'>('item');
const last = ref('');
</script>

<template>
  <DnDProvider>
    <div class="schedule">
      <div class="modes" role="group" aria-label="Swap preview">
        <button type="button" :aria-pressed="placeholder === 'item'" @click="placeholder = 'item'">Live</button>
        <button type="button" :aria-pressed="placeholder === 'indicator'" @click="placeholder = 'indicator'">On drop</button>
      </div>
      <div class="week">
        <ol class="days">
          <li v-for="day in DAYS" :key="day">{{ day }}</li>
        </ol>
        <Rotation v-for="column in columns" :key="column.id" :column="column" :placeholder="placeholder" @swap="last = $event" />
      </div>
      <p class="last">{{ last ? `Swapped ${last}` : 'Drag a person onto another' }}</p>
    </div>
  </DnDProvider>
</template>

<style scoped>
.schedule {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.modes {
  display: flex;
  padding: 3px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes button {
  padding: 6px 12px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  opacity: 0.6;
  cursor: pointer;
}

.modes button[aria-pressed='true'] {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  opacity: 1;
}

.week {
  display: flex;
  gap: 10px;
  width: 400px;
  max-width: 100%;
}

.days {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 21px 0 0;
  padding: 0;
  list-style: none;
}

.days li {
  height: 36px;
  font: 11px/36px ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.last {
  margin: 0;
  font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}
</style>
Rotation.vue
const { items } = makeSortableList(list, {
  id: props.column.id,
  items: toRef(props.column, 'people'),
  group: 'rotation',
  behavior: 'swap',
  placeholder: () => props.placeholder,   // 'item' (live) or 'indicator' (on drop)
  onDrop: (e) => {
    if (e.partner) emit('swap', `${e.items[0]?.name} ⇄ ${e.partner.item.name}`);
  },
});
  • 实时(placeholder: 'item',默认):拖拽时就显示交换。移回去或移出列表会撤销它。
  • 放置时(placeholder: 'indicator'):放置之前什么都不动。指示器标记放置时要交换的那个项,side: 'swap':给该项加上轮廓,而不是画一条线。方向键在项之间移动这个标记;回到被拖拽的项上时,标记消失。
PersonCell.vue
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>

放置事件带有 mode: 'swap' 和 partner,即与之交换的那个项:它的 key、item,以及它自己的 from 和 to。同一分组的两个列表都设置了 behavior: 'swap' 时,它们之间可以互相交换,就像这两列一样。此时悬停的项会移到另一个列表中,所以它必须被允许移出(group.pull)和移入(group.put):见共享项的列表。多项拖拽会排序而不是交换。

键盘

让一行或它的手柄获得焦点,按 Space 或 Enter 拿起它。沿列表方向的方向键每次移动一个位置,在网格中上下键会移到下一行,垂直于列表方向的方向键会把它移到同一分组的下一个列表。越过列表末尾后,方向键会继续进入该方向上的下一个列表(上下堆叠的列表),没有列表时则进入那里的放置目标,例如回收区。再按 Space 或 Enter 放下,按 Escape 放回原处。本页的每个演示都是这样工作的。屏幕阅读器的文本见无障碍。

动画

拖拽时各行滑到新位置,放下的行落到它的位置上。行进入和离开时,列表本身会变大或缩小到新的尺寸(animateSize)。这是默认开启的,不需要任何代码。不要用 <TransitionGroup> 包裹行:移动会被动画两次。也不要给行的 transform 加 CSS transition。对于非拖拽引起的变化(添加、删除、打乱),开启列表的 animateChanges 选项,就像标签页那样。见动画。

完整的选项列表见 makeSortableList 和 makeSortableItem API。