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

Оверлей

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

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

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

Пока вы перетаскиваете, за указателем следует блок — оверлей. Сам перетаскиваемый элемент остаётся на месте, как слот, в который он приземлится. При броске оверлей летит в этот слот и исчезает.

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

Live

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

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

<template>
  <DnDProvider>
    <Expenses />
  </DnDProvider>
</template>

Стандартный клон

Клон снимается при захвате, до того как что-либо сдвинется. Это глубокая копия элемента:

  • Значения полей форм и пиксели <canvas> копируются.
  • Вычисленные стили, которые элемент потерял бы вне своего списка, встраиваются инлайн: унаследованные шрифты и цвета, стили, которые применяются через предков (.list li { … }), ширины ячеек таблицы. Строка таблицы оборачивается в собственную таблицу.
  • id и атрибуты data-dnd-* удаляются, а клон получает aria-hidden.
  • Рябь и другие эффекты нажатия UI-китов (Vuetify, Quasar, PrimeVue, Naive UI) не копируются.

В таблице выше строка, в которую вы что-то ввели, едет со своим текстом, суммой, отметкой и графиком — в собственной таблице, ячейки которой сохраняют ширины настоящих.

Помечайте всё остальное, что не должно ехать с клоном, атрибутом data-dnd-no-clone: кнопку меню, тултип, отметку выделения. Кнопка удаления в каждой строке остаётся на месте:

ExpenseRow.vue
<tr ref="el" class="row">
  …
  <td><button type="button" aria-label="Remove" data-dnd-no-clone @click="emit('remove')">×</button></td>
</tr>

Фон не угадывается

Клон показывает элемент как есть. Строка без собственного фона, потому что его рисует список (<li> в белом <ul>, v-list-item, строка таблицы), даёт прозрачный клон. Задайте ему фон списка в CSS, здесь — на ячейках:

ExpenseRow.vue
<style scoped>
[data-dnd-overlay] .row td {
  background: color-mix(in srgb, currentColor 4%, Canvas);
}
</style>

Клон сохраняет классы и атрибуты элемента, включая scope-атрибут Vue, поэтому правило работает и в scoped-стилях строки, и в глобальной таблице стилей. Другой выход — фон на самой строке. Иконки и плитки, которые должны парить, остаются прозрачными намеренно.

Стилизация блока

Блок — это элемент с data-dnd-overlay. Он position: fixed, поверх всего (z-index: 9999), pointer-events: none и inert, а значение атрибута говорит, что он делает:

data-dnd-overlayКогда
"dragging"Следует за указателем.
"dropping"Приземляется в свой слот, летит обратно или ждёт асинхронного onBeforeDrop.

Приподнятый вид во время драга, который исчезает при приземлении:

[data-dnd-overlay='dragging'] .row td {
  box-shadow: 0 14px 20px -16px rgb(0 0 0 / 0.7);
}

Блок двигается через transform и получает размер как width и height: стилизуйте то, что внутри него, а не сам блок. Клон заполняет его, с width: 100%, height: 100% и transform: none на своём корне: поворачивайте и увеличивайте его свойствами rotate и scale, которые анимируют и переходы, и ключевые кадры. Рецепты — в анимациях.

Компоненты оверлея

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

Live

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

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

const columns = ref([
  {
    id: 'Backlog',
    tickets: [
      { id: 't1', title: 'Dark mode toggle', tag: 'UI', approved: false },
      { id: 't2', title: 'Retry failed uploads', tag: 'API', approved: true },
      { id: 't3', title: 'Keyboard shortcuts', tag: 'UI', approved: false },
    ],
  },
  { id: 'Doing', tickets: [{ id: 't4', title: 'Faster search', tag: 'API', approved: true }] },
  { id: 'Done', tickets: [{ id: 't5', title: 'Onboarding tour', tag: 'UI', approved: true }] },
]);
</script>

<template>
  <DnDProvider>
    <div class="board">
      <Column v-for="column in columns" :key="column.id" :column="column" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.board {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 12px;
  width: 100%;
  max-width: 600px;
  margin: 0 auto;
}
</style>
TicketOverlay.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { DnDStatus, DragItem, DragMode, Over } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[]; over: Over | null; accepted: boolean; status: DnDStatus; mode: DragMode }>();
const ticket = computed(() => props.items[0]?.item as Ticket);

const state = computed(() => {
  if (props.status === 'dropping') return props.over ? 'landed' : 'back'; // a cancel flies back
  if (!props.over) return 'free';
  return props.accepted ? 'ok' : 'refused';
});
</script>

<template>
  <!-- Fills the overlay box, which takes the size of the slot. -->
  <div class="ticket-overlay" :data-state="state">
    {{ ticket.title }}
    <span v-if="mode === 'copy'" class="copy">+ copy</span>
  </div>
</template>

<style scoped>
.ticket-overlay { width: 100%; height: 100%; }
.ticket-overlay[data-state='refused'] { background: color-mix(in srgb, #ef4444 8%, Canvas); }
</style>
ПропЧто в нём
activeДраг (id, ids, data, type, …).
itemsВсе перетаскиваемые элементы, взятый первым: { id, item, data, type }. item — элемент списка для сортируемых драгов.
overЦель под драгом; пока бросок приземляется — цель, на которую он приземляется. null над пустым местом и пока отклонённый или отменённый драг летит обратно. over.id сортируемого списка — id списка: колонки выше используют свои названия.
acceptedПринимает ли over драг: окрашивайте оверлей в красный, если нет.
status'dragging', затем 'dropping', пока он приземляется.
mode'move', 'copy' (Alt зажат при драге из списка с clone или драг из палитры), 'swap' или 'insert' (обычный перетаскиваемый элемент идёт в список).
toКуда бросок положил бы элементы сейчас: { list, index }, а в дереве ещё parentKey и depth. null, когда он не положил бы их ни в какой список, например над обычной целью. См. куда пойдёт бросок.

Пропсы — это OverlayProps; объявляйте только те, которые читаете. Передайте компонент тому, что перетаскивается:

import TicketOverlay from './TicketOverlay.vue';

makeSortableList(listRef, { items: tickets, overlay: TicketOverlay });   // every item of the list
makeSortableItem(el, { item: () => props.ticket, overlay: TicketOverlay }); // this item
makeDraggable(el, { id: 'chip', overlay: TicketOverlay });                // a plain draggable

Компонент можно передать как есть, в shallowRef, чтобы его переключать (в том числе посреди драга: оверлей меняется сразу), или функцией, которую Vue принимает за функциональный компонент: overlay: (props) => <TicketOverlay {...props} compact /> в TSX. Никогда не кладите его в глубокий ref или reactive: Vue сделает компонент реактивным и выдаст предупреждение.

Корень компонента оверлея должен заполнять блок (width: 100%; height: 100%): блок получает размер слота и анимирует его при броске. Фиксированный размер на корне вместо этого сохраняет этот размер внутри блока.

Слот #overlay провайдера

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

Live

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

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

<template>
  <DnDProvider>
    <Moodboard />
    <!--
      The overlay of every drag in the app that has none of its own. Notes get a ghost
      of their own; for photos the slot renders nothing, so they fall back to the clone.
    -->
    <template #overlay="{ items, status }">
      <NoteGhost v-if="items[0]?.type.includes('note')" :items="items" :status="status" />
    </template>
  </DnDProvider>
</template>
App.vue
<DnDProvider>
  <RouterView />
  <template #overlay="{ items, status }">
    <NoteGhost v-if="items[0]?.type.includes('note')" :items="items" :status="status" />
  </template>
</DnDProvider>

Слот получает те же пропсы, что и компонент оверлея. Когда он ничего не рендерит (v-if выше), драг возвращается к клону. Различайте драги по type: тип сортируемого элемента — имя его группы, если элемент не задаёт собственный (makeSortableItem(el, { type })).

Цели, которые меняют оверлей

Цель может задать, что показывает оверлей, пока над ней драг, который она принимает. makeDroppable, списки и деревья принимают overOverlay (overlay списка или дерева — для его собственных элементов). Над папкой чип перетаскиваемых писем превращается в то, что сделает бросок:

Live

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

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

<template>
  <!-- The mailbox reads the selection with `useDnD()`: it renders inside the provider. -->
  <DnDProvider>
    <Mailbox />
  </DnDProvider>
</template>
Folder.vue
makeDroppable(el, {
  id: () => props.folder.id,
  data: () => props.folder,
  overOverlay: MovePill,
  onDrop: (event) => emit('move', event.active.ids),
});

Компонент цели получает те же пропсы, что и любой компонент оверлея, в том числе over, а собственный оверлей драга (его компонент, слот провайдера или клон) — как свой слот по умолчанию. MovePill опускает слот, поэтому заменяет чип:

MovePill.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { DragItem, Over } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[]; over: Over | null }>();
// The folder under the drag: its `data`.
const folder = computed(() => props.over?.data as Folder | undefined);
</script>

<template>
  <!-- Fills the box like any overlay component. -->
  <div class="pill-box">
    <span v-if="folder" class="pill">Move {{ items.length }} to {{ folder.name }}</span>
  </div>
</template>

Отрендерите <slot />, чтобы сохранить собственный оверлей драга и обернуть его. Так делает Done на доске из компонентов оверлея выше: над Done одобренный тикет сохраняет собственный оверлей, Done ставит на него штамп, а тикет из другой колонки при броске получает конфетти.

Column.vue
makeSortableList(listRef, {
  items: tickets,
  group: { name: 'release', put },
  overlay: TicketOverlay, // the tickets' own, in every column
  overOverlay: strict ? DoneOverlay : undefined, // Done's, around it
});
DoneOverlay.vue
<template>
  <div class="done">
    <slot /> <!-- TicketOverlay -->
    <span class="stamp">Shipped</span>
    <span v-if="status === 'dropping' && arrives" class="confetti">…</span>
  </div>
</template>
  • Он показывается, только пока цель принимает драг, и остаётся, пока на неё приземляется бросок. Когда драг покидает цель, возвращается собственный оверлей драга.
  • Смена идёт кроссфейдом. Как именно — задаёт overlayTransition провайдера: false меняет сразу, объект — это пропсы <Transition> вокруг содержимого (name, mode, JS-хуки для GSAP или motion). Без mode: 'out-in' на мгновение видны оба: дайте уходящему position: absolute в .<name>-leave-active.
  • Бросок ждёт конфетти: CSS-анимации и переходы, которые идут в оверлее, пока он приземляется, доигрываются до конца, но не дольше двух секунд.
  • У драга с overlay: false нет оверлея ни над какой целью.

Папки к тому же приземляют плашку по-своему — в иконку папки: у цели тоже может быть overDropAnimation. См. анимации.

Куда пойдёт бросок: to

to сообщает оверлею, куда бросок положил бы элементы сейчас, — так же, как это скажет to события броска: список и индекс в нём, а в дереве — родителя и глубину. Он меняется, когда сдвигается слот. Страница этого меню сайта говорит, в какой раздел она попадёт и в какую строку:

Live

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

<script setup lang="ts">
import { provide, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import MenuTree from './MenuTree.vue';
import { MENU, site } from './menu';

// Provided above the provider: its overlay renders under it, so the overlay sees it too.
provide(MENU, ref(site()));
</script>

<template>
  <DnDProvider>
    <MenuTree />
  </DnDProvider>
</template>
MoveOverlay.vue
<script setup lang="ts">
import { computed } from 'vue';
import type { DragItem, DropDestination } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[]; to: DropDestination | null }>();
const page = computed(() => props.items[0]!.item as MenuNode);
// `parentKey` is the section the page would go into, `null` at the top level.
const where = computed(() => (props.to ? pathOf(menu.value, props.to.parentKey) : null));
</script>

<template>
  <div class="move">
    <div class="row">{{ page.title }}</div>
    <p v-if="to && where" class="where">Move to <b>{{ where }}</b>, row {{ to.index + 1 }}</p>
  </div>
</template>

Вне всех целей to — по-прежнему место слота: по умолчанию бросок там оставляет элементы на месте слота (dropOutside: 'placeholder'). Он равен null, когда бросок не положил бы их ни в какой список: над обычной целью, над списком, который их отклоняет, вне целей с dropOutside: 'cancel' или 'remove'.

Что побеждает

Для каждого драга — первое, что подходит:

  1. overOverlay цели (и списка или дерева тоже), пока драг над ней и она его принимает. То, что показалось бы иначе, становится её слотом по умолчанию.
  2. Собственный overlay элемента (makeSortableItem, makeDraggable).
  3. overlay его списка или дерева.
  4. Слот #overlay провайдера, если он что-то рендерит.
  5. Стандартный клон. При мульти-драге — колонка или стопка клонов: см. мульти-драг.

Есть ли у драга оверлей вообще (overlay: false), решается в его начале. Какой компонент показывается, читается на лету: его меняет ref, переключённый посреди драга, а также вход в цель со своим оверлеем или выход из неё.

DragPreview: клон в вашей раскладке

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

Live

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

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

<template>
  <DnDProvider>
    <Shelf />
  </DnDProvider>
</template>
FanOverlay.vue
<script setup lang="ts">
import { computed } from 'vue';
import { DragPreview, type DragItem } from '@vue-dnd-kit/core';

const props = defineProps<{ items: DragItem[] }>();
const fan = computed(() =>
  props.items.slice(0, 4).map((entry, index) => ({
    entry,
    angle: index === 0 ? 0 : (index % 2 ? -1 : 1) * Math.ceil(index / 2) * 10,
  })),
);
</script>

<template>
  <div class="fan">
    <div v-for="{ entry, angle } in fan" :key="entry.id" class="card" :style="{ transform: `rotate(${angle}deg)` }">
      <DragPreview :id="entry.id" class="face" :style="{ width: '100%', height: '100%' }">
        <div class="blank" /> <!-- an item without a clone -->
      </DragPreview>
    </div>
    <span v-if="items.length > 1" class="count">{{ items.length }}</span>
  </div>
</template>
  • Клоны есть для взятого элемента и других рядом с ним в порядке броска, столько, сколько задаёт overlayPreviews провайдера (по умолчанию 5, 'all' — для всех), если они отрендерены в момент захвата. Для любого другого элемента DragPreview рендерит свой слот по умолчанию.
  • Превью имеет размер своего элемента на момент захвата, заданный инлайн. Задавайте размер через style, который пробрасывается дальше (класс его не перекроет), как выше.
  • При сортируемом мульти-драге (multiple: 'group' или 'gaps') каждое превью при броске приземляется в слот своего элемента. Поворачивайте его обёртки через transform (а не свойство rotate): приземление по пути его снимает. Внешний вид элемента (скругления, тень) задавайте на самом превью, как это делает class="face": обёртки остаются в блоке.

В руководстве по мульти-драгу есть оверлей, который показывает счётчик и заголовки вместо клонов.

Размер: overlayFit

Над списком с элементами другого размера блок принимает размер слота, в который приземлится. Это переключает overlayFit провайдера; на странице DnDProvider есть демо с узким и широким списками.

overlayFit (провайдер)Блок
'slot' (по умолчанию)Принимает размер слота, в который приземлится, плавно подстраиваясь под него. Над широким списком он расширяется.
'source'Сохраняет размер перетаскиваемого элемента.

С 'slot' блок меняет размер вокруг указателя, который остаётся в той же доле блока. Содержимое никогда не масштабируется: оно заново раскладывается в новом размере, так что flex-ряд, как у задач в том демо, выглядит правильно при любой ширине. Слот, стилизованный намного меньше элемента, например тонкая линия, не сжимает блок по этой оси.

Бросок делает то же при любой настройке: блок летит к элементу, на который приземляется, и анимируется к его размеру, а содержимое по пути заново раскладывается. Когда элемент выглядит по-разному в двух списках (компактный чип в палитре, полная карточка на доске), клон источника заново раскладывается в размере цели и может выглядеть там сжатым или растянутым. Два способа сохранить его правильным:

  • Дайте элементу раскладку, которая работает в обоих размерах, как у строк задач, или одинаковый размер во всех списках, куда он может приземлиться.
  • Рендерите вид цели в компоненте оверлея. Он получает over, поэтому может переключаться на вид того списка, над которым находится, и заполняет блок при любом размере.

Где он рендерится: overlayTo

Провайдер телепортирует оверлей в body. Модальный <dialog> (открытый через showModal()) находится в верхнем слое, над всем в body, поэтому оверлей там был бы скрыт под ним. Направьте overlayTo на селектор или элемент внутри диалога. На странице DnDProvider есть диалог, чтобы попробовать с этим и без этого:

index.vue
<script setup lang="ts">
const dialog = useTemplateRef<HTMLDialogElement>('dialog');
const open = ref(false);
// While the dialog is open, the overlay renders inside it.
const overlayTo = computed(() => (open.value ? '#columns-dialog' : 'body'));

const show = () => {
  dialog.value?.showModal();
  open.value = true;
};
</script>

<template>
  <DnDProvider :overlay-to="overlayTo">
    <button type="button" @click="show">Edit columns</button>
    <dialog id="columns-dialog" ref="dialog" @close="open = false">
      <ColumnList v-model:columns="columns" />
    </dialog>
  </DnDProvider>
</template>
  • Цель должна быть на странице, когда провайдер рендерит оверлей, то есть после его монтирования. Закрытый <dialog> на странице есть: он просто скрыт. Пока он закрыт, переключайтесь обратно на body, чтобы оверлей других драгов был виден.
  • Блок — position: fixed. transform, filter или contain на цели или её предках делают её системой отсчёта блока вместо вьюпорта, поэтому не ставьте их на диалог.
  • Стили клона вычисляются для того места, где он показан: в shadow root направьте overlayTo внутрь него, и клон сохранит стили shadow root.

Без оверлея

У makeDraggable(el, { overlay: false }) блока нет вовсе: двигается сам элемент, через возвращённый transform.

const { transform } = makeDraggable(el, { overlay: false });
const style = computed(() =>
  transform.value ? { translate: `${transform.value.x}px ${transform.value.y}px` } : undefined,
);

См. drag and drop. Как приземляется драг с блоком, задаёт его dropAnimation: цели (overDropAnimation), элемента (или его списка) или провайдера. См. анимации.