Documentación/Guías

Árboles

Esquemas, menús, organigramas y backlogs con makeSortableTree — renderizados en plano con profundidades, anidados arrastrando hacia un lado o pasando por encima de una fila, con reglas, nodos plegados, una línea de inserción y listas en el mismo grupo.

Editar esta página

Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés

Un árbol es una lista ordenable cuyas filas tienen profundidad. Arrastra una sección a la izquierda o a la derecha para cambiar su nivel, o mantenla sobre el centro de otra sección para soltarla dentro. Los números siguen al arrastre.

Live

Cargando la demo…

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

Renderizado en plano

makeSortableTree recibe tus datos anidados y devuelve los nodos visibles como una lista plana, en orden, cada uno con su depth. Tú renderizas esa lista y sangras las filas. Todas las filas son hermanas dentro de un mismo contenedor, así que se deslizan y se animan como las filas de una lista.

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>

Las filas usan makeSortableItem, como las de una lista. Sángralas con un margen, no con un padding: así es la propia fila la que se mueve con su profundidad. El overlay toma el ancho del hueco y aterriza en su nivel, y un cambio de profundidad se desliza como cualquier otro movimiento. Un padding solo sangraría lo que hay dentro de la fila, y el overlay, una copia de la fila hecha al agarrar, conservaría el nivel en el que empezó el arrastre. items contiene FlatNode:

Campo de FlatNodeQué contiene
keyLa clave del nodo (getKey, por defecto node.id). Úsala para :key y para el id de la fila.
itemTu nodo.
depth0 para las raíces. La profundidad del nodo arrastrado es donde aterrizaría, así que su hueco se sangra en vivo.
parentKeyLa clave del padre, null para las raíces.
indexEl índice entre sus hermanos.
hasChildren, collapsedPara un botón de desplegar.

Como items es el borrador, todo lo que calcules a partir de él sigue al arrastre: la demo numera sus secciones a partir de items, así que «2.1» pasa a «3.1» mientras mueves. projected es donde aterrizaría el nodo arrastrado, { depth, parentKey }, o null mientras no está sobre este árbol.

Los hijos se leen de node.children y se escriben ahí. Los datos con otra forma usan getChildren y setChildren; el organigrama de abajo los guarda en members:

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

Anidamiento

Un arrastre cambia la profundidad de dos formas, ambas activadas por defecto:

nestingLa profundidad sale de…
'offset'Arrastrar hacia un lado: cada indentation píxeles a la izquierda o a la derecha sube o baja un nivel, hasta donde lo permitan las filas que rodean el hueco.
'hover'Mantener el arrastre sobre el centro de una fila: el drop va dentro de ella, como su primer hijo.
'both'Cualquiera de las dos. El valor por defecto.

Ajusta indentation a lo que tus filas añaden por nivel (24 por defecto), para que el hueco siga al puntero: en la demo, cambia a 40 y arrastra hacia un lado. Con 'hover' solo, un arrastre lateral mantiene el nivel y solo el centro de una fila anida. Las dos opciones aceptan una ref o un getter y se aplican al siguiente arrastre.

Con axis: 'y' (o restrictToVerticalAxis), el arrastre no puede moverse hacia los lados, pero las filas siguen anidándose al pasar por encima.

Reglas

Engineering acepta personas y equipos; Leadership está bloqueado; nada baja más allá de un equipo dentro de un equipo. Abre y cierra los equipos: las filas que muestran y ocultan entran y salen deslizándose.

Live

Cargando la demo…

<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 });
OpciónLimita
canNest(node, parent)Si node puede ir bajo parent. Un multi-drag pregunta por cada nodo arrastrado. Las raíces siempre están permitidas.
maxDepthLa profundidad máxima permitida, siendo 0 las raíces. Los descendientes del nodo arrastrado cuentan: un equipo con miembros no baja más allá de maxDepth - 1.
disabledEn una fila: no se arrastra. En el árbol: nada sale de él arrastrándose ni se suelta en él.
group.putSi el árbol acepta o no ítems de otras listas y árboles.
onBeforeDropTodo lo demás, decidido al soltar: consulta decidir un drop.

Cuando una regla rechaza una profundidad, el hueco toma la profundidad más honda que la cumple: arrastra a Noor debajo de Raj y hacia la derecha, y el hueco se queda en el primer nivel, porque Leadership está bloqueado y Raj es una persona. Donde no pasa ninguna profundidad, el hueco no se mueve ahí. Las copias y los nodos de createItem también respetan canNest y maxDepth.

Nodos plegados

collapsed es un Set de claves cuyos hijos están ocultos, como ref o como getter. Sus hijos no están en items; node.collapsed y node.hasChildren controlan el botón:

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

Un nodo arrastrado se lleva todo su subárbol, plegado o no. Con animateChanges: true, las filas que un nodo muestra u oculta al abrirse o cerrarse se animan al entrar y salir, y lo mismo pasa con los cambios de datos que no hizo ningún arrastre.

Una línea de inserción

Arrastra una plantilla desde la izquierda al menú, o mueve un ítem: el menú se queda quieto y una línea muestra adónde va el drop, empezando en el nivel en el que aterriza. Sobre el centro de una página o de un grupo, se ilumina la fila.

Live

Cargando la demo…

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

Con placeholder: 'indicator', el árbol no se mueve mientras arrastras. Cada fila lee su indicator y dibuja la línea:

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>

En los árboles, indicator añade depth y parentKey: el nivel y el padre que reciben los nodos soltados. Arrastrar hacia un lado mueve la línea entre niveles, y el teclado también la mueve. En este modo, un drop fuera del árbol cancela.

Las plantillas son elementos arrastrables simples (makeDraggable) con data. El árbol las convierte en nodos nuevos con createItem, en la posición y la profundidad de la línea:

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 barra que hay encima del menú renderiza menu, los datos guardados, no items: solo cambia al soltar.

Árboles y listas en un mismo grupo

Una historia que se mueve al backlog conserva sus tareas, fuera de la vista, y las trae de vuelta al roadmap. Mantén Alt al arrastrar fuera del roadmap para copiar en su lugar.

Live

Cargando la demo…

<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 árbol y una lista con el mismo group intercambian nodos, igual que dos listas. Un nodo conserva su subárbol vaya donde vaya: la lista simplemente no lo muestra. El mismo componente de fila sirve para ambos, ya que makeSortableItem({ item }) obtiene su clave de la lista o el árbol al que pertenece la fila.

Las copias funcionan como en las listas (clone, pull: 'clone', la tecla de copia), pero clone debe devolver la copia con todo su subárbol, y cada nodo que contiene necesita una clave nueva:

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

Consulta listas que comparten ítems para los grupos, la copia y createItem.

El drop

El evento de drop del árbol es el de una lista, con parentKey añadido a from y to:

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

to.index es la posición entre los nuevos hermanos, contada después de que el nodo deje su sitio anterior. Al soltar, la biblioteca da a los nodos modificados sus nuevos hijos (setChildren) y escribe un nuevo array de raíces en tu ref.

e.result.get(treeId) es el nuevo árbol como un nuevo array de raíces: los nodos de las rutas modificadas son copias, los subárboles intactos se comparten y tus nodos se quedan como están. Para un store o datos inmutables, pasa un getter, llama a e.preventDefault() y guarda e.result (consulta decidir un drop). Las copias se hacen con withChildren(node, children), por defecto { ...node } con los nuevos hijos; pasa la tuya para instancias de clases.

Teclado y multi-drag

Pon el foco en una fila, pulsa Space y luego:

TeclaMueve el nodo
ArrowUp / ArrowDownAl hueco de arriba o de abajo, una fila cada vez.
ArrowRightUn nivel más adentro, bajo la fila de arriba, donde las reglas lo permitan.
ArrowLeftUn nivel más arriba, fuera de su padre.
Space / EscapeLo suelta / lo devuelve a su sitio.

En modo indicador, las flechas mueven la línea en su lugar. Desde una lista o una plantilla junto al árbol, una flecha hacia el árbol mete el ítem en él, al nivel de la fila delante de la que va.

Los nodos seleccionados se mueven juntos como un solo bloque, cada uno con su subárbol, en el orden del documento. Un nodo seleccionado dentro de otro seleccionado viaja dentro del subárbol de su padre. Consulta multi-drag.

Todas las opciones están en la API de makeSortableTree. Para árboles con miles de filas, consulta listas virtuales.