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.
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.
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.
<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>
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 FlatNode | O que contém |
|---|---|
key | A key do nó (getKey, padrão node.id). Use-a no :key e no id da linha. |
item | O seu nó. |
depth | 0 para as raízes. A profundidade do nó arrastado é onde ele pousaria, então a posição dele indenta ao vivo. |
parentKey | A key do pai, null para as raízes. |
index | O índice entre os irmãos. |
hasChildren, collapsed | Para 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:
nesting | A 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.
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>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 });
| Opção | Limita |
|---|---|
canNest(node, parent) | Se node pode ficar sob parent. Um multi-drag pergunta para cada nó arrastado. As raízes são sempre permitidas. |
maxDepth | A 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. |
disabled | Em uma linha: ela não é arrastada. Na árvore: nada é arrastado dela nem solto nela. |
group.put | Se a árvore aceita itens de outras listas e árvores. |
onBeforeDrop | Qualquer 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.
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:
<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:
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.
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:
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:
| Tecla | Move o nó |
|---|---|
| ArrowUp / ArrowDown | Para a posição acima ou abaixo, uma linha por vez. |
| ArrowRight | Um nível mais fundo, sob a linha de cima, onde as regras permitem. |
| ArrowLeft | Um nível acima, para fora do pai. |
| Space / Escape | Solta 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.