ドキュメント/ガイド

ツリー

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 とともに返します。そのリストを描画し、行のインデントは自分で付けます。すべての行は 1 つのコンテナー内の兄弟要素のままなので、リストの行と同じようにスライドし、アニメーションします。

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ノードのキー(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 はロックされ、チームの中のチームより深くには何も入れません。チームを開いたり閉じたりしてみましょう。表示・非表示になる行がスライドして出入りします。

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 は、子が非表示になっているノードのキーの 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 では、ノードの展開・折りたたみで表示・非表示になる行がアニメーションで出入りし、ドラッグ以外によるデータの変化も同様です。

挿入ライン

左のテンプレートをメニューにドラッグするか、項目を動かしてみましょう。メニューは動かず、ドロップ先をラインが、着地する階層から示します。ページやグループの中央の上では、代わりにその行が強調表示されます。

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 を持つ通常のドラッグ可能な要素(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 を持つツリーとリストは、2 つのリストと同じようにノードをやり取りします。ノードはどこへ行ってもサブツリーを保持し、リストはそれを表示しないだけです。makeSortableItem({ item }) は行を所有するリストまたはツリーに応じてキー付けされるので、同じ行コンポーネントで両方に対応できます。

コピーはリストと同様に動作します(clone、pull: 'clone'、コピーキー)が、clone はサブツリー全体を含むコピーを返す必要があり、その中のすべてのノードに新しいキーが必要です。

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) は新しいルート配列としての新しいツリーです。変化した経路上のノードはコピーで、変わっていないサブツリーは共有され、あなたのノードはそのままです。ストアやイミュータブルなデータでは、ゲッターを渡し、e.preventDefault() を呼んで e.result を保存してください(ドロップの判断を参照)。コピーは withChildren(node, children) で作られ、デフォルトは新しい子を持つ { ...node } です。クラスのインスタンスには独自の関数を渡してください。

キーボードとマルチドラッグ

行にフォーカスし、:kbd{value="Space"} を押してから:

キーノードの移動先
ArrowUp / ArrowDown上または下の枠へ、1 行ずつ。
ArrowRight1 階層深く、上の行の下へ。ルールが許す場合のみ。
ArrowLeft1 階層上へ、親の外へ。
Space / Escapeドロップする / 元に戻す。

インジケーターモードでは、矢印キーは代わりにラインを動かします。ツリーの隣のリストやテンプレートからは、ツリーに向かう矢印キーでアイテムが取り込まれ、その前に入る行と同じ階層に置かれます。

選択されたノードは、それぞれのサブツリーとともに、ドキュメント順に 1 つのかたまりとして一緒に動きます。選択されたノードの中にある選択されたノードは、親のサブツリーに乗って一緒に動きます。マルチドラッグを参照してください。

すべてのオプションは makeSortableTree API にあります。数千行のツリーについては仮想リストを参照してください。