Документация/Руководства
Сортируемые списки
Списки, полосы вкладок и сетки, которые перестраиваются во время драга — makeSortableList рендерит черновик, makeSortableItem делает строки, а бросок записывает новый порядок в ваш ref. Ручки, линия вставки, обмен местами.
Страница переведена ИИ и может содержать неточности. Читать английский оригинал
Загружаем демо…
<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>Список и его строки
Сортируемому списку нужны два компонента: список и его строка.
<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>
<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, чтобы
перетаскивать только за одну часть:
<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"), но у него нет осмысленного имени.
touch-action: none, а
элементу — activation: { touch: { distance: 4 } }.Стратегии
strategy говорит, как устроен список, чтобы строки освобождали место правильно:
strategy | Для |
|---|---|
'vertical' (по умолчанию) | Колонка строк. |
'horizontal' | Один ряд: вкладки, чипы, таймлайн. |
'grid' | CSS-сетки и переносящиеся flex-ряды, в том числе с элементами разного размера. |
Ряд вкладок — горизонтальный список:
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Browser from './Browser.vue';
</script>
<template>
<DnDProvider>
<Browser />
</DnDProvider>
</template>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
добавляет то же для изменений, сделанных не драгом, здесь — кнопками (см.
анимации).
Сетка сортируется в двух измерениях, с плитками любого размера:
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import PhotoGrid from './PhotoGrid.vue';
</script>
<template>
<DnDProvider>
<PhotoGrid />
</DnDProvider>
</template>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 % с нулевой
специфичностью, так что любое ваше правило побеждает:
.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 и применяются к следующему драгу:
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });
Строка, которая должна оставаться первой или последней, что бы ни делали остальные, вообще не
элемент: рендерите её вне списка. Слой Background в панели стоит под <ul>.
Показать часть элементов
Поле поиска, тег: filter показывает элементы, для которых возвращает true, и драг
сортирует среди них. Остальные остаются в вашем массиве, на своих местах:
Загружаем демо…
<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>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' список во время драга стоит на месте, а линия показывает, куда
попадёт бросок. Линию рисуете вы:
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import IssueTable from './IssueTable.vue';
</script>
<template>
<DnDProvider>
<IssueTable />
</DnDProvider>
</template>const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
<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' перетаскиваемый элемент меняется местами с тем, что под ним, а остальные
стоят на месте:
Загружаем демо…
<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>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': обведите этот элемент вместо того, чтобы рисовать линию. Стрелки двигают отметку от элемента к элементу; на самом перетаскиваемом элементе её нет.
<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.