문서/가이드

트리

makeSortableTree로 만드는 목차, 메뉴, 조직도, 백로그. 깊이와 함께 평평하게 렌더링되고, 옆으로 드래그하거나 행 위에 머물러 중첩하며, 규칙, 접힌 노드, 삽입선, 같은 그룹의 리스트를 지원합니다.

이 페이지 편집

이 페이지는 AI가 번역했으며 부정확한 내용이 있을 수 있습니다. 영어 원문 읽기

트리는 행에 깊이가 있는 정렬 가능한 리스트입니다. 섹션을 왼쪽이나 오른쪽으로 드래그해 수준을 바꾸거나, 다른 섹션의 가운데에 잠시 머물러 그 안에 드롭하세요. 번호가 드래그를 따라 바뀝니다.

Live

데모 불러오는 중…

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

평평하게 렌더링하기

makeSortableTree는 중첩된 데이터를 받아 보이는 노드를 순서대로 각자의 depth와 함께 평평한 리스트로 반환합니다. 이 리스트를 렌더링하고 행의 들여쓰기는 직접 하세요. 모든 행이 한 컨테이너 안의 형제로 남으므로, 행은 리스트의 행처럼 미끄러지고 애니메이션됩니다.

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>

행은 리스트의 행처럼 makeSortableItem을 씁니다. 들여쓰기는 padding이 아니라 margin으로 하세요. 그래야 행 자체가 깊이에 따라 움직입니다. 오버레이는 자리의 너비를 받아 그 수준에 안착하고, 깊이가 바뀌는 것도 다른 이동처럼 미끄러집니다. padding은 행 안의 내용만 들여쓰므로, 집을 때 만든 행의 복사본인 오버레이는 드래그를 시작한 수준에 그대로 남습니다. items에는 FlatNode가 들어 있습니다.

FlatNode 필드담고 있는 것
key노드의 key(getKey, 기본값 node.id). :key와 행의 id에 쓰세요.
item원본 노드.
depth루트는 0. 드래그 중인 노드의 깊이는 안착할 위치이므로, 그 자리는 실시간으로 들여쓰기됩니다.
parentKey부모의 key. 루트는 null.
index형제 사이에서의 인덱스.
hasChildren, collapsed펼치기 버튼용.

items는 초안이므로 여기서 계산한 값은 모두 드래그를 따라갑니다. 데모는 items로 섹션 번호를 매기므로, 옮기는 동안 "2.1"이 "3.1"이 됩니다. projected는 드래그 중인 노드가 안착할 위치인 { depth, parentKey }이며, 노드가 이 트리 위에 없으면 null입니다.

자식은 node.children에서 읽고 거기에 기록합니다. 형태가 다른 데이터에는 getChildren과 setChildren을 쓰세요. 아래의 조직도는 자식을 members에 둡니다.

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

중첩

드래그는 두 가지 방법으로 깊이를 바꾸며, 둘 다 기본으로 켜져 있습니다.

nesting깊이를 정하는 것…
'offset'옆으로 드래그하기: 왼쪽이나 오른쪽으로 indentation 픽셀마다 한 단계씩 올라가거나 내려가며, 자리 주변의 행이 허용하는 만큼까지입니다.
'hover'행 가운데에 드래그를 잠시 머무르기: 드롭이 그 행 안의 첫 번째 자식으로 들어갑니다.
'both'둘 다. 기본값입니다.

indentation을 행이 단계마다 들여쓰는 값(기본값 24)으로 맞춰야 자리가 포인터를 따라갑니다. 데모에서 40으로 바꾸고 옆으로 드래그해 보세요. 'hover'만 쓰면 옆으로 드래그해도 수준은 그대로이고, 행의 가운데에서만 중첩됩니다. 두 옵션 모두 ref나 getter를 받으며, 다음 드래그부터 적용됩니다.

axis: 'y'(또는 restrictToVerticalAxis)를 쓰면 드래그가 옆으로 움직일 수 없지만, 행 위에 머무르는 방식으로는 여전히 중첩됩니다.

규칙

Engineering은 사람과 팀을 받고, Leadership은 잠겨 있으며, 팀 안의 팀보다 깊이 들어갈 수는 없습니다. 팀을 열고 닫아 보세요. 팀이 보여 주고 숨기는 행이 미끄러지듯 나타나고 사라집니다.

Live

데모 불러오는 중…

<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 });
옵션제한하는 것
canNest(node, parent)node가 parent 아래로 갈 수 있는지. 멀티 드래그에서는 드래그 중인 노드마다 묻습니다. 루트는 항상 허용됩니다.
maxDepth허용되는 가장 깊은 깊이(루트가 0). 드래그 중인 노드의 자손도 셉니다. 멤버가 있는 팀은 maxDepth - 1보다 깊이 들어가지 못합니다.
disabled행에 두면 그 행은 드래그되지 않습니다. 트리에 두면 트리에서 드래그하거나 트리로 드롭할 수 없습니다.
group.put트리가 다른 리스트나 트리의 항목을 아예 받는지.
onBeforeDrop그 밖의 모든 것을 드롭할 때 결정합니다. 드롭 결정하기를 참고하세요.

규칙이 어떤 깊이를 거부하면 자리는 통과하는 가장 깊은 깊이를 택합니다. Noor를 Raj 아래 오른쪽으로 드래그하면, Leadership은 잠겨 있고 Raj는 사람이므로 자리는 최상위 수준에 머뭅니다. 통과하는 깊이가 하나도 없는 곳으로는 자리가 움직이지 않습니다. 복사본과 createItem으로 만든 노드도 canNest와 maxDepth를 따릅니다.

접힌 노드

collapsed는 자식이 숨겨진 key의 Set이며, ref나 getter로 넘깁니다. 그 자식은 items에 없고, 버튼은 node.collapsed와 node.hasChildren으로 제어합니다.

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

드래그 중인 노드는 접혀 있든 아니든 하위 트리 전체를 데리고 갑니다. animateChanges: true를 쓰면 노드가 열리고 닫힐 때 보여 주고 숨기는 행이 애니메이션되며 나타나고 사라지고, 드래그가 아닌 데이터 변경도 마찬가지입니다.

삽입선

왼쪽의 템플릿을 메뉴로 드래그하거나 항목을 옮겨 보세요. 메뉴는 그대로 있고, 선이 드롭될 위치를 안착할 수준부터 보여 줍니다. 페이지나 그룹의 가운데 위에서는 대신 그 행이 강조됩니다.

Live

데모 불러오는 중…

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

placeholder: 'indicator'를 쓰면 드래그하는 동안 트리가 움직이지 않습니다. 각 행이 자기 indicator를 읽어 선을 그립니다.

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>

트리에서 indicator에는 depth와 parentKey가 추가됩니다. 드롭된 노드가 받을 수준과 부모입니다. 옆으로 드래그하면 선이 수준 사이를 오가고, 키보드로도 선을 움직일 수 있습니다. 이 모드에서는 트리 밖에 드롭하면 취소됩니다.

템플릿은 data를 가진 일반 draggable(makeDraggable)입니다. 트리는 createItem으로 이를 선의 위치와 깊이에 새 노드로 만듭니다.

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

메뉴 위의 바는 items가 아니라 저장된 데이터인 menu를 렌더링하므로, 드롭할 때만 바뀝니다.

한 그룹의 트리와 리스트

백로그로 옮긴 스토리는 작업을 눈에 보이지 않게 간직했다가 로드맵으로 다시 가져옵니다. 로드맵에서 드래그해 나갈 때 Alt를 누르고 있으면 옮기는 대신 복사합니다.

Live

데모 불러오는 중…

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

같은 group의 트리와 리스트는 두 리스트처럼 노드를 주고받습니다. 노드는 어디로 가든 하위 트리를 간직합니다. 리스트가 그것을 보여 주지 않을 뿐입니다. makeSortableItem({ item })은 그 행을 가진 리스트나 트리 어느 쪽의 key로든 식별되므로, 같은 행 컴포넌트를 양쪽에 쓸 수 있습니다.

복사는 리스트와 똑같이 동작하지만(clone, pull: 'clone', 복사 키), clone은 하위 트리 전체를 포함한 복사본을 반환해야 하고, 그 안의 모든 노드에 새 key가 필요합니다.

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

그룹, 복사, createItem은 항목을 공유하는 리스트를 참고하세요.

드롭

트리의 드롭 이벤트는 리스트의 드롭 이벤트에 from과 to의 parentKey가 추가된 것입니다.

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

to.index는 새 형제 사이에서의 위치로, 노드가 이전 자리를 떠난 뒤를 기준으로 셉니다. 드롭하면 라이브러리가 바뀐 노드에 새 자식을 주고(setChildren) 새 루트 배열을 ref에 기록합니다.

e.result.get(treeId)는 새 루트 배열로 표현한 새 트리입니다. 바뀐 경로의 노드는 복사본이고, 변경되지 않은 하위 트리는 공유되며, 원본 노드는 그대로 남습니다. 스토어나 불변 데이터에는 getter를 넘기고, e.preventDefault()를 호출한 뒤 e.result를 저장하세요 (드롭 결정하기 참고). 복사본은 withChildren(node, children)으로 만들며, 기본값은 새 자식을 가진 { ...node }입니다. 클래스 인스턴스에는 직접 만든 함수를 넘기세요.

키보드와 멀티 드래그

행에 포커스를 두고 Space를 누른 뒤:

키노드를 옮기는 곳
ArrowUp / ArrowDown위나 아래 자리로, 한 행씩.
ArrowRight규칙이 허용하는 경우 한 단계 더 깊이, 위 행의 아래로.
ArrowLeft한 단계 위로, 부모 밖으로.
Space / Escape드롭합니다 / 되돌립니다.

인디케이터 모드에서는 화살표 키가 노드 대신 선을 움직입니다. 트리 옆의 리스트나 템플릿에서 트리 쪽으로 화살표 키를 누르면, 항목이 트리 안으로 들어가 그 앞에 들어갈 행과 같은 수준에 놓입니다.

선택된 노드는 문서 순서대로, 각자의 하위 트리와 함께 한 블록으로 움직입니다. 다른 선택된 노드 안에 있는 선택된 노드는 부모의 하위 트리에 실려 함께 갑니다. 멀티 드래그를 참고하세요.

모든 옵션은 makeSortableTree API에 있습니다. 행이 수천 개인 트리는 가상 리스트를 참고하세요.