Programação Por Equipe DevBras

Capa do artigo: Reordenar lista no Vue com arrastar e soltar e também pelo teclado

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

  1. Uma função mover(de, para) altera o array. Arraste e botões só chamam essa função.
  2. Uma alça visível inicia o arraste, para o resto do item continuar selecionável e rolável.
  3. 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.
  4. Depois de mover pelo botão, o foco volta para o mesmo botão do item movido, na nova posição.
  5. Uma região aria-live anuncia "Emitir notas movida para a posição 1 de 4".
  6. 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?

Avalie este artigo

Referência: https://vueschool.io/articles/vuejs-tutorials/how-do-i-drag-and-drop-in-vue/