把一张卡片拖进 Done。放置会等待你的回答:移动卡片、继续把它拖到别处,或取消。
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Board from './Board.vue';
</script>
<template>
<DnDProvider>
<Board />
</DnDProvider>
</template>onBeforeDrop
makeSortableList(listRef, {
items: tasks,
onBeforeDrop: (e) => (e.items.some((task) => task.locked) ? 'reject' : 'accept'),
});
列表、树、放置目标(makeDroppable)和监听器(useDnDMonitor)都接受 onBeforeDrop。它在拖拽被松开时、任何东西改变之前运行,它的返回值决定结果:
| 返回值 | 结果 |
|---|---|
'accept'、true 或不返回 | 放置完成。 |
'reject' 或 false | 不放置:项飞回原处。抛出错误也会拒绝。 |
| key 数组 | 只有这些项被放置;其余的飞回原处。 |
'continue' | 不放置,拖拽继续。 |
| 以上任意值的 promise | 浮层在松开的地方等待,直到 promise 完成。 |
列表和树传入 SortableDropEvent:keys、items、from、to、mode、sameList 和 result,即这次放置所改变的每个列表的新数组。放置目标和监听器收到通用的 DnDEvent:active、over、position 和 source。见事件。
拖拽中的规则,或松开时的规则
Email 和 Chat 都只接收不超过 10 MB 的附件。把 demo.mov 分别拖到两者上方:Email 在悬停时就拒绝它,Chat 接收它,但在你松开时把它送回去。
正在加载演示…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'desktop',
title: 'Desktop',
files: [
{ id: 'notes', name: 'notes.pdf', size: 2 },
{ id: 'demo', name: 'demo.mov', size: 48 },
{ id: 'logo', name: 'logo.png', size: 1 },
{ id: 'backup', name: 'backup.zip', size: 120 },
],
},
// Both take files of 10 MB at most, and say so at different times.
{ id: 'email', title: 'Email', rule: 'put' as const, files: [] },
{ id: 'chat', title: 'Chat', rule: 'onBeforeDrop' as const, files: [] },
]);
</script>
<template>
<DnDProvider>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</DnDProvider>
</template>
<style scoped>
.board {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.board > * {
flex: 0 1 170px;
min-width: 150px;
}
</style>// Asked while dragging: a refused drag never enters the list.
group: { name: 'attachments', put: ({ items }) => fits(items as Attachment[]) },
// Asked on release: the drag enters the list, then flies back.
onBeforeDrop: (e) => (fits(e.items) ? 'accept' : 'reject'),
group.put(或目标的 accept) | onBeforeDrop | |
|---|---|---|
| 询问时机 | 拖拽中,拖拽一到达列表就询问。 | 拖拽被松开时,询问一次。 |
| 用户看到 | 列表立即拒绝:不会打开空位,isAccepting 为 false,over.accepted 为 false。 | 空位照常打开;松开时项飞回原处。 |
| 知道什么 | 拖拽的是什么:keys、items、types、from、to。 | 整个放置:to.index、sameList、mode、result。 |
| 能做什么 | 回答是或否。 | 等待 promise、接受部分 key、让拖拽继续。 |
仅凭项本身就能知道的规则(类型、标记、数量)用 group.put:用户在松开之前就能知道结果。取决于位置、用户的回答或服务器回答的规则用 onBeforeDrop。目标自己的规则优先:在拒绝该拖拽的列表或目标上方,放置会被取消,它的 onBeforeDrop 不会被询问。
接受部分项
有些规则只能在松开时向服务器核实。这个工作坊的名额也会被其他人预订:松开时,列表向服务器询问还剩多少名额,然后从拖进来的人中接受相应的人数。勾选几个人,把他们一起拖进去。
正在加载演示…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'waiting',
title: 'Waiting list',
people: [
{ id: 'ada', name: 'Ada', hue: 200 },
{ id: 'bo', name: 'Bo', hue: 30 },
{ id: 'cy', name: 'Cyrus', hue: 280 },
{ id: 'dee', name: 'Dee', hue: 140 },
],
},
{
id: 'workshop',
title: 'Workshop',
seats: true,
people: [
{ id: 'eli', name: 'Eli', hue: 340 },
{ id: 'fay', name: 'Fay', hue: 90 },
],
},
]);
</script>
<template>
<DnDProvider>
<div class="demo">
<p class="tip">Tick a few people, then drag one of them into the workshop</p>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.tip {
margin: 0;
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 230px));
justify-content: center;
gap: 14px;
width: 100%;
}
</style>makeSortableList(list, {
items: toRef(props.column, 'people'),
group: 'workshop',
// Some keys: those land, the others fly back to where they came from.
onBeforeDrop: async (e) => {
const { free } = await seatsLeft(props.column.people.length); // the overlay waits
return e.keys.slice(0, free);
},
onDrop: (e) => {
selection.delete(...e.accepted); // the ones sent back stay selected
notice.value = `${e.accepted.length} booked, ${e.rejected.length} back to the waiting list`;
},
});
在多项拖拽中,你返回的 key 对应的项一起落位,其余的飞回原位。随后 onDrop 在 e.accepted 和 e.rejected 中看到它们,它的 e.result 就是最终结果。空数组表示什么都不接受:放置被拒绝。
继续拖拽:'continue'
'continue' 取消的是放置,而不是拖拽。在上面的对话框中选择 Keep dragging:项从它所在的位置继续留在手中,页面回到拖拽模式,用户可以接着拖。
- 使用鼠标或触控笔时,项在不按住按键的情况下跟随指针。下一次松开——也就是一次点击——会放下它,这次点击永远不会传到下方的页面。
- 使用触摸时,手指落下的地方会重新拿起项;抬起手指就放下它。
- 键盘拖拽继续进行:方向键移动项,:kbd{value="Space"} 或 Enter 放下它。
- Escape 取消它,与任何拖拽一样。
- 下一次放置会再次询问
onBeforeDrop。
监听器会收到 onDragResume,屏幕阅读器会播报项没有被放下、仍在移动(用 provider 的 announcements.onDragResume 覆盖该文本)。
useDnDMonitor({
onDragResume() {
hint.value = 'Still dragging: click to drop, Esc to cancel';
},
});
询问用户
onBeforeDrop 可以返回 promise。打开一个对话框,用用户选择的按钮来完成 promise:
const { items } = makeSortableList(list, {
id: props.column.id,
items: toRef(props.column, 'tasks'),
group: 'release',
onBeforeDrop: (e) => (props.column.confirm && !e.sameList ? props.ask(e) : 'accept'),
});
type Answer = 'accept' | 'continue' | 'reject';
const question = shallowRef<{ title: string; resolve: (answer: Answer) => void } | null>(null);
/** Opens the dialog: `onBeforeDrop` waits for the button the user picks. */
const ask = (e: SortableDropEvent<Task>) =>
new Promise<Answer>((resolve) => {
question.value = { title: e.items[0]!.title, resolve };
});
promise 等待期间:
- 浮层在松开的地方等待,并被标记为
data-dnd-overlay="dropping"; useDnD()保持松开时的状态,status为'dropping';- 页面退出拖拽模式:光标和文本选择恢复,所以对话框照常工作;
- 不能开始其他拖拽。
showModal() 打开的模态 <dialog> 位于顶层,在等待中的浮层之上。像示例那样,把它的 cancel 事件(:kbd{value="Escape"})转换为 'reject'。钩子的顺序
给便签重新排序,把一张扔进垃圾桶,然后切换某个回答再做一次。面板按运行顺序列出上一次放置的钩子。
正在加载演示…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Monitor from './Monitor.vue';
import Notes from './Notes.vue';
import { ANSWERS, type Answer } from './types';
const monitorAnswer = ref<Answer>('accept');
const targetAnswer = ref<Answer>('accept');
// Every hook of the last drop, in the order they ran.
const steps = ref<{ who: string; what: string }[]>([]);
const trace = (who: string, what: string) => steps.value.push({ who, what });
</script>
<template>
<DnDProvider
@drag-start="steps = []"
@drag-end="trace('provider', '@drag-end')"
@drag-cancel="trace('provider', '@drag-cancel')"
@drag-settle="trace('provider', '@drag-settle: landed')"
>
<div class="demo">
<div class="switches">
<fieldset>
<legend>monitor answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: monitorAnswer === value }">
<input v-model="monitorAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<fieldset>
<legend>target answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: targetAnswer === value }">
<input v-model="targetAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
</div>
<div class="layout">
<Notes :answer="targetAnswer" :trace="trace" />
<ol class="steps" aria-label="Hooks of the last drop">
<li v-for="(step, index) in steps" :key="index">
<span class="who">{{ step.who }}</span>{{ step.what }}
</li>
<li v-if="!steps.length" class="empty">Move a note, or drop one in the trash</li>
</ol>
</div>
<Monitor :answer="monitorAnswer" :trace="trace" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.switches {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px 16px;
}
fieldset {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.layout {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
}
.steps {
box-sizing: border-box;
min-width: 220px;
min-height: 150px;
margin: 0;
padding: 10px 14px 10px 32px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.8 ui-monospace, Menlo, Consolas, monospace;
white-space: nowrap;
list-style: decimal;
}
.steps li::marker {
color: color-mix(in srgb, currentColor 45%, transparent);
}
.who {
display: inline-block;
min-width: 60px;
color: #42b883;
}
.steps li.empty {
margin-left: -18px;
list-style: none;
opacity: 0.45;
}
</style>- 依次运行每个
useDnDMonitor的onBeforeDrop。'reject'或'continue'会结束询问;key 会缩小放置的范围。 - 目标的
onBeforeDrop:项进入的列表或树,或放置目标。 - 只运行目标的
onDrop:项离开去往另一个列表时,被离开的列表不会被询问,它的新数组在目标的e.result中。项被移除时(回收区、dropOutside: 'remove'),会运行它们所在列表的onDrop,to.index为-1。 - 默认写入:这次放置改变的每个列表获得它的新数组。
- provider 的
onDragEnd(@drag-end)和监听器的onDragEnd。然后浮层落位,onDragSettle(@drag-settle)告知它已落位。
被拒绝的放置则会直接跳到 onDragCancel(@drag-cancel),项飞回原处。'continue' 也会结束询问,并触发 onDragResume:拖拽继续进行,下一次放置会重新询问。
监听器在目标之前做决定,所以否决永远不会出现在列表已经应用了自己的放置之后。这让监听器成为制定适用于整个应用每次放置的规则的地方:
const { syncing } = useSync();
// Read-only while syncing: every drop under the provider flies back.
useDnDMonitor({
onBeforeDrop: () => (syncing.value ? 'reject' : 'accept'),
});
onDrop:在写入之前通知
makeSortableList(listRef, {
items: tasks,
onDrop: (e) => {
// `tasks` still holds the old order, `e.result` the new one.
console.log(e.keys, e.from, e.to, e.result.get(e.to.list));
},
});
列表的 onDrop 在放置被接受后同步运行,紧接在列表写入之前。要在写入之后执行操作,使用 provider 的 @drag-end 或 useDnDMonitor({ onDragEnd })。在普通放置目标上,onDrop 是你修改数据的地方:库不会替它移动任何东西。
store 与服务器:受控列表
const store = useBoardStore();
const { items } = makeSortableList(listRef, {
id: 'todo',
group: 'board',
items: () => store.lists.todo, // a getter: read-only
onDrop: (e) => {
e.preventDefault();
for (const [listId, next] of e.result) store.setList(listId, next);
},
});
库只把放置写入可写的 ref。getter、computed 或 readonly ref 永远不会被写入:调用 e.preventDefault(),然后自己应用 e.result——它按列表 id 包含这次放置所改变的每个列表的新数组。preventDefault() 会跳过对所有这些列表的写入,因为只写入其中一部分会丢失或重复项。如果只读列表的放置被留给库来处理,开发构建会发出警告。
先保存到服务器
在服务器不稳定(每隔一个请求就失败一次)或宕机时移动一张卡片。请求进行期间,卡片带着一个徽标等待,然后落位,失败时则飞回原处。
正在加载演示…
<script setup lang="ts">
import { DnDProvider, DragPreview } from '@vue-dnd-kit/core';
import Column from './Column.vue';
import { mode, requests, saving, type ServerMode } from './server';
const MODES: ServerMode[] = ['reliable', 'flaky', 'down'];
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>server</legend>
<label v-for="value in MODES" :key="value" :class="{ on: mode === value }">
<input v-model="mode" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<div class="board">
<Column id="backlog" title="Backlog" />
<Column id="sprint" title="Sprint" />
</div>
<ol class="requests" aria-label="Requests">
<li v-for="request in requests" :key="request.id" :class="`status-${request.status}`">
{{ request.text }} <span>{{ request.status === 'pending' ? '…' : request.status }}</span>
</li>
<li v-if="!requests.length" class="empty">No requests yet</li>
</ol>
</div>
<!-- The overlay of every drag: the card's copy, and a badge while the server answers. -->
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.modes label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
.modes label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.board {
display: grid;
grid-template-columns: repeat(2, minmax(0, 220px));
gap: 14px;
justify-content: center;
width: 100%;
}
.requests {
width: min(100%, 454px);
min-height: 60px;
margin: 0;
padding: 8px 12px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.6 ui-monospace, Menlo, Consolas, monospace;
list-style: none;
white-space: pre;
overflow-x: auto;
}
.requests li + li {
opacity: 0.55;
}
.requests .status-200 span {
color: #42b883;
}
.requests .status-503 span {
color: #e5484d;
}
.requests li.empty {
color: inherit;
opacity: 0.45;
}
.carried {
position: relative;
width: 100%;
height: 100%;
}
.saving {
position: absolute;
top: 50%;
right: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
translate: 0 -50%;
padding: 4px 8px;
border-radius: 99px;
background: #42b883;
color: #fff;
font: 600 11px/1 system-ui, sans-serif;
}
.saving::before {
content: '';
width: 9px;
height: 9px;
border: 2px solid rgb(255 255 255 / 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
rotate: 1turn;
}
}
</style>const { items } = makeSortableList(list, {
id: props.id,
// A getter: the library never writes it. The drop is applied below, once saved.
items: () => board[props.id]!,
group: 'sprint',
onBeforeDrop: async (e) => {
e.preventDefault();
try {
await saveMove(e.keys, e.to);
} catch {
return 'reject'; // nothing applied: the card flies back
}
for (const [id, tasks] of e.result) board[id] = tasks; // lands in its new place
return 'accept';
},
});
在 promise 完成之前应用 e.result,浮层就会按新顺序落位。请求失败时,什么都不应用并返回拒绝:项飞回原处,监听器收到 onDragCancel。
徽标来自 provider 的 #overlay 插槽,它渲染每次拖拽的浮层。DragPreview 显示卡片通常的副本,请求进行期间徽标叠在它上面:
<DnDProvider>
<Board />
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
放到所有目标之外
在每种模式下,把一个应用拖出程序坞并松开。
正在加载演示…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type DropOutside } from '@vue-dnd-kit/core';
import Dock from './Dock.vue';
const MODES: DropOutside[] = ['placeholder', 'cancel', 'remove'];
const dropOutside = ref<DropOutside>('remove');
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>dropOutside</legend>
<label v-for="value in MODES" :key="value" :class="{ on: dropOutside === value }">
<input v-model="dropOutside" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<code class="call">makeSortableList(dock, { strategy: 'horizontal', dropOutside: '{{ dropOutside }}' })</code>
<Dock :drop-outside="dropOutside" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.modes label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
.modes label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.modes input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.call {
max-width: 100%;
padding: 6px 10px;
overflow-x: auto;
border-radius: 8px;
background: color-mix(in srgb, currentColor 6%, transparent);
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
white-space: nowrap;
color: #42b883;
}
</style>const { items } = makeSortableList(dock, {
items: apps,
strategy: 'horizontal',
dropOutside: () => props.dropOutside,
// Called on the list the items leave, with `to.index` -1, when they are removed.
onDrop: (e) => {
if (e.to.index === -1) notice.value = `Removed ${e.items.map((app) => app.name).join(', ')}`;
},
});
dropOutside | 放到所有目标之外时…… |
|---|---|
'placeholder' | 落到占位所在的地方:列表最后显示的位置。默认值。 |
'cancel' | 被取消:项飞回原处。placeholder: 'indicator' 时的默认值。 |
'remove' | 把项从它们的列表中移除。会先询问列表的 onBeforeDrop,它的 onDrop 收到的 to.index 为 -1。 |
它在放置时读取,所以 ref 或 getter 可以切换它。回收区有意做的也是同样的事:makeDroppable(trash, { onDrop: (e) => e.source?.remove() })。见拖放。