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

Деревья

Оглавления, меню, оргструктуры и бэклоги на makeSortableTree — рендерятся плоско с глубинами, вкладываются драгом вбок или наведением на строку, с правилами, свёрнутыми узлами, линией вставки и списками в той же группе.

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

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

Дерево — это сортируемый список, у строк которого есть глубина. Перетащите раздел влево или вправо, чтобы сменить его уровень, или задержите его над серединой другого раздела, чтобы бросить внутрь. Номера следуют за драгом.

Live

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

<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. Вы рендерите этот список и сами делаете отступы у строк. Все строки остаются соседями в одном контейнере, поэтому скользят и анимируются, как строки списка.

Outline.vue
<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>
Heading.vue
<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Ваш узел.
depth0 у корней. Глубина перетаскиваемого узла — та, на которой он приземлился бы, поэтому отступ его слота меняется вживую.
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 заблокирован; ничто не уходит глубже, чем команда в команде. Раскройте и сверните команды: строки, которые они показывают и скрывают, въезжают и уезжают.

Live

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

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

<template>
  <DnDProvider>
    <OrgTree />
  </DnDProvider>
</template>
OrgTree.vue
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,
});
OrgRow.vue
// 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 строки, которые узел показывает или скрывает при раскрытии и сворачивании, анимированно появляются и исчезают, как и изменения данных, сделанные не драгом.

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

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

Live

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

<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 и рисует линию:

MenuRow.vue
<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, в позиции и на глубине линии:

MenuTree.vue
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 во время драга из роадмапа, чтобы вместо этого скопировать.

Live

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

<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 должен вернуть копию со всем её поддеревом, и каждому узлу в ней нужен новый ключ:

Roadmap.vue
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. Для деревьев на тысячи строк см. виртуальные списки.