ツリーは、行が深さを持つ並べ替え可能なリストです。セクションを左右にドラッグすると階層が変わり、別のセクションの中央に重ねて保つとその中にドロップされます。番号はドラッグに追従します。
デモを読み込み中…
<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 とともに返します。そのリストを描画し、行のインデントは自分で付けます。すべての行は 1 つのコンテナー内の兄弟要素のままなので、リストの行と同じようにスライドし、アニメーションします。
<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>
行はリストの行と同じく makeSortableItem を使います。インデントは padding ではなく margin で付けてください。そうすると行そのものが深さに合わせて動きます。オーバーレイは枠の幅を取ってその階層に着地し、深さの変化もほかの移動と同じようにスライドします。padding では行の中身だけがインデントされ、持ち上げ時の行のコピーであるオーバーレイは、ドラッグを始めた階層のままになります。items には FlatNode が入ります。
FlatNode のフィールド | 内容 |
|---|---|
key | ノードのキー(getKey、デフォルトは node.id)。:key と行の id に使います。 |
item | あなたのノード。 |
depth | ルートは 0。ドラッグ中のノードの深さは着地する予定の位置なので、その枠はその場でインデントされます。 |
parentKey | 親のキー。ルートは 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; },
});
ネスト
ドラッグは 2 つの方法で深さを変えます。どちらもデフォルトでオンです。
nesting | 深さの決まり方 |
|---|---|
'offset' | 横方向のドラッグ:左右に indentation ピクセル動くごとに 1 階層上下します。枠の周りの行が許す範囲までです。 |
'hover' | 行の中央にドラッグを保つ:ドロップはその行の中に、最初の子として入ります。 |
'both' | どちらでも。デフォルトです。 |
indentation には行が 1 階層ごとに付けるインデント(デフォルト 24)を設定してください。そうすれば枠がポインターに追従します。デモで 40 に切り替えて、横にドラッグしてみましょう。'hover' だけの場合、横方向のドラッグでは階層は変わらず、行の中央だけがネストします。どちらのオプションも ref またはゲッターを受け取り、次のドラッグから適用されます。
axis: 'y'(または restrictToVerticalAxis)では、ドラッグは横に動けませんが、ホバーで行をネストすることはできます。
ルール
Engineering は人とチームを受け入れ、Leadership はロックされ、チームの中のチームより深くには何も入れません。チームを開いたり閉じたりしてみましょう。表示・非表示になる行がスライドして出入りします。
デモを読み込み中…
<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 });
| オプション | 制限するもの |
|---|---|
canNest(node, parent) | node を parent の下に置けるかどうか。マルチドラッグでは、ドラッグ中のすべてのノードについて問い合わせます。ルートは常に許可されます。 |
maxDepth | 許可される最大の深さ。ルートが 0 です。ドラッグ中のノードの子孫も数に入るので、メンバーのいるチームは maxDepth - 1 より深くには行けません。 |
disabled | 行に指定:その行はドラッグできません。ツリーに指定:そこからドラッグできず、そこへドロップもできません。 |
group.put | ツリーがほかのリストやツリーからアイテムをそもそも受け入れるかどうか。 |
onBeforeDrop | それ以外のすべて。ドロップ時に判断します:ドロップの判断を参照。 |
ルールがある深さを拒否すると、枠は許可される最も深い深さを取ります。Noor を Raj の下へ、右にずらしてドラッグしても、枠は最上位にとどまります。Leadership はロックされていて、Raj は人だからです。どの深さも許可されない場所へは、枠は動きません。コピーや createItem から作られたノードも canNest と maxDepth に従います。
折りたたまれたノード
collapsed は、子が非表示になっているノードのキーの Set で、ref またはゲッターとして渡します。それらの子は 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 では、ノードの展開・折りたたみで表示・非表示になる行がアニメーションで出入りし、ドラッグ以外によるデータの変化も同様です。
挿入ライン
左のテンプレートをメニューにドラッグするか、項目を動かしてみましょう。メニューは動かず、ドロップ先をラインが、着地する階層から示します。ページやグループの中央の上では、代わりにその行が強調表示されます。
デモを読み込み中…
<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 を読み取り、ラインを描きます。
<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 を持つ通常のドラッグ可能な要素(makeDraggable)です。ツリーは createItem でそれを新しいノードに変え、ラインの位置と深さに置きます。
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 を押していると、代わりにコピーになります。
デモを読み込み中…
<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 を持つツリーとリストは、2 つのリストと同じようにノードをやり取りします。ノードはどこへ行ってもサブツリーを保持し、リストはそれを表示しないだけです。makeSortableItem({ item }) は行を所有するリストまたはツリーに応じてキー付けされるので、同じ行コンポーネントで両方に対応できます。
コピーはリストと同様に動作します(clone、pull: 'clone'、コピーキー)が、clone はサブツリー全体を含むコピーを返す必要があり、その中のすべてのノードに新しいキーが必要です。
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) は新しいルート配列としての新しいツリーです。変化した経路上のノードはコピーで、変わっていないサブツリーは共有され、あなたのノードはそのままです。ストアやイミュータブルなデータでは、ゲッターを渡し、e.preventDefault() を呼んで e.result を保存してください(ドロップの判断を参照)。コピーは withChildren(node, children) で作られ、デフォルトは新しい子を持つ { ...node } です。クラスのインスタンスには独自の関数を渡してください。
キーボードとマルチドラッグ
行にフォーカスし、:kbd{value="Space"} を押してから:
| キー | ノードの移動先 |
|---|---|
| ArrowUp / ArrowDown | 上または下の枠へ、1 行ずつ。 |
| ArrowRight | 1 階層深く、上の行の下へ。ルールが許す場合のみ。 |
| ArrowLeft | 1 階層上へ、親の外へ。 |
| Space / Escape | ドロップする / 元に戻す。 |
インジケーターモードでは、矢印キーは代わりにラインを動かします。ツリーの隣のリストやテンプレートからは、ツリーに向かう矢印キーでアイテムが取り込まれ、その前に入る行と同じ階層に置かれます。
選択されたノードは、それぞれのサブツリーとともに、ドキュメント順に 1 つのかたまりとして一緒に動きます。選択されたノードの中にある選択されたノードは、親のサブツリーに乗って一緒に動きます。マルチドラッグを参照してください。
すべてのオプションは makeSortableTree API にあります。数千行のツリーについては仮想リストを参照してください。