このページの内容はすべてデフォルトでオンです。Tab で予定の項目にフォーカスし、:kbd{value="Space"} を押して矢印キーで動かし、もう一度 Space を押してください。元に戻すには Escape を押します。パネルには、スクリーンリーダーに伝えられる内容が、選んだ言語で表示されます。
デモを読み込み中…
<script setup lang="ts">
import { computed, ref } from 'vue';
import { DnDProvider, type Announcements, type Key } from '@vue-dnd-kit/core';
import Agenda from './Agenda.vue';
import { LANGUAGES } from './texts';
const agenda = ref([
{ id: 'welcome', title: 'Welcome', minutes: 5 },
{ id: 'roadmap', title: 'Roadmap', minutes: 15 },
{ id: 'demo', title: 'Live demo', minutes: 10 },
{ id: 'questions', title: 'Questions', minutes: 10 },
]);
const lang = ref('en');
const texts = computed(() => LANGUAGES[lang.value]!);
// Every text handed to the live region, newest first.
const spoken = ref<{ id: number; text: string }[]>([]);
let count = 0;
const say = (text: string) => {
if (spoken.value[0]?.text !== text) spoken.value = [{ id: ++count, text }, ...spoken.value].slice(0, 4);
return text;
};
const titleOf = (id: Key) => agenda.value.find((item) => item.id === id)?.title ?? String(id);
// `position` is where the built-in texts say the item is: picked up at, moving to, dropped
// at, back at. `index` counts from 0.
const announcements = computed<Partial<Announcements>>(() => {
const t = texts.value;
return {
onDragStart: ({ active }, { position }) =>
position && say(t.start(titleOf(active.id), position.index + 1, position.length)),
onDragOver: ({ active }, { position }) =>
position && say(t.move(titleOf(active.id), position.index + 1, position.length)),
onDragEnd: ({ active }, { position }) =>
position && say(t.drop(titleOf(active.id), position.index + 1, position.length)),
onDragCancel: ({ active }, { position }) => position && say(t.cancel(titleOf(active.id), position.index + 1)),
onDragResume: ({ active }) => say(t.resume(titleOf(active.id))),
};
});
</script>
<template>
<!-- `lang` around the provider: its live region renders inside, and a screen reader
speaks the announcements in their own language. -->
<div :lang="lang">
<DnDProvider :announcements="announcements" :screen-reader-instructions="texts.instructions">
<div class="demo">
<div class="languages" role="radiogroup" aria-label="Language of the announcements">
<label v-for="(language, code) in LANGUAGES" :key="code" :class="{ on: lang === code }">
<input v-model="lang" type="radio" :value="code" />
{{ language.name }}
</label>
</div>
<div class="layout">
<Agenda v-model="agenda" />
<section class="reader" aria-label="Announcements">
<h4>Announcements</h4>
<p class="instructions"><span>aria-describedby</span>{{ texts.instructions }}</p>
<ol class="spoken">
<li v-for="line in spoken" :key="line.id">{{ line.text }}</li>
<li v-if="!spoken.length" class="empty">Tab to an item and press Space</li>
</ol>
</section>
</div>
</div>
</DnDProvider>
</div>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.languages {
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
padding: 3px;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
.languages label {
padding: 5px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
.languages label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
.languages label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.languages input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.layout {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 18px;
width: 100%;
}
.reader {
box-sizing: border-box;
width: min(100%, 270px);
padding: 12px 14px;
border-radius: 12px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 12px/1.5 ui-monospace, Menlo, Consolas, monospace;
}
h4 {
margin: 0 0 8px;
font: inherit;
font-weight: 600;
opacity: 0.55;
}
.instructions {
margin: 0 0 10px;
padding-bottom: 10px;
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
font-size: 11px;
opacity: 0.7;
}
.instructions span {
display: block;
margin-bottom: 2px;
font-size: 10px;
opacity: 0.7;
}
.spoken {
min-height: 96px;
margin: 0;
padding: 0;
list-style: none;
}
.spoken li + li {
margin-top: 4px;
opacity: 0.5;
}
.spoken li:first-child {
color: #42b883;
}
.spoken li.empty {
color: inherit;
opacity: 0.45;
}
</style>通常あなたに残されているのは、アイテムの読み取りやすいテキストと、まったくドラッグできない人のためにアイテムを移動するボタンです。
キーボード
| キー | 操作 |
|---|---|
| Space / Enter | フォーカスされたアイテムを持ち上げる。ドロップする。 |
| 矢印キー | 動かす:リストでは枠ごと、ツリーでは行と深さごと、自由なドラッグでは keyboard.step ピクセルずつ。 |
| Escape | ドラッグをキャンセルする——ポインタードラッグも。 |
- リストでは、同じグループの別のリストの方向へ矢印キーを押すと、アイテムがそちらへ移動します。
- 自由なドラッグでは、:kbd{value="Shift"} でステップが大きくなります。
makeDraggable(el, { keyboardMode: 'droppables' })にすると、矢印キーは代わりに、そのアイテムを受け入れるターゲットの間をジャンプします。 - キーボードドラッグは、フォーカスされたドラッグ可能な要素またはそのハンドルから始まります——中にあるボタンや入力欄から始まることはないため、それらは自分のキー操作を保ちます。
- キーボードドラッグでは、端での自動スクロールの代わりに、アイテムがスクロールして表示されます。
- ドロップ後、行が別のリストで再描画された場合でも、フォーカスはアイテムに戻ります。
ほかのキー
<DnDProvider :keyboard="{ keys: { start: ['KeyD'], drop: ['KeyD', 'Enter'], cancel: ['Escape'] }, step: 10 }">
キーの割り当ては event.code('Space'、'KeyD')または event.key('Enter')と照合されます。step は、自由なドラッグで矢印キーを 1 回押したときに動くピクセル数です(デフォルトは 25)。:keyboard="false" でキーボードドラッグはオフになりますが、:kbd{value="Escape"} は引き続きどのドラッグもキャンセルします。キーを変更したら、screen-reader-instructions も変更してください。
ARIA 属性
各ショートカットにフォーカスするか、属性をオフにして、ライブラリが何を設定するかを見てみましょう。1 つをドラッグすると、ボタンの aria-pressed が付いたり外れたりします。
デモを読み込み中…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Shortcuts from './Shortcuts.vue';
const attributes = ref(true);
</script>
<template>
<DnDProvider>
<div class="demo">
<label class="switch">
<input v-model="attributes" type="checkbox" />
attributes: {{ attributes }}
</label>
<Shortcuts :attributes="attributes" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.switch {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
.switch:has(input:focus-visible) {
outline: 2px solid #42b883;
}
.switch input {
accent-color: #42b883;
}
</style>すべてのドラッグ可能な要素と並べ替え可能なアイテムには、自分で設定していない限り、次の属性が付きます。
| 属性 | 値 |
|---|---|
tabindex | 0。要素がもともとフォーカス可能でない場合(<div> など。<a href> や <button> は除く)。 |
role | button。ただし、もともとボタンである <button> には付けません。 |
aria-roledescription | draggable。並べ替え可能なアイテムでは sortable。 |
aria-describedby | プロバイダーの操作説明。既存の id の横に追加されます(ツールチップのものは残ります)。 |
aria-disabled | true または false:アイテムが無効かどうか(無効なドラッグエリアによる場合も含む)。 |
aria-pressed | button ロールを持つ要素で、ドラッグ中は true。 |
アイテムに attributes: false を指定すると、ライブラリが設定したものがその場で取り除かれます。パネルは属性を読み戻しています。aria-describedby はプロバイダーの操作説明を指し、スクリーンリーダーはアイテムの名前の後にそれを読み上げます。
ドラッグできるリンクとボタン
aria-roledescription はロールの名前を置き換えます。スクリーンリーダーは「Inbox, button」ではなく「Inbox, sortable」と読み上げます。ロールが重要な要素には自分で設定してください。ライブラリはそれに手を付けません。
<script setup lang="ts">
makeSortableItem(el, {
item: () => props.shortcut,
// Enter follows the link: only Space picks it up.
canDrag: (event) => !('key' in event && event.key === 'Enter'),
});
</script>
<template>
<a ref="el" :href="shortcut.href" role="link" aria-roledescription="draggable link">
{{ shortcut.title }}
</a>
</template>
- リンクはそのまま機能します。クリックで開きますが、ドラッグを終えるクリックがリンクに届くことはありません。:kbd{value="Enter"} ではドラッグが始まってしまうので、
canDragがそれを断り、ブラウザーがリンクをたどります。 <button>もクリックを保ち、ライブラリはroleもtabindexも付けません。- アイテムの中にあるボタン、リンク、入力欄はポインタードラッグを開始しないので、行は自分の操作を持てます。
ハンドル:本物のボタンにする
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';
const props = defineProps<{ track: { id: string; title: string } }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.track, handle: '.grip' });
</script>
<template>
<li ref="el" class="track">
{{ track.title }}
<button type="button" class="grip" :aria-label="`Drag ${track.title}`">⠿</button>
</li>
</template>
handle を指定すると、属性はハンドルに付き、キーボードドラッグもそこから始まります(上の Archive ショートカット)。<button> はフォーカス可能で、もともとボタンなので、tabindex も role も不要です。aria-label を付けてください。グリップのアイコンには名前がなく、「Drag Midnight Drive」のほうが「button」より多くを伝えます。
visibility: hidden や display: none を使わず、opacity: 0 を使ってください。非表示の要素はフォーカスを失います。ライブラリはドロップ後にフォーカスを戻しますが、その間にスクリーンリーダーが「body」と読み上げることがあります。読み上げ
プロバイダーのライブリージョンは、持ち上げ、移動、ドロップ、キャンセルのたびに読み上げます。並べ替え可能なリストはアイテムの位置を伝えます:「Picked up item t2 at position 2 of 5 in list playlist.」 移動はドラッグが 500 ms 止まってから読み上げられるので、すばやいポインタードラッグが枠ごとに読み上げられることはありません。デモのパネルは、ポインタードラッグが途中で通過した移動も含め、プロバイダーに渡されたすべてのテキストを記録します。
組み込みのテキストは英語で、label を与えない限り、ものを id で呼びます。アイテム、リスト、ツリー、ドロップターゲットはどれも label を受け取ります。
makeSortableList(list, { items: tracks, label: 'Playlist' });
makeSortableItem(el, { item: () => props.track, label: () => props.track.title });
makeDroppable(bin, { label: 'Trash', onDrop });
「Picked up Blue in Green at position 2 of 5 in Playlist.」 ラベルはあなた自身のテキストやモニターにも届きます:active.label、over.label、active.items の各要素、そして context の position.label と partner.label です。
ほかの言語やほかの言い回しにするには、予定表のようにテキストを自分で書いてください。各テキストはイベントと context を受け取ります:組み込みのテキストが読み上げる position(list、0 から数える index、length、リストの label)と、count、copy、入れ替えの partner です。
const titleOf = (id: Key) => agenda.value.find((item) => item.id === id)?.title ?? String(id);
const announcements = computed<Partial<Announcements>>(() => {
const t = LANGUAGES[lang.value]!;
return {
onDragStart: ({ active }, { position }) => position && t.start(titleOf(active.id), position.index + 1, position.length),
onDragOver: ({ active }, { position }) => position && t.move(titleOf(active.id), position.index + 1, position.length),
onDragEnd: ({ active }, { position }) => position && t.drop(titleOf(active.id), position.index + 1, position.length),
onDragCancel: ({ active }, { position }) => position && t.cancel(titleOf(active.id), position.index + 1),
onDragResume: ({ active }) => t.resume(titleOf(active.id)),
};
});
<DnDProvider :announcements="announcements" :screen-reader-instructions="texts.instructions">
context | 開始時 | 移動中 | 終了時 | キャンセル時 |
|---|---|---|---|---|
position | 持ち上げた位置 | 着地する予定の位置。リストの外ではなし | 着地した位置 | 戻った位置 |
partner | 入れ替え:入れ替わる相手のアイテムと、その現在の位置 | 同じもの、ドロップ後 | ||
count、copy | アイテムの数と、ドラッグがコピーかどうか |
| イベント | 読み上げのタイミング |
|---|---|
onDragStart | アイテムが持ち上げられたとき。 |
onDragOver | アイテムが別の場所(リストの枠、別のターゲット)へ移動し、ドラッグが 500 ms 止まったとき。 |
onDragEnd | ドロップされたとき。新しい順序はすでに書き込まれています。 |
onDragCancel | ドラッグがキャンセルされたか、ドロップが拒否されたとき:アイテムは元に戻りました。 |
onDragResume | onBeforeDrop が 'continue' を返したとき:ドラッグが続きます(ドロップの判断を参照)。 |
- テキストはそのイベントの読み上げを置き換えます。
undefinedならデフォルトのまま、''なら何も読み上げません。指定しなかったイベントは、並べ替え可能なリストの位置など、デフォルトのままです。 screenReaderInstructionsは、すべてのドラッグ可能な要素がaria-describedbyで参照するテキストです。どちらの prop もリアクティブで、デモは言語をその場で切り替えています。:announcements="false"はリージョンを無音にします。独自のリージョンで読み上げる場合などに使います。
lang も設定してください。ドラッグせずに移動する
まったくドラッグできない人もいます。WCAG 2.5.7 は、単一のクリックでアイテムを移動する方法を求めています。各行の矢印とチェックを使ってみましょう。ドラッグなしで本を移動でき、フォーカスはその場にとどまります。
デモを読み込み中…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import ReadingList from './ReadingList.vue';
</script>
<template>
<DnDProvider>
<ReadingList />
</DnDProvider>
</template>ボタンはデータを変え、リストが移動を描きます。リストで animateChanges をオンにすると、行はドラッグのときと同じようにスライドします(モーションの軽減を求めるユーザーには動きません)。
const { items } = makeSortableList(list, { id: 'reading', items: books, animateChanges: true });
// Buttons change the data, not a drag: the provider says nothing, so this region does.
const message = ref('');
const move = (from: number, to: number) => {
const next = [...books.value];
const [book] = next.splice(from, 1);
next.splice(to, 0, book!);
books.value = next;
message.value = `${book!.title} moved to position ${to + 1} of ${next.length}.`;
};
<p class="status" role="status">{{ message }}</p>
フォーカスを保つ
DOM 内で要素を移動するとフォーカスが失われるので、押したばかりのボタンからフォーカスが外れます。行が移動した後でフォーカスを戻してください。
const move = async (direction: 'up' | 'down') => {
emit('move', direction === 'up' ? props.index - 1 : props.index + 1);
await nextTick();
// Back on the same button, or on the other one when this one is now disabled.
const same = direction === 'up' ? up.value : down.value;
const other = direction === 'up' ? down.value : up.value;
(same?.disabled ? other : same)?.focus();
};
消える行(チェックで本を読了にする)はフォーカスを一緒に持っていきます。次の行か、最後の行へフォーカスを移してください。ドラッグにはこうした処理は不要です。ライブラリは、別のリストで再描画された場合でも、ドロップされたアイテム自体にフォーカスを戻します。
@vue-dnd-kit/actions を使うと、各ボタンを本物のキーボードドラッグにできます。moveItem(id, { index }) は読み上げられ、onBeforeDrop と onDrop を通り、アニメーションし、フォーカスをアイテムに保ちます。プログラムによるドラッグを参照してください。タッチ
タッチドラッグは 200 ms の長押しの後に始まります。その前に 5 px より大きく動くと代わりにページがスクロールするので、リストはスクロールできたままです。どちらもプロバイダーの activation で調整でき、useDnD().activation.progress から長押しのリングを描けます:プロバイダーを参照。タッチドラッグは開始時に短く振動します。:haptics="false" でオフになります。
モーションの軽減
prefers-reduced-motion: reduce では、ライブラリ自身のアニメーションは実行されません。並べ替え中はアイテムが所定の位置へ一気に移り、ドロップ時にオーバーレイは飛ばず、animateChanges の変化はすぐに現れます。独自のアニメーション関数は reducedMotion: true とともに引き続き実行されるので、判断はあなた次第です:省くか、短いフェードを残すか。アニメーションを参照してください。