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.
- 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. - 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. - 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.
- Ofereça cancelar. Com
AbortController, o botão Cancelar interrompe ofetche a lista anterior continua na tela. - Ignore respostas velhas. Uma busca nova cancela a anterior, e só a busca mais recente pode mexer na tela.
- 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.
Referência: http://carlos.inf.br/2012/07/27/jquery-waiting-deixando-o-tempo-carregamento-menos-constrangedor/