文档/指南

共享项的列表

同一分组的列表可以移动、复制和交换项——整列都能接收放置,pull 和 put 决定什么能进出,面板负责复制,受控列表把每次放置应用到带撤销历史的 store。

编辑此页

本页由 AI 翻译,可能存在不准确之处。 阅读英文原文

Live

正在加载演示…

<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 名称相同的列表可以交换项。默认情况下,每个列表自成一个分组。

Column.vue
<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>
index.vue
<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。

Column.vue
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 规定什么可以进入:

Live

正在加载演示…

<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>
选项取值
pulltrue(默认):项可以离开。false:不能离开。'clone':副本离开,项留下。
puttrue(默认):项可以进入。false:什么都不能进入。一个或多个类型('image'、['image', 'video']):这些类型的项可以进入。函数:由你按每次拖拽决定。
index.vue
// 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())。列表内部的目标(放进某一项)和包住列表的目标(容纳这些列表的面板)则让空位留在原处。

RuleList.vue
const { items, isOver, isAccepting } = makeSortableList(el, { … });
const { active } = useDnD();
const verdict = computed(() => (active.value ? (isAccepting.value ? 'takes' : 'refuses') : null));

数量限制

一个最多容纳三个文件的列表,Shortlist:

index.vue
const shortlist = reactive({
  files: [/* … */],
  group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
});

拖拽询问时,它会读取自己的项。把它装满,它就会拒绝下一个文件,并在标题中显示原因。

拖拽多个不同类型的选中项时,acceptMode 规定是 'all'(默认)还是 'some' 必须通过 put。使用 'some' 时,通过的项落位,其余的飞回原处。见多项拖拽。

每个列表一个分组

一个列表只属于一个分组:group 只接受一个名称,不能是多个。同一分组的列表共享每次拖拽(它们的草稿、被拖拽的内容、哪些列表接受它),因此项永远不会在分组之间移动。如果项只应朝某些方向流动,请把这些列表放进同一个分组,并让 put 根据 from(拖拽来自的列表)来决定:

index.vue
// 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 } });
如果你来自 SortableJS:在那里,put: ['a', 'b'] 列出的是列表可以从中接收项的分组名。而在这里,put 中的数组列出的是列表接收的项的类型。要从其他列表接收项,请让它们使用同一个分组,并像上面那样根据 from 来决定。

复制:面板和 Alt 键

Live

正在加载演示…

<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>
Palette.vue
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() }),
});
Sheet.vue
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:

Live

正在加载演示…

<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>
Table.vue
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}`);
  },
});
store.ts
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。