Documentação/Guias
Acessibilidade
Arrastes por teclado, anúncios para leitores de tela e atributos ARIA vêm embutidos. Aqui está o que eles fazem, como escrevê-los em qualquer idioma e como oferecer movimentos sem arrastar.
Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês
Tudo nesta página vem ligado por padrão. Chegue a um item da agenda com Tab, pressione Space, mova-o com as setas e pressione Space de novo, ou Escape para devolvê-lo. O painel mostra o que é dito a um leitor de tela, no idioma que você escolher.
Carregando a 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>O que normalmente fica por sua conta: textos legíveis para os itens, e botões que os movem para quem não consegue arrastar.
O teclado
| Tecla | Ação |
|---|---|
| Space / Enter | Pegar o item em foco; soltá-lo. |
| Setas | Movê-lo: de posição em posição em listas, por linhas e profundidade em árvores, keyboard.step pixels em arrastes livres. |
| Escape | Cancelar o arraste — arrastes por ponteiro também. |
- Em uma lista, uma seta em direção a outra lista do mesmo grupo move o item para lá.
- Em um arraste livre, Shift aumenta os passos.
makeDraggable(el, { keyboardMode: 'droppables' })faz as setas pularem entre os alvos que aceitam o item. - Um arraste por teclado começa a partir do draggable em foco, ou do handle dele — nunca a partir de um botão ou de um campo dentro dele, então esses mantêm as suas próprias teclas.
- Arrastes por teclado rolam o item até ficar visível em vez de usar a rolagem automática nas bordas.
- Depois do drop, o foco volta para o item, mesmo quando a linha dele foi renderizada de novo em outra lista.
Outras teclas
<DnDProvider :keyboard="{ keys: { start: ['KeyD'], drop: ['KeyD', 'Enter'], cancel: ['Escape'] }, step: 10 }">
As associações comparam event.code ('Space', 'KeyD') ou event.key ('Enter'). step é
quantos pixels cada seta move em arrastes livres (25 por padrão). :keyboard="false" desliga os
arrastes por teclado, e Escape ainda cancela qualquer arraste. Quando você mudar as
teclas, mude também as screen-reader-instructions.
Atributos ARIA
Foque cada atalho, ou desligue os atributos, e veja o que a biblioteca define. Arraste um para
ver o aria-pressed aparecer e sumir no botão.
Carregando a 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>Todo draggable e todo item ordenável recebe estes, a menos que você mesmo os defina:
| Atributo | Valor |
|---|---|
tabindex | 0, quando o elemento ainda não é focável (um <div>; não um <a href> ou um <button>). |
role | button, exceto em um <button>, que já é um. |
aria-roledescription | draggable, ou sortable em itens ordenáveis. |
aria-describedby | As instruções do provider, adicionadas ao lado dos ids que já estão ali (o de um tooltip sobrevive). |
aria-disabled | true ou false: se o item está desativado (também por uma área de arraste desativada). |
aria-pressed | true enquanto ele é arrastado, em elementos com o papel button. |
attributes: false em um item remove, ao vivo, o que a biblioteca tinha definido. O painel lê os
atributos de volta: aria-describedby aponta para as instruções do provider, que um leitor de
tela lê depois do nome do item.
Links e botões que são arrastados
aria-roledescription substitui o nome do papel: um leitor de tela diz "Inbox, sortable" em vez
de "Inbox, button". Para um elemento cujo papel importa, defina o seu próprio; a biblioteca não
mexe nele:
<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>
- Um link continua funcionando: um clique o abre, mas o clique que termina um arraste nunca chega
a ele. Enter iniciaria um arraste, então
canDragrecusa isso e o navegador segue o link. - Um
<button>também mantém o seu clique, e a biblioteca não adiciona nemrolenemtabindex. - Botões, links e campos dentro de um item não iniciam arrastes por ponteiro, então uma linha pode ter as suas próprias ações.
Handles: faça deles botões de verdade
<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>
Com um handle, os atributos vão para o handle, e os arrastes por teclado começam por ele (o
atalho Archive acima). Um <button> já é focável e já é um botão, então não precisa de
tabindex nem de role. Dê a ele um aria-label: um ícone de alça não tem nome próprio, e "Drag
Midnight Drive" diz mais que "button".
opacity: 0 em vez de visibility: hidden ou display: none no handle: um
elemento escondido perde o foco. A biblioteca o devolve depois do drop, mas um leitor de tela pode
dizer "body" no meio do caminho.Anúncios
Uma live region no provider anuncia cada início de arraste, movimento, drop e cancelamento. As listas ordenáveis dizem onde o item está: "Picked up item t2 at position 2 of 5 in list playlist." Os movimentos são anunciados depois que o arraste fica parado por 500 ms, para que um arraste rápido por ponteiro não seja lido posição por posição. O painel da demo registra cada texto que o provider recebe, incluindo os movimentos pelos quais um arraste por ponteiro passa no caminho.
Os textos embutidos estão em inglês e chamam as coisas pelos ids delas, a não ser que você dê a
elas um label. Itens, listas, árvores e alvos de drop aceitam um:
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." Os labels também chegam aos seus
próprios textos e monitores: active.label, over.label, cada um de active.items, e no context
position.label e partner.label.
Para outro idioma, ou outras palavras, escreva os textos você mesmo, como faz a agenda. Cada texto
recebe o evento e um context: a position que o texto embutido falaria (list, index a partir
de 0, length, o label da lista), e count, copy e o partner de uma troca.
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 | No início | Durante o movimento | No fim | No cancelamento |
|---|---|---|---|---|
position | Onde foi pego | Onde pousaria; nenhuma fora das listas | Onde pousou | Para onde voltou |
partner | Uma troca: o item trocado, e onde ele está agora | O mesmo, depois do drop | ||
count, copy | Quantos itens, e se o arraste copia |
| Evento | Anunciado quando |
|---|---|
onDragStart | O item é pego. |
onDragOver | O item vai para outro lugar (uma posição de uma lista, outro alvo), depois que o arraste fica parado por 500 ms. |
onDragEnd | Ele é solto. A nova ordem já está gravada. |
onDragCancel | O arraste é cancelado, ou o drop recusado: o item voltou. |
onDragResume | onBeforeDrop respondeu 'continue': o arraste continua (veja decidindo um drop). |
- Um texto substitui o anúncio do evento;
undefinedmantém o padrão;''não diz nada. Os eventos que você omite mantêm os padrões, como as posições das listas ordenáveis. screenReaderInstructionsé o texto que todo draggable aponta comaria-describedby. As duas props são reativas: a demo troca o idioma ao vivo.:announcements="false"silencia a região, por exemplo quando você anuncia pela sua própria.
lang da página, para que o
leitor de tela escolha a voz certa.Mover sem arrastar
Algumas pessoas não conseguem arrastar. A WCAG 2.5.7 pede uma forma de mover itens com cliques simples. Use as setas e o visto de cada linha: eles movem os livros sem arrastar, e o foco fica onde você está.
Carregando a demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import ReadingList from './ReadingList.vue';
</script>
<template>
<DnDProvider>
<ReadingList />
</DnDProvider>
</template>Um botão muda os dados; a lista desenha o movimento. Com animateChanges na lista, as linhas deslizam
como em um arraste (e ficam paradas para quem pede movimento reduzido):
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>
Manter o foco
Mover um elemento no DOM tira o foco dele, então o botão que acabou de ser pressionado o perde. Devolva o foco depois que a linha se moveu:
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();
};
Uma linha que sai (o visto marca um livro como lido) leva o foco junto: mova-o para a próxima linha, ou para a última. Os arrastes não precisam de nada disso: a biblioteca devolve o foco ao próprio item solto, mesmo quando ele é renderizado de novo em outra lista.
@vue-dnd-kit/actions transforma cada botão em um arraste por teclado de verdade:
moveItem(id, { index }) é anunciado, passa por onBeforeDrop e onDrop, é animado e mantém o
foco no item. Veja arrastes programáticos.Toque
Arrastes por toque começam depois de um toque longo de 200 ms. Mover mais de 5 px antes disso rola
a página, então as listas continuam roláveis. Ajuste os dois com a activation do provider, e
desenhe um anel de toque longo a partir de useDnD().activation.progress: veja
o provider. Um arraste por toque vibra
rapidamente quando começa; :haptics="false" desliga isso.
Movimento reduzido
Com prefers-reduced-motion: reduce, as animações da própria biblioteca não rodam: os itens pulam
para os seus lugares durante a ordenação, o overlay não voa no drop, e as mudanças de animateChanges
aparecem na hora. As suas próprias funções de animação continuam rodando, com
reducedMotion: true, então você decide: pulá-las, ou manter um esmaecimento curto. Veja
animações.