Criar widget acessível de upload de arquivos com Web Components
Use um botão nativo e um campo de seleção de arquivo dentro de um elemento personalizado para
selecionar um arquivo e passar seu objeto File para sua aplicação.
Este widget de upload de arquivos mantém seus estilos no Shadow DOM, verifica um limite de
10 MiB e fornece feedback sobre a seleção por meio de uma região de status. A aplicação host
cuida do upload; este exemplo não faz requisições de rede.
Por que usar Web Components em widgets de upload de arquivos?
Escolha um elemento personalizado quando várias aplicações precisarem do mesmo controle de seleção sem compartilhar um framework de interface. O Shadow DOM mantém os estilos internos separados, enquanto um evento DOM oferece uma interface pequena para o host. Se você só precisa de um campo estilizado em um formulário, um campo nativo de seleção de arquivo é mais simples.
Configurar a estrutura do Web Component
Você precisa de um navegador com Custom Elements e Shadow DOM, um editor de texto e um arquivo local para teste. A demonstração em HTML puro não precisa de gerenciador de pacotes, servidor ou etapa de build. O comportamento do teclado e do seletor de arquivos foi testado no Chromium 145 no Linux; antes de disponibilizar a solução, teste as combinações de navegador e tecnologia assistiva usadas pelo seu público.
Crie um diretório no seu editor. Salve o componente completo abaixo como
file-upload-widget.js dentro dele:
class FileUploadWidget extends HTMLElement {
static observedAttributes = ['disabled'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
this.addEventListeners();
this.ready = true;
}
connectedCallback() {
this.syncDisabled();
}
attributeChangedCallback() {
this.syncDisabled();
}
syncDisabled() {
const disabled = this.hasAttribute('disabled');
this.shadowRoot.querySelector('button').disabled = disabled;
this.shadowRoot.querySelector('input').disabled = disabled;
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
border: 2px dashed var(--border-color, #ccc);
padding: 20px;
text-align: center;
color-scheme: light dark;
background-color: Canvas;
color: CanvasText;
}
:host([hidden]) { display: none; }
button:focus-visible {
outline: 2px solid Highlight;
outline-offset: 4px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
button {
max-width: 100%;
white-space: normal;
padding: 12px;
}
.file-info {
margin-top: 10px;
font-size: 0.9em;
overflow-wrap: anywhere;
}
</style>
<input type="file" id="fileInput" hidden />
<button type="button" aria-describedby="fileHint">Choose a file</button>
<p id="fileHint">Maximum file size: 10 MiB.</p>
<p class="file-info" id="fileInfo" role="status" aria-atomic="true"></p>
`;
}
addEventListeners() {
const fileInput = this.shadowRoot.querySelector('#fileInput');
const fileInfo = this.shadowRoot.querySelector('#fileInfo');
this.shadowRoot.querySelector('button').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
if (this.hasAttribute('disabled')) {
fileInput.value = '';
return;
}
const file = fileInput.files[0];
if (file) {
if (file.size > 10 * 1024 * 1024) {
fileInfo.textContent = 'File too large (max 10 MiB). Please select another file.';
fileInput.value = ''; // Clear the invalid selection
return;
}
fileInfo.textContent = `Selected file: ${file.name}`;
this.dispatchEvent(new CustomEvent('file-selected', {
detail: { file },
bubbles: true,
composed: true
}));
fileInput.value = ''; // Allow the same file to be selected again.
} else {
fileInfo.textContent = '';
}
});
}
}
customElements.define('file-upload-widget', FileUploadWidget);
O construtor cria a árvore de sombra e instala os listeners uma única vez. O callback de conexão apenas sincroniza o estado desabilitado, então remover e reconectar o mesmo elemento preserva seu feedback sem adicionar listeners. O atributo observado também trata alterações posteriores; consulte a documentação do MDN sobre o ciclo de vida de elementos personalizados.
Salve esta página como index.html no mesmo diretório:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web component file selection</title>
<script src="file-upload-widget.js" defer></script>
<script src="demo.js" defer></script>
</head>
<body>
<main>
<h1>Select one file</h1>
<p id="basic-picker">
<label for="basic-file">Choose a file</label>
<input type="file" id="basic-file" name="file" />
</p>
<file-upload-widget id="picker" hidden></file-upload-widget>
<p id="received">No file received by the host.</p>
<noscript><p>The basic input selects a file; this demo does not upload it.</p></noscript>
</main>
</body>
</html>
Salve o código que recebe o evento como demo.js:
const widget = document.querySelector('#picker');
const basicPicker = document.querySelector('#basic-picker');
const received = document.querySelector('#received');
widget.addEventListener('file-selected', (event) => {
const { file } = event.detail;
window.selectedFile = file;
received.textContent = `Host received: ${file.name} (${file.size} bytes).`;
});
// Keep the basic input available if the component failed to load or initialize.
if (widget.ready === true) {
basicPicker.hidden = true;
widget.hidden = false;
}
Abra index.html no navegador. Pressione Tab para chegar a
Choose a file,
depois Enter ou Espaço para abrir o seletor nativo. Escolha um arquivo de no máximo 10 MiB.
O widget mostra Selected file: seguido do nome do arquivo,
e o host mostra Host received:
seguido do nome e da contagem de bytes.
window.selectedFile contém o File real para o host utilizar.
Implementar a navegação por teclado e o gerenciamento de foco
O botão nativo trata Enter e Espaço, então não precisa de um handler personalizado de keydown nem de um papel de botão. Clicar nele abre o campo oculto durante a ativação pelo usuário. Mantenha o contorno de foco visível e verifique se o foco retorna ao botão após selecionar um arquivo ou cancelar o seletor. O padrão de botão da WAI descreve esse comportamento esperado do teclado.
Adicione o atributo booleano disabled a file-upload-widget
para desabilitar os dois controles nativos. Remova o atributo para reabilitá-los;
disabled="false" ainda desabilita o elemento porque o atributo está presente.
Se sua aplicação desabilitar ou remover o controle que está com foco, mova o foco para um controle
útil que permaneça no host. Reconectar um nó não restaura, por si só, o foco perdido.
Adicionar atributos ARIA para oferecer suporte a leitores de tela
O texto visível do botão fornece seu nome acessível. aria-describedby="fileHint" associa
esse botão ao limite de tamanho dentro da mesma raiz de sombra. A
região role="status", separada e inicialmente vazia,
tem semântica de região dinâmica com anúncios não interruptivos;
aria-atomic="true" solicita a mensagem atualizada inteira. Atualizar o texto não move
o foco para a região de status.
Essa semântica oferece suporte a tecnologias assistivas, mas o feedback visível e uma árvore de acessibilidade não comprovam que houve um anúncio por voz. Na combinação testada de Orca 46.1 e Chromium 145 no Linux, o anúncio automático da seleção após o retorno do seletor nativo foi intermitente. Selecionar novamente o mesmo arquivo também produz texto idêntico, que um leitor de tela pode não anunciar de novo. Mantenha o feedback visível e execute as verificações com leitores de tela abaixo nas combinações usadas pelo seu público.
Tratar a seleção e a validação de arquivos
O listener de change lê o File selecionado,
escreve seu nome como texto em vez de HTML e emite um evento file-selected
para uma seleção aceita. event.detail.file é o arquivo, não seu caminho nem uma
cópia reconstruída. O evento se propaga e é composto, então um host fora de outra raiz de sombra
também pode recebê-lo.
O limite é de 10 × 1.024 × 1.024 bytes. Arquivos vazios e arquivos com tamanho igual ou inferior ao limite são aceitos. Um arquivo maior exibe File too large (max 10 MiB). Please select another file. e não emite nenhum evento de seleção. O arquivo anterior do host permanece intacto. O campo é limpo após a aceitação, a rejeição ou uma seleção ignorada enquanto o controle está desabilitado, então escolher o mesmo arquivo após reabilitar o controle pode produzir outro evento. Cancelar o seletor não produz uma nova seleção.
A validação no lado do cliente fornece feedback; seu servidor de upload deve validar tamanho e
conteúdo de forma independente e aplicar a autorização e as cotas de armazenamento. Este
componente não oferece arrastar e soltar, progresso de upload nem envio de formulário. Limpar seu
campo também significa que um formulário que o contenha não enviará o arquivo selecionado
automaticamente. Use o File do evento no código de upload do seu host.
Personalizar o estilo com propriedades personalizadas do CSS
A borda do componente usa var(--border-color, #ccc). Para sobrescrevê-la, coloque esta
regra em um elemento style no cabeçalho de index.html;
ela alcança o componente sem selecionar elementos dentro da árvore de sombra:
file-upload-widget {
--border-color: #007bff;
}
A demonstração usa cores do sistema para canvas, texto e foco com color-scheme: light dark.
Verifique o contraste e o indicador de foco do seu tema ao adicionar estilos. Nomes de arquivo
longos quebram em várias linhas em vez de forçar o controle a ficar mais largo que seu contêiner.
Aplicar melhoria progressiva e estratégias alternativas
O HTML inicial mostra um campo nativo com rótulo e mantém o widget aprimorado oculto. Somente após
o construtor terminar e o script receptor estar pronto, demo.js faz a troca.
Se o JavaScript estiver desabilitado, o download de qualquer um dos scripts falhar ou o componente
não conseguir inicializar, o campo básico permanece disponível. Isso cobre mais situações do que
apenas uma alternativa com noscript.
O campo básico apenas seleciona um arquivo: ele não tem validação de tamanho, handler de eventos do host nem endpoint de upload. Para um formulário que precise ser enviado sem JavaScript, conecte esse campo nativo ao seu formulário multipart real e ao servidor. Não o oculte até que o substituto consiga realizar a operação.
Testar a acessibilidade com leitores de tela
Teste com as combinações de navegador e leitor de tela que você oferece suporte, como NVDA ou JAWS no Windows, VoiceOver no macOS e Orca no Linux. A verificação automatizada no Linux feita aqui não é um estudo de usabilidade com pessoas usando leitores de tela e não comprova acessibilidade universal. Verifique:
- Tab chega a Choose a file uma única vez, com um indicador de foco visível.
- Enter e Espaço abrem o seletor de arquivos real. A seleção e o cancelamento devolvem o foco ao botão.
- O botão tem nome, papel de botão e descrição do limite de tamanho. Verifique o que é realmente anunciado, inclusive se a descrição exige o comando de ajuda do leitor de tela.
- Um novo nome de arquivo e a mensagem de arquivo acima do limite ficam disponíveis para o leitor de tela. Verifique os anúncios automáticos separadamente da leitura manual do feedback, inclusive ao repetir a seleção.
- Desabilitar e reabilitar o controle funciona. Removê-lo e reconectá-lo preserva o feedback sem duplicar eventos; o host recupera o foco quando remove um controle que está com foco.
- Com o JavaScript desabilitado e com um script de componente com problema, o campo básico ainda seleciona um arquivo.
Integrar com um framework
Registre file-upload-widget.js uma única vez no navegador antes de montar sua aplicação,
depois receba file-selected e leia event.detail.file no host.
Mantenha a renderização no servidor e o transporte do upload fora do componente. Estes são pontos
de integração para uma aplicação existente, em vez de tutoriais separados de projetos com frameworks:
- No React 19, elementos personalizados oferecem suporte a propriedades e handlers de eventos personalizados. Preserve o hífen e as maiúsculas e minúsculas do evento ao vincular um evento personalizado, ou instale um listener DOM por meio de uma ref e remova esse mesmo listener quando o wrapper for desmontado.
- No Vue, marque esta tag como um elemento personalizado com
compilerOptions.isCustomElementna sua configuração de build do Vue. Definirapp.config.compilerOptionssó funciona com a compilação de templates no navegador, não com componentes de arquivo único pré-compilados. Um listener no template recebe o evento DOM. - O
CUSTOM_ELEMENTS_SCHEMAdo Angular permite elementos personalizados com hífen no nome. Adicione-o aoschemasdo componente independente que usa o elemento, ou aoschemasdo NgModule que o declara em uma aplicação baseada em módulos. Vincule o evento DOM no template desse componente e trate seuFilena aplicação.
Conectar o host a um serviço de upload
Substitua o handler receptor da demonstração pela operação de upload da sua aplicação e forneça feedback próprio para o sucesso ou a falha do upload. Selecionar um arquivo não comprova que um servidor o aceitou. O Robot /upload/handle da Transloadit é uma leitura complementar sobre ingestão de arquivos no lado do servidor; ele não é um endpoint que possa ser usado diretamente nesta demonstração de seleção local.
