Elementos personalizados asociados a formularios

No recurras a un <input> oculto para mostrar el valor de un control personalizado en un formulario. Usa elementos personalizados asociados a formularios (FACE): con formAssociated y ElementInternals, el elemento envía su propio valor, participa en la validación y refleja el estado del formulario de forma nativa.

class RatingInput extends HTMLElement {
  // MANDATORY: opt in. Without this, attachInternals() form APIs are unavailable.
  static formAssociated = true;

  #internals;
  #value = '';

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

  connectedCallback() {
    // Validate initial state once attributes (such as `required`) are present.
    this.#validate();
  }

  get value() { return this.#value; }
  set value(v) {
    this.#value = v;
    // Push the value into the owning <form> for submission.
    this.#internals.setFormValue(v);
    this.#validate();
  }

  #validate() {
    if (this.hasAttribute('required') && !this.#value) {
      // Mark invalid and supply the native validation message + anchor.
      this.#internals.setValidity(
        { valueMissing: true },
        'Please choose a rating.',
        this.shadowRoot.querySelector('[tabindex]'),
      );
    } else {
      this.#internals.setValidity({}); // clear
    }
  }

  // Optional FACE lifecycle hooks:
  formResetCallback() { this.value = ''; }
  formDisabledCallback(disabled) {
    // Disable internal controls so they leave the tab order and ignore clicks.
    // Do NOT toggle `disabled` on `this`, or a `<fieldset disabled>` toggle will
    // leave the host permanently stuck with its own `disabled` attribute.
    for (const el of this.shadowRoot.querySelectorAll('button')) {
      el.disabled = disabled;
    }
  }
  formStateRestoreCallback(state) { this.value = state; }
}
customElements.define('rating-input', RatingInput);

Puntos clave:

  • static formAssociated = true es OBLIGATORIO; desbloquea los métodos ElementInternals relacionados con el formulario y el ciclo de vida de formXxxCallback.
  • setFormValue(value) es lo que envía, sin necesidad de una entrada oculta. Pasa un FormData para los controles de varios valores.
  • setValidity(flags, message, anchor) se integra con la validación de restricciones nativa: el formulario no se enviará si no es válido, se aplican :invalid/:valid y el elemento de anclaje recibe el foco en el informe. Llama a setValidity({}) para borrar. Las pseudoclases :user-valid/:user-invalid que reconocen la interacción no están conectadas de forma coherente para los elementos asociados a formularios en todos los navegadores. Verifica la compatibilidad antes de confiar en ellas, en lugar de suponer que hacen un seguimiento de la interacción del usuario de la misma manera que lo hacen para los controles nativos.
  • Implementa formResetCallback, formDisabledCallback y formStateRestoreCallback para que el elemento se comporte como un control nativo en el restablecimiento, fieldset[disabled] y la restauración de autocompletado hacia atrás o hacia adelante. En formDisabledCallback(disabled), inhabilita los controles internos (o quítalos del orden de tabulación y omite la entrada) en lugar de configurar disabled en el host. Configurar el atributo del host dentro de la devolución de llamada impide que se vuelva a habilitar cuando se vuelve a habilitar un <fieldset> principal, y el host ya coincide con :disabled automáticamente.
  • Sigue las convenciones de la plataforma para los eventos enviados y prefiere los eventos integrados input y change en lugar de los personalizados. input se activa cuando el valor cambia como resultado directo de la acción del usuario (escribir, arrastrar un control deslizante); change se activa una vez por flujo de cambios, cuando el usuario confirma (soltar el control deslizante, desenfocar el campo). Créalas con InputEvent y Event, respectivamente.

Estrategias de resguardo

Detecta funciones con compatibilidad con static formAssociated antes de depender de ella: 'attachInternals' in HTMLElement.prototype && 'setFormValue' in ElementInternals.prototype. Cuando no esté disponible, recurre a un <input> nativo oculto visualmente sincronizado con el valor del control para que el formulario se envíe de todos modos, el único caso en el que se justifica el patrón de entrada oculta.