カードを Done へドラッグしてみましょう。ドロップはあなたの答えを待ちます:カードを移動する、別の場所へドラッグし続ける、キャンセルする。
デモを読み込み中…
<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 | ドロップしません:アイテムは元の位置に戻ります。例外がスローされた場合も拒否になります。 |
| キーの配列 | そのアイテムだけがドロップされ、ほかは元の位置に戻ります。 |
'continue' | ドロップせず、ドラッグが続きます。 |
| これらのいずれかの Promise | Promise が確定するまで、オーバーレイは離した場所で待ちます。 |
リストとツリーは SortableDropEvent を渡します:keys、items、from、to、mode、sameList、そして result(ドロップで変わるすべてのリストの新しい配列)です。ドロップターゲットとモニターは汎用の DnDEvent を受け取ります:active、over、position、source。イベントを参照してください。
ドラッグ中のルールか、離したときのルールか
Email と Chat は、どちらも 10 MB までの添付ファイルを受け入れます。demo.mov をそれぞれの上へドラッグしてみましょう。Email はホバー中に拒否し、Chat は受け入れてから、離したときに送り返します。
デモを読み込み中…
<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>// 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 | |
|---|---|---|
| 問い合わせのタイミング | ドラッグ中、ドラッグがリストに届いた時点で。 | ドラッグが離されたときに 1 回。 |
| ユーザーに見えるもの | リストがすぐに拒否します:枠は開かず、isAccepting は false、over.accepted は false。 | 枠はいつもどおり開き、離したときにアイテムが元の位置に戻ります。 |
| わかること | 何がドラッグされているか:keys、items、types、from、to。 | ドロップ全体:to.index、sameList、mode、result。 |
| できること | はい・いいえを答える。 | Promise を待つ、一部のキーを受け入れる、ドラッグを続けさせる。 |
アイテムだけでわかること(型、フラグ、件数)には group.put を使ってください。ユーザーは離す前にそれを知ることができます。場所、ユーザーの答え、サーバーの答えに依存することには onBeforeDrop を使います。ターゲット自身のルールが先に来ます:ドラッグを拒否するリストやターゲットの上では、ドロップはキャンセルされ、その onBeforeDrop は問い合わせられません。
アイテムの一部を受け入れる
ルールによっては、離したときにサーバーに問い合わせないと確認できません。このワークショップの席はほかの人も予約しています:離したとき、リストは残りの席数をサーバーに問い合わせ、ドラッグされてきた人のうちその数だけを受け入れます。何人かにチェックを入れ、まとめてドラッグしてみましょう。
デモを読み込み中…
<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>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`;
},
});
マルチドラッグでは、返したキーのアイテムがまとめて着地し、残りは元の場所に戻ります。その後 onDrop はそれらを e.accepted と e.rejected で受け取り、その e.result が最終的な結果です。空の配列は何も受け入れないことを意味し、ドロップは拒否されます。
ドラッグを続ける:'continue'
'continue' がキャンセルするのはドロップであって、ドラッグではありません。上のダイアログで Keep dragging を選んでみましょう。アイテムはその場所から手に持ったままになり、ページはドラッグモードに戻り、ユーザーはそのまま続けられます。
- マウスやペンでは、ボタンを押さなくてもアイテムがポインターに追従します。次に離したとき——つまりクリック——でドロップされ、そのクリックが下のページに届くことはありません。
- タッチでは、指を置いた場所でアイテムを再び掴み、指を離すとドロップします。
- キーボードドラッグは続きます:矢印キーでアイテムを動かし、:kbd{value="Space"} か Enter でドロップします。
- Escape はほかのドラッグと同じくキャンセルします。
- 次のドロップでは、再び
onBeforeDropに問い合わせます。
モニターには onDragResume が届き、スクリーンリーダーにはアイテムがドロップされずにまだ動いていることが読み上げられます(テキストはプロバイダーの announcements.onDragResume で上書きできます)。
useDnDMonitor({
onDragResume() {
hint.value = 'Still dragging: click to drop, Esc to cancel';
},
});
ユーザーに確認する
onBeforeDrop は Promise を返せます。ダイアログを開き、ユーザーが選んだボタンで Promise を解決してください。
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'),
});
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' に変換してください。フックの順序
メモを並べ替え、1 つをゴミ箱にドロップしてから、答えを切り替えてもう一度やってみましょう。パネルには直前のドロップのフックが実行順に表示されます。
デモを読み込み中…
<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>- すべての
useDnDMonitorのonBeforeDropを順に。'reject'か'continue'で問い合わせは終わり、キーはドロップを絞り込みます。 - ターゲットの
onBeforeDrop:アイテムが入るリストまたはツリー、あるいはドロップターゲット。 - ターゲットの
onDropだけ:アイテムが別のリストへ出ていくリストには問い合わせず、その新しい配列はターゲットのe.resultに入っています。アイテムが取り除かれる場合(ゴミ箱ゾーン、dropOutside: 'remove')は、そのリストのonDropがto.indexを-1として実行されます。 - デフォルトの書き込み:ドロップで変わる各リストが新しい配列を受け取ります。
- プロバイダーの
onDragEnd(@drag-end)とモニターのonDragEnd。その後、オーバーレイが着地し、onDragSettle(@drag-settle)が着地したことを伝えます。
拒否されたドロップは代わりに onDragCancel(@drag-cancel)へ進み、アイテムは元の位置に戻ります。'continue' も問い合わせを終わらせ、onDragResume が呼ばれます:ドラッグは続き、次のドロップで再び問い合わせます。
モニターはターゲットより先に判断するので、リストが自身のドロップを適用した後で拒否されることはありません。そのため、アプリのすべてのドロップに関するルールはモニターに置くのが適しています。
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 は、ドロップが受け入れられると、リストが書き込む直前に同期的に実行されます。書き込みの後に処理するには、プロバイダーの @drag-end か useDnDMonitor({ onDragEnd }) を使ってください。通常のドロップターゲットでは、onDrop がデータを変更する場所です。ライブラリはそのために何も動かしません。
ストアとサーバー:制御されたリスト
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 だけです。ゲッター、computed、readonly の ref には書き込みません。e.preventDefault() を呼び、e.result——ドロップで変わるすべてのリストの新しい配列(リストの id ごと)——を自分で適用してください。preventDefault() はそれらすべてへの書き込みをスキップします。一部だけ書き込むと、アイテムが失われたり重複したりするためです。読み取り専用のリストのドロップをライブラリに任せたままにすると、開発ビルドで警告が出ます。
先にサーバーへ保存する
サーバーを flaky(リクエストが 1 回おきに失敗)か down にしてカードを動かしてみましょう。リクエストの間、カードはバッジを付けて待ち、その後着地するか、失敗したら元の位置に戻ります。
デモを読み込み中…
<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>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 が届きます。
バッジは、すべてのドラッグのオーバーレイを描画するプロバイダーの #overlay スロットから来ています。DragPreview がいつものカードのコピーを表示し、リクエストの間はバッジがその上に載ります。
<DnDProvider>
<Board />
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
すべてのターゲットの外へのドロップ
それぞれのモードで、アプリを Dock の外へドラッグして離してみましょう。
デモを読み込み中…
<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>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 やゲッターで切り替えられます。ゴミ箱ゾーンは同じことを意図的に行います:makeDroppable(trash, { onDrop: (e) => e.source?.remove() })。ドラッグ&ドロップを参照してください。