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.
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.
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.
<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>
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 FlatNode | Ce qu'il contient |
|---|---|
key | La clé du nœud (getKey, node.id par défaut). Utilisez-la pour :key et l'id de la ligne. |
item | Votre nœud. |
depth | 0 pour les racines. La profondeur du nœud glissé est celle où il atterrirait : son emplacement s'indente donc en direct. |
parentKey | La clé du parent, null pour les racines. |
index | L'index parmi ses frères. |
hasChildren, collapsed | Pour 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 :
nesting | La 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.
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>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 });
| Option | Limite |
|---|---|
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. |
maxDepth | La 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. |
disabled | Sur une ligne : elle ne glisse pas. Sur l'arbre : rien n'en part par glissement et rien n'y est déposé. |
group.put | Si l'arbre accepte ou non des items venant d'autres listes et arbres. |
onBeforeDrop | Tout 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.
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 :
<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 :
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.
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é :
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 :
| Touche | Déplace le nœud |
|---|---|
| ArrowUp / ArrowDown | Vers l'emplacement au-dessus ou en dessous, une ligne à la fois. |
| ArrowRight | Un niveau plus bas, sous la ligne du dessus, là où les règles le permettent. |
| ArrowLeft | Un niveau plus haut, hors de son parent. |
| Space / Escape | Le 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.