12k
All articles

Construa Controles de Formulário Reutilizáveis com Web Components

Crie um controle de formulário reutilizável com Web Components e ElementInternals. Adicione envio, validação, rótulos, reset e restauração nativos.

OpenReplay Team
OpenReplay Team
Construa Controles de Formulário Reutilizáveis com Web Components

Um elemento personalizado que envolve um <input> dentro de um Shadow DOM é invisível para o <form> pai: seu valor nunca chega ao FormData, a validação nativa o ignora e um reset não o afeta. A solução são duas linhas — static formAssociated = true mais this.attachInternals() no construtor — e a API ElementInternals que essas linhas desbloqueiam. Este artigo constrói um controle <text-field> completo que envia, valida, rotula, reseta e restaura como um input nativo, usando apenas a API da plataforma.

Com o lançamento do Safari 16.4 em março de 2023, os web components atingiram um marco em sua capacidade de interagir com o elemento de formulário usando a API ElementInternals; antes disso, elementos input localizados dentro de um Shadow DOM não eram detectáveis pelos formulários, o que significava que não seriam validados no envio e seus dados não seriam incluídos no objeto FormData. Essa questão de suporte está agora resolvida, portanto o artigo ignora a preocupação do “posso usar isso ainda?” e foca na receita completa que nenhum dos tutoriais mais antigos monta de ponta a ponta.

Principais Conclusões

  • Duas linhas transformam qualquer elemento personalizado autônomo em um controle de formulário: static formAssociated = true declara a intenção, e this.attachInternals() retorna o objeto ElementInternals que conecta seu elemento ao formulário pai.
  • A partir de setembro de 2025, os elementos personalizados associados a formulários são Baseline Amplamente Disponíveis — suportados no Chrome desde a versão 77, Edge desde a 79, Firefox desde a 98 e Safari desde a 16.4 — portanto, a antiga ressalva “isso já é suportado?” não se aplica mais.
  • O valor de um controle só chega ao FormData quando você chama internals.setFormValue(value); passar null remove o elemento do envio completamente.
  • setValidity() bloqueia o envio e aplica :invalid, mas uma validação acessível também requer uma mensagem visível vinculada com aria-describedby — caso contrário, o formulário recusa o envio silenciosamente.
  • Dos dois modos de restauração de estado definidos pela especificação, apenas "restore" é acionado de forma confiável nos navegadores disponíveis; "autocomplete" está especificado, mas não é implementado de forma confiável.

Por que o Shadow DOM quebra a participação nativa em formulários

Um <input> nativo participa de um formulário apenas quando é descendente do <form> na mesma árvore DOM. O Shadow DOM é uma árvore separada, portanto um input renderizado dentro do shadow root de um elemento personalizado é estruturalmente invisível para o formulário: o navegador nunca o adiciona a form.elements, nunca coleta seu valor no FormData e nunca executa a validação de restrições contra ele. Este é o trade-off de encapsulamento funcionando conforme projetado — e o problema exato que os elementos personalizados associados a formulários (FACE, do inglês form-associated custom elements) existem para resolver.

A resposta da plataforma é o ElementInternals. O método HTMLElement.attachInternals() retorna um objeto ElementInternals; esse método permite que um elemento personalizado participe de formulários HTML, e a interface fornece utilitários para trabalhar com esses elementos da mesma forma que você trabalharia com qualquer elemento de formulário HTML padrão, além de expor o Modelo de Objeto de Acessibilidade ao elemento. O FACE não é mais um recurso de fronteira: os elementos personalizados associados a formulários são Baseline Amplamente Disponíveis desde 27/09/2025, suportados no Chrome 77 (2019), Edge 79 (2020), Firefox 98 (2022) e Safari 16.4, lançado em 27/03/2023. Um element-internals-polyfill da comunidade ainda existe para engines legadas, mas com suporte Baseline em navegadores evergreen, a maioria dos projetos não precisa mais dele.

Uma restrição importante: adicionar uma propriedade estática formAssociated com valor true torna um elemento personalizado autônomo um elemento personalizado associado a formulário. O elemento deve estender HTMLElement diretamente — não uma subclasse nativa — e attachInternals() lança um NotSupportedError se o elemento não for um elemento personalizado.

O elemento personalizado associado a formulário mínimo

O menor controle útil declara formAssociated, obtém seus internals e envia seu valor ao formulário por meio de um setter. Todo o resto é construído sobre esse esqueleto.

class TextField extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['value', 'required'];

  #internals;
  #value = '';

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  get value() { return this.#value; }
  set value(v) {
    this.#value = v ?? '';
    this.#internals.setFormValue(this.#value);
  }

  get form() { return this.#internals.form; }
  get name() { return this.getAttribute('name'); }
  get type() { return this.localName; }
}

customElements.define('text-field', TextField);

setFormValue() é a chamada fundamental aqui. O valor de um controle só chega ao FormData quando você invoca internals.setFormValue(value); passe null e o elemento é removido do envio completamente. O padrão de setter garante que toda atribuição a .value — por script, um atributo ou entrada do usuário — ressincronize o valor enviado. Uma vez associado, o elemento se comporta como um nativo: a coleção elements do formulário inclui todos os <button>, <fieldset>, <input>, <object>, <output>, <select>, <textarea> e elementos personalizados associados a formulários vinculados ao formulário.

Se você só precisa injetar dados arbitrários em um envio — sem construir um controle reutilizável — o evento formdata com menor overhead é suficiente: escute-o no formulário e chame event.formData.append(...). Use ElementInternals quando estiver construindo um controle que precise validar, rotular, resetar e restaurar.

Renderizar e sincronizar o input interno

O controle precisa de um <input> interno cujo valor flua de volta pelo setter público, e cujos eventos input/change sejam redespachados do host para que os listeners em <text-field> se comportem como em um campo nativo. Como um shadow root só pode ser anexado uma vez, a configuração é protegida para que uma desconexão seguida de reconexão não chame attachShadow() uma segunda vez (o que lançaria um erro):

connectedCallback() {
  if (!this.shadowRoot) {
    const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
    root.innerHTML = `<input part="input" />`;
    this.input = root.querySelector('input');

    this.input.addEventListener('input', () => {
      this.value = this.input.value;        // ressincroniza setFormValue
      this.#validate();                      // validade em tempo real
    });
    this.input.addEventListener('change', (e) => {
      this.dispatchEvent(new e.constructor(e.type, e));
    });
  }

  this.value = this.getAttribute('value') ?? '';
  this.#validate();
}

Eventos change nativos não cruzam a fronteira do shadow, portanto clonar o evento com new e.constructor(e.type, e) e redespachá-lo do host o reemite no light DOM onde o código pai pode ouvi-lo. Conduzir this.value pelo listener input do input mantém o valor do FormData enviado atualizado a cada tecla pressionada.

Rotulagem, delegação de foco e tabindex

Um <label for="..."> nativo deve focar o controle quando clicado. Para um elemento personalizado associado a formulário, esse comportamento vem de delegatesFocus: true no shadow root. Definir delegatesFocus: true é o que faz um clique em <label for> mover o foco para o input interno, exatamente como um campo nativo — sem isso, clicar no label não faz nada e focar o host não alcança o input. Como um FACE é um elemento rotulável, você escreve a marcação exatamente como faria para um input nativo:

<form>
  <label for="email">Email</label>
  <text-field id="email" name="email" required></text-field>
</form>

A associação de labels para leitores de tela em controles personalizados tem sido historicamente inconsistente entre diferentes combinações de navegadores e tecnologias assistivas, portanto teste com a AT alvo e mantenha um <label> interno ao shadow como fallback se você suportar uma ampla matriz. Com delegatesFocus definido, geralmente não é necessário gerenciar tabindex manualmente — o foco é delegado ao input interno, que já está na ordem de tabulação.

Validação com setValidity() e os ValidityStateFlags completos

ElementInternals.setValidity() espelha o sistema nativo de validação de restrições. Você passa um objeto ValidityStateFlags, uma mensagem opcional legível por humanos e um elemento âncora opcional; o navegador então aplica a pseudo-classe :invalid e bloqueia o envio. Com essa configuração, a pseudo-classe :invalid é aplicada automaticamente ao elemento sempre que um flag é definido. Passar um objeto vazio {} limpa todos os erros.

Os flags mapeiam um a um para os motivos de validade nativos:

FlagAnálogo nativo
valueMissingrequired sem valor
typeMismatchvalor incorreto para o type (ex.: email)
patternMismatchvalor falha no pattern
tooLong / tooShortexcede maxlength / abaixo de minlength
rangeUnderflow / rangeOverflowabaixo de min / acima de max
stepMismatchvalor não alinhado ao step
badInputentrada não parseável
customErrorerro definido pelo autor via mensagem personalizada

Valide a cada evento input para feedback em tempo real e exponha os métodos padrão (checkValidity(), reportValidity(), validity, validationMessage) para que os chamadores possam tratar seu controle como qualquer campo nativo:

#validate() {
  const flags = {};
  let message = '';
  if (this.hasAttribute('required') && !this.#value) {
    flags.valueMissing = true;
    message = 'Este campo é obrigatório.';
  }
  this.#internals.setValidity(flags, message, this.input);
  this.#renderError(message);
}

checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }

setValidity() é apenas metade da validação acessível: o navegador bloqueará o envio e aplicará :invalid, mas a menos que você também renderize uma mensagem visível vinculada com aria-describedby, o usuário terá um formulário que recusa o envio silenciosamente. O passo #renderError é abordado na seção de acessibilidade.

Os quatro callbacks do ciclo de vida do formulário

Elementos personalizados associados a formulários recebem quatro callbacks de reação extras além do ciclo de vida padrão de elementos personalizados. Estes são a peça que define a completude e que a maioria dos tutoriais ignora. formAssociatedCallback(form) é chamado quando o formulário associado muda; formResetCallback() é chamado quando o formulário está sendo resetado e o elemento deve limpar o valor definido pelo usuário; formDisabledCallback(isDisabled) é chamado quando o estado desabilitado muda; e formStateRestoreCallback(state, reason) é chamado quando o navegador restaura o estado do elemento (razão "restore") ou realiza o preenchimento automático (razão "autocomplete").

CallbackDisparado quandoArgumentoCorpo típico
formAssociatedCallbackelemento ganha/perde um formulário proprietárioo novo form (ou null)configurar estado dependente do formulário
formDisabledCallbacko estado desabilitado do host ou fieldset ancestral mudabooleanespelhar no input interno
formResetCallbacko formulário é resetadonenhumrestaurar o valor padrão
formStateRestoreCallbacknavegação para frente/trás, recarregamento, preenchimento automáticoestado restaurado, razãoreidratar a partir do estado salvo
formDisabledCallback(disabled) {
  this.input.disabled = disabled;
}

formResetCallback() {
  this.value = this.getAttribute('value') ?? '';
  this.#internals.setValidity({});
  this.#renderError('');
}

formStateRestoreCallback(state, reason) {
  if (reason === 'restore') this.value = state;
}

Os atributos disabled e readonly têm significado específico na especificação aqui: o atributo disabled torna um elemento personalizado associado a formulário não interativo e impede que seu valor de envio seja submetido, enquanto o atributo readonly especifica que o elemento está impedido de participar da validação de restrições.

Restauração de estado, conforme a especificação

setFormValue() aceita um segundo argumento opcional — setFormValue(value, state) — onde state é uma representação exclusiva do cliente que não é enviada ao servidor, útil para restaurar um estado interno mais rico do que o simples valor de envio. A especificação agora é explícita sobre o que o callback de restauração recebe: quando o agente de usuário atualiza o valor de um elemento personalizado associado a formulário em nome de um usuário ou como parte da navegação, seu formStateRestoreCallback é chamado, recebendo o novo state e uma string indicando a razão, "autocomplete" ou "restore". Uma inconsistência anterior na especificação causou divergência real — o Chromium chamava o callback com o valor enquanto o WebKit o chamava com o state — mas isso foi resolvido em favor do state. Se você suportar versões mais antigas do Chromium/Edge, trate o argumento de forma defensiva.

Apenas "restore" é confiável hoje. O Firefox implementou o suporte a formStateRestoreCallback, mas observa que 'autocomplete' para elementos personalizados permanece sem suporte. Trate "restore" (navegação para trás e recarregamento) como o único modo no qual você pode confiar em todos os engines.

Acessibilidade feita corretamente

Definir a validade não é o mesmo que comunicá-la. Para tornar os erros perceptíveis, renderize uma mensagem dentro do shadow root, vincule-a ao input interno com aria-describedby e marque-a como uma região ativa para que a tecnologia assistiva a anuncie:

#renderError(message) {
  let err = this.shadowRoot.querySelector('.error');
  if (!err) {
    err = document.createElement('div');
    err.className = 'error';
    err.id = 'err';
    err.setAttribute('role', 'alert');
    err.setAttribute('aria-live', 'assertive');
    this.shadowRoot.append(err);
    this.input.setAttribute('aria-describedby', 'err');
  }
  err.textContent = message;
}

Como tanto o input quanto o erro vivem no mesmo shadow root, aria-describedby resolve corretamente dentro dessa árvore. A própria semântica ARIA do elemento host pode ser definida por meio das propriedades ElementInternals — e essa superfície agora tem amplo suporte, incluindo ElementInternals.role e as propriedades de reflexão aria*.

Um modo de falha comum em produção é o controle meio acabado que parece pronto mas falha silenciosamente — e esses são exatamente os bugs que a reprodução de sessão revela, pois não lançam nenhum erro no console. A reprodução mostra um usuário clicando em enviar repetidamente contra um controle que reportou valueMissing sem nenhuma mensagem visível; um clique no label que não vai a lugar nenhum porque delegatesFocus nunca foi definido; ou um payload enviado sem um campo porque o setter nunca chamou setFormValue. Mais uma armadilha que vale uma nota de revisão: colocar um input nativo required em um slot de um elemento personalizado deixa o controle no light DOM ativo no formulário junto com o host, produzindo participação dupla. Mantenha controles nativos fora dos slots quando o host for ele mesmo associado a formulário.

Usando isso dentro de um framework

As integrações de frameworks para elementos personalizados associados a formulários envolvem exatamente a API ElementInternals bruta mostrada aqui. O Stencil oferece suporte de primeira classe a partir da v4.5.0 por meio de um decorator @AttachInternals, e seu formStateRestoreCallback também recebe um segundo argumento mode, "restore" ou "autocomplete", indicando a razão da restauração. O Lit não inclui FACE nativamente — você adiciona um pequeno mixin FormAssociated você mesmo ou importa um da comunidade — mas esse mixin envolve o mesmo flag formAssociated, attachInternals(), setFormValue() e os callbacks de ciclo de vida mostrados aqui. Aprender a API da plataforma diretamente significa que você pode ler, depurar ou substituir qualquer um desses wrappers.

Monte as peças — o setter de valor, a sincronização do input interno, delegatesFocus, setValidity com uma mensagem role="alert" vinculada e todos os quatro callbacks de ciclo de vida — e você terá um único <text-field> que envia, valida, reseta e restaura de forma indistinguível de um input nativo. Coloque-o em um <form>, conecte um <label for> e o próximo passo concreto é verificar o ciclo completo: envie o formulário, leia o valor de volta de new FormData(form) e confirme que a validação de restrições bloqueia um campo obrigatório vazio.

Perguntas Frequentes

Qual é a diferença entre o evento formdata e ElementInternals para adicionar valores a um formulário?

O evento formdata injeta dados arbitrários em um envio: você o escuta no formulário e chama event.formData.append() para adicionar campos. ElementInternals constrói um controle reutilizável que participa completamente do formulário, incluindo validação de restrições, rotulagem, reset e restauração de estado por meio de setFormValue e setValidity. Use o evento formdata para injeção rápida de dados pontuais; use ElementInternals quando precisar de um controle que se comporte como um input nativo.

Por que attachInternals() lança um NotSupportedError?

attachInternals() lança um NotSupportedError quando o elemento não é um elemento personalizado autônomo. Elementos personalizados associados a formulários devem estender HTMLElement diretamente, não uma subclasse nativa como HTMLInputElement, e a classe deve ser registrada por meio de customElements.define antes da instanciação. Chamar attachInternals() mais de uma vez no mesmo elemento também lança um erro. Coloque a chamada uma única vez no construtor em uma classe que estende HTMLElement para evitar o erro.

delegatesFocus interfere no gerenciamento manual de tabindex em um elemento personalizado associado a formulário?

Com delegatesFocus definido como true no shadow root, geralmente não é necessário gerenciar tabindex manualmente. O foco é delegado ao primeiro elemento focalizável dentro do shadow root, que é o input interno já na ordem de tabulação, portanto o elemento host encaminha o foco automaticamente. Um clique em label-for e a tabulação pelo teclado ambos alcançam o input interno. Adicionar um tabindex manual no host pode criar uma parada de tabulação duplicada, portanto omita-o quando delegatesFocus gerenciar o roteamento.

O que acontece quando você chama setFormValue com null?

Chamar internals.setFormValue(null) remove o elemento do envio do formulário completamente: seu nome e valor são excluídos do objeto FormData que o formulário produz. Isso difere de passar uma string vazia, que envia o campo com um valor vazio. Use null para excluir deliberadamente um controle, como um campo desabilitado ou somente leitura, e passe uma string de valor real ou FormData sempre que o controle deva contribuir para o envio.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.