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

Сортируемые списки

Списки, полосы вкладок и сетки, которые перестраиваются во время драга — makeSortableList рендерит черновик, makeSortableItem делает строки, а бросок записывает новый порядок в ваш ref. Ручки, линия вставки, обмен местами.

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

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

Live

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

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

// Front to back, as a design tool lists them.
const layers = ref([
  { id: 'title', name: 'Title', kind: 'text' as const, visible: true, locked: false },
  { id: 'badge', name: 'Badge', kind: 'shape' as const, visible: true, locked: false },
  { id: 'photo', name: 'Photo', kind: 'image' as const, visible: true, locked: false },
  { id: 'card', name: 'Card', kind: 'shape' as const, visible: true, locked: true },
]);
</script>

<template>
  <DnDProvider>
    <div class="editor">
      <!-- The artboard reads `layers`: it restacks when a drop writes the new order. -->
      <div class="artboard" aria-hidden="true">
        <template v-for="(layer, index) in layers" :key="layer.id">
          <div v-show="layer.visible" class="shape" :class="layer.id" :style="{ zIndex: layers.length - index }">
            <template v-if="layer.id === 'title'">Summer sale</template>
            <template v-if="layer.id === 'badge'">-30%</template>
          </div>
        </template>
      </div>
      <LayersPanel v-model="layers" />
    </div>
  </DnDProvider>
</template>

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

.artboard {
  position: relative;
  overflow: hidden;
  width: 240px;
  height: 170px;
  border-radius: 4px;
  background: #fff;
  box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5);
}

.shape {
  position: absolute;
}

.card {
  inset: 18px;
  border-radius: 12px;
  background: #1e3a8a;
}

.photo {
  top: 34px;
  left: 66px;
  width: 120px;
  height: 84px;
  border-radius: 8px;
  background:
    radial-gradient(circle at 75% 30%, #fde68a 0 12px, transparent 13px),
    linear-gradient(170deg, #67e8f9, #0e7490);
}

.badge {
  top: 20px;
  left: 160px;
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: #facc15;
  color: #1e3a8a;
  font: 800 13px/1 system-ui, sans-serif;
}

.title {
  top: 104px;
  left: 30px;
  color: #fff;
  font: 800 24px/1 system-ui, sans-serif;
  letter-spacing: -0.02em;
  text-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
}
</style>

Список и его строки

Сортируемому списку нужны два компонента: список и его строка.

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

const layers = defineModel<Layer[]>({ required: true });
const list = useTemplateRef<HTMLElement>('list');
const { items } = makeSortableList(list, { items: layers });
</script>

<template>
  <ul ref="list">
    <LayerRow v-for="layer in items" :key="layer.id" :layer="layer" />
  </ul>
</template>
LayerRow.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';

const props = defineProps<{ layer: Layer }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.layer });
</script>

<template>
  <li ref="el">{{ layer.name }}</li>
</template>

Строка регистрируется в списке, внутри которого она отрендерена. Компонент может создать несколько списков (две колонки, список внутри списка): каждая строка входит в тот, чей элемент её содержит, а у вложенных списков — в самый внутренний.

Черновик

makeSortableList возвращает items — черновик вашего массива. Пока вы перетаскиваете, черновик перестраивается вживую: перетаскиваемая строка занимает новый слот, а остальные отъезжают в сторону. Ваш массив не меняется до броска. Тогда библиотека записывает в него новый порядок.

Панель слоёв выше показывает и то, и другое: панель рендерит items и двигается во время драга; монтажная область читает layers, сам массив, и меняет порядок слоёв, только когда вы отпускаете.

  • Рендерите items, а не свой массив. Если рендерить свой массив, до броска ничего не двигается.
  • Запись происходит после onDrop списка. В onDrop ваш массив ещё содержит старый порядок, а e.result — новый.
  • layers — обычный ref (здесь defineModel, и запись эмитит update:modelValue), поэтому остальное приложение видит изменение, как любое другое.
makeSortableList(list, {
  items: layers,
  onDrop: (e) => {
    console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
  },
});
Стор, серверное состояние или иммутабельные данные? Передайте геттер или computed вместо ref. Библиотека не пишет в источник только для чтения: вызовите e.preventDefault() и примените e.result сами. См. управляемые списки.

Ключи

Строки идентифицируются по своим данным, а не по индексу и не по автоматическому id. По умолчанию ключ — item.id, а для строк и чисел — сам элемент.

// LayersPanel.vue: your items have another key
makeSortableList(list, { items: layers, getKey: (layer) => layer.uuid });

// LayerRow.vue: `item` is keyed with the list's getKey…
makeSortableItem(el, { item: () => props.layer });
// …or pass the key yourself
makeSortableItem(el, { id: () => props.layer.uuid });
  • Используйте тот же ключ в v-for: :key="layer.uuid". С :key="index" анимируются не те строки, и их состояние перескакивает между строками.
  • Ключи должны быть уникальны среди всех списков, которые обмениваются элементами.
  • Передавайте item и id геттерами, чтобы строка оставалась синхронной при изменении пропсов.

Ручки

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

LayerRow.vue
<script setup lang="ts">
makeSortableItem(el, { item: () => props.layer, handle: '.grip' });
</script>

<template>
  <li ref="el">
    <button type="button" class="grip" :aria-label="`Move ${layer.name}`">⠿</button>
    {{ layer.name }}
  </li>
</template>
  • handle — селектор внутри элемента или ref на элемент.
  • Драг мышью начинается с ручки сразу, без обычных 4 px. Нажатие без движения остаётся кликом: ручка может открывать меню по click.
  • Ручка получает атрибуты драга (role, tabindex, aria-*), и перетаскивание с клавиатуры начинается с неё.

Сделайте ручку элементом <button type="button"> с aria-label: она фокусируется сама по себе, а скринридеры озвучивают её подпись. Голый <span> с иконкой тоже работает (библиотека даёт ему tabindex="0" и role="button"), но у него нет осмысленного имени.

Драг касанием начинается после долгого нажатия в 200 ms, чтобы страница могла прокручиваться. Чтобы перетаскивать за ручку сразу, дайте ручке touch-action: none, а элементу — activation: { touch: { distance: 4 } }.

Стратегии

strategy говорит, как устроен список, чтобы строки освобождали место правильно:

strategyДля
'vertical' (по умолчанию)Колонка строк.
'horizontal'Один ряд: вкладки, чипы, таймлайн.
'grid'CSS-сетки и переносящиеся flex-ряды, в том числе с элементами разного размера.

Ряд вкладок — горизонтальный список:

Live

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

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

<template>
  <DnDProvider>
    <Browser />
  </DnDProvider>
</template>
Browser.vue
const { items } = makeSortableList(strip, {
  items: tabs,
  strategy: 'horizontal',
  axis: 'x',        // the tabs stay on their row
  animateChanges: true, // the buttons open and close tabs: slide the others then too
});

Кнопка закрытия вкладки продолжает работать внутри перетаскиваемой вкладки, а клик, которым заканчивается драг, не выбирает вкладку. Во время драга строки скользят сами; animateChanges добавляет то же для изменений, сделанных не драгом, здесь — кнопками (см. анимации).

Сетка сортируется в двух измерениях, с плитками любого размера:

Live

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

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

<template>
  <DnDProvider>
    <PhotoGrid />
  </DnDProvider>
</template>
PhotoGrid.vue
const { items } = makeSortableList(grid, { items: photos, strategy: 'grid' });
.grid { display: grid; grid-template-columns: repeat(4, 76px); grid-auto-rows: 76px; grid-auto-flow: dense; }
.wide { grid-column: span 2; }
.tall { grid-row: span 2; }

grid-auto-flow: dense держит сетку плотной, когда двигается большая плитка. Без него сетка оставляет дыры там, где плитка больше не помещается, и вырастает на ряд.

Фиксация оси

makeSortableList(list, { items: layers, axis: 'y' });

axis: 'y' держит драг на вертикальной оси, 'x' — на горизонтальной: слои и вкладки выше так и делают. Собственный axis элемента важнее, чем у его списка. Чтобы ещё и удерживать драг внутри списка, добавьте модификатор restrictToParent. Подробнее — в ограничениях.

Стилизация слота

Перетаскиваемая строка остаётся в списке как слот, в который она приземлится, и получает data-dnd-placeholder. Стиль по умолчанию снижает её непрозрачность до 40 % с нулевой специфичностью, так что любое ваше правило побеждает:

LayerRow.vue
.layer[data-dnd-placeholder] {
  opacity: 1;
  box-shadow: inset 0 0 0 1.5px #42b883;
}

.layer[data-dnd-placeholder] > * {
  visibility: hidden;   /* an empty slot of the row's exact size */
}

makeSortableItem возвращает то же состояние в виде ref, и ещё кое-что:

ВозвращаетЧто это
isPlaceholderЭта строка — слот, в который приземлится перетаскиваемый элемент.
isDraggingЭта строка перетаскивается (каждая перетаскиваемая строка при мульти-драге).
indexЕё индекс в черновике: он меняется во время драга.
selectedЗаписываемый, связан с выделением провайдера.
indicatorВ режиме индикатора — где проходит линия.

Список возвращает isOver и isAccepting для собственной подсветки, а также activeKey и activeIndex — перетаскиваемый элемент и его место в черновике этого списка. Все состояния, с живыми демо, — в стилизации.

Отключение

ОпцияЧто она запрещает
disabled на элементеЭтот элемент нельзя перетащить. Остальные всё равно проходят мимо него: заблокируйте слой выше, затем перетащите через него другой.
disabled на спискеНикаких драгов из него и бросков в него.
sort: false на спискеЕго элементы нельзя переставлять внутри него, но можно вынести: палитра, см. списки с общими элементами.

Все они принимают геттер или ref и применяются к следующему драгу:

LayerRow.vue
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });

Строка, которая должна оставаться первой или последней, что бы ни делали остальные, вообще не элемент: рендерите её вне списка. Слой Background в панели стоит под <ul>.

Показать часть элементов

Поле поиска, тег: filter показывает элементы, для которых возвращает true, и драг сортирует среди них. Остальные остаются в вашем массиве, на своих местах:

Live

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

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

// In priority order: the array is the backlog.
const tasks = ref<Task[]>([
  { id: 1, title: 'Login fails on Safari', tag: 'bug' },
  { id: 2, title: 'Dark mode for settings', tag: 'feature' },
  { id: 3, title: 'Document the export API', tag: 'docs' },
  { id: 4, title: 'Crash when the list is empty', tag: 'bug' },
  { id: 5, title: 'Keyboard shortcuts', tag: 'feature' },
  { id: 6, title: 'Typo in the pricing page', tag: 'docs' },
  { id: 7, title: 'Slow search with 10k rows', tag: 'bug' },
  { id: 8, title: 'Share a board by link', tag: 'feature' },
]);
</script>

<template>
  <DnDProvider>
    <Backlog v-model="tasks" />
  </DnDProvider>
</template>
Backlog.vue
const query = ref('');
const tag = ref<Tag | null>(null);

const { items } = makeSortableList(list, {
  items: tasks,
  filter: (task) => (!tag.value || task.tag === tag.value) && task.title.includes(query.value),
});
  • items содержит подходящие элементы: рендерите его как обычно. Фильтр реактивный: ввод в поле поиска сразу обновляет список.
  • Бросок записывает весь массив. Каждый скрытый элемент остаётся сразу после показанного элемента, за которым шёл; если двигался именно тот, скрытый остаётся после ближайшего предыдущего, который не двигался.
  • e.to.index и activeIndex списка считают показанные элементы. e.result содержит весь массив, включая скрытые элементы.
computed, который фильтрует ваш массив и передаётся как items, тоже работает, но он только для чтения: e.result пришлось бы применять самим и самим решать, куда идут скрытые элементы. filter делает и то и другое.

Линия вставки

С placeholder: 'indicator' список во время драга стоит на месте, а линия показывает, куда попадёт бросок. Линию рисуете вы:

Live

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

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

<template>
  <DnDProvider>
    <IssueTable />
  </DnDProvider>
</template>
IssueTable.vue
const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
IssueRow.vue
<script setup lang="ts">
const { indicator } = makeSortableItem(el, { item: () => props.issue });
</script>

<template>
  <div ref="el" class="row" :class="indicator && `drop-${indicator.side}`">…</div>
</template>

<style scoped>
.row.drop-before::before { content: ''; position: absolute; top: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
.row.drop-after::before { content: ''; position: absolute; bottom: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
</style>
Поле indicatorЧто в нём
listСписок, в который попадает бросок.
indexИндекс вставки в списке в том виде, как он отрендерен.
keyЭлемент, к которому привязана линия; null в пустом списке (тогда рисуйте её в списке).
side'before' или 'after' этого элемента; 'swap' в списке, который меняет местами. Деревья добавляют 'inside'.
depth, parentKeyТолько в деревьях: куда приземляется бросок. См. деревья.
  • indicator списка задан, пока линия в этом списке (таблица его выводит), а indicator элемента — пока линия привязана к этому элементу.
  • Перетаскиваемая строка остаётся на месте, помеченная как плейсхолдер: приглушите её.
  • Клавиатура тоже двигает линию.
  • Бросок вне всех целей в этом режиме отменяется (dropOutside: 'cancel'), ведь список так и не показал другой порядок.
  • Оверлей закрывает часть строки под собой. Сделайте его чуть прозрачным, и линия будет просвечивать: [data-dnd-overlay] .row { opacity: 0.85 }.

Обмен местами вместо сдвига

С behavior: 'swap' перетаскиваемый элемент меняется местами с тем, что под ним, а остальные стоят на месте:

Live

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

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

const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const columns = reactive([
  {
    id: 'primary',
    title: 'Primary',
    people: [
      { id: 'ava', name: 'Ava', hue: 160 },
      { id: 'ben', name: 'Ben', hue: 20 },
      { id: 'cleo', name: 'Cleo', hue: 280 },
      { id: 'dev', name: 'Dev', hue: 210 },
      { id: 'eli', name: 'Eli', hue: 45 },
    ],
  },
  {
    id: 'backup',
    title: 'Backup',
    people: [
      { id: 'fay', name: 'Fay', hue: 330 },
      { id: 'gus', name: 'Gus', hue: 100 },
      { id: 'hana', name: 'Hana', hue: 190 },
      { id: 'ivo', name: 'Ivo', hue: 0 },
      { id: 'jun', name: 'Jun', hue: 250 },
    ],
  },
]);

/** Live: the swap shows while you drag. On drop: nothing moves, the partner is marked. */
const placeholder = ref<'item' | 'indicator'>('item');
const last = ref('');
</script>

<template>
  <DnDProvider>
    <div class="schedule">
      <div class="modes" role="group" aria-label="Swap preview">
        <button type="button" :aria-pressed="placeholder === 'item'" @click="placeholder = 'item'">Live</button>
        <button type="button" :aria-pressed="placeholder === 'indicator'" @click="placeholder = 'indicator'">On drop</button>
      </div>
      <div class="week">
        <ol class="days">
          <li v-for="day in DAYS" :key="day">{{ day }}</li>
        </ol>
        <Rotation v-for="column in columns" :key="column.id" :column="column" :placeholder="placeholder" @swap="last = $event" />
      </div>
      <p class="last">{{ last ? `Swapped ${last}` : 'Drag a person onto another' }}</p>
    </div>
  </DnDProvider>
</template>

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

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

.modes button {
  padding: 6px 12px;
  border: 0;
  border-radius: 7px;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  opacity: 0.6;
  cursor: pointer;
}

.modes button[aria-pressed='true'] {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  opacity: 1;
}

.week {
  display: flex;
  gap: 10px;
  width: 400px;
  max-width: 100%;
}

.days {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 21px 0 0;
  padding: 0;
  list-style: none;
}

.days li {
  height: 36px;
  font: 11px/36px ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.last {
  margin: 0;
  font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}
</style>
Rotation.vue
const { items } = makeSortableList(list, {
  id: props.column.id,
  items: toRef(props.column, 'people'),
  group: 'rotation',
  behavior: 'swap',
  placeholder: () => props.placeholder,   // 'item' (live) or 'indicator' (on drop)
  onDrop: (e) => {
    if (e.partner) emit('swap', `${e.items[0]?.name} ⇄ ${e.partner.item.name}`);
  },
});
  • Вживую (placeholder: 'item', по умолчанию): обмен виден во время драга. Если вернуться назад или уйти со списка, он отменяется.
  • При броске (placeholder: 'indicator'): до броска ничего не двигается. Индикатор отмечает элемент, с которым бросок поменяет местами, side: 'swap': обведите этот элемент вместо того, чтобы рисовать линию. Стрелки двигают отметку от элемента к элементу; на самом перетаскиваемом элементе её нет.
PersonCell.vue
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>

У события броска есть mode: 'swap' и partner — элемент, с которым произошёл обмен: его key, item и его собственные from и to. Два списка одной группы меняются элементами между собой, когда у обоих behavior: 'swap', как у двух колонок. Элемент под драгом тогда переходит в другой список, поэтому его должно быть можно вынести (group.pull) и внести (group.put): см. списки с общими элементами. Мульти-драг сортирует, а не меняет местами.

Клавиатура

Поставьте фокус на строку или её ручку и нажмите Space или Enter, чтобы взять её. Стрелки вдоль списка двигают её на один слот, в сетках вверх и вниз — на следующий ряд, а стрелки поперёк списка переносят её в следующий список её группы. За концом списка стрелка ведёт дальше, в следующий список в ту сторону (списки друг над другом), а если его нет — на цель в той стороне, например зону удаления. Нажмите Space или Enter ещё раз, чтобы бросить, или Escape, чтобы вернуть на место. Так работает каждое демо на этой странице. Тексты для скринридеров — в доступности.

Анимации

Во время драга строки скользят на новые места, а брошенная строка приземляется в свой слот. Сам список растёт или сжимается до нового размера, когда строки входят и уходят (animateSize). Это включено по умолчанию и не требует кода. Не оборачивайте строки в <TransitionGroup>: перемещения анимировались бы дважды. Не ставьте и CSS transition на transform строк. Для изменений, сделанных не драгом (добавление, удаление, перемешивание), включите опцию animateChanges списка, как у вкладок. См. анимации.

Полный список опций — в API makeSortableList и makeSortableItem.