Documentation/Guides

Accessibilité

Les glissements au clavier, les annonces pour lecteurs d'écran et les attributs ARIA sont intégrés. Voici ce qu'ils font, comment les formuler dans n'importe quelle langue, et comment proposer des déplacements sans glisser.

Modifier cette page

Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais

Tout ce qui figure sur cette page est activé par défaut. Atteignez un point de l'agenda avec Tab, appuyez sur Space, déplacez-le avec les flèches et appuyez de nouveau sur Space, ou sur Escape pour le remettre en place. Le panneau montre ce qui est dit à un lecteur d'écran, dans la langue que vous choisissez.

Live

Chargement de la démo…

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

Ce qui vous revient généralement : des textes lisibles pour les items, et des boutons qui les déplacent pour les personnes qui ne peuvent pas glisser du tout.

Le clavier

ToucheAction
Space / EnterSaisir l'item qui a le focus ; le déposer.
FlèchesLe déplacer : d'emplacement en emplacement dans les listes, par lignes et par profondeur dans les arbres, de keyboard.step pixels dans les glissements libres.
EscapeAnnuler le glissement — y compris les glissements au pointeur.
  • Dans une liste, une flèche vers une autre liste du même groupe y déplace l'item.
  • Dans un glissement libre, Shift agrandit les pas. makeDraggable(el, { keyboardMode: 'droppables' }) fait plutôt sauter les flèches d'une cible qui accepte l'item à l'autre.
  • Un glissement au clavier démarre depuis l'élément déplaçable qui a le focus, ou depuis sa poignée — jamais depuis un bouton ou un champ qu'il contient, qui gardent ainsi leurs propres touches.
  • Les glissements au clavier font défiler l'item pour le rendre visible au lieu de déclencher le défilement automatique aux bords.
  • Après le dépôt, le focus revient à l'item, même quand sa ligne a été rendue de nouveau dans une autre liste.

D'autres touches

<DnDProvider :keyboard="{ keys: { start: ['KeyD'], drop: ['KeyD', 'Enter'], cancel: ['Escape'] }, step: 10 }">

Les associations correspondent à event.code ('Space', 'KeyD') ou à event.key ('Enter'). step est le nombre de pixels par appui sur une flèche dans les glissements libres (25 par défaut). :keyboard="false" désactive les glissements au clavier, et Escape annule toujours n'importe quel glissement. Quand vous changez les touches, changez aussi screen-reader-instructions.

Attributs ARIA

Donnez le focus à chaque raccourci, ou désactivez les attributs, et observez ce que pose la bibliothèque. Faites-en glisser un pour voir aria-pressed apparaître et disparaître sur le bouton.

Live

Chargement de la démo…

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

Chaque élément déplaçable et chaque item triable reçoit ces attributs, sauf si vous les définissez vous-même :

AttributValeur
tabindex0, quand l'élément ne peut pas déjà recevoir le focus (un <div> ; pas un <a href> ni un <button>).
rolebutton, sauf sur un <button>, qui en est déjà un.
aria-roledescriptiondraggable, ou sortable sur les items triables.
aria-describedbyLes instructions du provider, ajoutées à côté des ids déjà présents (celui d'une infobulle est conservé).
aria-disabledtrue ou false : si l'item est désactivé (y compris par une zone de glissement désactivée).
aria-pressedtrue pendant qu'il est glissé, sur les éléments qui ont le rôle button.

attributes: false sur un item retire en direct ce que la bibliothèque avait posé. Le panneau relit les attributs : aria-describedby pointe vers les instructions du provider, qu'un lecteur d'écran lit après le nom de l'item.

aria-roledescription remplace le nom du rôle : un lecteur d'écran dit « Inbox, sortable » au lieu de « Inbox, button ». Pour un élément dont le rôle compte, définissez le vôtre ; la bibliothèque n'y touche pas :

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>
  • Un lien continue de fonctionner : un clic l'ouvre, mais le clic qui termine un glissement ne l'atteint jamais. Enter démarrerait un glissement : canDrag le refuse donc, et le navigateur suit le lien.
  • Un <button> garde aussi son clic, et la bibliothèque n'ajoute ni role ni tabindex.
  • Les boutons, les liens et les champs à l'intérieur d'un item ne démarrent pas de glissement au pointeur : une ligne peut donc avoir ses propres actions.

Poignées : faites-en de vrais boutons

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>

Avec une handle, les attributs vont sur la poignée, et les glissements au clavier démarrent depuis elle (le raccourci Archive ci-dessus). Un <button> peut recevoir le focus et est déjà un bouton : il n'a besoin ni de tabindex ni de role. Donnez-lui un aria-label : une icône de poignée n'a pas de nom à elle, et « Drag Midnight Drive » en dit plus que « button ».

La poignée garde le focus pendant tout le glissement au clavier, tandis que sa ligne sert d'emplacement. Videz l'emplacement avec opacity: 0 plutôt qu'avec visibility: hidden ou display: none sur la poignée : un élément masqué perd le focus. La bibliothèque le rend après le dépôt, mais un lecteur d'écran peut dire « body » entre-temps.

Annonces

Une région live dans le provider énonce chaque saisie, déplacement, dépôt et annulation. Les listes triables disent où se trouve l'item : « Picked up item t2 at position 2 of 5 in list playlist. » Les déplacements sont énoncés une fois que le glissement est resté immobile 500 ms : un glissement rapide au pointeur n'est donc pas lu emplacement par emplacement. Le panneau de la démo journalise chaque texte donné au provider, y compris les déplacements par lesquels passe un glissement au pointeur en chemin.

Les textes intégrés sont en anglais, et désignent les choses par leurs ids, sauf si vous leur donnez un label. Les items, les listes, les arbres et les cibles de dépôt en acceptent tous un :

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." Les labels arrivent aussi dans vos propres textes et vos moniteurs : active.label, over.label, chacun des active.items, et dans le contexte position.label et partner.label.

Pour une autre langue, ou d'autres mots, écrivez les textes vous-même, comme le fait l'agenda. Chaque texte reçoit l'événement et un context : la position que le texte intégré énoncerait (list, index à partir de 0, length, le label de la liste), ainsi que count, copy et le partner d'un échange.

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">
contextAu débutPendant le déplacementÀ la finÀ l'annulation
positionLà où il a été saisiLà où il atterrirait ; aucune en dehors des listesLà où il a atterriLà où il est revenu
partnerUn échange : l'item échangé, et où il se trouve maintenantLa même chose, après le dépôt
count, copyCombien d'items, et si le glissement copie
ÉvénementÉnoncé quand
onDragStartL'item est saisi.
onDragOverL'item passe à un autre endroit (un emplacement d'une liste, une autre cible), une fois que le glissement est resté immobile 500 ms.
onDragEndIl est déposé. Le nouvel ordre est déjà écrit.
onDragCancelLe glissement est annulé, ou le dépôt refusé : l'item est revenu.
onDragResumeonBeforeDrop a répondu 'continue' : le glissement continue (voir les décisions de dépôt).
  • Un texte remplace l'annonce de l'événement ; undefined garde celle par défaut ; '' ne dit rien. Les événements que vous omettez gardent leurs valeurs par défaut, comme les positions des listes triables.
  • screenReaderInstructions est le texte vers lequel chaque élément déplaçable pointe avec aria-describedby. Les deux props sont réactives : la démo change de langue en direct.
  • :announcements="false" rend la région muette, par exemple quand vous annoncez via la vôtre.
Pour une autre langue, donnez les cinq textes et les instructions : chacun de ceux que vous omettez est énoncé en anglais, positions triables comprises. Définissez aussi le lang de la page, pour que le lecteur d'écran choisisse la bonne voix.

Déplacer sans glisser

Certaines personnes ne peuvent pas glisser du tout. Le critère WCAG 2.5.7 demande un moyen de déplacer les items par de simples clics. Utilisez les flèches et la coche de chaque ligne : elles déplacent les livres sans glissement, et le focus reste là où vous êtes.

Live

Chargement de la démo…

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

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

Un bouton change les données ; la liste dessine le déplacement. Avec animateChanges sur la liste, les lignes glissent comme pendant un glissement (et restent immobiles pour les utilisateurs qui demandent un mouvement réduit) :

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>

Garder le focus

Déplacer un élément dans le DOM lui retire le focus : le bouton qui vient d'être pressé le perd donc. Rendez-le une fois que la ligne a bougé :

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();
};

Une ligne qui disparaît (la coche marque un livre comme lu) emporte le focus avec elle : déplacez-le vers la ligne suivante, ou vers la dernière. Les glissements n'ont besoin de rien de tout cela : la bibliothèque remet elle-même le focus sur l'item déposé, même quand il est rendu de nouveau dans une autre liste.

@vue-dnd-kit/actions transforme plutôt chaque bouton en vrai glissement au clavier : moveItem(id, { index }) est annoncé, passe par onBeforeDrop et onDrop, s'anime, et garde le focus sur l'item. Voir les glissements programmatiques.

Tactile

Les glissements tactiles démarrent après un appui long de 200 ms. Bouger de plus de 5 px avant cela fait défiler la page à la place : les listes restent donc défilables. Réglez les deux avec l'activation du provider, et dessinez un anneau d'appui long à partir de useDnD().activation.progress : voir le provider. Un glissement tactile vibre brièvement quand il commence ; :haptics="false" le désactive.

Mouvement réduit

Avec prefers-reduced-motion: reduce, les animations propres à la bibliothèque ne s'exécutent pas : les items sautent à leur place pendant le tri, l'overlay ne vole pas au dépôt, et les changements d'animateChanges apparaissent d'un coup. Vos propres fonctions d'animation s'exécutent toujours, avec reducedMotion: true : c'est donc vous qui décidez, les sauter ou garder un bref fondu. Voir les animations.