Документация/Руководства

Доступность

Перетаскивание с клавиатуры, объявления для скринридеров и ARIA-атрибуты встроены. Что они делают, как сформулировать их на любом языке и как дать возможность перемещать без перетаскивания.

Редактировать страницу

Страница переведена ИИ и может содержать неточности. Читать английский оригинал

Всё на этой странице включено по умолчанию. Перейдите клавишей Tab к пункту повестки, нажмите Space, подвигайте его стрелками и снова нажмите Space или Escape, чтобы вернуть на место. Панель показывает, что сообщается скринридеру, на выбранном вами языке.

Live

Загружаем демо…

<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 появляется и исчезает на кнопке.

Live

Загружаем демо…

<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>

Каждый перетаскиваемый и сортируемый элемент получает эти атрибуты, если вы не задали их сами:

АтрибутЗначение
tabindex0, если элемент ещё не фокусируемый (<div>; но не <a href> или <button>).
rolebutton, кроме <button>, который и так кнопка.
aria-roledescriptiondraggable или sortable на сортируемых элементах.
aria-describedbyИнструкции провайдера, добавленные к уже имеющимся id (id тултипа сохраняется).
aria-disabledtrue или false: отключён ли элемент (в том числе отключённой областью перетаскивания).
aria-pressedtrue, пока элемент перетаскивается, на элементах с ролью button.

attributes: false на элементе убирает то, что поставила библиотека, вживую. Панель считывает атрибуты обратно: aria-describedby указывает на инструкции провайдера, которые скринридер читает после имени элемента.

aria-roledescription заменяет название роли: скринридер говорит «Inbox, sortable» вместо «Inbox, button». Для элемента, у которого роль важна, задайте свою; библиотека её не тронет:

Shortcut.vue
<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.
  • Кнопки, ссылки и поля внутри элемента не начинают драг указателем, так что строка может содержать собственные действия.

Ручки: делайте их настоящими кнопками

Track.vue
<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 при обмене местами.

index.vue
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)),
  };
});
index.vue
<DnDProvider :announcements="announcements" :screen-reader-instructions="texts.instructions">
contextПри стартеВо время перемещенияВ концеПри отмене
positionГде элемент взялиГде он приземлился бы; нет вне списковГде он приземлилсяКуда он вернулся
partnerОбмен местами: элемент, с которым обменялись, и где он сейчасТо же, после броска
count, copyСколько элементов и копирует ли драг
СобытиеОзвучивается, когда
onDragStartЭлемент взят.
onDragOverЭлемент переходит в другое место (слот списка, другую цель), когда драг замер на 500 ms.
onDragEndОн брошен. Новый порядок уже записан.
onDragCancelДраг отменён или бросок отклонён: элемент вернулся.
onDragResumeonBeforeDrop ответил 'continue': драг продолжается (см. решение о броске).
  • Текст заменяет объявление события; undefined оставляет вариант по умолчанию; '' не озвучивает ничего. События, которые вы не указали, сохраняют значения по умолчанию, например позиции сортируемых списков.
  • screenReaderInstructions — текст, на который каждый перетаскиваемый элемент ссылается через aria-describedby. Оба пропа реактивны: демо переключает язык на лету.
  • :announcements="false" заглушает регион, например когда вы озвучиваете через собственный.
Для другого языка задайте все пять текстов и инструкции: каждый, который вы не укажете, озвучивается на английском, включая позиции в сортируемых списках. Задайте и lang страницы, чтобы скринридер выбрал правильный голос.

Перемещение без перетаскивания

Некоторые люди вообще не могут перетаскивать. WCAG 2.5.7 требует способа перемещать элементы одиночными кликами. Используйте стрелки и галочку на каждой строке: они перемещают книги без драга, а фокус остаётся там, где вы.

Live

Загружаем демо…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import ReadingList from './ReadingList.vue';
</script>

<template>
  <DnDProvider>
    <ReadingList />
  </DnDProvider>
</template>

Кнопка меняет данные; список отрисовывает перемещение. С animateChanges на списке строки скользят так же, как при драге (и стоят на месте у пользователей, которые просят уменьшить движение):

ReadingList.vue
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}.`;
};
ReadingList.vue
<p class="status" role="status">{{ message }}</p>

Сохраните фокус

Перемещение элемента в DOM забирает с него фокус, так что только что нажатая кнопка его теряет. Верните его, когда строка переместилась:

BookRow.vue
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, так что решаете вы: пропустить их или оставить короткое затухание. См. анимации.