正在加载演示…
<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>列表和它的行
可排序列表需要两个组件:列表,以及它的行。
<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>
<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));
},
});
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,就只能通过某一部分拖拽:
<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"),但它没有有意义的名称。
touch-action: none,并给项设置 activation: { touch: { distance: 4 } }。策略
strategy 说明列表的布局方式,这样各行能以正确的方式让出空间:
strategy | 适用于 |
|---|---|
'vertical'(默认) | 一列行。 |
'horizontal' | 单独一行:标签页、标签、时间线。 |
'grid' | CSS 网格和换行的 flex 行,项的尺寸也可以各不相同。 |
一排标签页就是一个横向列表:
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Browser from './Browser.vue';
</script>
<template>
<DnDProvider>
<Browser />
</DnDProvider>
</template>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 为非拖拽引起的变化添加同样的效果,这里就是按钮引起的变化(见动画)。
网格在两个维度上排序,图块可以是任意尺寸:
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import PhotoGrid from './PhotoGrid.vue';
</script>
<template>
<DnDProvider>
<PhotoGrid />
</DnDProvider>
</template>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%,优先级为零,所以你的任何规则都会胜出:
.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,并在下一次拖拽时生效:
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });
无论其他行怎样,都必须固定在最前或最后的行根本不是项:把它渲染在列表之外。面板的 Background 图层就位于 <ul> 之下。
只显示部分项
搜索框、标签:filter 只显示它返回 true 的项,拖拽在这些项之间排序。其余项留在你的数组中,位置不变:
正在加载演示…
<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>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' 时,拖拽期间列表保持不动,由一条线显示放置的位置。这条线由你来画:
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import IssueTable from './IssueTable.vue';
</script>
<template>
<DnDProvider>
<IssueTable />
</DnDProvider>
</template>const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
<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' 时,被拖拽的项与它下方的项交换位置,其他项保持不动:
正在加载演示…
<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>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':给该项加上轮廓,而不是画一条线。方向键在项之间移动这个标记;回到被拖拽的项上时,标记消失。
<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。