文档/指南

拖放

用 makeDraggable 实现自由拖拽,用 makeDroppable 创建放置目标——按类型接收部分拖拽、拒绝其他拖拽的目标,平面布置图,回收区,列表中的文件夹,以及被列表接收的普通项。

编辑此页

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

凡是不属于可排序列表的场景,都用 makeDraggable 和 makeDroppable:把垃圾放进回收箱、在平面图上摆家具、把文件放进文件夹、垃圾桶、棋盘。

Live

正在加载演示…

<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type Key } from '@vue-dnd-kit/core';
import Bin from './Bin.vue';
import { BINS, WASTE } from './waste';

// The tray takes everything back (no `accept`); the bins take what their rule allows.
const TRAY = { id: 'tray', name: 'To sort', color: '#42b883', accept: '*', rule: "'*'" };

/** Where each item is: the tray or a bin. */
const place = reactive<Record<string, string>>(Object.fromEntries(WASTE.map((waste) => [waste.id, 'tray'])));
const itemsIn = (bin: string) => WASTE.filter((waste) => place[waste.id] === bin);
const drop = (bin: string, id: Key) => (place[String(id)] = bin);
</script>

<template>
  <DnDProvider>
    <div class="recycling">
      <Bin :bin="TRAY" :items="itemsIn('tray')" tray @drop="drop('tray', $event)" />
      <div class="bins">
        <Bin v-for="bin in BINS" :key="bin.id" :bin="bin" :items="itemsIn(bin.id)" @drop="drop(bin.id, $event)" />
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.recycling {
  display: flex;
  flex-direction: column;
  gap: 12px;
  max-width: 600px;
  margin: 0 auto;
}

.bins {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
}

@media (max-width: 560px) {
  .bins {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
</style>

可拖拽元素

WasteItem.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeDraggable } from '@vue-dnd-kit/core';

const props = defineProps<{ waste: { id: string; name: string; type: string } }>();
const el = useTemplateRef<HTMLElement>('el');

const { isDragging } = makeDraggable(el, {
  id: () => props.waste.id,      // read back as `active.id`
  type: () => props.waste.type,  // matched by the targets' `accept`
  data: () => props.waste,       // anything: `active.data`
});
</script>

<template>
  <div ref="el" :class="{ dragging: isDragging }">{{ waste.name }}</div>
</template>

以 getter 的形式传入来自你数据的 id。默认 id 来自 useId(),每个组件实例都不同,所以对应用的其他部分毫无意义。选项在需要时才读取,因此 getter 或 ref 能让它们保持最新。

选项作用
id、data、type拖拽的是谁、携带什么,以及目标的 accept 匹配什么。
handle内部的一个选择器或元素:只有它能开始拖拽。见手柄。
disabled不开始任何拖拽。
overlaytrue(默认):一个副本跟随指针。一个组件:你自己的预览。false:元素自己移动(见下文)。
axis、modifiers把拖拽锁定到 'x' 或 'y'、吸附、限制在边界内:见约束。
keyboardMode方向键如何移动它:按像素(默认)或从目标到目标(见下文)。
activation、canDrag按下何时变成拖拽:见传感器。
attributes供键盘和屏幕阅读器使用的 role、tabindex 和 aria-*(默认 true)。
返回值是什么
isDragging此元素正被拖拽。
selected可写,绑定到 provider 的选中集合。
transform拖拽偏移,用于 overlay: false。
id正在使用的 id。

按钮、输入框和其他可交互的子元素不会开始指针拖拽,所以它们在可拖拽元素内部照常工作。所有选项见 makeDraggable API。

放置目标

Bin.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeDroppable, type Key } from '@vue-dnd-kit/core';
import type { Bin, Waste } from './waste';

const props = defineProps<{ bin: Bin; items: Waste[] }>();
const emit = defineEmits<{ drop: [id: Key] }>();
const el = useTemplateRef<HTMLElement>('el');

const { isOver, isAccepting } = makeDroppable(el, {
  id: props.bin.id,
  accept: props.bin.accept,   // 'glass', ['paper', 'cardboard'], a function…
  onDrop: (event) => emit('drop', event.active.id),
});
</script>

<template>
  <section ref="el" :class="{ accepting: isAccepting, over: isOver && isAccepting, refused: isOver && !isAccepting }">
    <WasteItem v-for="waste in items" :key="waste.id" :waste="waste" />
  </section>
</template>
返回值为 true 的条件
isOver拖拽位于此目标上方,无论它是否接收该拖拽。
isAccepting有东西正被拖拽,且此目标接收它:用它点亮有效的目标。
placement不是布尔值:{ top, right, bottom, left },指针位于目标的哪几半;未悬停时为 null。用它实现“放在这张卡片之前 / 之后”。

有 isOver 而没有 isAccepting,表示拖拽位于拒绝它的目标上方。像上面的回收箱那样把它显示出来:在那里放置会被取消,项飞回原处。项移动时,不能接收它的回收箱会淡出;更多样式见样式。

选项作用
id、data以 over.id 和 over.data 读回。
accept、acceptMode它接收哪些拖拽(下一节)。
disabled什么都不接收,拖拽会从它上方经过。
priority目标重叠但不嵌套时,值高者胜出(默认 0)。
onDragEnter、onDragLeave拖拽进入或离开。
onBeforeDrop、onDrop决定放置,然后应用它(见下文)。

目标接收哪些拖拽

可拖拽元素的 type 与目标的 accept 进行匹配:

accept接收
省略或 '*'一切。
'glass'类型为 glass 的可拖拽元素,以及没有类型的。
['paper', 'cardboard']两种类型之一,以及没有类型的。
({ active, items, types }) => boolean由你的函数决定。

没有 type 的可拖拽元素符合任何字符串规则。type 也可以是数组(['paper', 'large']):只要有一个类型匹配即可。函数可以根据拖拽的任何信息做决定。上面的堆肥箱读取的是项的数据,而不是类型:

waste.ts
{
  id: 'compost',
  accept: ({ items }) => items.every((item) => (item.data as Waste).compostable),
}

accept 是在拖拽寻找目标时实时读取的:传入一个 ref 就能改变它。

同时拖拽多个项时,acceptMode 规定需要有多少项符合:'all'(默认)或 'some'。使用 'some' 时,从 onBeforeDrop 返回要保留的 id,其余的飞回原处。见决定放置。

放置

普通放置目标的 onDrop 是你修改数据的地方。库自己不会移动任何东西:

index.vue
<script setup lang="ts">
/** Where each item is: the tray or a bin. */
const place = reactive<Record<string, string>>(Object.fromEntries(WASTE.map((waste) => [waste.id, 'tray'])));
const itemsIn = (bin: string) => WASTE.filter((waste) => place[waste.id] === bin);
const drop = (bin: string, id: Key) => (place[String(id)] = bin);
</script>

<template>
  <Bin v-for="bin in BINS" :key="bin.id" :bin="bin" :items="itemsIn(bin.id)" @drop="drop(bin.id, $event)" />
</template>
事件字段内容
active拖拽:id、ids、items(每项为 { id, data, type, item })、data、type。
over此目标:id、data、element、rect、accepted。
pointer、delta指针在哪里,以及它从起点移动了多远。
position被拖拽的盒子在此目标中的位置(下一节)。
source项来自可排序列表时才有:{ list, remove() }。

active.ids 包含所有被拖拽的 id,抓起的那个在最前。顺序是 onBeforeDrop(决定)、onDrop(应用),然后是 provider 的 drag-end。要在一个地方监听页面上的每一次拖拽,使用 useDnDMonitor(见 provider)。要在放置前询问、让拖拽继续,或只接受部分项,见决定放置。

放在哪就留在哪

平面布置图、画布、白板:项停在你松开的地方。每个事件都带有 position,即能让绝对定位的项正好停在放置处的 left / top。

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Planner from './Planner.vue';
</script>

<template>
  <DnDProvider>
    <Planner />
  </DnDProvider>
</template>
Planner.vue
const room = useTemplateRef<HTMLElement>('room');
makeDroppable(room, {
  id: 'room',
  onDrop: ({ active, position }) => {
    const piece = find(active.id);
    if (!piece || !position || !room.value) return;
    // Clamped, so a piece let go half outside stays inside the walls.
    piece.x = clamp(position.x, room.value.clientWidth - piece.width);
    piece.y = clamp(position.y, room.value.clientHeight - piece.height);
    piece.place = 'room';
  },
});
Piece.vue
<div ref="el" class="piece" :style="{ left: `${piece.x}px`, top: `${piece.y}px` }">…</div>

position 是被拖拽盒子在 over 中的左上角,从它的内边距边缘量起,并加上了它的滚动距离:房间的边框,也就是墙壁,不计算在内。下方没有目标时它为 null。

不要用 active.rect 和 delta 计算位置:那样会漏掉目标的边框和滚动。这类画板所需的网格、边界和轴锁定见约束。

不使用浮层

默认情况下,元素的一个副本跟随指针,元素本身留在原地:仓库里的家具就是这样。使用 overlay: false 时没有副本,元素按拖拽返回的 transform 自己移动:房间里的家具就是这样。

Piece.vue
const inRoom = computed(() => props.piece.place === 'room');
const { isDragging, transform } = makeDraggable(el, {
  id: () => props.piece.id,
  overlay: computed(() => !inRoom.value),   // a value or a ref, read when the drag starts
});

const style = computed(() => ({
  left: `${props.piece.x}px`,
  top: `${props.piece.y}px`,
  ...(transform.value && { transform: `translate3d(${transform.value.x}px, ${transform.value.y}px, 0)` }),
}));
  • 此时 position 是元素自己的角,所以放置处理函数无需改动。
  • 元素移动时位于指针下方;查找目标时,库会透过它查找。拖拽期间把它提升到相邻元素之上(z-index)。
  • 它随页面滚动,浮层则不会:transform 会减去拖拽开始以来它的容器移动的距离,所以页面滚动时它仍停在指针下方。
  • 它在自己的容器内移动:overflow: hidden 的父元素会裁剪它,它的层叠上下文决定它会经过哪些元素的上方。浮层没有这两个问题。

要用自定义预览代替副本,见浮层。

回收区

从可排序列表开始的拖拽带有 e.source。调用 remove(),列表就会去掉这些项:

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Triage from './Triage.vue';
</script>

<template>
  <DnDProvider>
    <Triage />
  </DnDProvider>
</template>
Triage.vue
const triage = (bin: 'done' | 'deleted') => (e: DnDEvent) => {
  bins[bin].push(...e.active.items.map((entry) => entry.item as Notice));
  e.source?.remove();
};

makeDroppable(doneZone, { id: 'done', onDrop: triage('done') });
makeDroppable(trashZone, { id: 'trash', onDrop: triage('deleted') });
  • e.active.items[i].item 就是列表中的项本身。保留它,撤销时就能把它放回原来的位置。
  • 随后列表自己的 onDrop 以 to.index === -1 运行,e.result 是去掉这些项之后的列表。
  • 浮层缩小进区域中,因为项已不再显示在任何地方。
  • 列表也可以移除放到所有目标之外的项:见 dropOutside。
使用键盘时,方向键在列表的位置之间以及同一分组的列表之间移动列表项;越过列表的末端后,再按一次会把项继续带到那个方向上的放置目标(这里是垃圾桶),按相反方向的方向键则把它带回来。不过对很多人来说,按钮、菜单或 Delete 键仍然更快:也提供其中一种。

放进可排序项

可排序列表的一行也可以是放置目标:接收书签的文件夹、接收任务的分组。在项的元素上加一个 droppable:

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Bookmarks from './Bookmarks.vue';
</script>

<template>
  <DnDProvider>
    <Bookmarks />
  </DnDProvider>
</template>
FolderRow.vue
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.folder, type: 'folder' });

const { isOver } = makeDroppable(el, {
  accept: 'link',
  onDrop: (e) => {
    emit('add', e.active.items.map((entry) => entry.item as Link)); // Bookmarks adds them
    e.source?.remove();   // take them out of the list they came from
  },
});

打开的文件夹把它的链接显示为同一分组的列表,只接收链接:链接在里面排序,也能拖回顶层或拖进另一个文件夹。

当列表本会对该拖拽进行排序时,这样的目标只在项的中间(沿列表轴向的 25% 到 75%)生效。在那里放置会放进项中,列表不会移动。靠近边缘时,列表照常排序。

  • accept 拒绝该拖拽的目标会被跳过:列表在该项上方照常排序,就像在其他项上方一样。文件夹只接收链接,所以拖拽的文件夹会直接越过其他文件夹排序。
  • 目标永远不会收到自己的拖拽。
  • 使用键盘时,方向键会先停在文件夹上;朝同一方向再按一次就会越过它。
  • 放置后该项不再显示,所以浮层会缩小进文件夹,而不是飞回原处。
  • 在文件夹上方时,列表把被拖拽行的位置留在原处,指针下的内容就不会移动。这时该位置带有 data-dnd-placeholder="into":放置不会落到那里,所以可以把它隐藏([data-dnd-placeholder='into'] { opacity: 0 })。

文件管理器在网格中做了同样的事,并带有嵌套文件夹。

把普通可拖拽元素拖进列表

可排序列表知道如何从 makeDraggable 拖来的元素创建项时,就会接收它们:

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Agenda from './Agenda.vue';
import BlockChip from './BlockChip.vue';

const BLOCKS = [
  { title: 'Focus', minutes: 90, color: '#42b883' },
  { title: 'Meeting', minutes: 30, color: '#3b82f6' },
  { title: 'Lunch', minutes: 60, color: '#f59e0b' },
  { title: 'Gym', minutes: 45, color: '#ec4899' },
  { title: 'Break', minutes: 15, color: '#a1a1aa' },
];
</script>

<template>
  <DnDProvider>
    <div class="planner">
      <aside class="blocks">
        <h4>Blocks</h4>
        <BlockChip v-for="block in BLOCKS" :key="block.title" :block="block" />
      </aside>
      <Agenda />
    </div>
  </DnDProvider>
</template>

<style scoped>
.planner {
  display: flex;
  justify-content: center;
  gap: 24px;
}

.blocks {
  display: flex;
  flex: none;
  flex-direction: column;
  gap: 6px;
  width: 140px;
}

h4 {
  margin: 0 0 6px;
  font: 600 11px/1 ui-monospace, Menlo, Consolas, monospace;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.55;
}

@media (max-width: 480px) {
  .blocks {
    width: 112px;
  }
}
</style>
Agenda.vue
const { items } = makeSortableList(list, {
  items: agenda,
  createItem: (active) => ({ ...(active.data as Block), id: `entry-${++created}` }),
});

可拖拽元素位于列表上方时,创建出的项就在草稿中,由你的模板像其他行一样渲染:日程的时间已经把它计算在内。放置时它成为真正的项(mode: 'insert'、from.list === null),浮层落到它上面。

  • 返回 undefined 表示拒绝。没有 createItem 的列表会忽略普通可拖拽元素。
  • 给新项一个它自己的 key:时间块仍留在面板中。
  • 使用键盘时,朝列表方向按方向键会把可拖拽元素接收进列表,与它平齐,之后的方向键按位置移动它。

来自电脑的文件

从桌面或文件管理器拖进来的文件不是本库的拖拽:它们由浏览器携带。放置目标用 external 接收它们,放置时它们在 e.external 中:

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import AvatarDrop from './AvatarDrop.vue';
import Attachments from './Attachments.vue';
</script>

<template>
  <DnDProvider>
    <div class="form">
      <AvatarDrop />
      <Attachments />
    </div>
  </DnDProvider>
</template>

<style scoped>
.form {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  justify-content: center;
  gap: 16px;
}
</style>
AvatarDrop.vue
const { isOver, isAccepting } = makeDroppable(el, {
  external: { files: 'image/*', multiple: false },
  onDrop: (e) => show(e.external!.files[0]!),
});
  • files 的写法与 <input accept> 相同:MIME 类型(image/*、application/pdf)和扩展名(.md)。true 接收任何文件,函数则逐个文件做决定。links 和 text 接收其他应用和标签页拖来的内容,types 接收任何其他 MIME 类型。
  • 这次拖拽作为 provider 的拖拽运行:isOver、isAccepting、各个事件和播报都和其他拖拽一样工作。没有 external 的目标会忽略它。没有浮层:拖拽的图像由浏览器绘制。
  • 文件悬停在页面上方时,浏览器只告知它们的类型(e.external.fileTypes)。文件名、大小和内容随放置一起到来,扩展名也在那时检查。文件夹会被排除;使用 multiple: false 时,拖入多个文件会被拒绝。

列表也接收文件、链接和文本,并在拖拽进行时为它们腾出位置。项也可以离开页面,进入另一个应用或你的应用的另一个窗口:见文件与其他应用。

键盘和屏幕阅读器用户无法拖拽文件。在放置区旁边提供一个 <input type="file">,就像演示中的 Browse 那样。

键盘

获得焦点的可拖拽元素用 Space 或 Enter 拿起,用同样的键放下,:kbd{value="Escape"} 取消。在这之间,方向键移动它:

keyboardMode按一次方向键
'pixels'(默认)移动 25 px,即 provider 的 keyboard.step。按住 Shift 时移动四倍距离。家具就是这样移动的。
'droppables'跳到该方向上最近的、接收它的目标。垃圾从一个回收箱跳到另一个,并跳过拒绝它的箱子。
makeDraggable(el, { id: () => props.waste.id, type: () => props.waste.type, keyboardMode: 'droppables' });

按键可以在 provider 上重新绑定。更多内容见无障碍。