Programação Por Equipe DevBras

Capa do artigo: Indicador de carregamento no lugar certo: sem cortina na página inteira

Indicador de carregamento no lugar certo: sem cortina na página inteira

Troque a tela bloqueada por um indicador na região que está recarregando, com texto para leitor de tela, opção de cancelar e garantia de que ele some no erro.

A atendente troca o filtro de pedidos de "Pendentes" para "Enviados" e a tela inteira escurece, com um spinner no meio. Durante quatro segundos ela não consegue ler o pedido que estava conferindo, não consegue cancelar e não consegue usar o teclado. Quando a rede falha, a cortina às vezes nem sai. A resposta curta: o indicador de espera fica onde a ação aconteceu, ou seja, na lista que está recarregando, com um texto que diz o que está acontecendo, um botão para cancelar se a operação puder demorar e a garantia de que ele some tanto no sucesso quanto no erro.

Cortina sobre a página inteira só faz sentido quando a página toda vai ser substituída e não existe nada útil para fazer no meio do caminho. Filtrar, paginar, salvar um campo e atualizar um painel não entram nessa categoria.

Por que a cortina global parece segura e não é

A cortina nasce de um medo legítimo: a pessoa clicar duas vezes, mudar o filtro no meio da requisição e a resposta antiga sobrescrever a nova. Bloquear tudo resolve esse problema da forma mais cara possível. Você troca uma condição de corrida por uma tela inutilizável.

O custo aparece em três lugares. Primeiro, a leitura: o conteúdo que já estava na tela continua válido por alguns segundos, e esconder isso não ajuda ninguém. Segundo, o controle: sem um caminho para cancelar, a única saída de uma busca lenta é recarregar a página e perder o que estava preenchido. Terceiro, o estado: uma cortina controlada por um contador global de requisições quebra fácil. Basta uma requisição que termina com erro sem passar pelo código que decrementa o contador, e o spinner fica para sempre. Indicador que nunca some é o bug mais silencioso da interface, porque o servidor respondeu e o log não acusa nada.

Há ainda quem não enxerga o spinner. Uma animação girando não diz nada para leitor de tela. Se o único sinal de "carregando" é visual, parte das pessoas simplesmente não sabe que precisa esperar.

Como trocar a cortina por um indicador local

O exemplo a seguir é a tela de pedidos da atendente: um formulário de filtro, uma lista e uma linha de status. A lista continua visível durante a busca, com a opacidade reduzida, e a linha de status fala em texto.

  1. Marque a região, não a página. Use aria-busy="true" na lista enquanto ela recarrega e um estilo visual discreto para essa marca.
  2. Diga em texto. Uma região com role="status" recebe frases como "Carregando pedidos..." e "12 pedidos encontrados". Leitores de tela anunciam mudanças nessa região sem roubar o foco.
  3. Espere um pouco antes de mostrar. Respostas que chegam em menos de 300 ms não precisam de indicador. Mostrar e esconder em um piscar de olhos incomoda mais do que ajuda.
  4. Ofereça cancelar. Com AbortController, o botão Cancelar interrompe o fetch e a lista anterior continua na tela.
  5. Ignore respostas velhas. Uma busca nova cancela a anterior, e só a busca mais recente pode mexer na tela.
  6. Tire o indicador no finally. Sucesso, erro ou cancelamento, o caminho de saída é o mesmo.

O HTML da tela de pedidos

<form id="filtro">
  <label for="status">Status</label>
  <select id="status" name="status">
    <option value="pendente">Pendentes</option>
    <option value="enviado">Enviados</option>
  </select>
  <button type="submit">Filtrar</button>
  <button type="button" id="cancelar" hidden>Cancelar busca</button>
</form>

<p id="aviso" role="status"></p>

<section id="lista" aria-label="Pedidos">
  <ul></ul>
</section>
#lista[aria-busy="true"] {
  opacity: 0.6;
  transition: opacity 0.2s;
}

O JavaScript que liga e desliga o indicador

const form = document.getElementById('filtro');
const lista = document.getElementById('lista');
const aviso = document.getElementById('aviso');
const cancelar = document.getElementById('cancelar');
let controle = null;

function desenharLista(pedidos) {
  const itens = pedidos.map((pedido) => {
    const li = document.createElement('li');
    li.textContent = '#' + pedido.id + ' - ' + pedido.cliente;
    return li;
  });
  lista.querySelector('ul').replaceChildren(...itens);
}

form.addEventListener('submit', async (evento) => {
  evento.preventDefault();
  if (controle) controle.abort();
  const meu = new AbortController();
  controle = meu;

  const status = form.elements.status.value;
  const atraso = setTimeout(() => {
    lista.setAttribute('aria-busy', 'true');
    aviso.textContent = 'Carregando pedidos...';
    cancelar.hidden = false;
  }, 300);

  try {
    const resposta = await fetch('/api/pedidos?status=' + encodeURIComponent(status), { signal: meu.signal });
    if (!resposta.ok) throw new Error('HTTP ' + resposta.status);
    const pedidos = await resposta.json();
    desenharLista(pedidos);
    aviso.textContent = pedidos.length + ' pedidos encontrados.';
  } catch (erro) {
    if (controle !== meu) return;
    aviso.textContent = erro.name === 'AbortError'
      ? 'Busca cancelada. A lista anterior continua na tela.'
      : 'Não foi possível carregar os pedidos. Tente de novo.';
  } finally {
    clearTimeout(atraso);
    if (controle === meu) {
      lista.removeAttribute('aria-busy');
      cancelar.hidden = true;
      controle = null;
    }
  }
});

cancelar.addEventListener('click', () => {
  if (controle) controle.abort();
});

Repare em dois detalhes. A variável meu guarda o controlador desta busca específica, e o bloco finally só mexe na tela se esta ainda for a busca atual. Assim, uma busca cancelada por outra mais nova não apaga o indicador da nova. E o return dentro do catch não pula o finally: em JavaScript ele sempre roda, o que faz dele o lugar certo para desligar o indicador.

Se a operação for um salvamento, e não uma leitura, o indicador vai no botão que salvou. O post sobre o botão que mostra que está esperando cobre o clique duplo nesse caso. Para busca enquanto digita, a espera começa antes do fetch, como explica o guia de busca que espera a pessoa pausar.

Quando bloquear a página é a escolha certa

Existe um caso legítimo: quando a próxima tela depende inteiramente da resposta e não há nada a ler ou fazer antes dela. Finalizar um pagamento é o exemplo clássico. Mesmo assim, o bloqueio precisa de texto ("Confirmando o pagamento, não feche esta janela"), de um tempo máximo e de uma saída quando esse tempo estoura. Requisição sem limite de tempo é outra fonte de spinner eterno, e o guia sobre timeout antes do infinito mostra como colocar um teto nela.

Se você bloquear, bloqueie de verdade: o atributo inert num contêiner tira os elementos dele da navegação por teclado e do clique. Um overlay só visual deixa o Tab passear por baixo da cortina, o que é pior do que não ter cortina.

Erros comuns

  • Contador global de requisições controlando o spinner: uma requisição que falha sem decrementar o contador deixa a tela travada até a pessoa recarregar.
  • Esconder o indicador só no caminho de sucesso: no primeiro erro de rede, o "carregando" fica para sempre e a pessoa acha que o sistema está lento, não quebrado.
  • Spinner sem texto: quem usa leitor de tela não recebe aviso de espera nem de conclusão, e tenta agir sobre uma lista que ainda vai mudar.
  • Apagar a lista antes da resposta chegar: a pessoa perde o que estava lendo e, se a busca falhar, fica olhando para uma tela vazia que parece "nenhum resultado".
  • Deixar respostas antigas sobrescreverem as novas: sem cancelar a busca anterior, a resposta lenta de "Pendentes" pode chegar depois da de "Enviados" e mostrar o filtro errado.
  • Overlay visual sem inert: o mouse para, mas o teclado continua acionando botões escondidos atrás da cortina.

Checklist

  • O indicador aparece na região que muda, não na página inteira.
  • Existe uma frase de status em texto, numa região com role="status".
  • Respostas rápidas não fazem o indicador piscar.
  • Operações que podem passar de alguns segundos têm botão de cancelar.
  • Uma busca nova cancela a anterior e só a mais recente atualiza a tela.
  • O indicador é removido num bloco finally, também no erro e no cancelamento.
  • Com a rede desligada no DevTools, a tela mostra uma mensagem de erro e o indicador some.

Abra a tela mais usada do seu sistema, troque um filtro e, enquanto ela carrega, tente ler o item que estava aberto e apertar Tab. Dá para continuar usando a página enquanto a lista recarrega? Se não dá, a cortina está grande demais.

Avalie este artigo

Referência: http://carlos.inf.br/2012/07/27/jquery-waiting-deixando-o-tempo-carregamento-menos-constrangedor/