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

Drag and drop

Свободное перетаскивание с makeDraggable и цели с makeDroppable — типизированные цели, которые принимают одни драги и отклоняют другие, план комнаты, зоны удаления, папки внутри списка и обычные элементы, которые принимает список.

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

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

Используйте makeDraggable и makeDroppable для всего, что не является сортируемым списком: мусор в баки, мебель на плане комнаты, файлы в папки, корзина, шахматная доска.

Live

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

<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type Key } from '@vue-dnd-kit/core';
import Bin from './Bin.vue';
import { BINS, WASTE } from './waste';

// The tray takes everything back (no `accept`); the bins take what their rule allows.
const TRAY = { id: 'tray', name: 'To sort', color: '#42b883', accept: '*', rule: "'*'" };

/** Where each item is: the tray or a bin. */
const place = reactive<Record<string, string>>(Object.fromEntries(WASTE.map((waste) => [waste.id, 'tray'])));
const itemsIn = (bin: string) => WASTE.filter((waste) => place[waste.id] === bin);
const drop = (bin: string, id: Key) => (place[String(id)] = bin);
</script>

<template>
  <DnDProvider>
    <div class="recycling">
      <Bin :bin="TRAY" :items="itemsIn('tray')" tray @drop="drop('tray', $event)" />
      <div class="bins">
        <Bin v-for="bin in BINS" :key="bin.id" :bin="bin" :items="itemsIn(bin.id)" @drop="drop(bin.id, $event)" />
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.recycling {
  display: flex;
  flex-direction: column;
  gap: 12px;
  max-width: 600px;
  margin: 0 auto;
}

.bins {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
}

@media (max-width: 560px) {
  .bins {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
</style>

Перетаскиваемый элемент

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

const props = defineProps<{ waste: { id: string; name: string; type: string } }>();
const el = useTemplateRef<HTMLElement>('el');

const { isDragging } = makeDraggable(el, {
  id: () => props.waste.id,      // read back as `active.id`
  type: () => props.waste.type,  // matched by the targets' `accept`
  data: () => props.waste,       // anything: `active.data`
});
</script>

<template>
  <div ref="el" :class="{ dragging: isDragging }">{{ waste.name }}</div>
</template>

Передавайте id из ваших данных, геттером. Id по умолчанию берётся из useId() и новый для каждого экземпляра компонента, поэтому остальному приложению он ни о чём не говорит. Опции читаются тогда, когда они нужны, так что геттер или ref держат их актуальными.

ОпцияЧто делает
id, data, typeКто перетаскивается, что он несёт и с чем сверяется accept целей.
handleСелектор или элемент внутри: только он начинает драг. См. ручки.
disabledДраг не начинается.
overlaytrue (по умолчанию): за указателем следует копия. Компонент: ваше собственное превью. false: элемент двигается сам (ниже).
axis, modifiersЗафиксировать драг по 'x' или 'y', привязать к сетке, удержать в границах: см. ограничения.
keyboardModeКак его двигают стрелки: по пикселям (по умолчанию) или от цели к цели (ниже).
activation, canDragКогда нажатие становится драгом: см. сенсоры.
attributesrole, tabindex и aria-* для клавиатуры и скринридеров (по умолчанию true).
ВозвращаетЧто это
isDraggingЭтот элемент перетаскивается.
selectedЗаписываемый, связан с выделением провайдера.
transformСмещение драга, для overlay: false.
idИспользуемый id.

Кнопки, поля ввода и другие интерактивные потомки не начинают драг указателем, поэтому внутри перетаскиваемого элемента они продолжают работать. Все опции — в API makeDraggable.

Цель

Bin.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeDroppable, type Key } from '@vue-dnd-kit/core';
import type { Bin, Waste } from './waste';

const props = defineProps<{ bin: Bin; items: Waste[] }>();
const emit = defineEmits<{ drop: [id: Key] }>();
const el = useTemplateRef<HTMLElement>('el');

const { isOver, isAccepting } = makeDroppable(el, {
  id: props.bin.id,
  accept: props.bin.accept,   // 'glass', ['paper', 'cardboard'], a function…
  onDrop: (event) => emit('drop', event.active.id),
});
</script>

<template>
  <section ref="el" :class="{ accepting: isAccepting, over: isOver && isAccepting, refused: isOver && !isAccepting }">
    <WasteItem v-for="waste in items" :key="waste.id" :waste="waste" />
  </section>
</template>
Возвращаетtrue, когда
isOverДраг над этой целью, принимает она его или нет.
isAcceptingЧто-то перетаскивается, и эта цель это принимает: подсвечивайте подходящие цели.
placementНе булево значение: { top, right, bottom, left } — половины цели, в которых находится указатель, или null, когда над ней ничего нет. Используйте его для «перед / после этой карточки».

isOver без isAccepting означает, что драг над целью, которая его отклоняет. Покажите это, как баки выше: бросок туда отменяется, и элемент летит обратно. Баки, которые не могут принять элемент, тускнеют, пока он движется; больше вариантов — в стилизации.

ОпцияЧто делает
id, dataЧитаются как over.id и over.data.
accept, acceptModeКакие драги она принимает (следующий раздел).
disabledОна ничего не принимает, и драги проходят над ней.
priorityПобеждает большее, когда цели перекрываются без вложенности (по умолчанию 0).
onDragEnter, onDragLeaveДраг вошёл или вышел.
onBeforeDrop, onDropРешить судьбу броска, затем применить его (ниже).

Какие драги принимает цель

type перетаскиваемого элемента сверяется с accept цели:

acceptПринимает
не задан или '*'Всё.
'glass'Перетаскиваемые элементы типа glass и элементы без типа.
['paper', 'cardboard']Любой из двух типов и элементы без типа.
({ active, items, types }) => booleanТо, что скажет ваша функция.

Перетаскиваемый элемент без type подходит под любое строковое правило. type может быть и массивом (['paper', 'large']): достаточно одного совпавшего типа. Функция решает по чему угодно в драге. Бак для компоста выше читает данные элементов, а не их тип:

waste.ts
{
  id: 'compost',
  accept: ({ items }) => items.every((item) => (item.data as Waste).compostable),
}

accept читается вживую, пока драг ищет цель: передайте ref, чтобы его менять.

Когда перетаскиваются сразу несколько элементов, acceptMode говорит, сколько из них должны подойти: 'all' (по умолчанию) или 'some'. С 'some' верните из onBeforeDrop id, которые нужно оставить, а остальные полетят обратно. См. решение о броске.

Бросок

onDrop обычной цели — место, где данные меняете вы. Сама библиотека ничего не перемещает:

index.vue
<script setup lang="ts">
/** Where each item is: the tray or a bin. */
const place = reactive<Record<string, string>>(Object.fromEntries(WASTE.map((waste) => [waste.id, 'tray'])));
const itemsIn = (bin: string) => WASTE.filter((waste) => place[waste.id] === bin);
const drop = (bin: string, id: Key) => (place[String(id)] = bin);
</script>

<template>
  <Bin v-for="bin in BINS" :key="bin.id" :bin="bin" :items="itemsIn(bin.id)" @drop="drop(bin.id, $event)" />
</template>
Поле событияЧто в нём
activeДраг: id, ids, items (каждый — { id, data, type, item }), data, type.
overЭта цель: id, data, element, rect, accepted.
pointer, deltaГде указатель и насколько он сдвинулся от начала.
positionГде перетаскиваемый блок находится в этой цели (следующий раздел).
sourceЗадан, когда элементы пришли из сортируемого списка: { list, remove() }.

active.ids содержит все перетаскиваемые id, взятый первым. Порядок такой: onBeforeDrop (решить), onDrop (применить), затем drag-end провайдера. Чтобы слышать каждый драг страницы в одном месте, используйте useDnDMonitor (см. провайдер). Чтобы спросить перед броском, продолжить драг или принять только часть элементов, см. решение о броске.

Оставить там, где бросили

План комнаты, холст, доска: элемент остаётся там, где вы его отпустили. Каждое событие несёт position — left / top, которые ставят абсолютно позиционированный элемент туда, где его бросили.

Live

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

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

<template>
  <DnDProvider>
    <Planner />
  </DnDProvider>
</template>
Planner.vue
const room = useTemplateRef<HTMLElement>('room');
makeDroppable(room, {
  id: 'room',
  onDrop: ({ active, position }) => {
    const piece = find(active.id);
    if (!piece || !position || !room.value) return;
    // Clamped, so a piece let go half outside stays inside the walls.
    piece.x = clamp(position.x, room.value.clientWidth - piece.width);
    piece.y = clamp(position.y, room.value.clientHeight - piece.height);
    piece.place = 'room';
  },
});
Piece.vue
<div ref="el" class="piece" :style="{ left: `${piece.x}px`, top: `${piece.y}px` }">…</div>

position — левый верхний угол перетаскиваемого блока в over, отсчитанный от края его padding, с учётом прокрутки: рамка комнаты, её стены, не учитывается. Над пустым местом он равен null.

Не вычисляйте место из active.rect и delta: так теряются рамка и прокрутка цели. Сетки, границы и фиксация оси для таких досок — в ограничениях.

Без оверлея

По умолчанию за указателем следует копия элемента, а сам элемент остаётся на месте: так работает мебель на складе. С overlay: false копии нет, и элемент двигается сам, по transform, который возвращает драг: так работает мебель в комнате.

Piece.vue
const inRoom = computed(() => props.piece.place === 'room');
const { isDragging, transform } = makeDraggable(el, {
  id: () => props.piece.id,
  overlay: computed(() => !inRoom.value),   // a value or a ref, read when the drag starts
});

const style = computed(() => ({
  left: `${props.piece.x}px`,
  top: `${props.piece.y}px`,
  ...(transform.value && { transform: `translate3d(${transform.value.x}px, ${transform.value.y}px, 0)` }),
}));
  • Тогда position — угол самого элемента, так что обработчик броска не меняется.
  • Элемент находится под указателем, пока движется; при поиске цели библиотека смотрит сквозь него. Поднимите его над соседями, пока он перетаскивается (z-index).
  • Он прокручивается вместе со страницей, а оверлей — нет: transform вычитает то, на сколько сдвинулся его контейнер с начала драга, так что при прокрутке страницы он остаётся под указателем.
  • Он движется внутри своего контейнера: родитель с overflow: hidden его обрезает, а его контекст наложения решает, над чем он проходит. У оверлея нет ни той, ни другой проблемы.

Для своего превью вместо копии см. оверлей.

Зоны удаления

Драг, который начинается в сортируемом списке, несёт e.source. Вызовите remove(), и список уберёт элементы:

Live

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

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

<template>
  <DnDProvider>
    <Triage />
  </DnDProvider>
</template>
Triage.vue
const triage = (bin: 'done' | 'deleted') => (e: DnDEvent) => {
  bins[bin].push(...e.active.items.map((entry) => entry.item as Notice));
  e.source?.remove();
};

makeDroppable(doneZone, { id: 'done', onDrop: triage('done') });
makeDroppable(trashZone, { id: 'trash', onDrop: triage('deleted') });
  • e.active.items[i].item — сам элемент списка. Сохраните его, и отмена вернёт его на прежнее место.
  • Затем собственный onDrop списка выполняется с to.index === -1, а e.result содержит список без этих элементов.
  • Оверлей сжимается в зону, потому что элемент больше нигде не показан.
  • Список может и сам убирать то, что брошено вне всего: см. dropOutside.
С клавиатуры стрелки перемещают элемент списка между слотами его списка и списками его группы; за концом списков следующее нажатие несёт его дальше, к цели в этом направлении — здесь к корзине, — а стрелка в обратную сторону возвращает его назад. Кнопка, меню или клавиша Delete для многих всё же быстрее: предложите и их.

Бросок в сортируемый элемент

Строка сортируемого списка тоже может быть целью: папка, которая принимает закладки, группа, которая принимает задачи. Поставьте droppable на элемент строки:

Live

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

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

<template>
  <DnDProvider>
    <Bookmarks />
  </DnDProvider>
</template>
FolderRow.vue
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.folder, type: 'folder' });

const { isOver } = makeDroppable(el, {
  accept: 'link',
  onDrop: (e) => {
    emit('add', e.active.items.map((entry) => entry.item as Link)); // Bookmarks adds them
    e.source?.remove();   // take them out of the list they came from
  },
});

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

Пока список сортировал бы драг, такая цель учитывается только в середине своего элемента (от 25 до 75 % по оси списка). Там бросок попадает в элемент, и список не двигается. У краёв список сортирует как обычно.

  • Цель, чей accept отклоняет драг, пропускается: список сортирует над этим элементом, как над любым другим. Папки принимают только ссылки, поэтому перетаскиваемая папка сортируется мимо остальных.
  • Цель никогда не получает собственный драг.
  • С клавиатуры стрелка сначала останавливается на папке; следующее нажатие в ту же сторону проходит мимо неё.
  • После броска элемент больше не показан, поэтому оверлей сжимается в папку, а не летит обратно.
  • Над папкой список оставляет слот перетаскиваемой строки на прежнем месте, чтобы под указателем ничего не сдвигалось. Слот получает тогда data-dnd-placeholder="into": бросок туда не попадёт, так что его можно скрыть ([data-dnd-placeholder='into'] { opacity: 0 }).

Файловый менеджер делает то же самое в сетке, с вложенными папками.

Обычные перетаскиваемые элементы в список

Сортируемый список принимает элементы, перетаскиваемые с makeDraggable, если знает, как сделать из них элемент:

Live

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

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

const BLOCKS = [
  { title: 'Focus', minutes: 90, color: '#42b883' },
  { title: 'Meeting', minutes: 30, color: '#3b82f6' },
  { title: 'Lunch', minutes: 60, color: '#f59e0b' },
  { title: 'Gym', minutes: 45, color: '#ec4899' },
  { title: 'Break', minutes: 15, color: '#a1a1aa' },
];
</script>

<template>
  <DnDProvider>
    <div class="planner">
      <aside class="blocks">
        <h4>Blocks</h4>
        <BlockChip v-for="block in BLOCKS" :key="block.title" :block="block" />
      </aside>
      <Agenda />
    </div>
  </DnDProvider>
</template>

<style scoped>
.planner {
  display: flex;
  justify-content: center;
  gap: 24px;
}

.blocks {
  display: flex;
  flex: none;
  flex-direction: column;
  gap: 6px;
  width: 140px;
}

h4 {
  margin: 0 0 6px;
  font: 600 11px/1 ui-monospace, Menlo, Consolas, monospace;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.55;
}

@media (max-width: 480px) {
  .blocks {
    width: 112px;
  }
}
</style>
Agenda.vue
const { items } = makeSortableList(list, {
  items: agenda,
  createItem: (active) => ({ ...(active.data as Block), id: `entry-${++created}` }),
});

Пока перетаскиваемый элемент над списком, созданный элемент находится в черновике и рендерится вашим шаблоном, как любая другая строка: время в расписании уже его учитывает. Настоящим он становится при броске (mode: 'insert', from.list === null), и оверлей приземляется на него.

  • Верните undefined, чтобы отказать. Списки без createItem игнорируют обычные перетаскиваемые элементы.
  • Дайте новому элементу собственный ключ: блок остаётся в палитре.
  • С клавиатуры стрелка в сторону списка забирает перетаскиваемый элемент на его уровне, а следующие стрелки двигают его по слотам.

Файлы с компьютера

Файлы, перетащенные с рабочего стола или из файлового менеджера, — не драги библиотеки: их несёт браузер. Цель принимает их с external, а бросок приносит их в e.external:

Live

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

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

<template>
  <DnDProvider>
    <div class="form">
      <AvatarDrop />
      <Attachments />
    </div>
  </DnDProvider>
</template>

<style scoped>
.form {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  justify-content: center;
  gap: 16px;
}
</style>
AvatarDrop.vue
const { isOver, isAccepting } = makeDroppable(el, {
  external: { files: 'image/*', multiple: false },
  onDrop: (e) => show(e.external!.files[0]!),
});
  • files читается как <input accept>: MIME-типы (image/*, application/pdf) и расширения (.md). true принимает любой файл, функция решает по каждому файлу. links и text принимают то, что перетаскивают другие приложения и вкладки, types — любой другой MIME-тип.
  • Драг идёт как драг провайдера: isOver, isAccepting, события и объявления работают как для любого другого. Цели без external его игнорируют. Оверлея нет: перетаскиваемое изображение рисует браузер.
  • Пока файлы держат над страницей, браузер сообщает только их типы (e.external.fileTypes). Имена, размеры и содержимое приходят с броском, и тогда же проверяются расширения. Папки пропускаются; с multiple: false драг нескольких файлов отклоняется.

Списки тоже принимают файлы, ссылки и текст и освобождают для них место, пока их держат. Элементы могут и покидать страницу — в другое приложение или в другое ваше окно: см. файлы и другие приложения.

Пользователи клавиатуры и скринридеров не могут перетаскивать файлы. Поставьте рядом с зоной <input type="file">, как демо делает с кнопкой Browse.

Клавиатура

Перетаскиваемый элемент в фокусе берётся клавишей Space или Enter, бросается теми же клавишами, а Escape отменяет драг. В промежутке его двигают стрелки:

keyboardModeСтрелка
'pixels' (по умолчанию)Сдвигает его на 25 px, keyboard.step провайдера. С Shift — в четыре раза дальше. Так двигается мебель.
'droppables'Прыгает к ближайшей цели в этом направлении, которая его принимает. Мусор прыгает от бака к баку и пропускает баки, которые его отклоняют.
makeDraggable(el, { id: () => props.waste.id, type: () => props.waste.type, keyboardMode: 'droppables' });

Клавиши можно переназначить на провайдере. Подробнее — в доступности.