Документация/Руководства
Деревья
Оглавления, меню, оргструктуры и бэклоги на makeSortableTree — рендерятся плоско с глубинами, вкладываются драгом вбок или наведением на строку, с правилами, свёрнутыми узлами, линией вставки и списками в той же группе.
Страница переведена ИИ и может содержать неточности. Читать английский оригинал
Дерево — это сортируемый список, у строк которого есть глубина. Перетащите раздел влево или вправо, чтобы сменить его уровень, или задержите его над серединой другого раздела, чтобы бросить внутрь. Номера следуют за драгом.
Загружаем демо…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type TreeNesting } from '@vue-dnd-kit/core';
import Outline from './Outline.vue';
const nesting = ref<TreeNesting>('both');
const indentation = ref(24);
</script>
<template>
<DnDProvider>
<div class="demo">
<div class="switches">
<fieldset>
<legend>nesting</legend>
<label v-for="value in ['both', 'offset', 'hover'] as const" :key="value" :class="{ on: nesting === value }">
<input v-model="nesting" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<fieldset>
<legend>indentation</legend>
<label v-for="value in [16, 24, 40]" :key="value" :class="{ on: indentation === value }">
<input v-model="indentation" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
</div>
<code class="call">makeSortableTree(el, { nesting: '{{ nesting }}', indentation: {{ indentation }}, maxDepth: 2 })</code>
<Outline :nesting="nesting" :indentation="indentation" />
</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 16px;
}
fieldset {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
label {
padding: 5px 9px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
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;
}
</style>Плоский рендеринг
makeSortableTree принимает ваши вложенные данные и возвращает видимые узлы плоским
списком, по порядку, каждый со своей depth. Вы рендерите этот список и сами делаете
отступы у строк. Все строки остаются соседями в одном контейнере, поэтому скользят и
анимируются, как строки списка.
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { makeSortableTree } from '@vue-dnd-kit/core';
import Heading from './Heading.vue';
interface Section { id: string; title: string; children?: Section[] }
const outline = ref<Section[]>([
{ id: 'start', title: 'Getting started', children: [{ id: 'install', title: 'Installation' }] },
{ id: 'faq', title: 'FAQ' },
]);
const root = useTemplateRef<HTMLElement>('root');
const { items, projected } = makeSortableTree(root, {
items: outline, // the root nodes: a writable ref takes every drop
indentation: 24, // px per level: match the rows' indentation
maxDepth: 2,
});
</script>
<template>
<ol ref="root">
<Heading v-for="node in items" :key="node.key" :node="node" />
</ol>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem, type FlatNode } from '@vue-dnd-kit/core';
const props = defineProps<{ node: FlatNode<{ id: string; title: string }> }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { id: () => props.node.key });
</script>
<template>
<li ref="el" :style="{ marginInlineStart: `${node.depth * 24}px` }">{{ node.item.title }}</li>
</template>
Строки используют makeSortableItem, как строки списка. Делайте отступ через margin, а не
padding: тогда вместе с глубиной сдвигается сама строка. Оверлей принимает ширину слота и
приземляется на его уровне, а смена глубины скользит, как любое другое перемещение. Padding
сдвинул бы только то, что внутри строки, а оверлей — копия строки в момент захвата — остался
бы на уровне, с которого начался драг. items содержит FlatNode:
Поле FlatNode | Что в нём |
|---|---|
key | Ключ узла (getKey, по умолчанию node.id). Используйте его для :key и id строки. |
item | Ваш узел. |
depth | 0 у корней. Глубина перетаскиваемого узла — та, на которой он приземлился бы, поэтому отступ его слота меняется вживую. |
parentKey | Ключ родителя, null у корней. |
index | Индекс среди соседних узлов. |
hasChildren, collapsed | Для кнопки раскрытия. |
Поскольку items — черновик, всё, что вы из него вычисляете, следует за драгом: демо
нумерует разделы по items, так что «2.1» становится «3.1», пока вы двигаете. projected —
куда приземлился бы перетаскиваемый узел, { depth, parentKey }, или null, пока он не над
этим деревом.
Потомки читаются из node.children и записываются туда же. Для данных другой формы есть
getChildren и setChildren; оргструктура ниже хранит их в members:
makeSortableTree(root, {
items: org,
getChildren: (member) => member.members,
setChildren: (member, members) => { member.members = members; },
});
Вложение
Драг меняет глубину двумя способами, оба включены по умолчанию:
nesting | Глубина берётся из… |
|---|---|
'offset' | Драга вбок: каждые indentation пикселей влево или вправо — уровень вверх или вниз, насколько позволяют строки вокруг слота. |
'hover' | Удержания драга над серединой строки: бросок попадает внутрь неё, первым потомком. |
'both' | Любого из двух. По умолчанию. |
Задайте indentation равным тому отступу, который ваши строки добавляют на уровень (по
умолчанию 24), чтобы слот следовал за указателем: в демо переключитесь на 40 и
перетаскивайте вбок. Только с 'hover' драг вбок сохраняет уровень, и вкладывает лишь
середина строки. Обе опции принимают ref или геттер и применяются к следующему драгу.
С axis: 'y' (или restrictToVerticalAxis) драг не может двигаться вбок, но строки всё равно
вкладываются наведением.
Правила
Engineering принимает людей и команды; Leadership заблокирован; ничто не уходит глубже, чем команда в команде. Раскройте и сверните команды: строки, которые они показывают и скрывают, въезжают и уезжают.
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import OrgTree from './OrgTree.vue';
</script>
<template>
<DnDProvider>
<OrgTree />
</DnDProvider>
</template>const { items } = makeSortableTree(root, {
items: org,
getChildren: (member) => member.members,
setChildren: (member, members) => { member.members = members; },
// Only teams take members, and a locked team takes no one.
canNest: (_member, team) => team.kind === 'team' && !team.locked,
maxDepth: 2,
collapsed,
animateChanges: true,
});
// A locked row doesn't drag (and says so with `aria-disabled`).
makeSortableItem(el, { id: () => props.node.key, disabled: () => !!props.node.item.locked });
| Опция | Ограничивает |
|---|---|
canNest(node, parent) | Может ли node встать под parent. Мульти-драг спрашивает для каждого перетаскиваемого узла. Корни разрешены всегда. |
maxDepth | Наибольшая допустимая глубина, у корней — 0. Потомки перетаскиваемого узла учитываются: команда с участниками уходит не глубже maxDepth - 1. |
disabled | На строке: она не перетаскивается. На дереве: из него ничего не перетаскивается и в него ничего не бросается. |
group.put | Принимает ли дерево элементы из других списков и деревьев вообще. |
onBeforeDrop | Всё остальное, решается при броске: см. решение о броске. |
Когда правило отклоняет глубину, слот берёт самую глубокую из допустимых: перетащите Noor ниже
Raj и вправо, и слот останется на верхнем уровне, ведь Leadership заблокирован, а Raj —
человек. Где не подходит ни одна глубина, слот туда не переходит. Копии и узлы из
createItem тоже подчиняются canNest и maxDepth.
Свёрнутые узлы
collapsed — Set ключей, чьи потомки скрыты, в виде ref или геттера. Их потомков нет в
items; node.collapsed и node.hasChildren управляют кнопкой:
const collapsed = ref(new Set<Key>());
const toggle = (key: Key) => (collapsed.value.has(key) ? collapsed.value.delete(key) : collapsed.value.add(key));
Перетаскиваемый узел берёт с собой всё своё поддерево, свёрнутое или нет. С animateChanges: true
строки, которые узел показывает или скрывает при раскрытии и сворачивании, анимированно
появляются и исчезают, как и изменения данных, сделанные не драгом.
Линия вставки
Перетащите шаблон слева в меню или подвиньте пункт: меню стоит на месте, а линия показывает, куда попадёт бросок, начинаясь на том уровне, где он приземлится. Над серединой страницы или группы вместо этого подсвечивается строка.
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import MenuTree from './MenuTree.vue';
import Template from './Template.vue';
</script>
<template>
<DnDProvider>
<div class="editor">
<aside class="templates">
<h3>Add</h3>
<Template title="Page" kind="page" />
<Template title="Link" kind="link" />
<Template title="Group" kind="group" />
</aside>
<MenuTree />
</div>
</DnDProvider>
</template>
<style scoped>
.editor {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 20px;
}
.templates {
display: flex;
flex-direction: column;
gap: 8px;
width: 120px;
}
h3 {
margin: 0 0 2px;
font: 600 10px/1 ui-monospace, Menlo, Consolas, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.55;
}
</style>С placeholder: 'indicator' дерево во время драга не двигается. Каждая строка читает свой
indicator и рисует линию:
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue';
import { makeSortableItem, type FlatNode } from '@vue-dnd-kit/core';
const props = defineProps<{ node: FlatNode<MenuItem>; indent: number }>();
const el = useTemplateRef<HTMLElement>('el');
const { indicator } = makeSortableItem(el, { id: () => props.node.key });
// 'inside': the drop goes into this row. Otherwise a line before or after it.
const inside = computed(() => indicator.value?.side === 'inside');
const line = computed(() => {
const value = indicator.value;
if (!value || value.side === 'inside') return null;
// From the level the drop lands at, measured from this row's own level.
return { side: value.side, left: `${((value.depth ?? 0) - props.node.depth) * props.indent}px` };
});
</script>
<template>
<div ref="el" class="item" :class="{ inside }" :style="{ marginLeft: `${node.depth * indent}px` }">
{{ node.item.title }}
<span v-if="line" class="line" :class="line.side" :style="{ left: line.left }" />
</div>
</template>
<style scoped>
.item { position: relative; }
.item.inside { box-shadow: 0 0 0 1.5px #42b883; }
.line { position: absolute; right: 0; height: 2px; background: #42b883; }
.line.before { top: -3px; }
.line.after { bottom: -3px; }
</style>
В деревьях indicator добавляет depth и parentKey: уровень и родителя, которых получат
брошенные узлы. Драг вбок двигает линию между уровнями, и клавиатура тоже двигает линию. В
этом режиме бросок вне дерева отменяется.
Шаблоны — обычные перетаскиваемые элементы (makeDraggable) с data. Дерево превращает их в
новые узлы через createItem, в позиции и на глубине линии:
let created = 0;
makeSortableTree(root, {
items: menu,
placeholder: 'indicator',
maxDepth: 1,
canNest: (_item, parent) => parent.kind !== 'link',
createItem: (active) => {
const template = active.data as { title: string; kind: MenuItem['kind'] };
return { id: `new-${++created}`, title: `New ${template.title.toLowerCase()}`, kind: template.kind };
},
});
Полоса над меню рендерит menu, сохранённые данные, а не items: она меняется только при
броске.
Деревья и списки в одной группе
История, перенесённая в бэклог, сохраняет свои задачи, скрытыми, и приносит их обратно в роадмап. Удерживайте Alt во время драга из роадмапа, чтобы вместо этого скопировать.
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Roadmap from './Roadmap.vue';
import Backlog from './Backlog.vue';
</script>
<template>
<DnDProvider>
<div class="planner">
<Roadmap />
<Backlog />
</div>
</DnDProvider>
</template>
<style scoped>
.planner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 260px));
justify-content: center;
gap: 14px;
width: 100%;
}
</style>Дерево и список с одинаковым group обмениваются узлами, как два списка. Узел сохраняет своё
поддерево, куда бы ни попал: список его просто не показывает. Один и тот же компонент строки
служит обоим, поскольку makeSortableItem({ item }) получает ключ от того списка или дерева,
которому принадлежит строка.
Копирование работает как в списках (clone, pull: 'clone', клавиша копирования), но clone
должен вернуть копию со всем её поддеревом, и каждому узлу в ней нужен новый ключ:
let copies = 0;
const copy = (work: Work): Work => ({ ...work, id: `${work.id}-copy-${++copies}`, children: work.children?.map(copy) });
makeSortableTree(root, {
items: roadmap,
group: 'work',
canNest: (work, parent) => RANK[parent.kind] < RANK[work.kind],
maxDepth: 2,
clone: copy,
});
Про группы, копирование и createItem см. списки с общими элементами.
Бросок
Событие броска дерева — то же, что у списка, с parentKey, добавленным в from и to:
makeSortableTree(root, {
items: outline,
onDrop: (e) => {
console.log(e.keys, e.from.parentKey, '→', e.to.parentKey, e.to.index);
},
});
to.index — позиция среди новых соседних узлов, отсчитанная после того, как узел покинул
старое место. При броске библиотека даёт изменённым узлам их новых потомков (setChildren) и
записывает новый массив корней в ваш ref.
e.result.get(treeId) — новое дерево в виде нового массива корней: узлы на изменённых путях —
копии, нетронутые поддеревья общие, а ваши узлы остаются как есть. Для стора или
иммутабельных данных передайте геттер, вызовите e.preventDefault() и сохраните e.result
(см. решение о броске).
Копии делаются через withChildren(node, children), по умолчанию { ...node } с новыми
потомками; для экземпляров классов передайте свою функцию.
Клавиатура и мульти-драг
Поставьте фокус на строку, нажмите Space, затем:
| Клавиша | Перемещает узел |
|---|---|
| ArrowUp / ArrowDown | В слот выше или ниже, по одной строке. |
| ArrowRight | На уровень глубже, под строку выше, где позволяют правила. |
| ArrowLeft | На уровень вверх, из родителя. |
| Space / Escape | Бросает его / возвращает на место. |
В режиме индикатора стрелки вместо этого двигают линию. Из списка или шаблона рядом с деревом стрелка в сторону дерева забирает элемент на уровень строки, перед которой он встанет.
Выделенные узлы перемещаются вместе одним блоком, каждый со своим поддеревом, в порядке документа. Выделенный узел внутри другого выделенного едет в поддереве своего родителя. См. мульти-драг.
Все опции — в API makeSortableTree. Для деревьев на тысячи строк см. виртуальные списки.