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
- Ao mudar o input (
change), leiainput.files[0]. - 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.
- Se o arquivo passar do limite, escreva o motivo com o tamanho real e limpe o input.
- Se estiver certo, mostre nome e tamanho, troque o texto do rótulo para "Trocar arquivo" e mostre o botão de remover.
- 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
accepte no limite do JavaScript: qualquer requisição montada fora do navegador passa direto.
Checklist
- Existe um
input type="file"real, ligado a umlabel. - O input não usa
display: nonenemvisibility: 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?
Referência: http://www.scriptbrasil.com.br/forum/topic/169007-d%C3%BAvida-input-file-personalizado/