树就是行带有深度的可排序列表。把一个章节向左或向右拖动可以改变它的层级,停在另一个章节的中间则会把它放进去。编号随拖拽更新。
正在加载演示…
<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。由你渲染这个列表并自行缩进各行。每一行都是同一个容器中的兄弟元素,因此各行像列表的行一样滑动并产生动画。
<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,与列表的行一样。用 margin 而不是 padding 来缩进:这样移动的是行本身,随它的深度而动。浮层采用空位的宽度并落到空位所在的层级,深度的变化也像其他任何移动一样滑动过去。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 被锁定;最深只能到团队中的团队。展开和折叠各个团队:它们显示和隐藏的行会滑入滑出。
正在加载演示…
<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 是一个 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 时,节点展开或折叠时显示或隐藏的行会以动画进出,非拖拽引起的数据变化也一样。
插入线
把左侧的模板拖进菜单,或移动一个菜单项:菜单保持不动,一条线显示放置的位置,从它将落到的层级开始画。在页面或分组的中间上方时,该行会高亮。
正在加载演示…
<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 };
},
});
菜单上方的栏渲染的是 menu,即已保存的数据,而不是 items:它只在放置时变化。
同一分组中的树和列表
移到待办列表中的 story 会保留它的任务,只是看不见,移回路线图时又会把它们带回来。从路线图拖出时按住 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 相同的树和列表可以交换节点,就像两个列表一样。节点无论去到哪里都保留它的子树:列表只是不显示它。同一个行组件可以同时服务两者,因为 makeSortableItem({ item }) 会按拥有该行的列表或树来确定 key。
复制的方式与列表相同(clone、pull: 'clone'、复制键),但 clone 必须返回带有整棵子树的副本,其中每个节点都需要新的 key:
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) 是以新根数组表示的新树:变化路径上的节点是副本,未改动的子树是共享的,你的节点保持原样。对于 store 或不可变数据,传入 getter,调用 e.preventDefault() 并保存 e.result(见决定放置)。副本由 withChildren(node, children) 创建,默认是带有新子节点的 { ...node };对于类实例,传入你自己的函数。
键盘与多项拖拽
让一行获得焦点,按 Space,然后:
| 按键 | 节点移动到 |
|---|---|
| ArrowUp / ArrowDown | 上方或下方的位置,每次一行。 |
| ArrowRight | 深一级,放到上一行之下,前提是规则允许。 |
| ArrowLeft | 上一级,移出它的父节点。 |
| Space / Escape | 放下它 / 放回原处。 |
在指示器模式下,方向键改为移动插入线。从树旁边的列表或模板出发,朝树方向按方向键会把项接收进来,与它将要放在其前面的那一行平齐。
选中的节点作为一个整体一起移动,每个都带着自己的子树,按文档顺序排列。位于另一个选中节点之内的选中节点,会随父节点的子树一起移动。见多项拖拽。
所有选项见 makeSortableTree API。对于有成千上万行的树,见虚拟列表。