本页的所有功能都默认开启。用 Tab 键移到一个日程项上,按 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 是自由拖拽中每按一次方向键移动的像素数(默认 25)。:keyboard="false" 关闭键盘拖拽,:kbd{value="Escape"} 仍然可以取消任何拖拽。更改按键时,也要更改 screen-reader-instructions。
ARIA 属性
让每个快捷方式依次获得焦点,或关闭这些属性,观察库设置了什么。拖动其中一个,可以看到按钮上的 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 | provider 的操作说明,追加在已有的 id 旁边(提示框的 id 会保留)。 |
aria-disabled | true 或 false:项是否被禁用(也包括被禁用的拖拽区域所禁用)。 |
aria-pressed | 在具有 button 角色的元素上,被拖拽期间为 true。 |
项上的 attributes: false 会实时移除库设置的属性。面板会读回这些属性:aria-describedby 指向 provider 的操作说明,屏幕阅读器会在项的名称之后读出它。
可拖拽的链接和按钮
aria-roledescription 会替换角色的名称:屏幕阅读器会说“Inbox, sortable”,而不是“Inbox, button”。对于角色很重要的元素,自己设置它;库不会去动它:
<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”传达的信息更多。
opacity: 0 清空空位,而不是在手柄上使用 visibility: hidden 或 display: none:隐藏的元素会失去焦点。放置后库会把焦点还给它,但在此期间屏幕阅读器可能会读出“body”。播报
provider 中的实时区域会播报每一次拿起、移动、放置和取消。可排序列表会说明项的位置:“Picked up item t2 at position 2 of 5 in list playlist.” 移动在拖拽停留 500 ms 后才播报,所以快速的指针拖拽不会被逐个位置地读出来。演示的面板会记录 provider 收到的每一条文本,包括指针拖拽途中经过的移动。
内置文本是英文的,除非你给它们设置 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." 这些 label 也会传到你自己的文本和监听器中: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 会改为滚动页面,所以列表仍然可以滚动。用 provider 的 activation 调整这两个值,并根据 useDnD().activation.progress 画出长按圆环:见 provider。触摸拖拽开始时会短暂振动;:haptics="false" 关闭它。
减少动态效果
在 prefers-reduced-motion: reduce 下,库自己的动画不会运行:排序时项直接跳到它们的位置,放置时浮层不会飞行,animateChanges 的变化立即出现。你自己的动画函数仍会运行,并带有 reducedMotion: true,所以由你决定:跳过它们,或保留一个短暂的淡入淡出。见动画。