Destino Dev

Blog

Form-Associated Custom Elements para formulários semânticos e acessíveis

Por John Pires · 16 de março de 2026

Form-Associated Custom Elements para formulários semânticos e acessíveis

Este artigo faz revisão técnica sobre como construir componentes customizados que participam de formulários HTML de forma semântica e acessível usando a API Form-Associated Custom Elements (FAE). O foco é prático: primeiro mostro o jeito errado — frequentemente visto em projetos — e depois corrijo com padrões, validações, fallback progressivo e integração com frameworks reais.

1) Quando e por que usar

1.1 Por que preferir elementos form-associated

Elementos form-associated permitem que um componente customizado se comporte como controles nativos do formulário: participar de submissão, expor valor via FormData, integrar com labels, aplicar constraint validation (required, pattern), responder a disabled/reset e ser reconhecido por leitores de tela quando corretamente implementado. Usar essa API melhora extremamente a semântica sem quebrar a experiência de formulários existentes.

1.2 Quando evitar ou adiar

Se seu componente é apenas um widget visual que não precisa participar de formulários (por exemplo, um decorador visual sem valor) não force form-associated. Também adie quando o suporte do navegador não for crítico e você puder usar um input nativo ou realizar integração via APIs do framework com menor custo.

Use o recurso quando precisar que o componente entregue um valor semântico como parte de uma submissão ou quando quiser reaproveitar validação nativa do navegador.

2) Comparação com alternativas

2.1 Jeito errado: replicar comportamento sem API

O erro mais comum é implementar um componente que só “parece” um input, mas não integra com o formulário. Isso quebra acessibilidade, não envia dados automaticamente e falha em integração com labels e leitores de tela.

Exemplo de erro: um componente que mantém estado interno e dispara um evento personalizado, mas não participa do FormData nem responde a reset/disabled.

<form id="f1" action="/echo" method="post">
  <my-bad-input name="bad"></my-bad-input>
  <button type="submit">Enviar</button>
</form>

<script>
  class MyBadInput extends HTMLElement {
    constructor(){
      super();
      this._value = '';
      this.attachShadow({mode:'open'}).innerHTML = `
        <input type="text"/>
      `;
      this.shadowRoot.querySelector('input').addEventListener('input', e => {
        this._value = e.target.value;
        this.dispatchEvent(new CustomEvent('change'));
      });
    }
    get value(){ return this._value; }
  }
  customElements.define('my-bad-input', MyBadInput);
</script>

O que esse código faz: cria um componente visual com um input interno. O que acontece: o navegador não inclui "bad" no FormData; labels e validação nativa não funcionam corretamente. Resultado: integração frágil e comportamento inesperado.

2.2 Jeito certo: usar form-associated

A alternativa correta é implementar a API form-associated. Isso transforma o elemento em um controle de formulário nativo, permitindo internals.setFormValue para sincronizar com FormData e callbacks para reset/disabled.

<form id="f2" action="/echo" method="post">
  <label for="good">Nome</label>
  <my-good-input id="good" name="good" required></my-good-input>
  <button type="submit">Enviar</button>
</form>

<script>
  class MyGoodInput extends HTMLElement {
    static formAssociated = true; // torna o elemento form-associated
    constructor(){
      super();
      this._internals = this.attachInternals();
      this.attachShadow({mode:'open'}).innerHTML = `
        <input part="native" aria-label="nome"/>
      `;
      this._input = this.shadowRoot.querySelector('input');
      this._input.addEventListener('input', e => this._onInput(e));
    }
    _onInput(e){
      this._value = e.target.value;
      // sincroniza com o FormData
      this._internals.setFormValue(this._value);
    }
    // expõe API pública
    get value(){ return this._value || ''; }
    set value(v){ this._value = v; this._internals.setFormValue(v); this._input.value = v; }
    // callbacks do ciclo de vida do form
    formResetCallback(){ this.value = ''; }
    formDisabledCallback(disabled){ this._input.disabled = disabled; }
  }
  customElements.define('my-good-input', MyGoodInput);
</script>

O que esse código faz: implementa FAE, sincroniza FormData, expõe value e responde a reset/disabled. O que acontece: comportamento consistente com inputs nativos; validação e labels funcionam melhor.

3) Implementação progressiva

3.1 Feature detection e fallback

Nem todos os navegadores suportam ElementInternals/FAE. Implementar fallback evita que a funcionalidade quebre em clientes antigos. Padrão: detectar window.ElementInternals e attachInternals, e criar um input oculto name-synced para submissão quando FAE não estiver disponível.

<!-- Exemplo: estratégia progressiva com fallback para browsers antigos -->
<my-progressive-input name="email" id="email"></my-progressive-input>

<script>
class MyProgressiveInput extends HTMLElement {
  static formAssociated = true; // tenta usar
  constructor(){
    super();
    this.attachShadow({mode:'open'}).innerHTML = `
      <input type="email" part="native"/>
    `;
    this._input = this.shadowRoot.querySelector('input');

    // Fallback: when ElementInternals not available
    if(!('attachInternals' in Element.prototype)){
      this._fallback = document.createElement('input');
      this._fallback.type = 'hidden';
      this._fallback.name = this.getAttribute('name');
      this.appendChild(this._fallback);
      this._input.addEventListener('input', () => this._fallback.value = this._input.value);
    } else {
      this._internals = this.attachInternals();
      this._input.addEventListener('input', () => this._internals.setFormValue(this._input.value));
    }
  }
}
customElements.define('my-progressive-input', MyProgressiveInput);
</script>

O que esse código faz: tenta usar FAE; se não suportado, cria um input hidden para incluir o valor no FormData. O que acontece: a experiência degrade graciosamente sem quebrar a submissão.

3.2 Sincronização de atributos e propriedades

Um erro comum é tratar attrs e props separadamente. Sempre sincronize ambos. Refletir atributos esperados (name, disabled, required) para a implementação garante interoperabilidade com ferramentas que manipulam DOM via atributos.

Exemplo de boas práticas:

// dentro do elemento form-associated
observedAttributes(){ return ['name','disabled','required']; }
attributeChangedCallback(name, oldVal, newVal){
  if(name === 'name' && this._fallback) this._fallback.name = newVal;
  if(name === 'disabled') this._input.disabled = newVal !== null;
  if(name === 'required') this._internals?.setValidity(this._validate() ? {} : {valueMissing: true});
}

O que esse código faz: mantém atributos sincronizados entre fallback, shadow DOM e internals. O que acontece: o componente se comporta de forma previsível quando manipulado pelo DOM ou por frameworks.

4) Integracao com ecossistema real

4.1 Integração com React / JSX

React não sabe automaticamente como lidar com elementos customizados para forms. Evite controlar via value prop como em inputs nativos. Prefira o padrão uncontrolled com refs ou expor eventos (input/change) e valores via propriedades.

Exemplo de uso em React (JSX) — a ideia é usar ref para ler o value antes do submit:

// React: uso do elemento form-associated via ref
function FormReact(){
  const ref = React.useRef(null);
  const onSubmit = (e) => {
    e.preventDefault();
    const fd = new FormData(e.target); // o elemento form-associated deve participar
    // leitura direta via ref quando precisar
    console.log('formData name:', fd.get('username'));
    console.log('value via ref:', ref.current.value);
  };
  return (
    <form onSubmit={onSubmit}>
      <my-good-input ref={ref} name="username" />
      <button type="submit">Enviar</button>
    </form>
  );
}

O que esse código faz: demonstra submissão de um formulário React contendo um elemento form-associated. O que acontece: se o componente implementa FAE corretamente, FormData incluirá o valor; use ref para acessar programaticamente quando necessário.

4.2 Integração com validação e testes e2e

Em testes end-to-end (Cypress/Playwright) assegure rotas de seleção baseadas em attributes ou roles. Exponha atributos data-testid se necessário. Para validação, use internals.setValidity e internals.reportValidity para integrar com APIs nativas e facilitar asserts nos testes.

Boas práticas de testes:

  • Testar submissão com FormData para garantir inclusão do name/valor.
  • Testar comportamento de reset e disabled.
  • Testar interação com labels e foco.

5) Checklist de qualidade para producao

5.1 Validações funcionais

Checklist objetivo:

  1. Tem static formAssociated = true quando necessário?
  2. Usa attachInternals e internals.setFormValue para sincronização?
  3. Responde a formResetCallback e formDisabledCallback?
  4. Expose value property e reflete atributo name?
  5. Implementa constraint validation (setValidity) corretamente?

5.2 Acessibilidade e semântica

Checklist de acessibilidade:

  • Label associado via for ou wrapper e testes com leitores de tela.
  • Uso correto de aria-* apenas quando necessário (não esconda estado nativo).
  • Keyboard interaction consistente: foco, Enter/Escape, etc.

Blockquote:

Priorize o comportamento semântico e deixe o visual para o CSS. Elementos que falham em semântica criam mais dívida técnica que resolver o estilo localmente.

Erros comuns, armadilhas e dicas práticas

Erros comuns

1) Não chamar internals.setFormValue: o valor não entra no FormData. 2) Esquecer de refletir name para fallback hidden input. 3) Não implementar formResetCallback — depois de reset o valor permanece. 4) Não sincronizar atributo disabled com o estado interno — forms nativos dependem disso.

Dicas práticas

1) Sempre exponha uma API pública simples: value, name, checkValidity(), reportValidity(). 2) Use roles e parts para styling e integração com ferramentas de design. 3) Documente quando o elemento exige polyfill e forneça fallback.

Exemplos reais (workflow de code review: errado -> certo)

Exemplo 1: componente sem suporte a labels e sem enviar valor (errado)

Contexto: componente que estiliza input mas não participa do formulário. Problema: não envia dados, label não foca automaticamente.

<my-bad>...</my-bad>

// O que acontece: o form não recebe valor; label[for] não conecta; leitores de tela ignoram o controle.

Exemplo 2: correção completa com form-associated

Contexto: transformar o my-bad em my-good implementando FAE, callbacks e validação.

<form id="signup">
  <label for="email">Email</label>
  <my-email id="email" name="email" required></my-email>
  <button type="submit">Enviar</button>
</form>

<script>
class MyEmail extends HTMLElement{
  static formAssociated = true;
  constructor(){
    super();
    this._internals = this.attachInternals();
    this.attachShadow({mode:'open'}).innerHTML = `
      <input type="email"/>
    `;
    this._input = this.shadowRoot.querySelector('input');
    this._input.addEventListener('input', e => this._sync(e.target.value));
  }
  _sync(v){
    this._value = v;
    this._internals.setFormValue(v);
    // validação básica
    if(this.hasAttribute('required') && !v) {
      this._internals.setValidity({valueMissing:true}, 'Campo obrigatório');
    } else {
      this._internals.setValidity({});
    }
  }
  formResetCallback(){ this._input.value = ''; this._sync(''); }
  formDisabledCallback(disabled){ this._input.disabled = disabled; }
  get value(){ return this._value || ''; }
}
customElements.define('my-email', MyEmail);
</script>

// O que acontece: o valor participa do FormData; required é respeitado e reportado; label foca o controle.

Esse trecho corrige os problemas listados: o elemento agora é um participante completo do formulário.

Conclusão prática

Form-associated custom elements permitem que Web Components atuem como controles nativos, garantindo semântica, acessibilidade e integração com o ecossistema. A regra de ouro em code review é: se o controle aparenta ser um input, ele deve se comportar como tal. Se faltar algum comportamento nativo (submissão, validação, reset, disabled, associação com label), trate como bug crítico.

Checklist rápido final:

  • Use static formAssociated = true quando o componente tiver valor semântico.
  • Chame attachInternals e internals.setFormValue para sincronizar FormData.
  • Implemente formResetCallback e formDisabledCallback.
  • Forneça fallback com input hidden quando FAE não for suportado.
  • Exponha API pública simples (value, checkValidity, reportValidity).

Implementar corretamente reduz bugs, facilita testes e melhora acessibilidade. Em revisões de código, trate qualquer controle de formulário customizado como parte crítica da camada de dados — falhas aqui causam perda de conversão e problemas de conformidade de acessibilidade.

Conclua: não se contente com um visual correto; garanta comportamento semântico. Em produção, foque em testes de integração que validem FormData, reset, disabled e interações com labels.

Assuntos: web-components, forms, acessibilidade, html-semantico, form-associated, HTML, React

Leia também