Документация/Руководства
Доступность
Перетаскивание с клавиатуры, объявления для скринридеров и ARIA-атрибуты встроены. Что они делают, как сформулировать их на любом языке и как дать возможность перемещать без перетаскивания.
Страница переведена ИИ и может содержать неточности. Читать английский оригинал
Всё на этой странице включено по умолчанию. Перейдите клавишей 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 | Отменить драг — в том числе драг указателем. |
- В списке стрелка в сторону другого списка той же группы переносит элемент туда.
- При свободном перетаскивании 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" отключает перетаскивание с клавиатуры, а 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 | Инструкции провайдера, добавленные к уже имеющимся id (id тултипа сохраняется). |
aria-disabled | true или false: отключён ли элемент (в том числе отключённой областью перетаскивания). |
aria-pressed | true, пока элемент перетаскивается, на элементах с ролью button. |
attributes: false на элементе убирает то, что поставила библиотека, вживую. Панель
считывает атрибуты обратно: aria-describedby указывает на инструкции провайдера, которые
скринридер читает после имени элемента.
Перетаскиваемые ссылки и кнопки
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>
- Ссылка продолжает работать: клик открывает её, но клик, которым заканчивается драг, до неё
не доходит. 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».Объявления
Live-регион в провайдере озвучивает каждый захват, перемещение, бросок и отмену. Сортируемые списки говорят, где находится элемент: «Picked up item t2 at position 2 of 5 in list playlist.» Перемещения озвучиваются, когда драг замер на 500 ms, так что быстрый драг указателем не зачитывается слот за слотом. Панель в демо записывает каждый текст, который получает провайдер, включая перемещения, которые драг указателем проходит по пути.
Встроенные тексты — на английском и называют всё по 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, а в
контексте — position.label и partner.label.
Для другого языка или других слов напишите тексты сами, как это делает повестка. Каждый текст
получает событие и context: position, которую озвучил бы встроенный текст (list, index
с 0, 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. Оба пропа реактивны: демо переключает язык на лету.: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, так что решаете вы: пропустить их или оставить короткое затухание. См.
анимации.