Dokumentation/Leitfäden

Bäume

Gliederungen, Menüs, Organigramme und Backlogs mit makeSortableTree – flach mit Tiefen gerendert, verschachtelt durch seitliches Ziehen oder Halten über einer Zeile, mit Regeln, eingeklappten Knoten, einer Einfügelinie und Listen in derselben Gruppe.

Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen

Ein Baum ist eine sortierbare Liste, deren Zeilen Tiefen haben. Ziehe einen Abschnitt nach links oder rechts, um seine Ebene zu ändern, oder halte ihn über der Mitte eines anderen Abschnitts, um ihn darin abzulegen. Die Nummern folgen dem Drag.

Live

Demo wird geladen…

<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>

Flach gerendert

makeSortableTree nimmt deine verschachtelten Daten und gibt die sichtbaren Knoten als flache Liste zurück, in Reihenfolge, jeden mit seiner depth. Du renderst diese Liste und rückst die Zeilen selbst ein. Jede Zeile bleibt ein Geschwister in einem Container, also gleiten und animieren die Zeilen wie die Zeilen einer Liste.

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>

Zeilen nutzen makeSortableItem, wie die Zeilen einer Liste. Rücke sie mit einem Margin ein, nicht mit einem Padding: Dann bewegt sich die Zeile selbst mit ihrer Tiefe. Das Overlay übernimmt die Breite des Slots und landet auf seiner Ebene, und ein Wechsel der Tiefe gleitet wie jede andere Bewegung. Ein Padding würde nur einrücken, was in der Zeile steht, und das Overlay, eine Kopie der Zeile beim Aufnehmen, bliebe auf der Ebene, auf der der Drag begann. items enthält FlatNodes:

Feld von FlatNodeWas es enthält
keyDer Schlüssel des Knotens (getKey, Standard node.id). Nimm ihn für :key und die id der Zeile.
itemDein Knoten.
depth0 für Wurzeln. Die Tiefe des gezogenen Knotens ist die, auf der er landen würde, also rückt sein Slot live ein.
parentKeyDer Schlüssel des Elternknotens, null für Wurzeln.
indexDer Index unter seinen Geschwistern.
hasChildren, collapsedFür einen Aufklapp-Button.

Da items der Entwurf ist, folgt alles, was du daraus berechnest, dem Drag: Die Demo nummeriert ihre Abschnitte aus items, also wird „2.1“ zu „3.1“, während du ziehst. projected ist, wo der gezogene Knoten landen würde, { depth, parentKey }, oder null, solange er nicht über diesem Baum ist.

Kinder werden aus node.children gelesen und dorthin geschrieben. Daten mit einer anderen Form nehmen getChildren und setChildren; das Organigramm unten hält sie in members:

makeSortableTree(root, {
  items: org,
  getChildren: (member) => member.members,
  setChildren: (member, members) => { member.members = members; },
});

Verschachteln

Ein Drag ändert die Tiefe auf zwei Arten, beide standardmäßig an:

nestingDie Tiefe kommt aus …
'offset'Seitlichem Ziehen: Alle indentation Pixel nach links oder rechts sind eine Ebene höher oder tiefer, so weit es die Zeilen um den Slot herum zulassen.
'hover'Halten des Drags über der Mitte einer Zeile: Der Drop geht in sie hinein, als ihr erstes Kind.
'both'Beidem. Der Standard.

Setze indentation auf das, was deine Zeilen pro Ebene einrücken (standardmäßig 24), damit der Slot dem Zeiger folgt: Schalte in der Demo auf 40 und ziehe seitwärts. Mit 'hover' allein behält ein seitlicher Drag die Ebene, und nur die Mitte einer Zeile verschachtelt. Beide Optionen nehmen ein Ref oder einen Getter und gelten ab dem nächsten Drag.

Mit axis: 'y' (oder restrictToVerticalAxis) kann sich der Drag nicht seitwärts bewegen, aber Zeilen verschachteln sich trotzdem durch Halten.

Regeln

Engineering nimmt Personen und Teams an; Leadership ist gesperrt; nichts geht tiefer als ein Team in einem Team. Klappe die Teams auf und zu: Die Zeilen, die sie zeigen und verbergen, gleiten herein und hinaus.

Live

Demo wird geladen…

<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 });
OptionBegrenzt
canNest(node, parent)Ob node unter parent darf. Ein Multi-Drag fragt für jeden gezogenen Knoten. Wurzeln sind immer erlaubt.
maxDepthDie tiefste erlaubte Tiefe, Wurzeln sind 0. Die Nachkommen des gezogenen Knotens zählen mit: Ein Team mit Mitgliedern geht nicht tiefer als maxDepth - 1.
disabledAn einer Zeile: Sie lässt sich nicht ziehen. Am Baum: Nichts wird aus ihm gezogen oder in ihn abgelegt.
group.putOb der Baum überhaupt Elemente aus anderen Listen und Bäumen annimmt.
onBeforeDropAlles andere, beim Drop entschieden: siehe Über einen Drop entscheiden.

Wenn eine Regel eine Tiefe ablehnt, nimmt der Slot die tiefste Tiefe, die durchgeht: Ziehe Noor unter Raj und nach rechts, und der Slot bleibt auf der obersten Ebene, da Leadership gesperrt und Raj eine Person ist. Wo keine Tiefe durchgeht, bewegt sich der Slot nicht dorthin. Kopien und Knoten aus createItem folgen ebenfalls canNest und maxDepth.

Eingeklappte Knoten

collapsed ist ein Set von Schlüsseln, deren Kinder verborgen sind, als Ref oder Getter. Ihre Kinder sind nicht in items; node.collapsed und node.hasChildren steuern den Button:

const collapsed = ref(new Set<Key>());
const toggle = (key: Key) => (collapsed.value.has(key) ? collapsed.value.delete(key) : collapsed.value.add(key));

Ein gezogener Knoten nimmt seinen ganzen Teilbaum mit, eingeklappt oder nicht. Mit animateChanges: true animieren die Zeilen, die ein Knoten beim Auf- oder Zuklappen zeigt oder verbirgt, herein und hinaus, ebenso Änderungen an den Daten, die kein Drag gemacht hat.

Eine Einfügelinie

Ziehe eine Vorlage von links ins Menü oder verschiebe einen Eintrag: Das Menü bleibt still, und eine Linie zeigt, wohin der Drop geht, beginnend auf der Ebene, auf der er landet. Über der Mitte einer Seite oder Gruppe leuchtet stattdessen die Zeile auf.

Live

Demo wird geladen…

<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>

Mit placeholder: 'indicator' bewegt sich der Baum beim Ziehen nicht. Jede Zeile liest ihr indicator und zeichnet die Linie:

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 ergänzt in Bäumen depth und parentKey: die Ebene und den Elternknoten, die die abgelegten Knoten bekommen. Seitliches Ziehen bewegt die Linie zwischen Ebenen, und die Tastatur bewegt die Linie ebenfalls. In diesem Modus bricht ein Drop außerhalb des Baums ab.

Die Vorlagen sind einfache ziehbare Elemente (makeDraggable) mit data. Der Baum macht aus ihnen mit createItem neue Knoten, an Position und Tiefe der Linie:

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 };
  },
});

Die Leiste über dem Menü rendert menu, die gespeicherten Daten, nicht items: Sie ändert sich nur beim Drop.

Bäume und Listen in einer Gruppe

Eine Story, die ins Backlog verschoben wird, behält ihre Aufgaben, unsichtbar, und bringt sie zurück in die Roadmap. Halte beim Ziehen aus der Roadmap Alt gedrückt, um stattdessen zu kopieren.

Live

Demo wird geladen…

<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>

Ein Baum und eine Liste mit derselben group tauschen Knoten aus, wie es zwei Listen tun. Ein Knoten behält seinen Teilbaum, wohin er auch geht: Die Liste zeigt ihn nur nicht. Dieselbe Zeilenkomponente dient beiden, da makeSortableItem({ item }) über die Liste oder den Baum identifiziert wird, dem die Zeile gehört.

Kopien funktionieren wie in Listen (clone, pull: 'clone', die Kopiertaste), aber clone muss die Kopie mit ihrem ganzen Teilbaum zurückgeben, und jeder Knoten darin braucht einen neuen Schlüssel:

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,
});

Siehe Listen mit gemeinsamen Elementen zu Gruppen, Kopieren und createItem.

Der Drop

Das Drop-Event des Baums ist das einer Liste, mit parentKey in from und to:

makeSortableTree(root, {
  items: outline,
  onDrop: (e) => {
    console.log(e.keys, e.from.parentKey, '→', e.to.parentKey, e.to.index);
  },
});

to.index ist die Position unter den neuen Geschwistern, gezählt, nachdem der Knoten seinen alten Platz verlassen hat. Beim Drop gibt die Bibliothek den geänderten Knoten ihre neuen Kinder (setChildren) und schreibt ein neues Wurzel-Array in dein Ref.

e.result.get(treeId) ist der neue Baum als neues Wurzel-Array: Die Knoten auf den geänderten Pfaden sind Kopien, unberührte Teilbäume werden geteilt, und deine Knoten bleiben, wie sie sind. Für einen Store oder unveränderliche Daten übergib einen Getter, ruf e.preventDefault() auf und speichere e.result (siehe Über einen Drop entscheiden). Kopien werden mit withChildren(node, children) erzeugt, standardmäßig { ...node } mit den neuen Kindern; übergib für Klasseninstanzen deine eigene Funktion.

Tastatur und Multi-Drag

Fokussiere eine Zeile, drücke Space und dann:

TasteBewegt den Knoten
ArrowUp / ArrowDownIn den Slot darüber oder darunter, Zeile für Zeile.
ArrowRightEine Ebene tiefer, unter die Zeile darüber, wo die Regeln es erlauben.
ArrowLeftEine Ebene höher, aus seinem Elternknoten heraus.
Space / EscapeLegt ihn ab / stellt ihn zurück.

Im Indikator-Modus bewegen die Pfeiltasten stattdessen die Linie. Aus einer Liste oder einer Vorlage neben dem Baum nimmt ein Pfeil in Richtung des Baums das Element auf, auf gleicher Höhe mit der Zeile, vor die es kommt.

Ausgewählte Knoten bewegen sich gemeinsam als ein Block, jeder mit seinem Teilbaum, in Dokumentreihenfolge. Ein ausgewählter Knoten innerhalb eines anderen ausgewählten fährt im Teilbaum seines Elternknotens mit. Siehe Multi-Drag und Auswahl.

Alle Optionen stehen in der makeSortableTree-API. Für Bäume mit Tausenden von Zeilen siehe Virtuelle Listen.