Destino Dev

Blog

Web Components semânticos para formulários: labels, validação e acessibilidade

Por John Pires · 11 de março de 2026

Web Components semânticos para formulários: labels, validação e acessibilidade

1. Motivação e trade-offs

Formulários são a espinha dorsal de muitas aplicações web. Componentizar controles de formulário com Web Components parece óbvio: encapsulamento, reutilização e estilo consistente. Porém, quando você introduz Shadow DOM, templates e APIs novas como ElementInternals, surgem problemas semânticos: labels deixam de apontar automaticamente para inputs internos, validação nativa pode parar de funcionar e leitores de tela perdem o contexto do campo.

Esse artigo foca em padrões práticos para construir componentes de formulário que preservem semântica e acessibilidade. Não é uma introdução ao básico de Web Components — partimos do pressuposto que você já conhece Custom Elements e Shadow DOM. O objetivo é apresentar decisões arquiteturais, alternativas e um caminho testado para componentes reais.

Problema central

Resumo do problema: quando um <label> aponta para um <input id="x">, o navegador sabe mapear clique, foco, e o nome acessível. Dentro do Shadow DOM esse vínculo quebra porque o input está fora do contexto do label. Além disso, quando usamos componentes customizados, precisamos mapear estado e valor para que formulários e validação nativos continuem a funcionar.

Trade-offs

Você tem basicamente três caminhos:

  • Não usar Shadow DOM e renderizar o controle no light DOM — fácil para semântica, perde encapsulamento.
  • Usar Shadow DOM e replicar semântica com aria-* e manipulação manual de foco e labels — mais código, boa compatibilidade, potencialmente frágil.
  • Usar Web Components com ElementInternals (form-associated custom elements) — integração nativa com formulários e API de validação, mas suporte de browser must be checked.

Em resumo: para componentes simples e maximum compatibility, às vezes o light DOM é a solução mais robusta; para controles avançados e encapsulados, prefira form-associated custom elements e providencie fallbacks.

Dica: sempre comece listando requisitos semânticos (labelable, focável, nome acessível, validação) antes de escolher Shadow DOM ou light DOM para um novo componente.

2. Fundamentos técnicos

Antes de ir para exemplos práticos, descrevo os conceitos essenciais que você precisa dominar para não quebrar semântica:

Labeling e Nome Acessível

O mecanismo padrão que conecta <label for="id"> e input funciona no DOM light. Para componentes que usam Shadow DOM, a solução é explicitar o nome acessível usando:

  • aria-labelledby apontando para um <label> fora
  • aria-label para nomes curtos
  • ou fornecer propriedades JS que permitam binding do label ao controle interno

Leitores de tela usam Accessible Name Computation — se você fornecer aria-labelledby apontando para o id do label externo, a maioria dos ATs (Assistive Technologies) consegue ler corretamente.

Form association e validação

O padrão de form-associated custom elements permite que um Custom Element se comporte como controle de formulário: participe do submit, receba reset e exponha valor para APIs de constraint validation. Para isso usamos ElementInternals:

  • this.attachInternals() para obter uma instância de ElementInternals
  • internals.setFormValue(value) para enviar valor no submit
  • internals.setValidity() para integrar validação

Caveat: nem todos os navegadores suportam isso ainda (fique atento ao caniuse). Tenha fallback para navegadores sem suporte.

Foco e interações

Shadow DOM impede que um clique no label foque o input interno. Contornos:

  • Interceptar clique no próprio elemento personalizado e então focar o controle interno via JS.
  • Usar delegatesFocus ao criar Shadow DOM para delegar foco ao primeiro elemento focável no shadow root.

Esses padrões garantem que clicar no <label> continue confortável e previsível.

3. Exemplo 1: caso simples

Objetivo: criar um componente leve que funcione bem com labels sem usar Shadow DOM — escolha pragmática quando semântica e compatibilidade são prioridade.

Como funciona antes do código

Estratégia: o componente cria um <input> dentro do seu conteúdo (light DOM), garante um id estável e expõe propriedades para leitura/escrita do valor. Como o input fica no light DOM, um <label for="..."> continua funcionando.

Prós: sem necessidade de ARIA adicional; comportamento nativo preservado. Contras: menos encapsulamento de estilo.

Abaixo está a implementação mínima.

<!-- wc-text-light.js: cria um componente que renderiza input no light DOM -->
class WcTextLight extends HTMLElement {
  constructor() {
    super();
    this._input = null;
    this._id = this.getAttribute('id') || `wc-text-${Math.random().toString(36).slice(2,8)}`;
  }

  connectedCallback() {
    if (!this.querySelector('input')) {
      this._input = document.createElement('input');
      this._input.type = this.getAttribute('type') || 'text';
      this._input.id = this._id;
      this._input.name = this.getAttribute('name') || '';
      this.appendChild(this._input);
    } else {
      this._input = this.querySelector('input');
      this._input.id = this._id;
    }

    // proxy value
    Object.defineProperty(this, 'value', {
      get: () => this._input.value,
      set: v => { this._input.value = v; }
    });
  }
}
customElements.define('wc-text-light', WcTextLight);

O que esse bloco faz: registra o componente <wc-text-light>, cria ou atualiza um input no light DOM e expõe a propriedade value. Como o input está no light DOM, qualquer label for="id" que aponte para o id gerado funciona normalmente.

Uso no HTML:

<label for="myname">Nome</label>
<wc-text-light id="myname" name="username"></wc-text-light>

<!-- O label foca e o form envia o valor normalmente -->

O que acontece: semântica preservada, foco e envio do formulário se comportam como esperado. Limitação: o componente não participa da API de validação se você precisar de integrações mais profundas (como custom validity messages automatizados).

4. Exemplo 2: caso avançado do mundo real

Objetivo: criar um componente encapsulado em Shadow DOM que seja labelable, participe do submit e suporte validação customizada. Estratégia: usar form-associated custom element via ElementInternals, mapear nome acessível com aria-labelledby e tratar foco manualmente.

Antes do código: decisões

Requisitos:

  • Encapsulamento visual (Shadow DOM)
  • Participar do submit
  • Ser labelable e focável quando um <label for="..."> aponta para o componente
  • Expor validação nativa

Decisão técnica: usar static formAssociated = true; e attachInternals(). Para labels, confiaremos em aria-labelledby apontando para o id do label externo, e também escutaremos eventos de click no próprio custom element para encaminhar foco ao input interno.

Implementação abaixo (funcional em navegadores que suportam form-associated custom elements).

<!-- wc-text-advanced.js: form-associated custom element com Shadow DOM -->
class WcTextAdvanced extends HTMLElement {
  static formAssociated = true; // important

  constructor() {
    super();
    this._internals = this.attachInternals();
    this._shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });

    this._input = document.createElement('input');
    this._input.type = this.getAttribute('type') || 'text';
    this._input.id = 'inner';

    const style = document.createElement('style');
    style.textContent = `:host { display: inline-block; } input { font: inherit; }`;

    this._shadow.appendChild(style);
    this._shadow.appendChild(this._input);

    this._onInput = this._onInput.bind(this);
    this._onClick = this._onClick.bind(this);
  }

  connectedCallback() {
    this._input.addEventListener('input', this._onInput);
    this.addEventListener('click', this._onClick); // forward click from external label

    // if host has id, use it for external <label for>
    if (!this.id) this.id = this.getAttribute('id') || `wc-adv-${Math.random().toString(36).slice(2,8)}`;

    // if aria-labelledby is not provided, try to link automatically to first preceding label
    if (!this.hasAttribute('aria-labelledby')) {
      const label = document.querySelector(`label[for="${this.id}"]`);
      if (label) this.setAttribute('aria-labelledby', label.id || (label.id = `${this.id}-lbl`));
    }

    // initial value
    const initial = this.getAttribute('value') || '';
    this._input.value = initial;
    this._internals.setFormValue(this._input.value);
  }

  disconnectedCallback() {
    this._input.removeEventListener('input', this._onInput);
    this.removeEventListener('click', this._onClick);
  }

  _onInput() {
    // propagate to form
    this._internals.setFormValue(this._input.value);
    // example of constraint validation: required
    if (this.hasAttribute('required') && !this._input.value) {
      this._internals.setValidity({ valueMissing: true }, 'Preencha este campo', this._input);
    } else {
      this._internals.setValidity({});
    }
  }

  _onClick(e) {
    // se o usuário clicou no label externo, focar o input interno
    this._input.focus();
  }

  // reflect value property
  get value() { return this._input.value; }
  set value(v) { this._input.value = v; this._internals.setFormValue(v); }
}
customElements.define('wc-text-advanced', WcTextAdvanced);

O que esse bloco faz: cria um componente encapsulado que participa do submit via internals.setFormValue, trata validação básica e tenta associar automaticamente um label externo. Quando o label tem for="id-do-componente", o componente adiciona aria-labelledby apontando para o label.

Uso no HTML com validação:

<form id="f" action="#" method="post" onsubmit="event.preventDefault(); console.log(new FormData(f).get('email'))">
  <label for="email" id="email-label">E-mail</label>
  <wc-text-advanced id="email" name="email" required></wc-text-advanced>
  <button type="submit">Enviar</button>
</form>

O que acontece: ao submeter, o FormData conterá o campo email com o valor do componente. Se vazio e com required, o componente usa setValidity para sinalizar erro. No entanto, lembre-se: o navegador só exibirá mensagens nativas automáticas de validação para controles nativos — para mensagens customizadas, você pode renderizar UI de erro no shadow DOM.

Fallbacks e feature-detection

Nem todo usuário estará em um navegador com suporte a form-associated custom elements. Faça feature-detection:

if ('attachInternals' in HTMLElement.prototype) {
  // podemos usar form-associated pattern
} else {
  // fallback: usar light DOM ou polyfill
}

Proposta prática: em navegadores sem suporte, degrade graciosamente para um componente que injeta um input no light DOM (como no Exemplo 1).

5. Debugging e troubleshooting

Nesta seção compilamos erros comuns, armadilhas e dicas práticas para depurar problemas de semântica, foco e validação.

Erros comuns e armadilhas

1) Label não foca o controle: normalmente isso acontece porque o input está no Shadow DOM e você não redirecionou o foco. Solução: interceptar clique no host e chamar input.focus() ou usar delegatesFocus se aplicável.

2) FormData não inclui o valor: esqueceu de usar internals.setFormValue() em um form-associated element. Alternativa: injete um input hidden no light DOM como fallback.

3) Leitores de tela não anunciam o label: verifique aria-labelledby ou aria-label. Se usar for no label apontando para o id do componente, adicione aria-labelledby no host apontando para esse label.

Dicas práticas de debugging

  1. Use as ferramentas de acessibilidade do navegador (Chrome Accessibility pane, Lighthouse) para inspecionar a Accessible Name Computation.
  2. Teste com um leitor de tela real (NVDA, VoiceOver) — emulação não substitui teste real.
  3. Adicione logs no connectedCallback para garantir que IDs e aria-attributes estão sendo setados.
  4. Verifique o suporte a ElementInternals e construa um fallback simples.
Dica: uma estratégia robusta é implementar ambos: um input no light DOM como fallback e um caminho encapsulado otimizado para browsers modernos. Isso reduz furos de compatibilidade.

Exemplo de fallback que injeta um input hidden caso attachInternals não exista:

if (!('attachInternals' in HTMLElement.prototype)) {
  // in component connectedCallback
  const fallback = document.createElement('input');
  fallback.type = 'hidden';
  fallback.name = this.getAttribute('name') || '';
  fallback.value = this._input ? this._input.value : '';
  this.appendChild(fallback);
}

O que acontece: formulários que não suportam ElementInternals ainda recebem um campo enviado no POST/GET. É uma técnica simples e efetiva para compatibility-first deployments.

Conclusão prática

Construir Web Components semânticos para formulários exige decisões explícitas. Para cada componente, responda: preciso de Shadow DOM? preciso que o navegador trate a validação nativamente? preciso de encapsulamento estrito? Com base nisso escolha entre:

  • Light DOM para máxima compatibilidade e semântica nativa
  • Shadow DOM + aria/manual handlers para componentes interativos simples
  • Form-associated custom elements para integração profunda com formulários (com fallback)

Práticas recomendadas rápidas:

  • Priorize o nome acessível: sempre mostre o label para ATs via aria-labelledby ou aria-label.
  • Teste com ATs: NVDA/VoiceOver e ferramentas de acessibilidade são obrigatórias.
  • Feature-detect: degrade para light DOM ou hidden inputs quando ElementInternals não estiver disponível.
  • Documente a API: exponha propriedades value, métodos focus() e eventos customizados de validação para facilitar integrações.

Implementando esses padrões você terá componentes reutilizáveis, encapsulados e que não sacrificam acessibilidade nem comportamento nativo de formulários. O custo inicial de engenharia compensa em manutenção e consistência de UX.

Assuntos: web-components, acessibilidade, formularios, html-semantico, elementinternals

Leia também