Documentation/Guides

Arbres

Plans, menus, organigrammes et backlogs avec makeSortableTree — rendus à plat avec des profondeurs, imbriqués en glissant de côté ou en survolant une ligne, avec des règles, des nœuds repliés, une ligne d'insertion et des listes dans le même groupe.

Modifier cette page

Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais

Un arbre est une liste triable dont les lignes ont des profondeurs. Faites glisser une section à gauche ou à droite pour changer son niveau, ou maintenez-la au-dessus du milieu d'une autre section pour la déposer à l'intérieur. Les numéros suivent le glissement.

Live

Chargement de la démo…

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

Rendu à plat

makeSortableTree prend vos données imbriquées et renvoie les nœuds visibles sous forme de liste à plat, dans l'ordre, chacun avec sa depth. Vous rendez cette liste et indentez les lignes vous-même. Chaque ligne reste une sœur dans un seul conteneur : les lignes glissent et s'animent donc comme celles d'une 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>

Les lignes utilisent makeSortableItem, comme celles d'une liste. Indentez-les avec une marge, pas avec un padding : c'est alors la ligne elle-même qui se déplace avec sa profondeur. L'overlay prend la largeur de l'emplacement et atterrit à son niveau, et un changement de profondeur glisse comme n'importe quel autre déplacement. Un padding n'indenterait que ce qui se trouve dans la ligne, et l'overlay, une copie de la ligne prise à la saisie, garderait le niveau où le glissement a commencé. items contient des FlatNode :

Champ de FlatNodeCe qu'il contient
keyLa clé du nœud (getKey, node.id par défaut). Utilisez-la pour :key et l'id de la ligne.
itemVotre nœud.
depth0 pour les racines. La profondeur du nœud glissé est celle où il atterrirait : son emplacement s'indente donc en direct.
parentKeyLa clé du parent, null pour les racines.
indexL'index parmi ses frères.
hasChildren, collapsedPour un bouton de dépliage.

Comme items est le brouillon, tout ce que vous calculez à partir de lui suit le glissement : la démo numérote ses sections à partir de items, si bien que « 2.1 » devient « 3.1 » pendant le déplacement. projected est l'endroit où atterrirait le nœud glissé, { depth, parentKey }, ou null tant qu'il n'est pas au-dessus de cet arbre.

Les enfants sont lus dans node.children et écrits à cet endroit. Des données d'une autre forme prennent getChildren et setChildren ; l'organigramme ci-dessous les garde dans members :

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

Imbrication

Un glissement change la profondeur de deux façons, toutes deux activées par défaut :

nestingLa profondeur vient…
'offset'Du glissement de côté : chaque tranche de indentation pixels vers la gauche ou la droite fait monter ou descendre d'un niveau, autant que le permettent les lignes autour de l'emplacement.
'hover'Du maintien du glissement au-dessus du milieu d'une ligne : le dépôt va à l'intérieur, comme premier enfant.
'both'De l'un ou de l'autre. La valeur par défaut.

Réglez indentation sur ce que vos lignes ajoutent par niveau (24 par défaut), pour que l'emplacement suive le pointeur : dans la démo, passez à 40 et glissez de côté. Avec 'hover' seul, un glissement de côté garde le niveau, et seul le milieu d'une ligne imbrique. Les deux options acceptent une ref ou un getter et s'appliquent au glissement suivant.

Avec axis: 'y' (ou restrictToVerticalAxis), le glissement ne peut pas aller de côté, mais les lignes s'imbriquent toujours par survol.

Règles

Engineering accepte des personnes et des équipes ; Leadership est verrouillé ; rien ne descend plus bas qu'une équipe dans une équipe. Ouvrez et fermez les équipes : les lignes qu'elles affichent et masquent glissent pour entrer et sortir.

Live

Chargement de la démo…

<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 });
OptionLimite
canNest(node, parent)Si node peut aller sous parent. Un multi-drag pose la question pour chaque nœud glissé. Les racines sont toujours autorisées.
maxDepthLa profondeur maximale autorisée, les racines étant à 0. Les descendants du nœud glissé comptent : une équipe avec des membres ne descend pas plus bas que maxDepth - 1.
disabledSur une ligne : elle ne glisse pas. Sur l'arbre : rien n'en part par glissement et rien n'y est déposé.
group.putSi l'arbre accepte ou non des items venant d'autres listes et arbres.
onBeforeDropTout le reste, décidé au dépôt : voir les décisions de dépôt.

Quand une règle refuse une profondeur, l'emplacement prend la profondeur la plus grande qui passe : faites glisser Noor sous Raj et vers la droite, et l'emplacement reste au premier niveau, puisque Leadership est verrouillé et que Raj est une personne. Là où aucune profondeur ne passe, l'emplacement ne s'y déplace pas. Les copies et les nœuds issus de createItem suivent aussi canNest et maxDepth.

Nœuds repliés

collapsed est un Set de clés dont les enfants sont masqués, sous forme de ref ou de getter. Leurs enfants ne sont pas dans items ; node.collapsed et node.hasChildren pilotent le bouton :

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

Un nœud glissé emporte tout son sous-arbre, replié ou non. Avec animateChanges: true, les lignes qu'un nœud affiche ou masque quand il s'ouvre ou se ferme s'animent à l'entrée et à la sortie, tout comme les changements de données qui ne viennent pas d'un glissement.

Une ligne d'insertion

Faites glisser un modèle depuis la gauche dans le menu, ou déplacez un item : le menu reste immobile et une ligne montre où va le dépôt, en partant du niveau où il atterrit. Au-dessus du milieu d'une page ou d'un groupe, c'est la ligne survolée qui s'éclaire à la place.

Live

Chargement de la démo…

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

Avec placeholder: 'indicator', l'arbre ne bouge pas pendant le glissement. Chaque ligne lit son indicator et dessine le trait :

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>

Dans les arbres, indicator ajoute depth et parentKey : le niveau et le parent que reçoivent les nœuds déposés. Glisser de côté déplace le trait d'un niveau à l'autre, et le clavier le déplace aussi. Dans ce mode, un dépôt en dehors de l'arbre annule.

Les modèles sont des éléments déplaçables simples (makeDraggable) avec des data. L'arbre en fait de nouveaux nœuds avec createItem, à la position et à la profondeur du trait :

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

La barre au-dessus du menu rend menu, les données enregistrées, et non items : elle ne change qu'au dépôt.

Arbres et listes dans un même groupe

Une story déplacée dans le backlog garde ses tâches, hors de vue, et les ramène dans la roadmap. Maintenez Alt en glissant hors de la roadmap pour copier à la place.

Live

Chargement de la démo…

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

Un arbre et une liste qui ont le même group s'échangent des nœuds, comme deux listes. Un nœud garde son sous-arbre où qu'il aille : la liste ne l'affiche simplement pas. Le même composant de ligne sert aux deux, puisque makeSortableItem({ item }) prend sa clé auprès de la liste ou de l'arbre auquel appartient la ligne.

Les copies fonctionnent comme dans les listes (clone, pull: 'clone', la touche de copie), mais clone doit renvoyer la copie avec tout son sous-arbre, et chaque nœud qu'elle contient a besoin d'une nouvelle clé :

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

Voir les listes qui partagent des items pour les groupes, la copie et createItem.

Le dépôt

L'événement de dépôt de l'arbre est celui d'une liste, avec parentKey ajouté à from et to :

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

to.index est la position parmi les nouveaux frères, comptée après que le nœud a quitté son ancienne place. Au dépôt, la bibliothèque donne aux nœuds modifiés leurs nouveaux enfants (setChildren) et écrit un nouveau tableau de racines dans votre ref.

e.result.get(treeId) est le nouvel arbre sous forme de nouveau tableau de racines : les nœuds sur les chemins modifiés sont des copies, les sous-arbres intacts sont partagés, et vos nœuds restent tels quels. Pour un store ou des données immuables, passez un getter, appelez e.preventDefault() et enregistrez e.result (voir les décisions de dépôt). Les copies sont faites avec withChildren(node, children), par défaut { ...node } avec les nouveaux enfants ; passez la vôtre pour des instances de classe.

Clavier et multi-drag

Donnez le focus à une ligne, appuyez sur Space, puis :

ToucheDéplace le nœud
ArrowUp / ArrowDownVers l'emplacement au-dessus ou en dessous, une ligne à la fois.
ArrowRightUn niveau plus bas, sous la ligne du dessus, là où les règles le permettent.
ArrowLeftUn niveau plus haut, hors de son parent.
Space / EscapeLe dépose / le remet en place.

En mode indicateur, les flèches déplacent le trait à la place. Depuis une liste ou un modèle à côté de l'arbre, une flèche vers l'arbre y fait entrer l'item, au niveau de la ligne devant laquelle il va.

Les nœuds sélectionnés se déplacent ensemble en un seul bloc, chacun avec son sous-arbre, dans l'ordre du document. Un nœud sélectionné à l'intérieur d'un autre nœud sélectionné voyage dans le sous-arbre de son parent. Voir le multi-drag.

Toutes les options se trouvent dans l'API de makeSortableTree. Pour les arbres de milliers de lignes, voir les listes virtuelles.