Документация/Руководства
Решение о броске
Принять, отклонить или отложить бросок — правила во время драга или при отпускании, часть мульти-драга, диалог, сервер, который должен сначала согласиться, мониторы с правом вето и броски вне всех целей.
Страница переведена ИИ и может содержать неточности. Читать английский оригинал
Перетащите карточку в 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 | Броска нет: элементы летят обратно. Брошенная ошибка тоже отклоняет. |
| Массив ключей | Бросаются только эти элементы; остальные летят обратно. |
'continue' | Броска нет, и драг продолжается. |
| Промис любого из этих ответов | Оверлей ждёт там, где его отпустили, пока промис не завершится. |
Списки и деревья передают SortableDropEvent: keys, items, from, to, mode, sameList и
result — новые массивы всех списков, которые меняет бросок. Цели и мониторы получают общий
DnDEvent: active, over, position и source. См. события.
Правило во время драга или при отпускании
Email и Chat принимают вложения не больше 10 МБ. Перетащите 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. |
| Может | Сказать да или нет. | Дождаться промиса, принять часть ключей, продолжить драг. |
Используйте 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`;
},
});
При мульти-драге возвращённые ключи приземляются вместе, а остальные
летят обратно на свои места. onDrop затем видит их в e.accepted и e.rejected, а его
e.result — окончательный. Пустой массив не принимает ничего: бросок отклоняется.
Продолжить драг: 'continue'
'continue' отменяет бросок, а не драг. Ответьте Keep dragging в диалоге выше: элемент
остаётся в руке с того места, где был, страница возвращается в режим драга, и пользователь
продолжает.
- С мышью или пером элемент следует за указателем без зажатой кнопки. Следующее отпускание — клик — бросает его, и этот клик никогда не доходит до страницы под ним.
- Касание снова берёт элемент там, куда опустился палец; поднятый палец бросает его.
- Перетаскивание с клавиатуры продолжается: стрелки двигают элемент, Space или Enter бросают его.
- Escape отменяет его, как любой драг.
- Следующий бросок снова спрашивает
onBeforeDrop.
Мониторы слышат onDragResume, а скринридеры — что элемент не брошен и всё ещё движется
(текст переопределяется через announcements.onDragResume провайдера).
useDnDMonitor({
onDragResume() {
hint.value = 'Still dragging: click to drop, Esc to cancel';
},
});
Спросить пользователя
onBeforeDrop может вернуть промис. Откройте диалог и разрешите промис кнопкой, которую выберет
пользователь:
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 };
});
Пока промис ожидает:
- оверлей ждёт там, где его отпустили, с пометкой
data-dnd-overlay="dropping"; useDnD()хранит состояние на момент отпускания, аstatusравен'dropping';- страница выходит из режима драга: курсор и выделение текста возвращаются, так что диалог работает как обычно;
- никакой другой драг начаться не может.
<dialog>, открытый через showModal(), находится в верхнем слое, над ожидающим
оверлеем. Превратите его событие cancel (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>onBeforeDropкаждогоuseDnDMonitor, по очереди.'reject'или'continue'прекращает опрос; ключи сужают бросок.onBeforeDropцели: списка или дерева, куда идут элементы, или цели броска.onDropтолько цели: список, который элементы покидают ради другого, не спрашивается, а его новый массив лежит вe.resultцели. Когда элементы удаляются (зона удаления,dropOutside: 'remove'), выполняетсяonDropих списка, сto.index, равным-1.- Запись по умолчанию: каждый список, который меняет бросок, получает свой новый массив.
onDragEndпровайдера (@drag-end) и мониторов. Затем оверлей приземляется, и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 списка выполняется, как только бросок принят, синхронно, прямо перед тем как список его
записывает. Чтобы действовать после записи, используйте @drag-end провайдера или
useDnDMonitor({ onDragEnd }). На обычной цели onDrop — место, где вы меняете свои данные:
библиотека ничего за неё не перемещает.
Сторы и серверы: управляемые списки
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. В геттер, computed или readonly ref
она никогда не пишет: вызовите e.preventDefault() и примените e.result сами — новые массивы
всех списков, которые меняет бросок, по id списка. preventDefault() пропускает запись во все
из них, поскольку запись только в часть потеряла бы или продублировала элементы. Dev-сборки
предупреждают, когда бросок в список только для чтения оставлен библиотеке.
Сначала сохранить на сервер
Переместите карточку, когда сервер flaky (каждый второй запрос падает) или down. Карточка ждёт с бейджем, пока идёт запрос, затем приземляется или летит обратно, если запрос не удался.
Загружаем демо…
<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';
},
});
Примените e.result до того, как промис разрешится, и оверлей приземлится в новом порядке.
Когда запрос падает, ничего не применяйте и отклоните: элементы летят обратно, а мониторы
слышат onDragCancel.
Бейдж берётся из слота #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 или геттер. Зона удаления делает то
же самое намеренно: makeDroppable(trash, { onDrop: (e) => e.source?.remove() }). См.
drag and drop.