Programação Por Equipe DevBras

Capa do artigo: Campo de arquivo personalizado: escolher, confirmar e trocar sem perder o teclado

Campo de arquivo personalizado: escolher, confirmar e trocar sem perder o teclado

Como trocar o botão padrão de arquivo por um rótulo bonito mantendo o input real: foco visível, nome do arquivo anunciado, erro de tamanho na hora, botão de remover e a mesma regra no servidor.

O formulário de reembolso da empresa tinha um botão verde "Anexar nota fiscal". Era uma div com um clique que disparava um input escondido com display: none. No mouse, funcionava. No teclado, o Tab pulava o botão. No leitor de tela, nada era anunciado. E quem escolhia uma foto de 14 MB só descobria o limite depois de enviar o formulário inteiro e perder o resto do que tinha digitado.

A resposta curta: o botão bonito é o rótulo do input, não um substituto dele. Mantenha um input type="file" real, escondido só visualmente, com um label estilizado apontando para ele. Mostre o foco no rótulo quando o input estiver focado. Depois da escolha, escreva o nome e o tamanho do arquivo numa região anunciada, ofereça trocar e remover e valide o tamanho na hora. No servidor, a regra continua a mesma de sempre.

Por que a div não serve

Um input type="file" traz de graça três coisas que uma div não tem: entra na ordem do Tab, abre o seletor de arquivos com Enter ou Espaço e é anunciado como controle de formulário, com o nome vindo do label. Quando você troca por uma div, precisa recriar tudo isso, e quase sempre recria só o clique.

O display: none piora: ele tira o input da árvore de acessibilidade e da ordem de foco. Se o campo for required, alguns navegadores nem conseguem mostrar a mensagem de obrigatoriedade, porque não há onde focar. O jeito de esconder o input sem esconder o teclado está detalhado em botão de escolher arquivo: esconder o input sem esconder o teclado. Este guia segue daí: o que acontece depois que a pessoa escolhe.

A marcação: input real, rótulo visível, estado anunciado

<div class="campo-arquivo">
  <input type="file" id="nota" name="nota"
         accept=".pdf,image/jpeg,image/png"
         aria-describedby="nota-ajuda nota-status"
         class="visualmente-oculto">
  <label for="nota" class="botao" id="nota-rotulo">Anexar nota fiscal</label>
  <p id="nota-ajuda">PDF, JPG ou PNG, até 5 MB.</p>
  <p id="nota-status" aria-live="polite">Nenhum arquivo escolhido.</p>
  <button type="button" id="nota-remover" hidden>Remover arquivo</button>
</div>

O label for="nota" faz duas coisas: dá nome ao input e repassa o clique para ele. O aria-describedby faz o leitor de tela ler a ajuda e o estado atual quando o foco chega ao campo. O parágrafo com aria-live="polite" já existe desde o carregamento da página, e por isso as mudanças nele são anunciadas. Uma região criada no mesmo instante em que recebe o texto costuma passar em silêncio.

.visualmente-oculto {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.campo-arquivo input:focus-visible + label {
  outline: 3px solid #1a5fb4;
  outline-offset: 2px;
}

O input continua lá, com 1 pixel, recebendo foco. Como ele não aparece, o contorno de foco é desenhado no rótulo vizinho pelo seletor :focus-visible + label. Sem essa regra, quem usa teclado aperta Tab e não vê onde está.

Como tratar a escolha, o erro e a troca

  1. Ao mudar o input (change), leia input.files[0].
  2. Se não houver arquivo, volte ao estado inicial. Dependendo do navegador, cancelar o seletor pode manter ou limpar a escolha anterior, e o código precisa lidar com os dois casos.
  3. Se o arquivo passar do limite, escreva o motivo com o tamanho real e limpe o input.
  4. Se estiver certo, mostre nome e tamanho, troque o texto do rótulo para "Trocar arquivo" e mostre o botão de remover.
  5. Ao remover, limpe o input e devolva o foco para ele.
const input = document.getElementById('nota');
const rotulo = document.getElementById('nota-rotulo');
const statusNota = document.getElementById('nota-status');
const remover = document.getElementById('nota-remover');
const LIMITE = 5 * 1024 * 1024;

function estadoInicial(mensagem) {
  input.value = '';
  rotulo.textContent = 'Anexar nota fiscal';
  statusNota.textContent = mensagem;
  remover.hidden = true;
}

input.addEventListener('change', function () {
  const arquivo = input.files[0];
  if (!arquivo) {
    estadoInicial('Nenhum arquivo escolhido.');
    return;
  }
  if (arquivo.size > LIMITE) {
    const mb = (arquivo.size / 1048576).toFixed(1).replace('.', ',');
    estadoInicial(arquivo.name + ' tem ' + mb + ' MB. O limite é 5 MB. Escolha outro arquivo.');
    return;
  }
  const kb = Math.ceil(arquivo.size / 1024);
  statusNota.textContent = 'Arquivo escolhido: ' + arquivo.name + ' (' + kb + ' KB).';
  rotulo.textContent = 'Trocar arquivo';
  remover.hidden = false;
});

remover.addEventListener('click', function () {
  estadoInicial('Arquivo removido. Nenhum arquivo escolhido.');
  input.focus();
});

No caso da foto de 14 MB, a pessoa ouve e lê na hora: "IMG_2041.jpg tem 14,2 MB. O limite é 5 MB. Escolha outro arquivo." Nada do resto do formulário se perde. O nome do arquivo sai sempre por textContent, nunca por innerHTML, porque o nome é escolhido por quem envia e pode conter qualquer coisa.

Por que devolver o foco ao input depois de remover? Porque o botão "Remover" acabou de ficar oculto. Se o foco estivesse nele, ficaria perdido no documento, e o próximo Tab começaria do topo da página. Para formulários com vários campos, a ideia geral está em foco no campo certo.

Obrigatório sem depender da bolha do navegador

Com o input escondido visualmente, a mensagem nativa de required aparece presa a um ponto de 1 pixel, às vezes num canto estranho. Valide no envio e escreva o erro no mesmo parágrafo de status:

document.querySelector('form').addEventListener('submit', function (e) {
  if (!input.files.length) {
    e.preventDefault();
    statusNota.textContent = 'Anexe a nota fiscal para enviar o pedido de reembolso.';
    input.focus();
  }
});

O servidor não sabe que o botão é bonito

O accept filtra a lista do seletor, mas é só uma sugestão: a pessoa pode escolher "Todos os arquivos", e um script nem passa pelo navegador. O limite de 5 MB no JavaScript é conforto, não proteção. No servidor, confira tipo pelo conteúdo, tamanho e nome do jeito descrito em upload de arquivo em PHP, e lembre que o limite efetivo é o menor entre a sua regra, o PHP e o servidor web, como mostra limite de upload de verdade.

Erros comuns

  • Botão feito de div: some do Tab e do leitor de tela, e o formulário fica impossível sem mouse.
  • Input com display: none: sai da ordem de foco e quebra a mensagem de obrigatoriedade.
  • Esconder sem estilizar o foco do rótulo: o foco existe, mas ninguém vê onde está.
  • Não mostrar o arquivo escolhido: a pessoa não sabe se escolheu a nota certa ou a foto do cachorro.
  • Limite só no servidor, sem aviso antes: a pessoa envia, espera o upload inteiro e perde o que digitou.
  • Confiar no accept e no limite do JavaScript: qualquer requisição montada fora do navegador passa direto.

Checklist

  • Existe um input type="file" real, ligado a um label.
  • O input não usa display: none nem visibility: hidden.
  • O rótulo mostra contorno quando o input recebe foco pelo teclado.
  • Depois da escolha, nome e tamanho aparecem em texto numa região anunciada.
  • Arquivo grande demais gera mensagem com o tamanho real e o limite.
  • Há como trocar e remover o arquivo, e o foco volta ao campo.
  • O servidor aplica tipo e tamanho como se o JavaScript não existisse.

Desligue o mouse, abra o seu formulário e tente anexar um arquivo só com Tab, Espaço e as setas do seletor: no fim, a tela diz qual arquivo você escolheu?

Avalie este artigo

Referência: http://www.scriptbrasil.com.br/forum/topic/169007-d%C3%BAvida-input-file-personalizado/