文档/指南

决定放置

接受、拒绝或推迟一次放置——拖拽中或松开时的规则、多项拖拽的一部分、对话框、必须先同意的服务器、行使否决权的监听器,以及放到所有目标之外的情况。

编辑此页

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

把一张卡片拖进 Done。放置会等待你的回答:移动卡片、继续把它拖到别处,或取消。

Live

正在加载演示…

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

<template>
  <DnDProvider>
    <Board />
  </DnDProvider>
</template>

onBeforeDrop

makeSortableList(listRef, {
  items: tasks,
  onBeforeDrop: (e) => (e.items.some((task) => task.locked) ? 'reject' : 'accept'),
});

列表、树、放置目标(makeDroppable)和监听器(useDnDMonitor)都接受 onBeforeDrop。它在拖拽被松开时、任何东西改变之前运行,它的返回值决定结果:

返回值结果
'accept'、true 或不返回放置完成。
'reject' 或 false不放置:项飞回原处。抛出错误也会拒绝。
key 数组只有这些项被放置;其余的飞回原处。
'continue'不放置,拖拽继续。
以上任意值的 promise浮层在松开的地方等待,直到 promise 完成。

列表和树传入 SortableDropEvent:keys、items、from、to、mode、sameList 和 result,即这次放置所改变的每个列表的新数组。放置目标和监听器收到通用的 DnDEvent:active、over、position 和 source。见事件。

拖拽中的规则,或松开时的规则

Email 和 Chat 都只接收不超过 10 MB 的附件。把 demo.mov 分别拖到两者上方:Email 在悬停时就拒绝它,Chat 接收它,但在你松开时把它送回去。

Live

正在加载演示…

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

const columns = reactive([
  {
    id: 'desktop',
    title: 'Desktop',
    files: [
      { id: 'notes', name: 'notes.pdf', size: 2 },
      { id: 'demo', name: 'demo.mov', size: 48 },
      { id: 'logo', name: 'logo.png', size: 1 },
      { id: 'backup', name: 'backup.zip', size: 120 },
    ],
  },
  // Both take files of 10 MB at most, and say so at different times.
  { id: 'email', title: 'Email', rule: 'put' as const, files: [] },
  { id: 'chat', title: 'Chat', rule: 'onBeforeDrop' as const, files: [] },
]);
</script>

<template>
  <DnDProvider>
    <div class="board">
      <Column v-for="column in columns" :key="column.id" :column="column" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.board {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
}

.board > * {
  flex: 0 1 170px;
  min-width: 150px;
}
</style>
Column.vue
// Asked while dragging: a refused drag never enters the list.
group: { name: 'attachments', put: ({ items }) => fits(items as Attachment[]) },

// Asked on release: the drag enters the list, then flies back.
onBeforeDrop: (e) => (fits(e.items) ? 'accept' : 'reject'),
group.put(或目标的 accept)onBeforeDrop
询问时机拖拽中,拖拽一到达列表就询问。拖拽被松开时,询问一次。
用户看到列表立即拒绝:不会打开空位,isAccepting 为 false,over.accepted 为 false。空位照常打开;松开时项飞回原处。
知道什么拖拽的是什么:keys、items、types、from、to。整个放置:to.index、sameList、mode、result。
能做什么回答是或否。等待 promise、接受部分 key、让拖拽继续。

仅凭项本身就能知道的规则(类型、标记、数量)用 group.put:用户在松开之前就能知道结果。取决于位置、用户的回答或服务器回答的规则用 onBeforeDrop。目标自己的规则优先:在拒绝该拖拽的列表或目标上方,放置会被取消,它的 onBeforeDrop 不会被询问。

接受部分项

有些规则只能在松开时向服务器核实。这个工作坊的名额也会被其他人预订:松开时,列表向服务器询问还剩多少名额,然后从拖进来的人中接受相应的人数。勾选几个人,把他们一起拖进去。

Live

正在加载演示…

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

const columns = reactive([
  {
    id: 'waiting',
    title: 'Waiting list',
    people: [
      { id: 'ada', name: 'Ada', hue: 200 },
      { id: 'bo', name: 'Bo', hue: 30 },
      { id: 'cy', name: 'Cyrus', hue: 280 },
      { id: 'dee', name: 'Dee', hue: 140 },
    ],
  },
  {
    id: 'workshop',
    title: 'Workshop',
    seats: true,
    people: [
      { id: 'eli', name: 'Eli', hue: 340 },
      { id: 'fay', name: 'Fay', hue: 90 },
    ],
  },
]);
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <p class="tip">Tick a few people, then drag one of them into the workshop</p>
      <div class="board">
        <Column v-for="column in columns" :key="column.id" :column="column" />
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

.tip {
  margin: 0;
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.board {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 230px));
  justify-content: center;
  gap: 14px;
  width: 100%;
}
</style>
Column.vue
makeSortableList(list, {
  items: toRef(props.column, 'people'),
  group: 'workshop',
  // Some keys: those land, the others fly back to where they came from.
  onBeforeDrop: async (e) => {
    const { free } = await seatsLeft(props.column.people.length); // the overlay waits
    return e.keys.slice(0, free);
  },
  onDrop: (e) => {
    selection.delete(...e.accepted); // the ones sent back stay selected
    notice.value = `${e.accepted.length} booked, ${e.rejected.length} back to the waiting list`;
  },
});

在多项拖拽中,你返回的 key 对应的项一起落位,其余的飞回原位。随后 onDrop 在 e.accepted 和 e.rejected 中看到它们,它的 e.result 就是最终结果。空数组表示什么都不接受:放置被拒绝。

继续拖拽:'continue'

'continue' 取消的是放置,而不是拖拽。在上面的对话框中选择 Keep dragging:项从它所在的位置继续留在手中,页面回到拖拽模式,用户可以接着拖。

  • 使用鼠标或触控笔时,项在不按住按键的情况下跟随指针。下一次松开——也就是一次点击——会放下它,这次点击永远不会传到下方的页面。
  • 使用触摸时,手指落下的地方会重新拿起项;抬起手指就放下它。
  • 键盘拖拽继续进行:方向键移动项,:kbd{value="Space"} 或 Enter 放下它。
  • Escape 取消它,与任何拖拽一样。
  • 下一次放置会再次询问 onBeforeDrop。

监听器会收到 onDragResume,屏幕阅读器会播报项没有被放下、仍在移动(用 provider 的 announcements.onDragResume 覆盖该文本)。

useDnDMonitor({
  onDragResume() {
    hint.value = 'Still dragging: click to drop, Esc to cancel';
  },
});

询问用户

onBeforeDrop 可以返回 promise。打开一个对话框,用用户选择的按钮来完成 promise:

Column.vue
const { items } = makeSortableList(list, {
  id: props.column.id,
  items: toRef(props.column, 'tasks'),
  group: 'release',
  onBeforeDrop: (e) => (props.column.confirm && !e.sameList ? props.ask(e) : 'accept'),
});
Board.vue
type Answer = 'accept' | 'continue' | 'reject';
const question = shallowRef<{ title: string; resolve: (answer: Answer) => void } | null>(null);

/** Opens the dialog: `onBeforeDrop` waits for the button the user picks. */
const ask = (e: SortableDropEvent<Task>) =>
  new Promise<Answer>((resolve) => {
    question.value = { title: e.items[0]!.title, resolve };
  });

promise 等待期间:

  • 浮层在松开的地方等待,并被标记为 data-dnd-overlay="dropping";
  • useDnD() 保持松开时的状态,status 为 'dropping';
  • 页面退出拖拽模式:光标和文本选择恢复,所以对话框照常工作;
  • 不能开始其他拖拽。
用 showModal() 打开的模态 <dialog> 位于顶层,在等待中的浮层之上。像示例那样,把它的 cancel 事件(:kbd{value="Escape"})转换为 'reject'。

钩子的顺序

给便签重新排序,把一张扔进垃圾桶,然后切换某个回答再做一次。面板按运行顺序列出上一次放置的钩子。

Live

正在加载演示…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Monitor from './Monitor.vue';
import Notes from './Notes.vue';
import { ANSWERS, type Answer } from './types';

const monitorAnswer = ref<Answer>('accept');
const targetAnswer = ref<Answer>('accept');

// Every hook of the last drop, in the order they ran.
const steps = ref<{ who: string; what: string }[]>([]);
const trace = (who: string, what: string) => steps.value.push({ who, what });
</script>

<template>
  <DnDProvider
    @drag-start="steps = []"
    @drag-end="trace('provider', '@drag-end')"
    @drag-cancel="trace('provider', '@drag-cancel')"
    @drag-settle="trace('provider', '@drag-settle: landed')"
  >
    <div class="demo">
      <div class="switches">
        <fieldset>
          <legend>monitor answers</legend>
          <label v-for="value in ANSWERS" :key="value" :class="{ on: monitorAnswer === value }">
            <input v-model="monitorAnswer" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
        <fieldset>
          <legend>target answers</legend>
          <label v-for="value in ANSWERS" :key="value" :class="{ on: targetAnswer === value }">
            <input v-model="targetAnswer" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
      </div>
      <div class="layout">
        <Notes :answer="targetAnswer" :trace="trace" />
        <ol class="steps" aria-label="Hooks of the last drop">
          <li v-for="(step, index) in steps" :key="index">
            <span class="who">{{ step.who }}</span>{{ step.what }}
          </li>
          <li v-if="!steps.length" class="empty">Move a note, or drop one in the trash</li>
        </ol>
      </div>
      <Monitor :answer="monitorAnswer" :trace="trace" />
    </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 10px;
  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;
}

.layout {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 16px;
}

.steps {
  box-sizing: border-box;
  min-width: 220px;
  min-height: 150px;
  margin: 0;
  padding: 10px 14px 10px 32px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 5%, transparent);
  font: 11px/1.8 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  list-style: decimal;
}

.steps li::marker {
  color: color-mix(in srgb, currentColor 45%, transparent);
}

.who {
  display: inline-block;
  min-width: 60px;
  color: #42b883;
}

.steps li.empty {
  margin-left: -18px;
  list-style: none;
  opacity: 0.45;
}
</style>
  1. 依次运行每个 useDnDMonitor 的 onBeforeDrop。'reject' 或 'continue' 会结束询问;key 会缩小放置的范围。
  2. 目标的 onBeforeDrop:项进入的列表或树,或放置目标。
  3. 只运行目标的 onDrop:项离开去往另一个列表时,被离开的列表不会被询问,它的新数组在目标的 e.result 中。项被移除时(回收区、dropOutside: 'remove'),会运行它们所在列表的 onDrop,to.index 为 -1。
  4. 默认写入:这次放置改变的每个列表获得它的新数组。
  5. provider 的 onDragEnd(@drag-end)和监听器的 onDragEnd。然后浮层落位,onDragSettle(@drag-settle)告知它已落位。

被拒绝的放置则会直接跳到 onDragCancel(@drag-cancel),项飞回原处。'continue' 也会结束询问,并触发 onDragResume:拖拽继续进行,下一次放置会重新询问。

监听器在目标之前做决定,所以否决永远不会出现在列表已经应用了自己的放置之后。这让监听器成为制定适用于整个应用每次放置的规则的地方:

Monitor.vue
const { syncing } = useSync();

// Read-only while syncing: every drop under the provider flies back.
useDnDMonitor({
  onBeforeDrop: () => (syncing.value ? 'reject' : 'accept'),
});

onDrop:在写入之前通知

makeSortableList(listRef, {
  items: tasks,
  onDrop: (e) => {
    // `tasks` still holds the old order, `e.result` the new one.
    console.log(e.keys, e.from, e.to, e.result.get(e.to.list));
  },
});

列表的 onDrop 在放置被接受后同步运行,紧接在列表写入之前。要在写入之后执行操作,使用 provider 的 @drag-end 或 useDnDMonitor({ onDragEnd })。在普通放置目标上,onDrop 是你修改数据的地方:库不会替它移动任何东西。

store 与服务器:受控列表

const store = useBoardStore();
const { items } = makeSortableList(listRef, {
  id: 'todo',
  group: 'board',
  items: () => store.lists.todo, // a getter: read-only
  onDrop: (e) => {
    e.preventDefault();
    for (const [listId, next] of e.result) store.setList(listId, next);
  },
});

库只把放置写入可写的 ref。getter、computed 或 readonly ref 永远不会被写入:调用 e.preventDefault(),然后自己应用 e.result——它按列表 id 包含这次放置所改变的每个列表的新数组。preventDefault() 会跳过对所有这些列表的写入,因为只写入其中一部分会丢失或重复项。如果只读列表的放置被留给库来处理,开发构建会发出警告。

先保存到服务器

在服务器不稳定(每隔一个请求就失败一次)或宕机时移动一张卡片。请求进行期间,卡片带着一个徽标等待,然后落位,失败时则飞回原处。

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider, DragPreview } from '@vue-dnd-kit/core';
import Column from './Column.vue';
import { mode, requests, saving, type ServerMode } from './server';

const MODES: ServerMode[] = ['reliable', 'flaky', 'down'];
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <fieldset class="modes">
        <legend>server</legend>
        <label v-for="value in MODES" :key="value" :class="{ on: mode === value }">
          <input v-model="mode" type="radio" :value="value" />
          {{ value }}
        </label>
      </fieldset>
      <div class="board">
        <Column id="backlog" title="Backlog" />
        <Column id="sprint" title="Sprint" />
      </div>
      <ol class="requests" aria-label="Requests">
        <li v-for="request in requests" :key="request.id" :class="`status-${request.status}`">
          {{ request.text }} <span>{{ request.status === 'pending' ? '…' : request.status }}</span>
        </li>
        <li v-if="!requests.length" class="empty">No requests yet</li>
      </ol>
    </div>

    <!-- The overlay of every drag: the card's copy, and a badge while the server answers. -->
    <template #overlay="{ active }">
      <div class="carried">
        <DragPreview :id="active.id" />
        <span v-if="saving" class="saving">Saving…</span>
      </div>
    </template>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.modes {
  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;
}

.modes label {
  padding: 5px 10px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.board {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 220px));
  gap: 14px;
  justify-content: center;
  width: 100%;
}

.requests {
  width: min(100%, 454px);
  min-height: 60px;
  margin: 0;
  padding: 8px 12px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 5%, transparent);
  font: 11px/1.6 ui-monospace, Menlo, Consolas, monospace;
  list-style: none;
  white-space: pre;
  overflow-x: auto;
}

.requests li + li {
  opacity: 0.55;
}

.requests .status-200 span {
  color: #42b883;
}

.requests .status-503 span {
  color: #e5484d;
}

.requests li.empty {
  color: inherit;
  opacity: 0.45;
}

.carried {
  position: relative;
  width: 100%;
  height: 100%;
}

.saving {
  position: absolute;
  top: 50%;
  right: 10px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  translate: 0 -50%;
  padding: 4px 8px;
  border-radius: 99px;
  background: #42b883;
  color: #fff;
  font: 600 11px/1 system-ui, sans-serif;
}

.saving::before {
  content: '';
  width: 9px;
  height: 9px;
  border: 2px solid rgb(255 255 255 / 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

@keyframes spin {
  to {
    rotate: 1turn;
  }
}
</style>
Column.vue
const { items } = makeSortableList(list, {
  id: props.id,
  // A getter: the library never writes it. The drop is applied below, once saved.
  items: () => board[props.id]!,
  group: 'sprint',
  onBeforeDrop: async (e) => {
    e.preventDefault();
    try {
      await saveMove(e.keys, e.to);
    } catch {
      return 'reject'; // nothing applied: the card flies back
    }
    for (const [id, tasks] of e.result) board[id] = tasks; // lands in its new place
    return 'accept';
  },
});

在 promise 完成之前应用 e.result,浮层就会按新顺序落位。请求失败时,什么都不应用并返回拒绝:项飞回原处,监听器收到 onDragCancel。

徽标来自 provider 的 #overlay 插槽,它渲染每次拖拽的浮层。DragPreview 显示卡片通常的副本,请求进行期间徽标叠在它上面:

index.vue
<DnDProvider>
  <Board />
  <template #overlay="{ active }">
    <div class="carried">
      <DragPreview :id="active.id" />
      <span v-if="saving" class="saving">Saving…</span>
    </div>
  </template>
</DnDProvider>

放到所有目标之外

在每种模式下,把一个应用拖出程序坞并松开。

Live

正在加载演示…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type DropOutside } from '@vue-dnd-kit/core';
import Dock from './Dock.vue';

const MODES: DropOutside[] = ['placeholder', 'cancel', 'remove'];
const dropOutside = ref<DropOutside>('remove');
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <fieldset class="modes">
        <legend>dropOutside</legend>
        <label v-for="value in MODES" :key="value" :class="{ on: dropOutside === value }">
          <input v-model="dropOutside" type="radio" :value="value" />
          {{ value }}
        </label>
      </fieldset>
      <code class="call">makeSortableList(dock, { strategy: 'horizontal', dropOutside: '{{ dropOutside }}' })</code>
      <Dock :drop-outside="dropOutside" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.modes {
  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;
}

.modes label {
  padding: 5px 10px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes 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>
Dock.vue
const { items } = makeSortableList(dock, {
  items: apps,
  strategy: 'horizontal',
  dropOutside: () => props.dropOutside,
  // Called on the list the items leave, with `to.index` -1, when they are removed.
  onDrop: (e) => {
    if (e.to.index === -1) notice.value = `Removed ${e.items.map((app) => app.name).join(', ')}`;
  },
});
dropOutside放到所有目标之外时……
'placeholder'落到占位所在的地方:列表最后显示的位置。默认值。
'cancel'被取消:项飞回原处。placeholder: 'indicator' 时的默认值。
'remove'把项从它们的列表中移除。会先询问列表的 onBeforeDrop,它的 onDrop 收到的 to.index 为 -1。

它在放置时读取,所以 ref 或 getter 可以切换它。回收区有意做的也是同样的事:makeDroppable(trash, { onDrop: (e) => e.source?.remove() })。见拖放。