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.

Editar esta página

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.

Live

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

TeclaAção
Space / EnterPegar o item em foco; soltá-lo.
SetasMovê-lo: de posição em posição em listas, por linhas e profundidade em árvores, keyboard.step pixels em arrastes livres.
EscapeCancelar 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.

Live

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:

AtributoValor
tabindex0, quando o elemento ainda não é focável (um <div>; não um <a href> ou um <button>).
rolebutton, exceto em um <button>, que já é um.
aria-roledescriptiondraggable, ou sortable em itens ordenáveis.
aria-describedbyAs instruções do provider, adicionadas ao lado dos ids que já estão ali (o de um tooltip sobrevive).
aria-disabledtrue ou false: se o item está desativado (também por uma área de arraste desativada).
aria-pressedtrue 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.

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:

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>
  • 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 canDrag recusa isso e o navegador segue o link.
  • Um <button> também mantém o seu clique, e a biblioteca não adiciona nem role nem tabindex.
  • 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

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>

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

O handle mantém o foco durante todo o arraste por teclado, enquanto a linha dele é a posição. Esvazie a posição com 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.

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">
contextNo inícioDurante o movimentoNo fimNo cancelamento
positionOnde foi pegoOnde pousaria; nenhuma fora das listasOnde pousouPara onde voltou
partnerUma troca: o item trocado, e onde ele está agoraO mesmo, depois do drop
count, copyQuantos itens, e se o arraste copia
EventoAnunciado quando
onDragStartO item é pego.
onDragOverO item vai para outro lugar (uma posição de uma lista, outro alvo), depois que o arraste fica parado por 500 ms.
onDragEndEle é solto. A nova ordem já está gravada.
onDragCancelO arraste é cancelado, ou o drop recusado: o item voltou.
onDragResumeonBeforeDrop respondeu 'continue': o arraste continua (veja decidindo um drop).
  • Um texto substitui o anúncio do evento; undefined manté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 com aria-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.
Para outro idioma, forneça os cinco textos e as instruções: cada um que você omitir é falado em inglês, incluindo as posições das listas ordenáveis. Defina também o 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á.

Live

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

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>

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:

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

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.