正在加载演示…
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'backlog',
title: 'Backlog',
stories: [
{ id: 's1', title: 'Saved searches', points: 5 },
{ id: 's2', title: 'Crash on empty board', points: 2, bug: true },
{ id: 's3', title: 'CSV export', points: 3 },
{ id: 's4', title: 'Dark mode email', points: 2 },
],
},
{
id: 'sprint',
title: 'Sprint 14',
stories: [{ id: 's5', title: 'Onboarding tour', points: 8 }],
},
]);
const wholeColumn = ref(true);
</script>
<template>
<DnDProvider>
<div class="planning">
<label class="switch">
<input v-model="wholeColumn" type="checkbox" />
<code>dropzone</code>: the whole column takes drops
</label>
<div class="columns">
<Column v-for="column in columns" :key="column.id" :column="column" :whole-column="wholeColumn" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.planning {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
}
.switch input {
accent-color: #42b883;
}
.switch code {
font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
color: #42b883;
}
.columns {
display: flex;
justify-content: center;
gap: 14px;
width: 100%;
}
</style>分组
group 名称相同的列表可以交换项。默认情况下,每个列表自成一个分组。
<script setup lang="ts">
import { toRef, useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import StoryCard from './StoryCard.vue';
type Story = { id: string; title: string; points: number };
const props = defineProps<{ column: { id: string; title: string; stories: Story[] } }>();
const list = useTemplateRef<HTMLElement>('list');
const { items, isOver } = makeSortableList(list, {
id: props.column.id, // names the list in drop events
items: toRef(props.column, 'stories'), // a writable ref into the reactive column
group: 'planning', // same name: stories move between these lists
});
</script>
<template>
<section :class="{ over: isOver }">
<h3>{{ column.title }}</h3>
<ul ref="list">
<StoryCard v-for="story in items" :key="story.id" :story="story" />
</ul>
</section>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import Column from './Column.vue';
const columns = reactive([
{ id: 'backlog', title: 'Backlog', stories: [{ id: 's1', title: 'Saved searches', points: 5 }] },
{ id: 'sprint', title: 'Sprint 14', stories: [] },
]);
</script>
<template>
<Column v-for="column in columns" :key="column.id" :column="column" />
</template>
- 这里每一列都是一个组件,列通常也是这样写的。一个组件也可以创建多个列表,例如并排固定的 Todo 和 Done:每张卡片加入元素包含它的那个列表。
toRef(props.column, 'stories')是可写的,因为父组件传入的是响应式对象。放置会写入两个数组,即 story 离开的那个和进入的那个,两列的点数也随之更新。id在放置事件中以from.list和to.list出现,也是e.result的 key,e.result包含这次放置所改变的每个列表的新数组。id和group只读取一次。- key 在整个分组中必须唯一。
列表的 onDrop 只为放进它的放置运行。从 backlog 移到 sprint 会调用 sprint 列表的 onDrop,它的 e.result 包含两个列表的新数组。
整列接收放置:dropzone
高高的列里放一个短列表,最后一张卡片下面会留出空白。使用 dropzone 时,由另一个元素替列表接收放置:这里是整列。在最后一个 story 下方松开,它会落到末尾。story 仍在 <ul> 中;只有接收放置的区域变大了。演示会在拖拽时给这个区域加上轮廓:关闭它之后,只有列表本身接收 story。
const zone = useTemplateRef<HTMLElement>('zone');
const { items } = makeSortableList(list, {
items: toRef(props.column, 'stories'),
group: 'planning',
dropzone: zone, // or a getter: () => (wholeColumn.value ? zone.value : undefined)
});
dropzone 时,空列表仍需要有尺寸才能接收放置:给它设置 min-height。什么能进出:pull 和 put
group 也接受对象。pull 规定什么可以离开列表,put 规定什么可以进入:
正在加载演示…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type SortablePutContext } from '@vue-dnd-kit/core';
import RuleList from './RuleList.vue';
// Four lists of one group, `files`. What each gives and takes is its `group.pull` and `group.put`.
const shortlist = reactive({
id: 'shortlist',
title: 'Shortlist',
code: 'put: ({ keys }) => shortlist.files.length + keys.length <= 3',
refusal: 'full',
group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
files: [
{ id: 'pitch', name: 'pitch.pdf', kind: 'doc' as const },
{ id: 'mock', name: 'mockup.png', kind: 'image' as const },
],
});
const lists = reactive([
{
id: 'inbox',
title: 'Inbox',
code: '{ pull: true, put: false }',
refusal: 'put: false',
group: { name: 'files', pull: true, put: false },
files: [
{ id: 'logo', name: 'logo.svg', kind: 'image' as const },
{ id: 'brief', name: 'brief.pdf', kind: 'doc' as const },
{ id: 'hero', name: 'hero.png', kind: 'image' as const },
],
},
{
id: 'templates',
title: 'Templates',
code: "{ pull: 'clone', put: false }, sort: false",
refusal: 'put: false',
group: { name: 'files', pull: 'clone' as const, put: false },
sort: false,
files: [
{ id: 'invoice', name: 'invoice.doc', kind: 'doc' as const },
{ id: 'banner', name: 'banner.png', kind: 'image' as const },
],
},
{
id: 'images',
title: 'Images',
code: "put: 'image'",
refusal: 'images only',
group: { name: 'files', put: 'image' },
files: [],
},
shortlist,
]);
</script>
<template>
<DnDProvider>
<div class="rules">
<RuleList v-for="list in lists" :key="list.id" :list="list" />
</div>
</DnDProvider>
</template>
<style scoped>
.rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 260px));
gap: 12px;
justify-content: center;
}
@media (max-width: 480px) {
.rules {
grid-template-columns: minmax(0, 1fr);
}
}
</style>| 选项 | 取值 |
|---|---|
pull | true(默认):项可以离开。false:不能离开。'clone':副本离开,项留下。 |
put | true(默认):项可以进入。false:什么都不能进入。一个或多个类型('image'、['image', 'video']):这些类型的项可以进入。函数:由你按每次拖拽决定。 |
// Inbox: gives, takes nothing.
group: { name: 'files', pull: true, put: false }
// Templates: gives copies, takes nothing, keeps its order.
group: { name: 'files', pull: 'clone', put: false }, sort: false
// Images: takes images only.
group: { name: 'files', put: 'image' }
类型是被拖拽项的 type 选项(makeSortableItem(el, { item, type }));没有该选项的项按分组名计。put 中写类型时,每个被拖拽项都必须具有其中之一(使用 acceptMode: 'some' 时,具有的项可以进入)。
put 函数会收到 { keys, items, types, from, to }:被拖拽的 key 和项、它们的类型、它们来自的列表(普通可拖拽元素为 null)以及本列表。列表内部的移动总是允许的:Inbox 仍可以对自己的文件排序。
在拒绝该拖拽的列表上方,空位会回到拖拽开始的地方,这样各列表显示的就是放置会产生的结果:什么都没有。在那里放置会被取消,项飞回原处。在这样的拖拽中,列表的 isAccepting 为 false,而在它上方时 isOver 仍为 true:演示中的每个列表就是这样知道该说什么的。
在列表之外的独立放置目标(例如垃圾桶)上方,空位同样会回到原处:在那里放置归该目标处理,列表会保留该项,除非目标的 onDrop 把它移出(event.source.remove())。列表内部的目标(放进某一项)和包住列表的目标(容纳这些列表的面板)则让空位留在原处。
const { items, isOver, isAccepting } = makeSortableList(el, { … });
const { active } = useDnD();
const verdict = computed(() => (active.value ? (isAccepting.value ? 'takes' : 'refuses') : null));
数量限制
一个最多容纳三个文件的列表,Shortlist:
const shortlist = reactive({
files: [/* … */],
group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
});
拖拽询问时,它会读取自己的项。把它装满,它就会拒绝下一个文件,并在标题中显示原因。
拖拽多个不同类型的选中项时,acceptMode 规定是 'all'(默认)还是 'some' 必须通过 put。使用 'some' 时,通过的项落位,其余的飞回原处。见多项拖拽。
每个列表一个分组
一个列表只属于一个分组:group 只接受一个名称,不能是多个。同一分组的列表共享每次拖拽(它们的草稿、被拖拽的内容、哪些列表接受它),因此项永远不会在分组之间移动。如果项只应朝某些方向流动,请把这些列表放进同一个分组,并让 put 根据 from(拖拽来自的列表)来决定:
// Review takes tasks from both teams. A team takes tasks back from Review,
// never from the other team.
const fromReview = ({ from }: SortablePutContext) => from === 'review';
makeSortableList(review, { id: 'review', items: reviewTasks, group: 'tasks' });
makeSortableList(frontend, { id: 'frontend', items: frontendTasks, group: { name: 'tasks', put: fromReview } });
makeSortableList(backend, { id: 'backend', items: backendTasks, group: { name: 'tasks', put: fromReview } });
put: ['a', 'b'] 列出的是列表可以从中接收项的分组名。而在这里,put 中的数组列出的是列表接收的项的类型。要从其他列表接收项,请让它们使用同一个分组,并像上面那样根据 from 来决定。复制:面板和 Alt 键
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Palette from './Palette.vue';
import Sheet from './Sheet.vue';
</script>
<template>
<!-- The palette and the page are lists of one group: copies go from one to the other. -->
<DnDProvider>
<div class="builder">
<Palette />
<Sheet />
</div>
</DnDProvider>
</template>
<style scoped>
.builder {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 24px;
}
</style>makeSortableList(list, {
items: templates,
group: { name: 'blocks', pull: 'clone', put: false }, // copies go out, nothing comes in
sort: false, // its own order stays
clone: (block) => ({ ...block, id: crypto.randomUUID() }),
});
makeSortableList(list, {
items: blocks,
group: 'blocks',
clone: (block) => ({ ...block, id: crypto.randomUUID() }), // for Alt-drags
dropOutside: 'remove', // drag off the Page panel to delete
});
pull: 'clone'会复制从该列表拖出的每一次拖拽。原件留下,副本被拖走并落位。- 拖拽时按住 Alt 也会复制,从任何带
clone函数的列表都可以:副本由它生成。用 provider 的copyModifier更换按键,或设为null关闭。 - 无论哪种方式,项所来自的列表都需要
clone。它必须返回带有新 key 的副本。没有clone就不会复制任何东西。 - 放置事件带有
mode: 'copy',e.items是副本。浮层组件也会收到mode,可以在复制时显示一个“+”(见浮层)。
在列表之间交换
同一分组的两个列表都设置了 behavior: 'swap' 时,它们之间可以交换项:被拖拽的项占据悬停项的位置,悬停的项移到被拖拽项所在的列表。它必须被允许离开自己的列表(pull: true)并进入另一个列表(put)。可排序列表中的值班轮换就在它的两列之间交换。
普通可拖拽元素
列表有 createItem 时,会接收用 makeDraggable 拖来的项(一个标签、一个模板、来自树的一个文件)。group.put 同样对它们做决定,此时 from: null。见把普通可拖拽元素拖进列表。
放到外面
拖拽在没有任何目标的地方结束时会发生什么,由列表的 dropOutside 决定:
dropOutside | 项 |
|---|---|
'placeholder'(默认) | 落到空位最后显示的地方。 |
'cancel' | 飞回原处。placeholder: 'indicator' 时的默认值。 |
'remove' | 离开它们的列表:拖出去即删除。面板演示中的页面就是这样做的。 |
'remove' 会先询问列表的 onBeforeDrop。然后列表的 onDrop 以 to.index === -1 运行,e.result 是去掉这些项之后的列表。接收放置的垃圾桶也是同样的工作方式:见回收区。
受控列表
列表可以读取不归它所有的 store:Pinia、服务端状态、不可变数据。以 getter(或 computed、只读 ref)的形式传入 items:库永远不会写入它。在 onDrop 中调用 e.preventDefault(),然后自己应用 e.result:
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Table from './Table.vue';
import { createSeating } from './store';
const seating = createSeating();
const { state, undo } = seating;
</script>
<template>
<DnDProvider>
<div class="seating">
<div class="tables">
<Table id="a" title="Table A" :seating="seating" />
<Table id="b" title="Table B" :seating="seating" />
</div>
<section class="history">
<header>
<h4>History</h4>
<button type="button" :disabled="!state.history.length" @click="undo">Undo</button>
</header>
<ol>
<li v-for="(entry, index) in state.history" :key="state.history.length - index">{{ entry.label }}</li>
<li v-if="!state.history.length" class="none">Move a guest</li>
</ol>
</section>
</div>
</DnDProvider>
</template>
<style scoped>
.seating {
display: flex;
flex-direction: column;
gap: 12px;
width: 460px;
max-width: 100%;
margin: 0 auto;
}
.tables {
display: flex;
gap: 12px;
}
.history {
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, currentColor 5%, transparent);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
h4 {
margin: 0;
font: 600 11px/1 ui-monospace, Menlo, Consolas, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.55;
}
button {
padding: 5px 12px;
border: 0;
border-radius: 7px;
background: #42b883;
color: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
button:disabled {
background: color-mix(in srgb, currentColor 12%, transparent);
color: inherit;
opacity: 0.5;
cursor: default;
}
ol {
display: flex;
flex-direction: column;
gap: 4px;
height: 66px;
margin: 8px 0 0;
padding: 0;
overflow-y: auto;
list-style: none;
font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace;
}
li:first-child {
color: #42b883;
}
li.none {
color: inherit;
opacity: 0.5;
}
</style>const { items } = makeSortableList(list, {
id: props.id,
items: () => props.seating.state.tables[props.id]!, // read-only
group: 'seating',
onDrop: (e) => {
e.preventDefault(); // no write
props.seating.apply(e.result, `${e.items[0]?.name} → ${props.title}`);
},
});
const apply = (result: ReadonlyMap<Key, Guest[]>, label: string) => {
const before = [...result.keys()].map((id): [Key, Guest[]] => [id, state.tables[id]!]);
for (const [id, guests] of result) state.tables[id] = guests;
state.history.unshift({ label, before });
};
const undo = () => {
const last = state.history.shift();
for (const [id, guests] of last?.before ?? []) state.tables[id] = guests;
};
e.result把这次放置改变的每个列表映射到它的新数组:移动后是两张桌子,重新排序后是一张。它们都是新数组,所以 store 可以保留旧数组用于撤销。- 草稿跟随 store:在
onDrop中(或在异步的onBeforeDrop完成之前)应用result,浮层就会按新顺序落位。 preventDefault()会跳过对result中每个列表的写入。如果哪里都没有应用,列表保持原样,项飞回原处。- 如果只读列表的放置被留给库来处理,开发构建会发出警告:那样什么都不会改变。
要在放置落位之前保存到服务器,或先询问用户,见决定放置。所有列表选项见 makeSortableList API。