Документация/Руководства
Списки с общими элементами
Списки одной группы перемещают, копируют и меняют местами элементы — вся колонка принимает броски, pull и put решают, что входит и выходит, палитры копируют, а управляемые списки применяют каждый бросок к стору с историей отмены.
Страница переведена ИИ и может содержать неточности. Читать английский оригинал
Загружаем демо…
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'backlog',
title: 'Backlog',
stories: [
{ id: 's1', title: 'Saved searches', points: 5 },
{ id: 's2', title: 'Crash on empty board', points: 2, bug: true },
{ id: 's3', title: 'CSV export', points: 3 },
{ id: 's4', title: 'Dark mode email', points: 2 },
],
},
{
id: 'sprint',
title: 'Sprint 14',
stories: [{ id: 's5', title: 'Onboarding tour', points: 8 }],
},
]);
const wholeColumn = ref(true);
</script>
<template>
<DnDProvider>
<div class="planning">
<label class="switch">
<input v-model="wholeColumn" type="checkbox" />
<code>dropzone</code>: the whole column takes drops
</label>
<div class="columns">
<Column v-for="column in columns" :key="column.id" :column="column" :whole-column="wholeColumn" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.planning {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
}
.switch input {
accent-color: #42b883;
}
.switch code {
font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
color: #42b883;
}
.columns {
display: flex;
justify-content: center;
gap: 14px;
width: 100%;
}
</style>Группа
Списки с одинаковым именем group обмениваются элементами. По умолчанию каждый список — сам
себе группа.
<script setup lang="ts">
import { toRef, useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import StoryCard from './StoryCard.vue';
type Story = { id: string; title: string; points: number };
const props = defineProps<{ column: { id: string; title: string; stories: Story[] } }>();
const list = useTemplateRef<HTMLElement>('list');
const { items, isOver } = makeSortableList(list, {
id: props.column.id, // names the list in drop events
items: toRef(props.column, 'stories'), // a writable ref into the reactive column
group: 'planning', // same name: stories move between these lists
});
</script>
<template>
<section :class="{ over: isOver }">
<h3>{{ column.title }}</h3>
<ul ref="list">
<StoryCard v-for="story in items" :key="story.id" :story="story" />
</ul>
</section>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import Column from './Column.vue';
const columns = reactive([
{ id: 'backlog', title: 'Backlog', stories: [{ id: 's1', title: 'Saved searches', points: 5 }] },
{ id: 'sprint', title: 'Sprint 14', stories: [] },
]);
</script>
<template>
<Column v-for="column in columns" :key="column.id" :column="column" />
</template>
- Каждая колонка здесь — компонент, как обычно и бывает с колонками. Один компонент может создать и несколько списков, например постоянные Todo и Done рядом: каждая карточка входит в список, чей элемент её содержит.
toRef(props.column, 'stories')записываемый, потому что родитель передаёт реактивный объект. Бросок записывает оба массива — тот, который история покинула, и тот, в который вошла, — и очки обеих колонок пересчитываются.idпоявляется в событиях броска какfrom.listиto.listи служит ключом вe.result— новых массивах всех списков, которые меняет бросок.idиgroupчитаются один раз.- Ключи должны быть уникальны во всей группе.
onDrop списка выполняется для бросков в него. Перенос из backlog в sprint вызывает
onDrop списка sprint, а его e.result содержит новые массивы обоих списков.
Вся колонка принимает броски: dropzone
Короткий список в высокой колонке оставляет пустое место под последней карточкой. С
dropzone броски за список принимает другой элемент: здесь — вся колонка. История,
отпущенная ниже последней, приземляется в конец. Истории остаются в <ul>; растёт только
область, которая принимает броски. Демо обводит эту область во время драга: выключите её, и
историю будет принимать только сам список.
const zone = useTemplateRef<HTMLElement>('zone');
const { items } = makeSortableList(list, {
items: toRef(props.column, 'stories'),
group: 'planning',
dropzone: zone, // or a getter: () => (wholeColumn.value ? zone.value : undefined)
});
dropzone пустому списку всё равно нужен размер, чтобы принимать броски: задайте ему
min-height.Что входит и выходит: pull и put
group принимает и объект. pull говорит, что может покинуть список, put — что может в него
войти:
Загружаем демо…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type SortablePutContext } from '@vue-dnd-kit/core';
import RuleList from './RuleList.vue';
// Four lists of one group, `files`. What each gives and takes is its `group.pull` and `group.put`.
const shortlist = reactive({
id: 'shortlist',
title: 'Shortlist',
code: 'put: ({ keys }) => shortlist.files.length + keys.length <= 3',
refusal: 'full',
group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
files: [
{ id: 'pitch', name: 'pitch.pdf', kind: 'doc' as const },
{ id: 'mock', name: 'mockup.png', kind: 'image' as const },
],
});
const lists = reactive([
{
id: 'inbox',
title: 'Inbox',
code: '{ pull: true, put: false }',
refusal: 'put: false',
group: { name: 'files', pull: true, put: false },
files: [
{ id: 'logo', name: 'logo.svg', kind: 'image' as const },
{ id: 'brief', name: 'brief.pdf', kind: 'doc' as const },
{ id: 'hero', name: 'hero.png', kind: 'image' as const },
],
},
{
id: 'templates',
title: 'Templates',
code: "{ pull: 'clone', put: false }, sort: false",
refusal: 'put: false',
group: { name: 'files', pull: 'clone' as const, put: false },
sort: false,
files: [
{ id: 'invoice', name: 'invoice.doc', kind: 'doc' as const },
{ id: 'banner', name: 'banner.png', kind: 'image' as const },
],
},
{
id: 'images',
title: 'Images',
code: "put: 'image'",
refusal: 'images only',
group: { name: 'files', put: 'image' },
files: [],
},
shortlist,
]);
</script>
<template>
<DnDProvider>
<div class="rules">
<RuleList v-for="list in lists" :key="list.id" :list="list" />
</div>
</DnDProvider>
</template>
<style scoped>
.rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 260px));
gap: 12px;
justify-content: center;
}
@media (max-width: 480px) {
.rules {
grid-template-columns: minmax(0, 1fr);
}
}
</style>| Опция | Значения |
|---|---|
pull | true (по умолчанию): элементы могут уходить. false: не могут. 'clone': уходят копии, элементы остаются. |
put | true (по умолчанию): элементы могут входить. false: ничего не входит. Тип или типы ('image', ['image', 'video']): входят элементы этих типов. Функция: решаете вы, для каждого драга. |
// Inbox: gives, takes nothing.
group: { name: 'files', pull: true, put: false }
// Templates: gives copies, takes nothing, keeps its order.
group: { name: 'files', pull: 'clone', put: false }, sort: false
// Images: takes images only.
group: { name: 'files', put: 'image' }
Типы — это опции type перетаскиваемых элементов (makeSortableItem(el, { item, type }));
элементы без неё считаются именем группы. С типами в put каждый перетаскиваемый элемент
должен иметь один из них (с acceptMode: 'some' входят те, у кого он есть).
Функция put получает { keys, items, types, from, to }: перетаскиваемые ключи и элементы, их
типы, список, из которого они пришли (null для обычных перетаскиваемых элементов), и этот
список. Перемещения внутри списка разрешены всегда: Inbox по-прежнему сортирует собственные
файлы.
Над списком, который отклоняет драг, слот возвращается туда, где драг начался, так что
списки показывают, что сделал бы бросок: ничего. Бросок туда отменяется, и элемент летит
обратно. isAccepting списка во время такого драга равен false, а isOver над ним всё равно
true: так каждый список в демо знает, что сказать.
Слот возвращается на место и над собственной целью вне списков, например над корзиной: бросок
там принадлежит цели, и списки сохраняют элемент, если её onDrop его не забирает
(event.source.remove()). Цели внутри списков (бросок в элемент) и вокруг них (панель, в
которой лежат списки) оставляют слот там, где он есть.
const { items, isOver, isAccepting } = makeSortableList(el, { … });
const { active } = useDnD();
const verdict = computed(() => (active.value ? (isAccepting.value ? 'takes' : 'refuses') : null));
Лимиты
Список, который вмещает не больше трёх файлов, — Shortlist:
const shortlist = reactive({
files: [/* … */],
group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
});
Он читает собственные элементы, когда драг спрашивает. Заполните его, и он отклонит следующий файл, с причиной в заголовке.
Когда перетаскиваются несколько выделенных элементов разных типов, acceptMode говорит,
должны ли пройти put 'all' (по умолчанию) или 'some' из них. С 'some' прошедшие
приземляются, а остальные летят обратно. См. мульти-драг.
Одна группа на список
Список принадлежит одной группе: group принимает одно имя, никогда не несколько. Списки
группы делят каждый драг (свои черновики, что перетаскивается, какие из них его принимают),
поэтому элементы никогда не переходят между группами. Когда элементы должны ходить только в
некоторые стороны, поместите списки в одну группу и пусть put решает по from — списку,
из которого пришёл драг:
// Review takes tasks from both teams. A team takes tasks back from Review,
// never from the other team.
const fromReview = ({ from }: SortablePutContext) => from === 'review';
makeSortableList(review, { id: 'review', items: reviewTasks, group: 'tasks' });
makeSortableList(frontend, { id: 'frontend', items: frontendTasks, group: { name: 'tasks', put: fromReview } });
makeSortableList(backend, { id: 'backend', items: backendTasks, group: { name: 'tasks', put: fromReview } });
put: ['a', 'b'] называет группы, из которых список
принимает элементы. Здесь массив в put перечисляет типы элементов, которые список
принимает. Чтобы принимать элементы из других списков, дайте им одну группу и решайте по
from, как выше.Копирование: палитры и клавиша Alt
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Palette from './Palette.vue';
import Sheet from './Sheet.vue';
</script>
<template>
<!-- The palette and the page are lists of one group: copies go from one to the other. -->
<DnDProvider>
<div class="builder">
<Palette />
<Sheet />
</div>
</DnDProvider>
</template>
<style scoped>
.builder {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 24px;
}
</style>makeSortableList(list, {
items: templates,
group: { name: 'blocks', pull: 'clone', put: false }, // copies go out, nothing comes in
sort: false, // its own order stays
clone: (block) => ({ ...block, id: crypto.randomUUID() }),
});
makeSortableList(list, {
items: blocks,
group: 'blocks',
clone: (block) => ({ ...block, id: crypto.randomUUID() }), // for Alt-drags
dropOutside: 'remove', // drag off the Page panel to delete
});
pull: 'clone'копирует каждый драг из списка. Оригинал остаётся, а копия едет и приземляется.- Удержание Alt во время драга тоже копирует, из любого списка с функцией
clone: она и делает копию. Смените клавишу опцией провайдераcopyModifierили отключите её значениемnull. - В обоих случаях списку, из которого берётся элемент, нужен
clone. Он должен вернуть копию с новым ключом. Безcloneничего не копируется. - У события броска
mode: 'copy', аe.items— это копии. Компонент оверлея тоже получаетmode, чтобы показывать «+» при копировании (см. оверлей).
Обмен местами между списками
Два списка одной группы, у которых обоих behavior: 'swap', обмениваются элементами:
перетаскиваемый элемент занимает место того, что под ним, а тот переходит в список
перетаскиваемого. Ему должно быть разрешено покинуть свой список (pull: true) и войти в
другой (put). Дежурный график в сортируемых списках
меняет местами элементы двух своих колонок.
Обычные перетаскиваемые элементы
Список принимает элементы, перетаскиваемые с makeDraggable (чип, шаблон, файл из дерева),
если у него есть createItem. group.put решает и за них, с from: null. См.
обычные перетаскиваемые элементы в список.
Бросок вне целей
Что происходит, когда драг заканчивается вообще не над целью, решает dropOutside списка:
dropOutside | Элементы |
|---|---|
'placeholder' (по умолчанию) | Приземляются туда, где слот был показан последним. |
'cancel' | Летят обратно. По умолчанию при placeholder: 'indicator'. |
'remove' | Покидают свой список: утащите за пределы, чтобы удалить. Так делает страница в демо палитры. |
'remove' сначала спрашивает onBeforeDrop списка. Затем onDrop списка выполняется с
to.index === -1, а e.result содержит список без этих элементов. Корзина, которая принимает
броски, работает так же: см. зоны удаления.
Управляемые списки
Списки могут читать стор, которым не владеют: Pinia, серверное состояние, иммутабельные
данные. Передайте items геттером (или computed, или readonly ref): библиотека никогда в
него не пишет. Вызовите e.preventDefault() в onDrop и примените e.result сами:
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Table from './Table.vue';
import { createSeating } from './store';
const seating = createSeating();
const { state, undo } = seating;
</script>
<template>
<DnDProvider>
<div class="seating">
<div class="tables">
<Table id="a" title="Table A" :seating="seating" />
<Table id="b" title="Table B" :seating="seating" />
</div>
<section class="history">
<header>
<h4>History</h4>
<button type="button" :disabled="!state.history.length" @click="undo">Undo</button>
</header>
<ol>
<li v-for="(entry, index) in state.history" :key="state.history.length - index">{{ entry.label }}</li>
<li v-if="!state.history.length" class="none">Move a guest</li>
</ol>
</section>
</div>
</DnDProvider>
</template>
<style scoped>
.seating {
display: flex;
flex-direction: column;
gap: 12px;
width: 460px;
max-width: 100%;
margin: 0 auto;
}
.tables {
display: flex;
gap: 12px;
}
.history {
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, currentColor 5%, transparent);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
h4 {
margin: 0;
font: 600 11px/1 ui-monospace, Menlo, Consolas, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.55;
}
button {
padding: 5px 12px;
border: 0;
border-radius: 7px;
background: #42b883;
color: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
button:disabled {
background: color-mix(in srgb, currentColor 12%, transparent);
color: inherit;
opacity: 0.5;
cursor: default;
}
ol {
display: flex;
flex-direction: column;
gap: 4px;
height: 66px;
margin: 8px 0 0;
padding: 0;
overflow-y: auto;
list-style: none;
font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace;
}
li:first-child {
color: #42b883;
}
li.none {
color: inherit;
opacity: 0.5;
}
</style>const { items } = makeSortableList(list, {
id: props.id,
items: () => props.seating.state.tables[props.id]!, // read-only
group: 'seating',
onDrop: (e) => {
e.preventDefault(); // no write
props.seating.apply(e.result, `${e.items[0]?.name} → ${props.title}`);
},
});
const apply = (result: ReadonlyMap<Key, Guest[]>, label: string) => {
const before = [...result.keys()].map((id): [Key, Guest[]] => [id, state.tables[id]!]);
for (const [id, guests] of result) state.tables[id] = guests;
state.history.unshift({ label, before });
};
const undo = () => {
const last = state.history.shift();
for (const [id, guests] of last?.before ?? []) state.tables[id] = guests;
};
e.resultсопоставляет каждому списку, который меняет бросок, его новый массив: оба стола после переноса, один — после перестановки. Это новые массивы, так что стор может хранить старые для отмены.- Черновик следует за стором: примените
resultвonDrop(или до того, как разрешится асинхронныйonBeforeDrop), и оверлей приземлится в новом порядке. preventDefault()пропускает запись во все списки изresult. Если ничего не применить, списки остаются как были, а элементы летят обратно.- Dev-сборки предупреждают, когда бросок в список только для чтения оставлен библиотеке: ничего бы не изменилось.
Чтобы сохранить на сервер до того, как бросок приземлится, или сначала спросить пользователя, см. решение о броске. Все опции списка — в API makeSortableList.