デモを読み込み中…
<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>
- ここでは各列が 1 つのコンポーネントです。列はたいていそうなっています。1 つのコンポーネントで複数のリストを作ることもできます(固定の Todo と Done を横に並べるなど):各カードは自分を含む要素のリストに加わります。
- 親がリアクティブなオブジェクトを渡しているので、
toRef(props.column, 'stories')は書き込み可能です。ドロップは、ストーリーが出ていった配列と入った配列の両方に書き込まれ、両方の列のポイントも追従します。 idはドロップイベントでfrom.listとto.listとして現れ、e.result(ドロップで変わるすべてのリストの新しい配列)のキーにもなります。idとgroupは一度だけ読み取られます。- キーはグループ全体で一意である必要があります。
リストの onDrop は、そのリストへのドロップで実行されます。backlog から sprint への移動では sprint リストの onDrop が呼ばれ、その e.result には両方のリストの新しい配列が入ります。
列全体でドロップを受け入れる:dropzone
縦長の列に短いリストがあると、最後のカードの下に空きができます。dropzone を使うと、別の要素がリストの代わりにドロップを受け入れます:ここでは列全体です。最後のストーリーの下で離すと、末尾に着地します。ストーリーは <ul> の中のままで、大きくなるのはドロップを受け入れる領域だけです。デモではドラッグ中にその領域を枠線で示しています。オフにすると、ストーリーを受け入れるのはリスト自体だけになります。
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 } を受け取ります:ドラッグ中のキーとアイテム、それらの型、移動元のリスト(通常のドラッグ可能な要素では 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));
上限
ファイルを最大 3 つまで保持するリスト、Shortlist:
const shortlist = reactive({
files: [/* … */],
group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
});
ドラッグから問い合わせがあると、自分のアイテムを読み取ります。いっぱいにすると次のファイルを拒否し、その理由をヘッダーに表示します。
型の異なる複数の選択中アイテムをドラッグする場合、acceptMode は 'all'(デフォルト)か 'some' のどちらが put を通る必要があるかを指定します。'some' では、通ったものが着地し、それ以外は元の位置に戻ります。マルチドラッグを参照してください。
リストごとに 1 つのグループ
リストが属するグループは 1 つだけです:group は名前を 1 つだけ受け取り、複数は受け取りません。同じグループのリストは各ドラッグ(それぞれのドラフト、ドラッグ中のもの、どのリストがそれを受け入れるか)を共有するため、アイテムがグループ間を移動することはありません。アイテムを特定の方向にだけ流したい場合は、リストを 1 つのグループに入れ、ドラッグの移動元リストである from で put に判断させます:
// 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関数を持つリストならどれからでも可能で、コピーはその関数が作ります。キーはプロバイダーのcopyModifierで変更でき、nullでオフにできます。 - いずれの場合も、アイテムの移動元のリストには
cloneが必要です。cloneは新しいキーを持つコピーを返す必要があります。cloneがなければ何もコピーされません。 - ドロップイベントは
mode: 'copy'を持ち、e.itemsはコピーです。オーバーレイコンポーネントもmodeを受け取るので、コピー中に「+」を表示できます(オーバーレイを参照)。
リスト間での入れ替え
同じグループの 2 つのリストがどちらも behavior: 'swap' を持つ場合、互いの間でアイテムを入れ替えます。ドラッグ中のアイテムがホバーされたアイテムの場所に入り、ホバーされたアイテムはドラッグ中のアイテムのリストへ移ります。ホバーされたアイテムは自分のリストから出ること(pull: true)と、もう一方へ入ること(put)が許可されている必要があります。並べ替え可能なリストのオンコール当番表は、2 つの列の間で入れ替えを行います。
通常のドラッグ可能な要素
リストは createItem を持っていれば、makeDraggable でドラッグされたアイテム(チップ、テンプレート、ツリーのファイル)を受け入れます。group.put はそれらについても from: null で判断します。通常のドラッグ可能な要素をリストへを参照してください。
外へのドロップ
ドラッグがどのターゲットの上でもなく終わったときに何が起きるかは、リストの dropOutside で決まります。
dropOutside | アイテムは |
|---|---|
'placeholder'(デフォルト) | 枠が最後に表示された場所に着地します。 |
'cancel' | 元の位置に戻ります。placeholder: 'indicator' のときのデフォルトです。 |
'remove' | リストから出ます:外へドラッグして削除。パレットのデモのページがこれを行っています。 |
'remove' はまずリストの onBeforeDrop に問い合わせます。その後、リストの onDrop が to.index === -1 で実行され、e.result にはアイテムを除いたリストが入ります。ドロップを受け入れるゴミ箱も同じように動作します:ゴミ箱ゾーンを参照。
制御されたリスト
リストは自分が所有していないストアを読み取れます:Pinia、サーバーの状態、イミュータブルなデータ。items をゲッター(または computed、読み取り専用の ref)として渡すと、ライブラリはそれに書き込みません。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は、ドロップで変わるすべてのリストを新しい配列に対応付けます:移動なら 2 つのテーブル、並べ替えなら 1 つです。新しい配列なので、ストアは元に戻す用に古い配列を保持できます。- ドラフトはストアに追従します。
onDropで(または非同期のonBeforeDropが解決する前に)resultを適用すれば、オーバーレイは新しい順序に着地します。 preventDefault()はresultのすべてのリストへの書き込みをスキップします。どこにも適用しなければ、リストはそのままで、アイテムは元の位置に戻ります。- 読み取り専用のリストのドロップをライブラリに任せたままにすると、開発ビルドで警告が出ます:何も変わらないためです。
ドロップが着地する前にサーバーへ保存する、あるいは先にユーザーに確認するには、ドロップの判断を参照してください。リストのすべてのオプションは makeSortableList API にあります。