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

Провайдер

DnDProvider отвечает за каждый драг под собой. Он находит цель, запускает драги, прокручивает и сообщает useDnD и useDnDMonitor обо всём, что происходит.

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

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

Один провайдер на приложение

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

<template>
  <DnDProvider>
    <RouterView />
  </DnDProvider>
</template>

Поставьте один DnDProvider повыше, в App.vue или в лейаут, и забудьте о нём. Каждый композабл (makeSortableList, makeDraggable, useDnD…) ищет ближайший провайдер над компонентом, в котором он вызван.

Композабл должен вызываться в компоненте, отрендеренном внутри провайдера. Вызов в компоненте, чей собственный шаблон содержит <DnDProvider>, бросает ошибку No <DnDProvider> found above this component: inject во Vue видит только предков.

За что он отвечает

ЧастьЧто делает провайдер
ВводПревращает ввод указателем, касанием и с клавиатуры в драги (sensors, activation), по одному за раз.
Поиск целиПроверяет, какие цели под драгом (collision), и сообщает им об этом.
ОверлейРендерит блок, который следует за указателем, телепортирует его в body и приземляет при броске.
ВыделениеОдин набор выделенных id, общий для всех списков и перетаскиваемых элементов под ним.
АвтоскроллПрокручивает контейнеры и окно, когда драг подходит к их краям.
Live-регионОзвучивает для скринридеров захват, перемещения, бросок и отмену.

Больше он ничего не рендерит: ваша разметка остаётся как есть.

Опции вкратце

У каждой опции есть рабочее значение по умолчанию, так что обычно провайдеру не передают ничего. Провайдер читает пропсы тогда, когда они ему нужны: демо ниже переключают collision, activation и autoScroll на лету.

ПропПо умолчаниюЧто задаёт
collisionpointerFirstКак выбирается цель под драгом. Ниже.
activationмышь 4 px, касание 200 msКогда нажатие становится драгом, для каждого типа указателя. Ниже.
autoScrollвключёнПрокрутка у краёв: { threshold, maxSpeed, acceleration } или false. Ниже.
hapticsкороткая вибрация при захвате касаниемВибрации для драгов касанием, или false. Ниже.
copyModifier'Alt'Клавиша, которая, если её держать во время драга, превращает перемещение в копирование.
modifiers[]Правила движения для каждого драга: см. ограничения.
animation / dropAnimationвключеныСкольжение элементов при сортировке / приземление оверлея: см. анимации.
animateChangesвыключенЗначение по умолчанию для списков и деревьев: анимировать изменения данных, сделанные не драгом.
keyboardSpace / Enter, EscapeКлавиши и шаг клавиатуры; false отключает перетаскивание с клавиатуры. См. доступность.
sensorsуказатель + клавиатураЧто превращает ввод в драги: см. сенсоры.
overlayTo'body'Куда телепортируется оверлей.
overlayFit'slot'Размер оверлея: слота, в который он приземлится, или исходного элемента. См. оверлей.
overlayTransitionкроссфейдКак оверлей меняется с одного компонента на другой посреди драга: false или пропсы <Transition>. См. цели, которые меняют оверлей.
overlayClass—Классы для содержимого оверлея ([data-dnd-overlay-content]) — элемента, который анимируют целиком: подъём при захвате, посадка при приземлении. См. оверлей: подъём, наклон, посадка.
overlayPreviews5Мульти-драг: сколько перетаскиваемых элементов получают клон при захвате, или 'all'. См. оверлей по умолчанию.
announcementsвстроенныеТексты для скринридеров, или false.
screenReaderInstructionsвстроенныйТекст, на который каждый перетаскиваемый элемент ссылается через aria-describedby.
defaultStylestrueВнедряет CSS с нулевой специфичностью для состояний драга: см. стилизацию.

Полный список со всеми формами — в API DnDProvider. Провайдер также эмитит drag-start, drag-move, drag-over, drag-end, drag-cancel, drag-resume и drag-settle — те же события, что слышит useDnDMonitor.

Читайте драг: useDnD()

Live

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

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

<template>
  <!-- One provider: the list drags, the panel reads the drag. Neither imports the other. -->
  <DnDProvider>
    <div class="demo">
      <Tasks />
      <StatePanel />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 24px;
}
</style>

useDnD() возвращает драг как реактивное состояние в любом компоненте под провайдером:

import { useDnD } from '@vue-dnd-kit/core';

const { status, active, over, pointer, activation, isAutoScrolling, selection, cancel } = useDnD();
ПолеЧто в нём
status'idle', 'activating' (нажатие ждёт активации), 'dragging', 'dropping' (драг не идёт, но оверлей ещё приземляется, или асинхронный onBeforeDrop принимает решение), 'selecting' (рамка выделения).
activeЧто перетаскивается: id, ids (все перетаскиваемые id, взятый первым), items, data, type, element, rect, sensor. null в простое.
overЦель под драгом: id, data, element, rect и accepted. null, если под драгом ничего нет.
pointerУказатель в координатах вьюпорта, после модификаторов.
activation.progressОт 0 до 1, пока нажатие ждёт активации: рисуйте по нему кольцо.
isAutoScrollingАвтоскролл что-то прокручивает.
selectionВыделение провайдера: ids, has, add, delete, toggle, set, clear. См. мульти-драг.
cancel()Отменяет текущий драг (элементы летят обратно), заряжающееся нажатие или рамку выделения.
DropHint.vue
<script setup lang="ts">
import { computed } from 'vue';
import { useDnD } from '@vue-dnd-kit/core';

const { status, over } = useDnD();
const refused = computed(() => status.value === 'dragging' && over.value?.accepted === false);
</script>

<template>
  <p v-if="refused" role="status">This list doesn't take that.</p>
</template>

Как только решение о броске принято, active, over и pointer читаются как null, даже пока оверлей ещё приземляется (status равен 'dropping'). UI, построенный на них, никогда не показывает старый драг рядом с новыми данными. Пока ждёт асинхронный onBeforeDrop, состояние остаётся таким, каким было в момент отпускания.

Следующий драг не ждёт приземления: он начинается сразу, и status равен 'dragging', пока вокруг него приземляется последний оверлей. Нажатие на приземляющийся оверлей ловит его в полёте: драг продолжается с того места, где находится оверлей. onDragSettle сообщает, когда оверлей драга добрался до места.

Слушайте каждый драг: useDnDMonitor()

import { useDnDMonitor } from '@vue-dnd-kit/core';

useDnDMonitor({
  onDragStart: ({ active }) => {},
  onDragMove: ({ pointer, delta }) => {},
  onDragOver: ({ over }) => {},                  // the target changed
  onBeforeDrop: (e) => 'accept',                 // or 'reject', 'continue', some ids; may be async
  onDragEnd: ({ active, over, dropped, accepted, position }) => {},
  onDragCancel: ({ active }) => {},
  onDragResume: ({ active }) => {},              // onBeforeDrop answered 'continue'
  onDragSettle: ({ active, over }) => {},        // its overlay has landed
});

Монитор слышит каждый драг под своим провайдером, где бы тот ни начался: панель выше записывает драги списка, не импортируя его. Монитор останавливается, когда размонтируется компонент, который его вызвал.

СобытиеКогда
onDragStartДраг начинается.
onDragMoveДраг движется.
onDragOverМеняется цель под драгом.
onBeforeDropДраг отпущен. Его ответ решает судьбу броска: см. решение о броске.
onDragEndБросок состоялся. Сортируемые списки уже выполнили свой onDrop и записали новый порядок. dropped равен false, если драг закончился не над целью.
onDragCancelEscape, бросок на цель, которая его отклоняет, или onBeforeDrop, ответивший 'reject'.
onDragResumeonBeforeDrop ответил 'continue': ничего не брошено, и драг продолжается.
onDragSettleОверлей драга приземлился (или улетел обратно, или его поймал следующий драг), с тем же событием, что получил onDragEnd или onDragCancel. Тем временем могли начаться другие драги.

Каждое событие несёт active, over, pointer, delta (смещение от начала), position (где перетаскиваемый блок находится в over, см. drag and drop) и, для элементов из сортируемого списка, source. Полные формы — в API событий.

onDragEnd — место для работы «после броска»: сохранения, сброса выделения, аналитики. Собственный onDrop списка выполняется до его записи, поэтому видит ещё старый порядок.

Поиск цели

Выберите функцию, затем перетащите булавку или карточку над целями. Бейджи показывают, как функция ранжирует их на этом кадре; зелёная — цель.

Live

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

<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import {
  DnDProvider,
  closestCenter,
  closestCorners,
  pointerFirst,
  pointerWithin,
  rectIntersection,
  type CollisionDetection,
  type DnDEndEvent,
  type Key,
} from '@vue-dnd-kit/core';
import Desk from './Desk.vue';
import { magnet } from './magnet';

const DETECTORS: Record<string, CollisionDetection> = {
  pointerFirst,
  pointerWithin,
  rectIntersection,
  closestCenter,
  closestCorners,
  magnet,
};
const names = Object.keys(DETECTORS);
const mode = ref('pointerFirst');

// The ranking of the current frame, best first: a wrapper around the chosen function
// records it, so the targets can show their rank.
const ranked = shallowRef<Key[]>([]);
const collision = computed<CollisionDetection>(() => {
  const detect = DETECTORS[mode.value]!;
  return (args) => (ranked.value = detect(args));
});

const result = ref('Drag the pin or the card over the targets');
const onEnd = ({ over }: DnDEndEvent) => {
  ranked.value = [];
  result.value = over ? `Dropped on ${over.id}` : 'Dropped on nothing';
};
const onCancel = () => {
  ranked.value = [];
  result.value = 'Cancelled';
};
</script>

<template>
  <DnDProvider :collision="collision" @drag-end="onEnd" @drag-cancel="onCancel">
    <div class="lab">
      <div class="modes" role="radiogroup" aria-label="Collision detection">
        <label v-for="name in names" :key="name" :class="{ on: mode === name }">
          <input v-model="mode" type="radio" :value="name" />
          {{ name }}
        </label>
      </div>
      <code class="call">&lt;DnDProvider :collision="{{ mode }}"&gt;</code>
      <Desk :ranked="ranked" />
      <p class="result" aria-live="polite">{{ result }}</p>
    </div>
  </DnDProvider>
</template>

<style scoped>
.lab {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

.modes {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 2px;
  max-width: 100%;
  padding: 3px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes label {
  padding: 5px 9px;
  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;
}

.result {
  margin: 0;
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}
</style>

На каждом кадре драга провайдер передаёт collision цели в игре, а она возвращает их id, лучшие первыми. В библиотеке пять функций:

ФункцияРанжируетПодходит для
pointerFirstТо, что под указателем; если там ничего нет — то, что перекрывает перетаскиваемый блок.По умолчанию: точно под указателем, снисходительно в промежутках.
pointerWithinТолько то, что под указателем, самые внутренние первыми.Точных целей: бросок в промежуток никуда не попадает.
rectIntersectionВсе цели, которые перекрывает перетаскиваемый блок: выше priority, затем глубже вложенность, затем больше перекрытие.Больших элементов над маленькими целями, когда указатель у края элемента.
closestCenterВсе цели, по расстоянию от центра блока до их центров.Случаев, когда кто-то должен победить всегда, даже издалека: сеток и списков из элементов одного размера.
closestCornersВсе цели, по расстояниям между четырьмя углами.Колонок, стоящих рядом (канбан), целей очень разной высоты.

Под указателем проверка попадания учитывает то, что видит пользователь: из перекрывающихся целей попадает только верхняя (front, никогда back там, где они перекрываются), а из вложенных первой идёт самая внутренняя (subfolder, затем folder).

Своя функция

magnet.ts
import type { CollisionDetection, Coordinates, Rect } from '@vue-dnd-kit/core';

/** How far the point is from the rect: 0 inside it. */
const gap = (rect: Rect, point: Coordinates) =>
  Math.hypot(
    Math.max(rect.left - point.x, 0, point.x - rect.right),
    Math.max(rect.top - point.y, 0, point.y - rect.bottom),
  );

/** What is under the pointer; in a gap, the nearest target within 40px. */
export const magnet: CollisionDetection = ({ hits, pointer, candidates }) => {
  if (hits.length) return hits;
  return candidates
    .filter((candidate) => gap(candidate.rect, pointer) <= 40)
    .sort((a, b) => gap(a.rect, pointer) - gap(b.rect, pointer))
    .map((candidate) => candidate.id);
};
АргументЧто в нём
pointerУказатель, после модификаторов.
overlayRectПеретаскиваемый блок в том виде, в каком он показан; null без оверлея.
candidatesВсе цели в игре: id, rect (его видимая часть, обрезанная скролл-контейнерами), priority, depth (0 на верхнем уровне, больше при вложенности).
hitsЦели под указателем, по результатам проверки попадания, лучшие первыми.

Верните пустой массив, чтобы сказать «цели нет». Отключённые цели, цели внутри перетаскиваемых элементов и цели, скрытые своими скролл-контейнерами, в функцию не попадают. Цель, которая отклоняет драг, всё равно участвует: над ней over.accepted равен false, и бросок отклоняется (цель внутри сортируемого элемента вместо этого уступает, чтобы список сортировался над ней). Демо оборачивает выбранную функцию, чтобы записать её ранжирование: (args) => (ranked.value = detect(args)).

Провайдер гасит переключения, которые запрашивает функция. Цель, которую драг только что покинул, не может снова победить, пока указатель не сдвинется на 10 px, поэтому границы не мерцают. Между целями, вложенными в одного родителя (промежутки колонки карточек), остаётся последняя цель.

Когда нажатие становится драгом

Нажатие не начинает драг сразу. activation задаёт для каждого типа указателя (mouse, touch, pen), чего оно ждёт: расстояния distance в px, задержки delay в ms (с допуском tolerance, за которым нажатие сдаётся, чтобы страница могла прокручиваться) или того, что наступит раньше.

<DnDProvider :activation="{ mouse: { distance: 8 }, touch: { delay: 300, tolerance: 8 } }">
УказательПо умолчанию
mouse, pen{ distance: 4 }: клик остаётся кликом. Нажатие мышью на handle начинает драг сразу и остаётся кликом, если мышь не сдвинулась.
touch{ delay: 200, tolerance: 5 }: долгое нажатие, чтобы списки продолжали прокручиваться под пальцем.

Тип, который вы не указали, сохраняет значение по умолчанию. Собственная activation перетаскиваемого элемента важнее, чем у области перетаскивания, а та — важнее, чем у провайдера. Пока нажатие ждёт, useDnD().status равен 'activating', а activation.progress идёт от 0 до 1; Escape или useDnD().cancel() отменяет его. Попробуйте условия вживую и нарисуйте по прогрессу кольцо долгого нажатия в сенсорах.

Автоскролл

Перетащите канал к верху или к низу списка. Зелёные полосы — краевые зоны: чем глубже в зону, тем быстрее прокручивается список.

Live

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

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

const OPTIONS = {
  threshold: [0.1, 0.2, 0.4],
  maxSpeed: [10, 25, 50],
  acceleration: [4, 10, 25],
} as const;

const on = ref(true);
// The defaults: an edge zone of 20% of the container (24 to 96px), 10px a frame at the
// edge, up to 25px a frame past it.
const options = ref({ threshold: 0.2, maxSpeed: 25, acceleration: 10 });
const autoScroll = computed(() => (on.value ? options.value : false));
const call = computed(() =>
  on.value
    ? `:auto-scroll="{ threshold: ${options.value.threshold}, maxSpeed: ${options.value.maxSpeed}, acceleration: ${options.value.acceleration} }"`
    : ':auto-scroll="false"',
);
</script>

<template>
  <DnDProvider :auto-scroll="autoScroll">
    <div class="demo">
      <div class="switches">
        <label class="switch">
          <input v-model="on" type="checkbox" />
          autoScroll
        </label>
        <fieldset v-for="(values, name) in OPTIONS" :key="name" :disabled="!on">
          <legend>{{ name }}</legend>
          <label v-for="value in values" :key="value" :class="{ on: options[name] === value }">
            <input v-model="options[name]" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
      </div>
      <code class="call">{{ call }}</code>
      <Channels :threshold="on ? options.threshold : 0" />
    </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 14px;
}

fieldset {
  display: inline-flex;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

fieldset:disabled {
  opacity: 0.4;
}

legend {
  float: left;
  align-self: center;
  padding: 0 8px 0 6px;
  font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

fieldset label {
  padding: 5px 9px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

fieldset label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

fieldset label:has(input:focus-visible),
.switch:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

fieldset input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.switch {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.switch input {
  accent-color: #42b883;
}

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

Любой элемент с overflow: auto или scroll прокручивается сам, как и окно. Провайдер сначала прокручивает контейнер под указателем, затем его предков, затем окно.

ОпцияПо умолчаниюЧто задаёт
threshold0.2Краевая зона как доля размера контейнера, от 24 до 96 px.
acceleration10Скорость у самого края, в px за кадр. Она растёт с квадратом глубины в зоне, поэтому начало медленное и точное.
maxSpeed25Максимальная скорость. За краем (указатель вне контейнера) скорость продолжает расти до неё.

:auto-scroll="false" отключает его. Направление прокручивается, только когда указатель сдвинулся в эту сторону, поэтому элемент, взятый у края, не прокручивает сам по себе. Списки, деревья и области перетаскивания принимают собственный autoScroll для своего контейнера:

makeSortableList(list, { items: channels, autoScroll: { maxSpeed: 10 } });

Перетаскивание с клавиатуры никогда не использует автоскролл: вместо этого оно прокручивает элемент в зону видимости. isAutoScrolling (из useDnD() или из области перетаскивания) равен true, пока что-то прокручивается.

Вибрация и клавиша копирования

Драг касанием коротко вибрирует при старте. :haptics="false" отключает это; объект задаёт каждую вибрацию:

<DnDProvider :haptics="{ start: true, enter: 10, drop: 10, refuse: [20, 40, 20] }">
КлючВибрирует, когдаПо умолчанию
startДолгое нажатие превращается в драг.true (10 ms)
enterДраг входит в цель, которая его принимает.выключено
dropБросок состоялся.выключено
refuseБросок отклонён, и элементы летят обратно.выключено

Каждый принимает true (шаблон по умолчанию), длительность в ms, шаблон «вкл/выкл» или false. Вибрируют только драги, начатые касанием, а iOS Safari не умеет вибрировать вовсе.

copyModifier — клавиша, которая, если её держать во время драга между списками, превращает перемещение в копирование: по умолчанию 'Alt', любой KeyboardEvent.key или null, чтобы её не было. Списку, из которого берутся элементы, нужна функция clone; см. списки с общими элементами.

Несколько провайдеров

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

Live

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

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

const reading = ref([
  { id: 'dune', title: 'Dune', hue: 32 },
  { id: 'emma', title: 'Emma', hue: 330 },
  { id: 'ulysses', title: 'Ulysses', hue: 210 },
]);
const wishlist = ref([
  { id: 'beloved', title: 'Beloved', hue: 140 },
  { id: 'solaris', title: 'Solaris', hue: 260 },
]);

const separate = ref(false);
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <div class="modes" role="radiogroup" aria-label="Wishlist">
        <label :class="{ on: !separate }"><input v-model="separate" type="radio" :value="false" />one provider</label>
        <label :class="{ on: separate }"><input v-model="separate" type="radio" :value="true" />wishlist in its own</label>
      </div>
      <div class="columns">
        <div class="column">
          <Shelf id="reading" v-model="reading" title="Reading" />
          <EventLog title="outer monitor" />
        </div>
        <!-- A provider inside a provider: its items belong to it, and drags never cross. -->
        <DnDProvider v-if="separate">
          <div class="column island">
            <span class="tag">&lt;DnDProvider&gt;</span>
            <Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
            <EventLog title="inner monitor" />
          </div>
        </DnDProvider>
        <div v-else class="column">
          <Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
        </div>
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
}

.modes {
  display: inline-flex;
  padding: 3px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes label {
  padding: 5px 10px;
  border-radius: 6px;
  font-size: 12px;
  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;
}

.columns {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 230px));
  gap: 16px;
  max-width: 100%;
}

.column {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}

.island {
  position: relative;
  margin: -10px;
  padding: 10px;
  border: 1.5px dashed color-mix(in srgb, #42b883 60%, transparent);
  border-radius: 16px;
}

.tag {
  position: absolute;
  top: -9px;
  right: 14px;
  padding: 0 6px;
  background: Canvas;
  font: 10px/18px ui-monospace, Menlo, Consolas, monospace;
  color: #42b883;
}
</style>
<DnDProvider>
  <Shelf v-model="reading" />
  <DnDProvider>
    <Shelf v-model="wishlist" />
    <EventLog />            <!-- hears the inner provider's drags only -->
  </DnDProvider>
</DnDProvider>
  • Провайдеры независимы: драг никогда не переходит из одного в другой.
  • Вложенные провайдеры допустимы. Побеждает ближайший владелец: элемент принадлежит ближайшему провайдеру над ним, а внешний провайдер его игнорирует.
  • У каждого провайдера своё выделение, оверлей, live-регион и мониторы.

Второй провайдер имеет смысл только для части страницы, которой нужны собственные настройки на уровне провайдера (определение цели, клавиши клавиатуры, объявления) и элементы которой никогда её не покидают. Если элементы не переносятся между двумя частями страницы, проверьте, не стоят ли они под двумя провайдерами.

Правилам для одной части страницы (доска с привязкой к сетке, лента с более долгим нажатием касанием, панель, где ничего не перетаскивается) собственный провайдер не нужен:
makeDragArea(board, { modifiers: [snapToGrid(20)], activation: { touch: { delay: 400 } } });
Элементы области остаются в провайдере приложения и по-прежнему могут перемещаться куда угодно. См. ограничения.

Приложение, смонтированное в другом месте, внутри shadow root или как отдельное Vue-приложение, может присоединиться к существующему провайдеру: передайте useDnDContext() в provideDnDContext(app, context) перед монтированием. Проверка попадания и оверлей работают через границы shadow root.

SSR и Nuxt

Провайдер безопасен для SSR. Списки и элементы рендерятся на сервере, как любая другая разметка. Оверлей и live-регион телепортируются и монтируются только на клиенте, поэтому никогда не вызывают рассинхронизацию гидрации. Id по умолчанию берутся из useId() Vue и совпадают на сервере и клиенте.

В Nuxt модуль регистрирует DnDProvider глобально и автоимпортирует композаблы: см. установку.