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

Сенсоры и программное перетаскивание

Как ввод становится драгом — сенсоры указателя и клавиатуры, условия активации, canDrag, собственные сенсоры и драги, которые вы задаёте скриптом через @vue-dnd-kit/actions.

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

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

Сенсор превращает ввод в драги. Он лишь говорит провайдеру «перетащи этот элемент отсюда», затем куда сдвинулся ввод и был ли бросок или отмена. Остальное делает провайдер, одинаково для всех сенсоров: один драг за раз, отказ отключённым элементам, условия активации, сортировка, автоскролл, объявления, Escape и весь бросок.

Встроенные сенсоры

СенсорВвод
pointerSensorМышь, касание и перо, через Pointer Events. Захватывает указатель, не даёт странице выделять текст или прокручиваться под драгом касанием и поглощает клик, который следует за драгом.
keyboardSensorSpace и Enter берут и бросают, стрелки двигают, Escape отменяет — любой драг, в том числе драг указателем.

Оба работают по умолчанию. sensors провайдера заменяет список:

<DnDProvider :sensors="[pointerSensor, keyboardSensor, joystick.sensor]">

Чтобы отключить перетаскивание с клавиатуры, лучше используйте :keyboard="false", а не убирайте keyboardSensor: сенсор клавиатуры — это ещё и то, что отменяет любой драг по Escape. Его клавиши переназначаются через keyboard.keys (см. Доступность).

Условия активации

Нажатие не начинает драг сразу: оно ждёт своего условия активации, так что клик остаётся кликом, а свайп по-прежнему может прокручивать страницу. Переключите правила этого списка и нажмите на строку:

Live

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

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

<template>
  <DnDProvider>
    <Inbox />
  </DnDProvider>
</template>

У условия три поля:

ПолеДраг начинается…
distanceКогда указатель сдвинулся на столько px.
delayКогда нажатие длится столько ms. Вместе с distance — что наступит раньше.
toleranceС delay: насколько указатель может сдвинуться за это время. Сверх этого нажатие сдаётся, чтобы страница могла прокручиваться. По умолчанию 5 px, если нет distance.

Если своих условий нет, у каждого типа указателя своё значение по умолчанию:

УказательПо умолчанию
МышьПосле сдвига на 4 px. Сразу при нажатии на handle, если не задано условие для мыши.
ПероПосле сдвига на 4 px.
КасаниеПосле нажатия в 200 ms. Сдвиг больше чем на 5 px до этого отменяет нажатие, и страница вместо этого прокручивается.

Где их задавать

Для каждого типа указателя — mouse, touch, pen — на провайдере, области перетаскивания или элементе. Для каждого типа важнее собственное условие элемента, затем его области, затем провайдера:

<DnDProvider :activation="{ mouse: { distance: 8 }, touch: { delay: 250, tolerance: 5 } }">
// Inbox.vue: the rules of every drag that starts inside the panel.
makeDragArea(panel, { activation: () => MODES[mode.value].activation });

// One item: a longer press by touch, the area's rule for the mouse.
makeSortableItem(el, { item: () => props.task, activation: { touch: { delay: 400, tolerance: 8 } } });

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

Кольцо долгого нажатия

Пока нажатие ждёт, useDnD().status равен 'activating', а activation.progress идёт от 0 до 1 — по времени с delay, по пройденному расстоянию с distance. Демо рисует кольцо там, где началось нажатие:

Inbox.vue
<script setup lang="ts">
const { status, activation } = useDnD();
</script>

<template>
  <svg v-if="status === 'activating'" class="ring" viewBox="0 0 40 40">
    <circle cx="20" cy="20" r="17" pathLength="1" :stroke-dasharray="`${activation.progress.value} 1`" />
  </svg>
</template>

Дайте кольцу pointer-events: none: оно находится под указателем, а драг проверяет попадание по тому, что там. Нажатие, которое сдаётся — сдвинулось дальше своего tolerance или отпущено слишком рано, — сразу возвращает status в 'idle'.

Ручки начинают сразу

С handle драг начинает только ручка, и нажатие мышью на неё перетаскивает сразу: захват за ручку не с чем перепутать. Нажатие, отпущенное там, где началось, остаётся кликом, так что ручка может открывать меню по click (указатель захватывается, только когда сдвинется). Касание всё равно ждёт долгого нажатия, если только ручка не может прокручивать страницу. Дайте ей touch-action: none, а элементу — небольшое расстояние:

Message.vue
makeSortableItem(el, {
  item: () => props.chat,
  handle: '.grip',
  // The grip has `touch-action: none`: a touch on it can't scroll, so it drags after 4 px.
  activation: { touch: { distance: 4 } },
});

Условие для мыши, заданное на элементе, его области или провайдере, действует и на ручки. Сделайте ручку <button>, как в демо: она получает фокус, так что с неё начинается и перетаскивание с клавиатуры.

Только некоторые нажатия

canDrag — ещё один фильтр на перетаскиваемых и сортируемых элементах. Он получает событие ввода и отвечает true или false для этого нажатия. Здесь драгу указателем нужен Shift; клавиатура перетаскивает как обычно:

Live

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

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

<template>
  <DnDProvider>
    <Checklist />
  </DnDProvider>
</template>
Task.vue
makeSortableItem(el, {
  item: () => props.task,
  // Never: the row is locked (and says so to screen readers with aria-disabled).
  disabled: () => !!props.task.locked,
  // Per press: pointer drags only with Shift held; the keyboard as usual.
  canDrag: (event) => {
    if (!props.shiftOnly || event instanceof KeyboardEvent || event.shiftKey) return true;
    emit('refused');
    return false;
  },
});
ОпцияКогда спрашиваетсяДля чего
disabledВживую, и выставляется как aria-disabledЭлементы, которые вообще не могут двигаться: заблокированные, только для чтения.
canDragОдин раз на нажатие, с его событиемПравила про ввод: зажатая клавиша, кнопка, тип указателя.
handleКуда приходится нажатиеЭлементы, которые начинают драг только с одной части.

canDrag спрашивается до условия активации, с pointerdown или keydown. Тип указателя — ещё одно правило, которое он может задать:

// Mouse and pen drag; a touch on the row always scrolls the page.
canDrag: (event) => !(event instanceof PointerEvent && event.pointerType === 'touch'),

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

Собственный сенсор

Геймпад, экранный джойстик, голосовые команды: всё, что может указать на элемент и двигаться, может управлять драгом. Этот геймпад — сенсор. Удерживайте A над героем, чтобы взять его, управляйте крестовиной, повторное A бросает, B отменяет. Мышь и клавиатура рядом с ним по-прежнему работают:

Live

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

<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue';
import { DnDProvider, keyboardSensor, pointerSensor } from '@vue-dnd-kit/core';
import Party from './Party.vue';
import Gamepad from './Gamepad.vue';
import { createJoystick } from './joystick';

const stage = useTemplateRef<HTMLElement>('stage');
const joystick = createJoystick(() => stage.value);
// The gamepad drives drags next to the mouse, touch and the keyboard.
const sensors = [pointerSensor, keyboardSensor, joystick.sensor];

onMounted(() => {
  const first = stage.value?.querySelector('li');
  if (first) joystick.aim(first);
});
</script>

<template>
  <DnDProvider :sensors="sensors">
    <div ref="stage" class="arcade">
      <Party />
      <Gamepad :joystick="joystick" />
      <span class="cursor" :style="{ left: `${joystick.cursor.value.x}px`, top: `${joystick.cursor.value.y}px` }" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.arcade {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 32px 48px;
  padding: 8px;
}

/* The gamepad's cursor: above everything, never what a hit test finds. */
.cursor {
  position: absolute;
  z-index: 1;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border: 2px solid #42b883;
  border-radius: 50%;
  background: color-mix(in srgb, #42b883 25%, transparent);
  box-shadow: 0 0 0 4px color-mix(in srgb, #42b883 15%, transparent);
  pointer-events: none;
}
</style>

defineSensor даёт сенсору имя и setup, который получает сторону провайдера, один раз на провайдер:

joystick.ts
const sensor = defineSensor({
  name: 'joystick',
  // Once per provider, in the browser. The cleanup runs when the provider unmounts.
  setup(ctx) {
    context = ctx;
    return () => {
      context = null;
      drag = null;
    };
  },
});

Нажатие запрашивает у провайдера драг через activate(). Цель проверяется как настоящее нажатие: это должен быть перетаскиваемый элемент этого провайдера, не отключённый и не кнопка внутри него:

joystick.ts
/** A goes down: drops a running drag, or starts a hold over what is under the cursor. */
const press = () => {
  if (drag?.state === 'dragging') return void drag.drop();
  const at = point();
  const target = document.elementFromPoint(at.x, at.y);
  if (!target || !context) return;
  drag = context.activate({
    target, // checked like a real press: a draggable of this provider, not disabled
    point: at,
    type: 'pointer', // follows points: lists sort under it, containers auto-scroll
    activation: { delay: 300 }, // `useDnD().activation.progress` runs meanwhile
    onEnd: (ended) => {
      if (drag === ended) drag = null;
    },
  }); // null: nothing to drag there
};

Затем драг движется вместе с крестовиной, а A или B его завершают:

joystick.ts
const move = (dx: number, dy: number) => {
  cursor.value = { x: cursor.value.x + dx, y: cursor.value.y + dy }; // clamped in the demo
  drag?.move(point()); // pending: checks the tolerance; dragging: moves
};

const release = () => {
  if (drag?.state === 'pending') drag.cancel(); // A let go before the hold is over
};

const cancel = () => drag?.cancel();

Добавьте сенсор в sensors провайдера, как это делает демо, или запустите его на время жизни компонента через useSensor(joystick.sensor). Точки — в координатах вьюпорта: демо хранит свой курсор в координатах сцены, чтобы он прокручивался вместе со страницей, и переводит его в point().

Что получает сенсор

setup(context) получает сторону провайдера:

ЧленЧто это
propsПропсы провайдера (activation, keyboard…). Читайте их, когда нужно: они могут меняться.
statusСтатус драга провайдера, каким бы сенсором ни шёл драг.
dragТекущий драг провайдера, ожидающий или идущий, каким бы сенсором он ни был начат. null, если драга нет.
activate(options)Запрашивает драг. Возвращает SensorDrag или null, если драг начать нельзя.
cancel()Отменяет драг провайдера или ожидающее нажатие.

activate() принимает:

ОпцияЗначение
targetКуда пришёлся ввод: элемент внутри перетаскиваемого элемента или id перетаскиваемого элемента. Элемент проверяется как настоящее нажатие (ручка, не интерактивный потомок); id пропускает проверки.
pointГде начинается драг, в координатах вьюпорта. По умолчанию — центр перетаскиваемого элемента.
type'pointer' (по умолчанию): следует за точками, проверяет попадание по целям, использует автоскролл. 'keyboard': двигается по элементам, прокручивает к целям, в конце возвращает фокус.
activationУсловие ({ delay: 300 }) или тип указателя ('touch'), чтобы использовать настроенное для него. По умолчанию — нет, драг начинается сразу.
eventСобытие ввода, передаётся в canDrag элемента (без него canDrag не спрашивается). pointerType касания делает драг драгом касанием (вибрация).
onStart(drag)Драг начался, после своей активации.
onEnd(drag)Драг окончен: прерван, брошен или отменён — кем угодно.
onResume(drag)onBeforeDrop ответил 'continue': драг продолжается, и move, drop и cancel снова действуют.

Возвращаемый SensorDrag:

ЧленЧто делает
id, typeId перетаскиваемого элемента и 'pointer' или 'keyboard'.
state'pending' (ждёт активации), 'dragging', затем 'ended'.
startedДраг начался, даже если с тех пор закончился.
move(point)Ввод сдвинулся. В ожидании проверяет допуск; во время драга двигает драг.
step(direction)Перетаскивание с клавиатуры: один шаг стрелки, 'up' | 'down' | 'left' | 'right'.
place({ list, index })Ставит элемент в позицию сортируемого списка, как это делают перемещения с клавиатуры. Разрешается в false, если ни один список не принимает его туда.
drop()Бросает. Разрешается в true, если бросок прошёл, когда он приземлился.
cancel()Отменяет: элемент летит обратно, или ожидающее нажатие оставляется.

Вызовы, которые больше не имеют смысла — перемещение после конца, второй drop(), — игнорируются. Полные типы: Сенсоры.

Чтобы обернуть встроенный сенсор, запустите его setup с собственным контекстом. Создайте его через Object.create(context, { activate: { value: myActivate } }), а не через spread, чтобы живые геттеры (status, drag, props) продолжали работать.

Программное перетаскивание

@vue-dnd-kit/actions перемещает элементы из кода: кнопки «Move up», меню, горячие клавиши, отмена, обучающие туры, тесты. Он построен на публичном API сенсоров, и его драги — настоящие: озвучиваются и анимируются, проходят через onBeforeDrop, onDrop, e.result и preventDefault(), по правилам провайдера (один драг за раз, отключённые элементы, accept и group.put).

Live

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

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

<template>
  <!-- @vue-dnd-kit/actions moves the rows from code: buttons, a shuffle, a scripted drag. -->
  <DnDProvider>
    <Queue />
  </DnDProvider>
</template>
npm i @vue-dnd-kit/actions

moveItem: один вызов — одно перемещение

import { useDnDActions } from '@vue-dnd-kit/actions';

const { moveItem } = useDnDActions(); // in setup, under a provider

await moveItem('card-1', { index: 0 }); // to the top of its list
await moveItem('card-1', { list: 'done' }); // to the end of another list
await moveItem('card-1', { list: 'done', index: 2 }); // to a place in another list
  • list — id сортируемого списка. Без него элемент остаётся в своём списке.
  • index — где окажется элемент, считая без самого элемента, и ограничен размером списка. Без него элемент идёт в конец.
  • Разрешается в true, если бросок прошёл, и в false, если элемент не отрендерен или отключён, ни один список не принимает его туда или бросок отклонён.
  • Вызовы встают в очередь: каждый ждёт, пока провайдер освободится, так что быстрый второй клик не теряется. Перемешивание в демо — пять вызовов подряд.
  • Он выполняется как перетаскивание с клавиатуры, поэтому озвучивается, а фокус остаётся на элементе.
  • Только списки: у позиций в дереве есть ещё и глубина.

Строку с кнопками перемещения см. в Доступности.

startDrag: скриптовый драг

const { startDrag } = useDnDActions();

const drag = startDrag('card-1'); // null when it can't start now
if (drag) {
  await drag.moveTo(document.querySelector('#trash')!); // onto an element
  await drag.moveTo({ x: 400, y: 120 }); // to a point, in viewport coordinates
  await drag.moveTo({ list: 'done', index: 2 }); // to a list position
  await drag.drop(); // true when the drop went through; or drag.cancel()
}

moveTo разрешается, когда перемещение отрендерилось, drop — когда бросок приземлился. Кнопка «Play a scripted drag» в демо плавно ведёт последний трек вверх маленькими шагами moveTo, затем бросает его наверху. startDrag(id, { from: { x, y } }) берёт элемент в точке вместо его центра.

Передать драг клавиатуре

startDrag(id, { type: 'keyboard' }) берёт элемент как перетаскивание с клавиатуры. Ваш код может двигать его через await drag.step('down') или оставить это пользователю: сенсор клавиатуры управляет каждым перетаскиванием с клавиатуры, кто бы его ни начал. Так получается пункт меню «Move»:

Live

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

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

<template>
  <!-- The ⋯ menus move rows with @vue-dnd-kit/actions. -->
  <DnDProvider>
    <Planner />
  </DnDProvider>
</template>
Row.vue
const { moveItem, startDrag } = useDnDActions();

// One move in one call: picked up, put there and dropped, announced and animated.
const toOther = () => moveItem(props.task.id, { list: props.other.id, index: 0 });
// A keyboard drag, handed to the arrow keys: Space or Enter drops, Escape cancels.
const withKeys = () => {
  el.value?.focus();
  startDrag(props.task.id, { type: 'keyboard' });
};

Стрелки двигают строку по слотам, ArrowLeft и ArrowRight — на другую неделю (списки разделяют group). Когда драг заканчивается, фокус возвращается на строку. Подсказки клавиш под доской подсвечиваются, пока useDnD().active.value?.sensor равен 'keyboard'.

Вне компонентов

useDnDActions() запускает один сенсор, по имени actions, в провайдере выше. Чтобы управлять провайдером из стора или тестового окружения либо под собственным именем, создайте действия в сенсоре, который запускаете сами:

import { defineSensor } from '@vue-dnd-kit/core';
import { createDnDActions, type DnDActions } from '@vue-dnd-kit/actions';

export let tour: DnDActions | undefined;
export const tourSensor = defineSensor({
  name: 'tour',
  setup: (context) => void (tour = createDnDActions(context)),
});
<DnDProvider :sensors="[pointerSensor, keyboardSensor, tourSensor]">

Затем вызывайте await tour?.moveItem('card-1', { list: 'done' }) откуда угодно. При серверном рендеринге ничего не перетаскивается: moveItem разрешается в false, а startDrag возвращает null.