Документация/Руководства

Решение о броске

Принять, отклонить или отложить бросок — правила во время драга или при отпускании, часть мульти-драга, диалог, сервер, который должен сначала согласиться, мониторы с правом вето и броски вне всех целей.

Редактировать страницу

Страница переведена ИИ и может содержать неточности. Читать английский оригинал

Перетащите карточку в Done. Бросок ждёт вашего ответа: переместить карточку, продолжить тащить её в другое место или отменить.

Live

Загружаем демо…

<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 принимает и отправляет обратно, когда вы отпускаете.

Live

Загружаем демо…

<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>
Column.vue
// 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 не спрашивается.

Принять часть элементов

Некоторые правила можно проверить только при отпускании, на сервере. Места на этот воркшоп бронируют и другие: при отпускании список спрашивает у сервера, сколько мест осталось, и берёт столько перетащенных людей. Отметьте нескольких и перетащите их вместе.

Live

Загружаем демо…

<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>
Column.vue
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 может вернуть промис. Откройте диалог и разрешите промис кнопкой, которую выберет пользователь:

Column.vue
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'),
});
Board.vue
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', как это делает пример.

Порядок хуков

Переставьте заметки, бросьте одну в корзину, затем переключите ответ и сделайте это снова. Панель перечисляет хуки последнего броска в том порядке, в котором они выполнились.

Live

Загружаем демо…

<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>
  1. onBeforeDrop каждого useDnDMonitor, по очереди. 'reject' или 'continue' прекращает опрос; ключи сужают бросок.
  2. onBeforeDrop цели: списка или дерева, куда идут элементы, или цели броска.
  3. onDrop только цели: список, который элементы покидают ради другого, не спрашивается, а его новый массив лежит в e.result цели. Когда элементы удаляются (зона удаления, dropOutside: 'remove'), выполняется onDrop их списка, с to.index, равным -1.
  4. Запись по умолчанию: каждый список, который меняет бросок, получает свой новый массив.
  5. onDragEnd провайдера (@drag-end) и мониторов. Затем оверлей приземляется, и onDragSettle (@drag-settle) сообщает об этом.

Отклонённый бросок вместо этого переходит к onDragCancel (@drag-cancel), и элементы летят обратно. 'continue' тоже прекращает опрос, с onDragResume: драг продолжается, а следующий бросок спрашивает снова.

Мониторы решают раньше цели, так что вето никогда не приходит после того, как список уже применил собственный бросок. Поэтому монитор — место для правил, касающихся каждого броска в приложении:

Monitor.vue
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. Карточка ждёт с бейджем, пока идёт запрос, затем приземляется или летит обратно, если запрос не удался.

Live

Загружаем демо…

<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>
Column.vue
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 показывает обычную копию карточки, а бейдж лежит поверх, пока идёт запрос:

index.vue
<DnDProvider>
  <Board />
  <template #overlay="{ active }">
    <div class="carried">
      <DragPreview :id="active.id" />
      <span v-if="saving" class="saving">Saving…</span>
    </div>
  </template>
</DnDProvider>

Бросок вне всех целей

Вытащите приложение из дока и отпустите, в каждом режиме.

Live

Загружаем демо…

<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>
Dock.vue
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.