Reordenar lista no Vue com arrastar e soltar e também pelo teclado
Monte uma lista reordenável em Vue 3 com arraste nativo, botões Subir e Descer, foco preservado, anúncio para leitor de tela e volta da ordem quando o servidor falha.
O painel de tarefas da equipe de suporte ganhou arrastar e soltar para definir a prioridade. Ficou ótimo no mouse. O Paulo, que usa o computador só pelo teclado depois de uma lesão no punho, chega na lista e não consegue mudar nada. A resposta curta: arrastar é atalho, não a única forma. A ação real é "mover este item para cima ou para baixo", e ela precisa existir como botão. O arraste e os botões chamam a mesma função, que atualiza o mesmo array e grava a mesma ordem.
Este guia monta esse componente em Vue 3 com <script setup>: arraste nativo do HTML por uma alça, botões Subir e Descer, foco que não se perde, anúncio da nova posição para leitor de tela e volta da ordem anterior se o servidor recusar.
Por que o arraste sozinho exclui gente
A API de arrastar e soltar do HTML depende de um ponteiro: pressionar, mover e soltar. Teclado não gera esses eventos, e leitores de tela não têm como "segurar" um elemento. Em tela de toque, o suporte ao arraste nativo é irregular entre navegadores, e o gesto ainda briga com a rolagem da página.
Botões resolvem os três casos: são focáveis, funcionam com Enter e Espaço, são anunciados pelo leitor de tela e não exigem precisão motora. O arraste continua lá para quem prefere.
O desenho do componente
- Uma função
mover(de, para)altera o array. Arraste e botões só chamam essa função. - Uma alça visível inicia o arraste, para o resto do item continuar selecionável e rolável.
- Dois botões por item, com nome que inclui o título da tarefa. O primeiro item não sobe e o último não desce.
- Depois de mover pelo botão, o foco volta para o mesmo botão do item movido, na nova posição.
- Uma região
aria-liveanuncia "Emitir notas movida para a posição 1 de 4". - A ordem é gravada no servidor; se falhar, a lista volta ao estado anterior e a falha é anunciada.
O componente completo
<script setup>
import { ref, nextTick } from 'vue';
const tarefas = ref([
{ id: 11, titulo: 'Conferir estoque' },
{ id: 12, titulo: 'Emitir notas' },
{ id: 13, titulo: 'Responder chamados' },
{ id: 14, titulo: 'Fechar caixa' },
]);
const anuncio = ref('');
const lista = ref(null);
const origem = ref(null);
const alvo = ref(null);
async function salvarOrdem(ids) {
const resp = await fetch('/api/tarefas/ordem', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
}
async function mover(de, para) {
const total = tarefas.value.length;
if (para < 0 || para >= total || de === para) return null;
const anterior = tarefas.value.slice();
const [item] = tarefas.value.splice(de, 1);
tarefas.value.splice(para, 0, item);
anuncio.value = item.titulo + ' movida para a posição ' + (para + 1) + ' de ' + total + '.';
try {
await salvarOrdem(tarefas.value.map((t) => t.id));
} catch {
tarefas.value = anterior;
anuncio.value = 'Não foi possível salvar a nova ordem. A lista voltou ao que era.';
}
return item;
}
async function moverComBotao(indice, direcao) {
const item = await mover(indice, indice + direcao);
if (!item) return;
await nextTick();
const preferido = lista.value.querySelector('[data-id="' + item.id + '"][data-acao="' + direcao + '"]');
const outro = lista.value.querySelector('[data-id="' + item.id + '"][data-acao="' + -direcao + '"]');
(preferido && !preferido.disabled ? preferido : outro).focus();
}
function iniciarArraste(indice, evento) {
origem.value = indice;
evento.dataTransfer.effectAllowed = 'move';
evento.dataTransfer.setData('text/plain', String(indice));
}
function soltar(indice) {
if (origem.value !== null) mover(origem.value, indice);
origem.value = null;
alvo.value = null;
}
</script>
<template>
<p id="instrucoes">Arraste pela alça ou use os botões Subir e Descer.</p>
<ol ref="lista" aria-describedby="instrucoes">
<li
v-for="(t, i) in tarefas"
:key="t.id"
:class="{ alvo: alvo === i }"
@dragover.prevent="alvo = i"
@drop.prevent="soltar(i)"
>
<span class="alca" draggable="true" aria-hidden="true"
@dragstart="iniciarArraste(i, $event)"
@dragend="origem = null; alvo = null">::</span>
<span class="titulo">{{ t.titulo }}</span>
<button type="button" :data-id="t.id" data-acao="-1"
:disabled="i === 0" :aria-label="'Subir ' + t.titulo"
@click="moverComBotao(i, -1)">Subir</button>
<button type="button" :data-id="t.id" data-acao="1"
:disabled="i === tarefas.length - 1" :aria-label="'Descer ' + t.titulo"
@click="moverComBotao(i, 1)">Descer</button>
</li>
</ol>
<p class="visualmente-oculto" aria-live="polite">{{ anuncio }}</p>
</template>
<style scoped>
li { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem; border: 2px solid transparent; }
li.alvo { border: 2px dashed #1a5fb4; }
.alca { cursor: grab; padding: 0 0.5rem; }
.visualmente-oculto { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
</style>
Os detalhes que fazem diferença
O foco depois de mover
Quando o Vue reposiciona um elemento com :key, ele move o nó no DOM, e o navegador tira o foco de um elemento que foi movido. Sem a busca em moverComBotao, o Paulo aperta "Subir", o foco cai no começo da página e ele precisa voltar com Tab até a lista. Com ela, o foco fica no botão do item que ele acabou de mover. Se o item chegou ao topo, "Subir" fica desabilitado, e o foco vai para "Descer".
O anúncio
A região aria-live="polite" é lida pelo leitor de tela quando o texto muda, sem tirar o foco do lugar. A frase diz o quê, para onde e de quantos: "Emitir notas movida para a posição 1 de 4". O mesmo texto serve para o arraste, então quem usa leitor de tela com mouse também sabe o que aconteceu.
O alvo visível
Durante o arraste, o item sob o ponteiro ganha uma borda tracejada, não só uma mudança de cor de fundo. Cor sozinha some para quem tem baixa visão ou daltonismo e em tela com contraste ruim.
Firefox e o setData
O Firefox só inicia o arraste nativo se o evento dragstart gravar algum dado com dataTransfer.setData. Por isso a linha existe, mesmo que o componente use o índice guardado em origem.
Como testar com leitor de tela
No Windows, o NVDA é gratuito; no macOS, o VoiceOver já vem instalado. Com o leitor ligado, navegue até a lista com Tab. Ao chegar em "Subir Emitir notas, botão", aperte Enter. Você deve ouvir "Emitir notas movida para a posição 1 de 4" e continuar com o foco num botão do mesmo item. Depois, desligue a rede nas ferramentas do navegador e repita: a lista precisa voltar à ordem anterior e a falha precisa ser lida em voz alta. Se nada for anunciado, confira se a região aria-live já existia na página antes da mudança de texto.
Celular e bibliotecas
Se o público usa muito o celular, o arraste nativo pode não bastar. Bibliotecas baseadas em SortableJS tratam eventos de toque e de ponteiro e têm integração com Vue. Mesmo com elas, mantenha os botões e a função única de mover; a biblioteca só substitui a parte do arraste. O mesmo desenho em Blazor está em arrastar no Blazor sem obrigar o mouse.
Erros comuns
- Arraste como única forma de reordenar. Quem usa teclado, leitor de tela ou tem tremor não consegue mudar a ordem.
- Arraste e botões com lógicas separadas. As duas formas divergem e uma delas grava a ordem errada.
- Botões com nome só "Subir". O leitor de tela anuncia doze "Subir" iguais e ninguém sabe qual item move.
- Perder o foco depois de mover. Cada movimento obriga a navegar de novo até a lista.
- Manter a nova ordem na tela quando o servidor falha. A tela mente até o próximo recarregamento.
- Item inteiro arrastável no celular. Rolar a página vira arrastar sem querer.
Checklist
- Todo item pode ser movido só com teclado.
- Os botões têm nome que inclui o título do item.
- O foco permanece no item movido.
- A nova posição é anunciada em texto.
- O arraste e os botões chamam a mesma função.
- Falha ao gravar devolve a ordem anterior e avisa.
- O alvo do arraste é indicado por forma, não só por cor.
- A ordem continua a mesma depois de recarregar a página.
Para testar o teclado em qualquer componente, vale o roteiro de evento de mouse não cobre o teclado. Abra a sua lista reordenável, guarde o mouse e mova o terceiro item para o topo usando só Tab, Enter e Espaço. Você conseguiu, o foco ficou no item, e a nova ordem sobreviveu ao recarregar a página?
Referência: https://vueschool.io/articles/vuejs-tutorials/how-do-i-drag-and-drop-in-vue/