12k
All articles

Construye Controles de Formulario Reutilizables con Web Components

Crea un control de formulario reutilizable con Web Components y ElementInternals. Añade envío, validación, etiquetas, reset y restauración nativos.

OpenReplay Team
OpenReplay Team
Construye Controles de Formulario Reutilizables con Web Components

Un elemento personalizado que envuelve un <input> dentro de Shadow DOM es invisible para su <form> padre: su valor nunca llega a FormData, la validación nativa lo omite y un reset lo deja intacto. La solución son dos líneas — static formAssociated = true más this.attachInternals() en el constructor — y la API ElementInternals que esas líneas habilitan. Este artículo construye un control <text-field> completo que envía datos, valida, etiqueta, resetea y restaura como un input nativo, usando únicamente la API de la plataforma sin dependencias externas.

Con el lanzamiento de Safari 16.4 en marzo de 2023, los web components alcanzaron un hito en su capacidad para interactuar con el elemento form mediante la API ElementInternals; antes de esto, los elementos input ubicados dentro de un Shadow DOM no eran detectables por los formularios, lo que significaba que no se validaban al enviar el formulario y sus datos no se incluían en el objeto FormData. Esa pregunta sobre compatibilidad ya está resuelta, por lo que este artículo omite las dudas del tipo “¿puedo usar esto ya?” y se enfoca en la receta completa que ninguno de los tutoriales más antiguos ensambla de principio a fin.

Puntos Clave

  • Dos líneas convierten cualquier elemento personalizado autónomo en un control de formulario: static formAssociated = true declara la intención, y this.attachInternals() devuelve el objeto ElementInternals que conecta tu elemento con su formulario padre.
  • A partir de septiembre de 2025, los elementos personalizados asociados a formularios son Baseline Widely Available — compatibles con Chrome desde la versión 77, Edge desde la 79, Firefox desde la 98 y Safari desde la 16.4 — por lo que la advertencia de “¿está esto soportado?” ya no aplica.
  • El valor de un control solo llega a FormData cuando se llama a internals.setFormValue(value); pasar null excluye el elemento del envío por completo.
  • setValidity() bloquea el envío y aplica :invalid, pero la validación accesible también requiere un mensaje visible vinculado con aria-describedby; de lo contrario, el formulario se niega a enviarse silenciosamente.
  • De los dos modos de restauración de estado que define la especificación, solo "restore" se activa de manera confiable en los navegadores actuales; "autocomplete" está especificado pero no está implementado de forma consistente.

Por qué Shadow DOM rompe la participación nativa en formularios

Un <input> nativo participa en un formulario solo cuando desciende del <form> en el mismo árbol DOM. Shadow DOM es un árbol separado, por lo que un input renderizado dentro del shadow root de un elemento personalizado es estructuralmente invisible para el formulario: el navegador nunca lo agrega a form.elements, nunca recopila su valor en FormData y nunca ejecuta la validación de restricciones sobre él. Esta es la contrapartida de la encapsulación funcionando según lo diseñado — y el problema exacto que los elementos personalizados asociados a formularios (FACE, por sus siglas en inglés) existen para resolver.

La respuesta de la plataforma es ElementInternals. El método HTMLElement.attachInternals() devuelve un objeto ElementInternals; este método permite que un elemento personalizado participe en formularios HTML, y la interfaz proporciona utilidades para trabajar con estos elementos de la misma manera que se trabajaría con cualquier elemento de formulario HTML estándar, al tiempo que expone el Modelo de Objeto de Accesibilidad al elemento. FACE ya no es una característica experimental: los elementos personalizados asociados a formularios han sido Baseline Widely Available desde el 27 de septiembre de 2025, compatibles con Chrome 77 (2019), Edge 79 (2020), Firefox 98 (2022) y Safari 16.4, lanzado el 27 de marzo de 2023. Existe un element-internals-polyfill de la comunidad para motores heredados, pero con soporte Baseline en todos los navegadores evergreen, la mayoría de los proyectos ya no lo necesitan.

Una restricción importante: agregar una propiedad estática formAssociated con valor true convierte un elemento personalizado autónomo en un elemento personalizado asociado a formulario. El elemento debe extender HTMLElement directamente — no una subclase integrada — y attachInternals() lanza un NotSupportedError si el elemento no es un elemento personalizado.

El elemento personalizado asociado a formulario mínimo

El control más pequeño y funcional declara formAssociated, obtiene sus internals y envía su valor al formulario a través de un setter. Todo lo demás se construye sobre este 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() es la llamada fundamental aquí. El valor de un control solo llega a FormData cuando se invoca internals.setFormValue(value); si se pasa null, el elemento queda excluido del envío por completo. El patrón setter garantiza que cada asignación a .value — desde un script, un atributo o la entrada del usuario — resincronice el valor enviado. Una vez asociado, el elemento se comporta como uno integrado: la colección elements del formulario incluye todos los <button>, <fieldset>, <input>, <object>, <output>, <select>, <textarea> y elementos personalizados asociados a formularios vinculados con el formulario.

Si solo necesitas inyectar datos arbitrarios en un envío — sin construir un control reutilizable — el evento formdata es suficiente: escúchalo en el formulario y llama a event.formData.append(...). Recurre a ElementInternals cuando estés construyendo un control que deba validar, etiquetar, resetear y restaurar.

Renderizar y sincronizar el input interno

El control necesita un <input> interno cuyo valor fluya de vuelta a través del setter público, y cuyos eventos input/change se redespachen desde el host para que los listeners en <text-field> se comporten como lo harían en un campo nativo. Dado que un shadow root solo puede adjuntarse una vez, la configuración está protegida para que una desconexión y reconexión no llame a attachShadow() por segunda vez (lo que lanzaría un error):

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;        // re-sincroniza setFormValue
      this.#validate();                      // validación en tiempo real
    });
    this.input.addEventListener('change', (e) => {
      this.dispatchEvent(new e.constructor(e.type, e));
    });
  }

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

Los eventos change nativos no atraviesan el límite del shadow DOM, por lo que clonar el evento con new e.constructor(e.type, e) y redespacharlo desde el host lo re-emite en el light DOM donde el código padre puede escucharlo. Manejar this.value desde el listener input del input mantiene el valor de FormData actualizado en cada pulsación de tecla.

Etiquetado, delegación de foco y tabindex

Un <label for="..."> nativo debería enfocar el control al hacer clic. Para un elemento personalizado asociado a formulario, ese comportamiento proviene de delegatesFocus: true en el shadow root. Configurar delegatesFocus: true es lo que hace que un clic en <label for> mueva el foco hacia tu input interno, exactamente como un campo nativo — sin esto, hacer clic en la etiqueta no hace nada y enfocar el host no alcanza el input. Dado que un FACE es un elemento etiquetable, el marcado se escribe exactamente como se haría para un input nativo:

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

La asociación de etiquetas con lectores de pantalla para controles personalizados ha sido históricamente inconsistente entre combinaciones de navegadores y tecnologías de asistencia, por lo que se recomienda probar con la tecnología de asistencia objetivo y mantener un <label> de respaldo dentro del shadow DOM si se da soporte a una amplia variedad de configuraciones. Con delegatesFocus configurado, generalmente no es necesario gestionar tabindex manualmente — el foco se delega al input interno, que ya está en el orden de tabulación.

Validación con setValidity() y los ValidityStateFlags completos

ElementInternals.setValidity() refleja el sistema de validación de restricciones nativo. Se pasa un objeto ValidityStateFlags, un mensaje legible por humanos opcional y un elemento ancla opcional; el navegador entonces aplica la pseudoclase :invalid y bloquea el envío. Con esta configuración, la pseudoclase :invalid se aplica automáticamente al elemento cuando se establece algún indicador. Pasar un objeto vacío {} limpia todos los errores.

Los indicadores se corresponden uno a uno con los motivos de validez nativos:

IndicadorAnalogía de activación nativa
valueMissingrequired sin valor
typeMismatchvalor incorrecto para type (p. ej., email)
patternMismatchel valor no cumple pattern
tooLong / tooShortsupera maxlength / no alcanza minlength
rangeUnderflow / rangeOverflowpor debajo de min / por encima de max
stepMismatchel valor no está alineado con step
badInputentrada no analizable
customErrorerror definido por el autor mediante mensaje personalizado

Valida en cada evento input para obtener retroalimentación en tiempo real, y expón los métodos estándar (checkValidity(), reportValidity(), validity, validationMessage) para que los consumidores puedan tratar tu control como cualquier campo nativo:

#validate() {
  const flags = {};
  let message = '';
  if (this.hasAttribute('required') && !this.#value) {
    flags.valueMissing = true;
    message = 'Este campo es obligatorio.';
  }
  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() es solo la mitad de la validación accesible: el navegador bloqueará el envío y aplicará :invalid, pero a menos que también se renderice un mensaje visible vinculado con aria-describedby, el usuario se encontrará con un formulario que se niega a enviarse silenciosamente. El paso #renderError se cubre en la sección de accesibilidad.

Los cuatro callbacks del ciclo de vida del formulario

Los elementos personalizados asociados a formularios reciben cuatro callbacks de reacción adicionales más allá del ciclo de vida estándar de los elementos personalizados. Esta es la pieza que define la completitud y que la mayoría de los tutoriales omiten. formAssociatedCallback(form) se llama cuando cambia el formulario asociado; formResetCallback() se llama cuando el formulario se está reseteando y el elemento debe limpiar el valor establecido por el usuario; formDisabledCallback(isDisabled) se llama cuando cambia el estado de deshabilitación; y formStateRestoreCallback(state, reason) se llama cuando el navegador restaura el estado del elemento (razón "restore") o completa el autocompletado (razón "autocomplete").

CallbackSe activa cuandoArgumentoCuerpo típico
formAssociatedCallbackel elemento gana/pierde un formulario propietarioel nuevo form (o null)configurar el estado dependiente del formulario
formDisabledCallbackcambia el estado deshabilitado del host o de un fieldset ancestrobooleanreflejar en el input interno
formResetCallbackel formulario se reseteaningunorestaurar el valor predeterminado
formStateRestoreCallbacknavegación atrás/adelante, recarga, autocompletadoestado restaurado, razónrehidratar desde el estado guardado
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;
}

Los atributos disabled y readonly tienen un significado específico en la especificación: el atributo disabled hace que un elemento personalizado asociado a formulario sea no interactivo e impide que su valor se envíe, mientras que el atributo readonly especifica que el elemento está excluido de la validación de restricciones.

Restauración de estado según la especificación

setFormValue() acepta un segundo argumento opcional — setFormValue(value, state) — donde state es una representación solo para el cliente que no se envía al servidor, útil para restaurar un estado interno más rico que el valor de envío básico. La especificación ahora es explícita sobre lo que recibe el callback de restauración: cuando el agente de usuario actualiza el valor de un elemento personalizado asociado a formulario en nombre del usuario o como parte de la navegación, se llama a su formStateRestoreCallback, con el nuevo estado y una cadena que indica la razón, "autocomplete" o "restore". Una inconsistencia anterior en la especificación causó una divergencia real — Chromium llamaba al callback con el valor mientras WebKit lo llamaba con el estado — pero eso se resolvió a favor del estado. Si se da soporte a versiones antiguas de Chromium/Edge, maneja el argumento de forma defensiva.

Solo "restore" es confiable hoy en día. Firefox implementó el soporte para formStateRestoreCallback, pero indica que 'autocomplete' para elementos personalizados sigue sin ser compatible. Trata "restore" (navegación hacia atrás y recarga) como el único modo en el que puedes confiar en todos los motores.

Accesibilidad implementada correctamente

Establecer la validez no es lo mismo que comunicarla. Para que los errores sean perceptibles, renderiza un mensaje dentro del shadow root, vincúlalo al input interno con aria-describedby y márcalo como una región activa para que la tecnología de asistencia lo 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;
}

Dado que tanto el input como el error viven en el mismo shadow root, aria-describedby se resuelve correctamente dentro de ese árbol. La semántica ARIA propia del elemento host puede establecerse a través de las propiedades de ElementInternals — y esa superficie ahora tiene amplio soporte, incluyendo ElementInternals.role y las propiedades de reflexión aria*.

Un modo de fallo común en producción es el control a medias que parece terminado pero falla silenciosamente — y estos son exactamente los errores que la reproducción de sesiones pone en evidencia, porque no lanzan ningún error en la consola. La reproducción revela a un usuario haciendo clic repetidamente en enviar contra un control que reportó valueMissing sin ningún mensaje visible; un clic en una etiqueta que no llega a ningún lado porque delegatesFocus nunca se configuró; o un payload enviado al que le falta un campo porque el setter nunca llamó a setFormValue. Otra trampa que vale la pena señalar en revisión de código: colocar un input nativo con required en un slot de un elemento personalizado deja el control del light DOM activo en el formulario junto con el host, produciendo una doble participación. Mantén los controles nativos fuera de los slots cuando el host es en sí mismo un elemento asociado a formulario.

Uso dentro de un framework

Las integraciones de frameworks para elementos personalizados asociados a formularios envuelven exactamente la API ElementInternals mostrada aquí. Stencil incluye soporte de primera clase desde la versión v4.5.0 mediante un decorador @AttachInternals, y su formStateRestoreCallback también recibe un segundo argumento mode, ya sea "restore" o "autocomplete", que indica la razón de la restauración. Lit no incluye FACE de forma integrada — se agrega un pequeño mixin FormAssociated propio o se importa uno de la comunidad — pero ese mixin envuelve los mismos indicadores formAssociated, attachInternals(), setFormValue() y los callbacks del ciclo de vida mostrados aquí. Aprender la API de la plataforma directamente significa que puedes leer, depurar o reemplazar cualquiera de esos wrappers.

Ensambla las piezas — el setter de valor, la sincronización del input interno, delegatesFocus, setValidity con un mensaje role="alert" vinculado, y los cuatro callbacks del ciclo de vida — y tendrás un <text-field> que envía, valida, resetea y restaura de manera indistinguible de un input nativo. Colócalo en un <form>, conecta un <label for>, y el siguiente paso concreto es verificar el ciclo completo: enviar el formulario, leer el valor de vuelta desde new FormData(form) y confirmar que la validación de restricciones bloquea un campo requerido vacío.

Preguntas Frecuentes

¿Cuál es la diferencia entre el evento formdata y ElementInternals para agregar valores a un formulario?

El evento formdata inyecta datos arbitrarios en un envío: se escucha en el formulario y se llama a event.formData.append() para agregar campos. ElementInternals construye un control reutilizable que participa completamente en el formulario, incluyendo validación de restricciones, etiquetado, reset y restauración de estado a través de setFormValue y setValidity. Usa el evento formdata para inyección de datos rápida y puntual; recurre a ElementInternals cuando necesitas un control que se comporte como un input nativo.

¿Por qué attachInternals() lanza un NotSupportedError?

attachInternals() lanza un NotSupportedError cuando el elemento no es un elemento personalizado autónomo. Los elementos personalizados asociados a formularios deben extender HTMLElement directamente, no una subclase integrada como HTMLInputElement, y la clase debe estar registrada mediante customElements.define antes de la instanciación. Llamar a attachInternals() más de una vez en el mismo elemento también lanza un error. Coloca la llamada una sola vez en el constructor de una clase que extienda HTMLElement para evitar el error.

¿delegatesFocus interfiere con la gestión manual de tabindex en un elemento personalizado asociado a formulario?

Con delegatesFocus configurado como true en el shadow root, generalmente no es necesario gestionar tabindex manualmente. El foco se delega al primer elemento enfocable dentro del shadow root, que es el input interno ya presente en el orden de tabulación, por lo que el elemento host reenvía el foco automáticamente. Tanto un clic en label-for como la tabulación por teclado alcanzan el input interno. Agregar un tabindex manual en el host puede crear un tab stop duplicado, por lo que se recomienda omitirlo cuando delegatesFocus gestiona el enrutamiento.

¿Qué sucede cuando se llama a setFormValue con null?

Llamar a internals.setFormValue(null) elimina el elemento del envío del formulario por completo: su nombre y valor quedan excluidos del objeto FormData que produce el formulario. Esto difiere de pasar una cadena vacía, que envía el campo con un valor vacío. Usa null para excluir deliberadamente un control, como un campo deshabilitado o de solo lectura, y pasa una cadena de valor real o FormData cuando el control deba contribuir al envío.

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.