Dokumentation/Leitfäden
Barrierefreiheit
Ziehen per Tastatur, Screenreader-Ansagen und ARIA-Attribute sind eingebaut. Hier steht, was sie tun, wie du sie in jeder Sprache formulierst und wie du Verschieben ohne Ziehen anbietest.
Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen
Alles auf dieser Seite ist standardmäßig an. Springe mit Tab zu einem Agenda-Eintrag, drücke Space, bewege ihn mit den Pfeiltasten und drücke erneut Space oder Escape, um ihn zurückzustellen. Das Panel zeigt, was einem Screenreader gesagt wird, in der Sprache, die du wählst.
Demo wird geladen…
<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>Was meist dir überlassen bleibt: lesbare Texte für die Elemente und Buttons, die sie für Menschen bewegen, die gar nicht ziehen können.
Die Tastatur
| Taste | Aktion |
|---|---|
| Space / Enter | Das fokussierte Element aufnehmen; es ablegen. |
| Pfeiltasten | Es bewegen: slotweise in Listen, nach Zeilen und Tiefe in Bäumen, um keyboard.step Pixel bei freien Drags. |
| Escape | Den Drag abbrechen – auch Zeiger-Drags. |
- In einer Liste bewegt ein Pfeil in Richtung einer anderen Liste derselben Gruppe das Element dorthin.
- Bei einem freien Drag macht Shift die Schritte größer.
makeDraggable(el, { keyboardMode: 'droppables' })lässt die Pfeile stattdessen zwischen den Zielen springen, die das Element annehmen. - Ein Drag per Tastatur startet am fokussierten ziehbaren Element oder an seinem Handle – nie an einem Button oder Feld darin, die behalten also ihre eigenen Tasten.
- Drags per Tastatur scrollen das Element in den sichtbaren Bereich, statt an den Rändern automatisch zu scrollen.
- Nach dem Drop geht der Fokus zurück auf das Element, auch wenn seine Zeile in einer anderen Liste neu gerendert wurde.
Andere Tasten
<DnDProvider :keyboard="{ keys: { start: ['KeyD'], drop: ['KeyD', 'Enter'], cancel: ['Escape'] }, step: 10 }">
Belegungen werden mit event.code ('Space', 'KeyD') oder event.key ('Enter')
abgeglichen. step sind die Pixel pro Pfeildruck bei freien Drags (standardmäßig 25).
:keyboard="false" schaltet Drags per Tastatur ab, und Escape bricht trotzdem jeden
Drag ab. Wenn du die Tasten änderst, ändere auch die screen-reader-instructions.
ARIA-Attribute
Fokussiere jeden Shortcut oder schalte die Attribute ab und beobachte, was die Bibliothek setzt.
Ziehe einen, um zu sehen, wie aria-pressed am Button kommt und geht.
Demo wird geladen…
<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>Jedes ziehbare und sortierbare Element bekommt diese, sofern du sie nicht selbst setzt:
| Attribut | Wert |
|---|---|
tabindex | 0, wenn das Element nicht schon fokussierbar ist (ein <div>; kein <a href> oder <button>). |
role | button, außer an einem <button>, der schon einer ist. |
aria-roledescription | draggable oder bei sortierbaren Elementen sortable. |
aria-describedby | Die Anweisungen des Providers, ergänzt neben den schon vorhandenen IDs (die eines Tooltips bleibt erhalten). |
aria-disabled | true oder false: ob das Element deaktiviert ist (auch durch einen deaktivierten Drag-Bereich). |
aria-pressed | true, während es gezogen wird, an Elementen mit der Rolle button. |
attributes: false an einem Element entfernt live, was die Bibliothek gesetzt hatte. Das Panel
liest die Attribute zurück: aria-describedby verweist auf die Anweisungen des Providers, die ein
Screenreader nach dem Namen des Elements vorliest.
Links und Buttons, die ziehen
aria-roledescription ersetzt den Namen der Rolle: Ein Screenreader sagt „Inbox, sortable“ statt
„Inbox, button“. Für ein Element, dessen Rolle wichtig ist, setz deine eigene; die Bibliothek lässt
sie in Ruhe:
<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>
- Ein Link funktioniert weiter: Ein Klick öffnet ihn, aber der Klick, der einen Drag beendet,
erreicht ihn nie. Enter würde einen Drag starten, also lehnt
canDragdas ab, und der Browser folgt dem Link. - Ein
<button>behält ebenfalls seinen Klick, und die Bibliothek ergänzt wederrolenochtabindex. - Buttons, Links und Felder in einem Element starten keine Zeiger-Drags, eine Zeile kann also ihre eigenen Aktionen enthalten.
Handles: Mach sie zu echten Buttons
<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>
Mit einem handle kommen die Attribute ans Handle, und Drags per Tastatur starten an ihm (der
Shortcut Archive oben). Ein <button> ist fokussierbar und schon ein Button, er braucht also
weder tabindex noch role. Gib ihm ein aria-label: Ein Griff-Icon hat keinen eigenen Namen,
und „Drag Midnight Drive“ sagt mehr als „button“.
opacity: 0 statt mit visibility: hidden oder display: none am
Handle: Ein verborgenes Element verliert den Fokus. Die Bibliothek gibt ihn nach dem Drop zurück,
aber ein Screenreader sagt dazwischen vielleicht „body“.Ansagen
Eine Live-Region im Provider spricht jedes Aufnehmen, Bewegen, Ablegen und Abbrechen. Sortierbare Listen sagen, wo das Element ist: „Picked up item t2 at position 2 of 5 in list playlist.“ Bewegungen werden gesprochen, sobald der Drag 500 ms geruht hat, damit ein schneller Zeiger-Drag nicht Slot für Slot vorgelesen wird. Das Panel der Demo protokolliert jeden Text, den der Provider bekommt, auch die Bewegungen, die ein Zeiger-Drag unterwegs passiert.
Die eingebauten Texte sind englisch und nennen Dinge bei ihren IDs, außer du gibst ihnen ein
label. Elemente, Listen, Bäume und Drop-Ziele nehmen alle eines:
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.“ Die Labels erreichen auch deine
eigenen Texte und Monitore: active.label, over.label, jedes von active.items, und im
Kontext position.label und partner.label.
Für eine andere Sprache oder andere Worte schreib die Texte selbst, wie es die Agenda tut. Jeder
Text bekommt das Event und einen context: die position, die der eingebaute Text sprechen würde
(list, index ab 0, length, das label der Liste), sowie count, copy und den partner
eines Tauschs.
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 | Beim Start | Beim Bewegen | Am Ende | Beim Abbrechen |
|---|---|---|---|---|
position | Wo es aufgenommen wurde | Wo es landen würde; keine außerhalb der Listen | Wo es gelandet ist | Wohin es zurückgegangen ist |
partner | Ein Tausch: das Element, mit dem getauscht wurde, und wo es jetzt ist | Dasselbe, nach dem Drop | ||
count, copy | Wie viele Elemente und ob der Drag kopiert |
| Event | Gesprochen, wenn |
|---|---|
onDragStart | Das Element aufgenommen wird. |
onDragOver | Das Element an einen anderen Ort geht (einen Slot einer Liste, ein anderes Ziel), sobald der Drag 500 ms ruht. |
onDragEnd | Es abgelegt wird. Die neue Reihenfolge ist schon geschrieben. |
onDragCancel | Der Drag abgebrochen oder der Drop abgelehnt wird: Das Element ist zurückgegangen. |
onDragResume | onBeforeDrop 'continue' geantwortet hat: Der Drag geht weiter (siehe Über einen Drop entscheiden). |
- Ein Text ersetzt die Ansage des Events;
undefinedbehält den Standard;''sagt nichts. Events, die du weglässt, behalten ihre Standards, etwa die Positionen sortierbarer Listen. screenReaderInstructionsist der Text, auf den jedes ziehbare Element mitaria-describedbyverweist. Beide Props sind reaktiv: Die Demo wechselt die Sprache live.:announcements="false"bringt die Region zum Schweigen, z. B. wenn du über deine eigene ansagst.
lang der Seite, damit
der Screenreader die richtige Stimme wählt.Verschieben ohne Ziehen
Manche Menschen können gar nicht ziehen. WCAG 2.5.7 verlangt eine Möglichkeit, Elemente mit einzelnen Klicks zu verschieben. Nimm die Pfeile und das Häkchen an jeder Zeile: Sie verschieben die Bücher ohne Drag, und der Fokus bleibt, wo du bist.
Demo wird geladen…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import ReadingList from './ReadingList.vue';
</script>
<template>
<DnDProvider>
<ReadingList />
</DnDProvider>
</template>Ein Button ändert die Daten; die Liste zeichnet die Bewegung. Mit animateChanges an der Liste gleiten
die Zeilen wie bei einem Drag (und bleiben still für Nutzer, die reduzierte Bewegung wünschen):
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>
Den Fokus behalten
Ein Element im DOM zu verschieben nimmt ihm den Fokus, also verliert der Button, der gerade gedrückt wurde, ihn. Gib ihn zurück, sobald sich die Zeile bewegt hat:
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();
};
Eine Zeile, die verschwindet (das Häkchen markiert ein Buch als gelesen), nimmt den Fokus mit: Setze ihn auf die nächste Zeile oder die letzte. Drags brauchen nichts davon: Die Bibliothek setzt den Fokus selbst zurück auf das abgelegte Element, auch wenn es in einer anderen Liste neu gerendert wird.
@vue-dnd-kit/actions macht aus jedem Button stattdessen einen echten Drag per Tastatur:
moveItem(id, { index }) wird angesagt, durchläuft onBeforeDrop und onDrop, animiert und
behält den Fokus auf dem Element. Siehe
programmatisches Ziehen.Touch
Touch-Drags starten nach einem langen Drücken von 200 ms. Wer sich vorher mehr als 5 px bewegt,
scrollt stattdessen die Seite, Listen bleiben also scrollbar. Stimme beides mit activation des
Providers ab und zeichne aus useDnD().activation.progress einen Ring fürs lange Drücken: siehe
Der Provider. Ein Touch-Drag vibriert kurz,
wenn er startet; :haptics="false" schaltet das ab.
Reduzierte Bewegung
Bei prefers-reduced-motion: reduce laufen die eigenen Animationen der Bibliothek nicht: Elemente
springen beim Sortieren an ihre Plätze, das Overlay fliegt beim Drop nicht, und Änderungen durch
animateChanges erscheinen sofort. Deine eigenen Animationsfunktionen laufen weiterhin, mit
reducedMotion: true, du entscheidest also: sie auslassen oder ein kurzes Ausblenden behalten.
Siehe Animationen.