Documentación/Guías
Accesibilidad
Los arrastres con teclado, los anuncios para lectores de pantalla y los atributos ARIA vienen integrados. Aquí está qué hacen, cómo redactarlos en cualquier idioma y cómo ofrecer movimientos sin arrastrar.
Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés
Todo lo de esta página está activado por defecto. Llega con Tab a un punto de la agenda, pulsa Space, muévelo con las flechas y vuelve a pulsar Space, o Escape para devolverlo a su sitio. El panel muestra lo que se le dice a un lector de pantalla, en el idioma que elijas.
Cargando la demo…
<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>Lo que suele quedar de tu parte: textos legibles para los ítems, y botones que los muevan para quien no puede arrastrar en absoluto.
El teclado
| Tecla | Acción |
|---|---|
| Space / Enter | Agarrar el ítem con el foco; soltarlo. |
| Flechas | Moverlo: de hueco en hueco en las listas, por filas y profundidad en los árboles, keyboard.step píxeles en los arrastres libres. |
| Escape | Cancelar el arrastre — también los de puntero. |
- En una lista, una flecha hacia otra lista del mismo grupo mueve el ítem allí.
- En un arrastre libre, Shift agranda los pasos.
makeDraggable(el, { keyboardMode: 'droppables' })hace en cambio que las flechas salten entre los destinos que aceptan el ítem. - Un arrastre con teclado empieza desde el elemento arrastrable con el foco, o desde su asa — nunca desde un botón o un campo que contenga, que así conservan sus propias teclas.
- Los arrastres con teclado desplazan el ítem hasta hacerlo visible en lugar de usar el desplazamiento automático en los bordes.
- Tras el drop, el foco vuelve al ítem, incluso cuando su fila se volvió a renderizar en otra lista.
Otras teclas
<DnDProvider :keyboard="{ keys: { start: ['KeyD'], drop: ['KeyD', 'Enter'], cancel: ['Escape'] }, step: 10 }">
Las asignaciones coinciden con event.code ('Space', 'KeyD') o con event.key ('Enter').
step son los píxeles por pulsación de flecha en los arrastres libres (25 por defecto).
:keyboard="false" desactiva los arrastres con teclado, y Escape sigue cancelando
cualquier arrastre. Cuando cambies las teclas, cambia también screen-reader-instructions.
Atributos ARIA
Pon el foco en cada acceso directo, o desactiva los atributos, y observa lo que pone la
biblioteca. Arrastra uno para ver cómo aria-pressed aparece y desaparece en el botón.
Cargando la demo…
<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>Cada elemento arrastrable e ítem ordenable recibe estos atributos, salvo que los pongas tú:
| Atributo | Valor |
|---|---|
tabindex | 0, cuando el elemento no puede recibir ya el foco (un <div>; no un <a href> ni un <button>). |
role | button, salvo en un <button>, que ya lo es. |
aria-roledescription | draggable, o sortable en los ítems ordenables. |
aria-describedby | Las instrucciones del provider, añadidas junto a los ids que ya hay (el de un tooltip se conserva). |
aria-disabled | true o false: si el ítem está desactivado (también por un área de arrastre desactivada). |
aria-pressed | true mientras se arrastra, en los elementos con el rol button. |
attributes: false en un ítem quita en vivo lo que había puesto la biblioteca. El panel vuelve a
leer los atributos: aria-describedby apunta a las instrucciones del provider, que un lector de
pantalla lee después del nombre del ítem.
Enlaces y botones que se arrastran
aria-roledescription sustituye el nombre del rol: un lector de pantalla dice «Inbox, sortable»
en lugar de «Inbox, button». Para un elemento cuyo rol importa, pon el tuyo; la biblioteca no lo
toca:
<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 enlace sigue funcionando: un clic lo abre, pero el clic que termina un arrastre nunca le
llega. Enter iniciaría un arrastre, así que
canDraglo rechaza y el navegador sigue el enlace. - Un
<button>también conserva su clic, y la biblioteca no añade nirolenitabindex. - Los botones, enlaces y campos dentro de un ítem no inician arrastres de puntero, así que una fila puede tener sus propias acciones.
Asas: hazlas botones de verdad
<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>
Con un handle, los atributos van en el asa, y los arrastres con teclado empiezan desde ella (el
acceso directo Archive de arriba). Un <button> puede recibir el foco y ya es un botón, así
que no necesita tabindex ni role. Dale un aria-label: un icono de asa no tiene nombre propio,
y «Drag Midnight Drive» dice más que «button».
opacity: 0 en lugar de visibility: hidden o display: none en el asa: un
elemento oculto pierde el foco. La biblioteca lo devuelve tras el drop, pero un lector de
pantalla puede decir «body» entretanto.Anuncios
Una región viva en el provider anuncia cada agarre, movimiento, drop y cancelación. Las listas ordenables dicen dónde está el ítem: «Picked up item t2 at position 2 of 5 in list playlist.» Los movimientos se anuncian cuando el arrastre lleva 500 ms quieto, así que un arrastre rápido de puntero no se lee hueco a hueco. El panel de la demo registra cada texto que recibe el provider, incluidos los movimientos por los que pasa un arrastre de puntero en su camino.
Los textos integrados están en inglés, y nombran las cosas por sus ids a menos que les des un
label. Los ítems, las listas, los árboles y los destinos de drop aceptan uno:
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." Los labels también llegan a tus
propios textos y monitores: active.label, over.label, cada uno de active.items, y en el
contexto position.label y partner.label.
Para otro idioma, u otras palabras, escribe tú los textos, como hace la agenda. Cada texto recibe
el evento y un context: la position que diría el texto integrado (list, index desde 0,
length, el label de la lista), y count, copy y el partner de un intercambio.
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 | Al empezar | Durante el movimiento | Al terminar | Al cancelar |
|---|---|---|---|---|
position | Dónde se agarró | Dónde aterrizaría; ninguna fuera de las listas | Dónde aterrizó | Adónde volvió |
partner | Un intercambio: el ítem intercambiado y dónde está ahora | Lo mismo, tras el drop | ||
count, copy | Cuántos ítems, y si el arrastre copia |
| Evento | Se anuncia cuando |
|---|---|
onDragStart | Se agarra el ítem. |
onDragOver | El ítem pasa a otro sitio (un hueco de una lista, otro destino), cuando el arrastre lleva 500 ms quieto. |
onDragEnd | Se suelta. El nuevo orden ya está escrito. |
onDragCancel | Se cancela el arrastre, o se rechaza el drop: el ítem volvió. |
onDragResume | onBeforeDrop respondió 'continue': el arrastre continúa (consulta decidir un drop). |
- Un texto sustituye al anuncio del evento;
undefinedmantiene el de por defecto;''no dice nada. Los eventos que omitas conservan sus valores por defecto, como las posiciones de las listas ordenables. screenReaderInstructionses el texto al que apunta cada elemento arrastrable conaria-describedby. Las dos props son reactivas: la demo cambia de idioma en vivo.:announcements="false"silencia la región, por ejemplo cuando anuncias a través de la tuya.
lang de la página, para que el
lector de pantalla elija la voz correcta.Mover sin arrastrar
Algunas personas no pueden arrastrar en absoluto. El criterio WCAG 2.5.7 pide una forma de mover los ítems con clics simples. Usa las flechas y la marca de cada fila: mueven los libros sin arrastre, y el foco se queda donde estás.
Cargando la demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import ReadingList from './ReadingList.vue';
</script>
<template>
<DnDProvider>
<ReadingList />
</DnDProvider>
</template>Un botón cambia los datos; la lista dibuja el movimiento. Con animateChanges en la lista, las filas se
deslizan como en un arrastre (y se quedan quietas para quien pide movimiento reducido):
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>
Conservar el foco
Mover un elemento en el DOM le quita el foco, así que el botón que se acaba de pulsar lo pierde. Devuélveselo una vez que la fila se ha movido:
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();
};
Una fila que desaparece (la marca señala un libro como leído) se lleva el foco con ella: muévelo a la fila siguiente, o a la última. Los arrastres no necesitan nada de esto: la biblioteca devuelve ella misma el foco al ítem soltado, incluso cuando se vuelve a renderizar en otra lista.
@vue-dnd-kit/actions convierte en cambio cada botón en un arrastre con teclado de verdad:
moveItem(id, { index }) se anuncia, pasa por onBeforeDrop y onDrop, se anima y mantiene el
foco en el ítem. Consulta arrastres programáticos.Táctil
Los arrastres táctiles empiezan tras una pulsación larga de 200 ms. Moverse más de 5 px antes
desplaza la página en su lugar, así que las listas siguen siendo desplazables. Ajusta ambas cosas
con la activation del provider, y dibuja un anillo de pulsación larga a partir de
useDnD().activation.progress: consulta el provider.
Un arrastre táctil vibra brevemente al empezar; :haptics="false" lo desactiva.
Movimiento reducido
Con prefers-reduced-motion: reduce, las animaciones propias de la biblioteca no se ejecutan: los
ítems saltan a su sitio al ordenar, el overlay no vuela al soltar y los cambios de animateChanges
aparecen de golpe. Tus propias funciones de animación siguen ejecutándose, con
reducedMotion: true, así que decides tú: saltarlas o mantener un fundido corto. Consulta
animaciones.