Documentação/Guias

Árvores

Sumários, menus, organogramas e backlogs com makeSortableTree — renderizados de forma plana com profundidades, aninhados arrastando para o lado ou passando sobre uma linha, com regras, nós recolhidos, uma linha de inserção e listas no mesmo grupo.

Editar esta página

Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês

Uma árvore é uma lista ordenável cujas linhas têm profundidades. Arraste uma seção para a esquerda ou para a direita para mudar o nível dela, ou segure-a sobre o meio de outra seção para soltá-la dentro dela. Os números acompanham o arraste.

Live

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

Renderizada de forma plana

makeSortableTree recebe os seus dados aninhados e retorna os nós visíveis como uma lista plana, em ordem, cada um com a sua depth. Você renderiza essa lista e indenta as linhas você mesmo. Todas as linhas continuam irmãs em um único contêiner, então elas deslizam e são animadas como as linhas de uma 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>

As linhas usam makeSortableItem, como as linhas de uma lista. Indente-as com margin, não com padding: assim é a própria linha que se move com a profundidade. O overlay pega a largura da posição e pousa no nível dela, e uma mudança de profundidade desliza como qualquer outro movimento. Um padding indentaria só o que está dentro da linha, e o overlay, uma cópia da linha feita ao pegar, manteria o nível em que o arraste começou. items contém FlatNodes:

Campo de FlatNodeO que contém
keyA key do nó (getKey, padrão node.id). Use-a no :key e no id da linha.
itemO seu nó.
depth0 para as raízes. A profundidade do nó arrastado é onde ele pousaria, então a posição dele indenta ao vivo.
parentKeyA key do pai, null para as raízes.
indexO índice entre os irmãos.
hasChildren, collapsedPara um botão de expandir.

Como items é o rascunho, tudo o que você calcula a partir dele acompanha o arraste: a demo numera as seções a partir de items, então "2.1" vira "3.1" enquanto você move. projected é onde o nó arrastado pousaria, { depth, parentKey }, ou null enquanto ele não está sobre esta árvore.

Os filhos são lidos de node.children e gravados ali. Dados com outro formato usam getChildren e setChildren; o organograma abaixo os guarda em members:

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

Aninhamento

Um arraste muda a profundidade de dois jeitos, ambos ligados por padrão:

nestingA profundidade vem de…
'offset'Arrastar para o lado: cada indentation pixels para a esquerda ou para a direita é um nível acima ou abaixo, até onde as linhas em volta da posição permitem.
'hover'Segurar o arraste sobre o meio de uma linha: o drop vai para dentro dela, como o primeiro filho.
'both'Qualquer um dos dois. O padrão.

Defina indentation como o que as suas linhas acrescentam por nível (24 por padrão), para que a posição siga o ponteiro: na demo, mude para 40 e arraste para o lado. Só com 'hover', um arraste para o lado mantém o nível, e só o meio de uma linha aninha. As duas opções aceitam um ref ou um getter e valem para o próximo arraste.

Com axis: 'y' (ou restrictToVerticalAxis), o arraste não pode ir para o lado, mas as linhas ainda aninham passando sobre elas.

Regras

Engineering aceita pessoas e times; Leadership está trancado; nada vai mais fundo que um time dentro de um time. Abra e feche os times: as linhas que eles mostram e escondem entram e saem deslizando.

Live

Carregando a 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 });
OpçãoLimita
canNest(node, parent)Se node pode ficar sob parent. Um multi-drag pergunta para cada nó arrastado. As raízes são sempre permitidas.
maxDepthA profundidade máxima permitida, sendo as raízes 0. Os descendentes do nó arrastado contam: um time com membros não vai mais fundo que maxDepth - 1.
disabledEm uma linha: ela não é arrastada. Na árvore: nada é arrastado dela nem solto nela.
group.putSe a árvore aceita itens de outras listas e árvores.
onBeforeDropQualquer outra coisa, decidida no drop: veja decidindo um drop.

Quando uma regra recusa uma profundidade, a posição assume a maior profundidade que passa: arraste Noor para baixo de Raj e para a direita, e a posição fica no nível de cima, já que Leadership está trancado e Raj é uma pessoa. Onde nenhuma profundidade passa, a posição não vai para lá. Cópias e nós de createItem também seguem canNest e maxDepth.

Nós recolhidos

collapsed é um Set de keys cujos filhos estão escondidos, como ref ou getter. Os filhos deles não estão em items; node.collapsed e node.hasChildren controlam o botão:

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

Um nó arrastado leva toda a sua subárvore junto, recolhida ou não. Com animateChanges: true, as linhas que um nó mostra ou esconde ao abrir ou fechar entram e saem animadas, assim como as mudanças nos dados que nenhum arraste fez.

Uma linha de inserção

Arraste um modelo da esquerda para o menu, ou mova um item: o menu fica parado e uma linha mostra para onde vai o drop, começando no nível em que ele pousa. Sobre o meio de uma página ou de um grupo, é o próprio item que se acende.

Live

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

Com placeholder: 'indicator', a árvore não se mexe enquanto você arrasta. Cada linha lê o seu indicator e desenha a linha de inserção:

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>

Em árvores, indicator acrescenta depth e parentKey: o nível e o pai que os nós soltos recebem. Arrastar para o lado move a linha de inserção entre os níveis, e o teclado também a move. Neste modo, um drop fora da árvore cancela.

Os modelos são draggables simples (makeDraggable) com data. A árvore os transforma em novos nós com createItem, na posição e na profundidade da linha de inserção:

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

A barra acima do menu renderiza menu, os dados salvos, não items: ela só muda no drop.

Árvores e listas em um mesmo grupo

Uma história movida para o backlog guarda as suas tarefas, fora de vista, e as traz de volta ao roadmap. Segure Alt ao arrastar para fora do roadmap para copiar em vez de mover.

Live

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

Uma árvore e uma lista com o mesmo group trocam nós, como duas listas fazem. Um nó guarda a sua subárvore aonde quer que vá: a lista só não a mostra. O mesmo componente de linha serve aos dois, já que makeSortableItem({ item }) recebe a key da lista ou da árvore que é dona da linha.

As cópias funcionam como nas listas (clone, pull: 'clone', a tecla de cópia), mas clone precisa retornar a cópia com toda a sua subárvore, e cada nó dela precisa de uma key nova:

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

Veja listas que compartilham itens sobre grupos, cópia e createItem.

O drop

O evento de drop da árvore é o de uma lista, com parentKey acrescentado a from e to:

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

to.index é a posição entre os novos irmãos, contada depois que o nó deixou o lugar antigo. No drop, a biblioteca dá aos nós alterados os novos filhos (setChildren) e grava um novo array de raízes no seu ref.

e.result.get(treeId) é a nova árvore como um novo array de raízes: os nós nos caminhos alterados são cópias, as subárvores intactas são compartilhadas, e os seus nós ficam como estão. Para uma store ou dados imutáveis, passe um getter, chame e.preventDefault() e salve e.result (veja decidindo um drop). As cópias são feitas com withChildren(node, children), por padrão { ...node } com os novos filhos; passe a sua própria função para instâncias de classes.

Teclado e multi-drag

Foque uma linha, pressione Space, e então:

TeclaMove o nó
ArrowUp / ArrowDownPara a posição acima ou abaixo, uma linha por vez.
ArrowRightUm nível mais fundo, sob a linha de cima, onde as regras permitem.
ArrowLeftUm nível acima, para fora do pai.
Space / EscapeSolta o nó / devolve-o ao lugar.

No modo indicador, as setas movem a linha de inserção em vez do nó. A partir de uma lista ou de um modelo ao lado da árvore, uma seta em direção à árvore traz o item para dentro, no nível da linha antes da qual ele entra.

Os nós selecionados se movem juntos como um bloco, cada um com a sua subárvore, na ordem do documento. Um nó selecionado dentro de outro selecionado vai junto na subárvore do pai. Veja multi-drag.

Todas as opções estão na API do makeSortableTree. Para árvores com milhares de linhas, veja listas virtuais.