12k
All articles

Créer des contrôles de formulaire réutilisables avec les Web Components

Créez un contrôle de formulaire réutilisable avec les Web Components et ElementInternals. Ajoutez soumission, validation, étiquettes, reset et restauration.

OpenReplay Team
OpenReplay Team
Créer des contrôles de formulaire réutilisables avec les Web Components

Un élément personnalisé qui encapsule un <input> dans un Shadow DOM est invisible pour son <form> parent : sa valeur n’est jamais intégrée dans FormData, la validation native l’ignore, et une réinitialisation ne l’affecte pas. La solution tient en deux lignes — static formAssociated = true et this.attachInternals() dans le constructeur — ainsi que l’API ElementInternals qu’elles déverrouillent. Cet article construit un contrôle <text-field> complet qui soumet, valide, étiquette, réinitialise et restaure ses données comme un input natif, en utilisant uniquement l’API de la plateforme brute.

Avec la sortie de Safari 16.4 en mars 2023, les web components ont franchi une étape décisive dans leur capacité à interagir avec l’élément form grâce à l’API ElementInternals ; auparavant, les éléments input situés dans un Shadow DOM n’étaient pas détectables par les formulaires, ce qui signifiait qu’ils n’étaient pas validés à la soumission et que leurs données n’étaient pas incluses dans l’objet FormData. Cette question de compatibilité est désormais réglée, aussi cet article fait l’impasse sur les tergiversations du type « puis-je l’utiliser maintenant » et se concentre sur la recette complète qu’aucun des anciens tutoriels n’assemble de bout en bout.

Points clés à retenir

  • Deux lignes suffisent à transformer n’importe quel élément personnalisé autonome en contrôle de formulaire : static formAssociated = true déclare l’intention, et this.attachInternals() retourne l’objet ElementInternals qui connecte votre élément à son formulaire parent.
  • En septembre 2025, les éléments personnalisés associés aux formulaires font partie de la Baseline Widely Available — pris en charge depuis Chrome 77, Edge 79, Firefox 98 et Safari 16.4 — de sorte que la mise en garde habituelle « est-ce pris en charge ? » ne s’applique plus.
  • La valeur d’un contrôle n’atteint FormData que lorsque vous appelez internals.setFormValue(value) ; passer null exclut totalement l’élément de la soumission.
  • setValidity() bloque la soumission et applique :invalid, mais une validation accessible exige également un message visible lié via aria-describedby — sans quoi le formulaire refuse silencieusement de se soumettre.
  • Parmi les deux modes de restauration d’état définis par la spécification, seul "restore" est déclenché de manière fiable dans les navigateurs actuels ; "autocomplete" est spécifié mais pas implémenté de façon fiable.

Pourquoi le Shadow DOM rompt la participation native aux formulaires

Un <input> natif ne participe à un formulaire que s’il descend du <form> dans le même arbre DOM. Le Shadow DOM est un arbre distinct, de sorte qu’un input rendu dans le shadow root d’un élément personnalisé est structurellement invisible pour le formulaire : le navigateur ne l’ajoute jamais à form.elements, ne collecte jamais sa valeur dans FormData, et n’exécute jamais la validation des contraintes contre lui. C’est le compromis inhérent à l’encapsulation qui fonctionne comme prévu — et le problème exact que les éléments personnalisés associés aux formulaires (FACE) existent pour résoudre.

La réponse de la plateforme est ElementInternals. La méthode HTMLElement.attachInternals() retourne un objet ElementInternals ; cette méthode permet à un élément personnalisé de participer aux formulaires HTML, et l’interface fournit des utilitaires pour travailler avec ces éléments de la même façon qu’avec n’importe quel élément de formulaire HTML standard, tout en exposant l’Accessibility Object Model à l’élément. FACE n’est plus une fonctionnalité expérimentale : les éléments personnalisés associés aux formulaires font partie de la Baseline Widely Available depuis le 27 septembre 2025, pris en charge dans Chrome 77 (2019), Edge 79 (2020), Firefox 98 (2022) et Safari 16.4, sorti le 27 mars 2023. Un element-internals-polyfill communautaire existe toujours pour les moteurs hérités, mais avec la prise en charge Baseline sur les navigateurs evergreen, la plupart des projets n’en ont plus besoin.

Une contrainte importante : l’ajout d’une propriété statique formAssociated avec la valeur true transforme un élément personnalisé autonome en élément personnalisé associé à un formulaire. L’élément doit étendre HTMLElement directement — et non une sous-classe intégrée — et attachInternals() lève une NotSupportedError si l’élément n’est pas un élément personnalisé.

L’élément personnalisé associé à un formulaire minimal

Le contrôle le plus simple déclare formAssociated, récupère ses internals, et transmet sa valeur au formulaire via un setter. Tout le reste s’appuie sur ce squelette.

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() est l’appel fondamental ici. La valeur d’un contrôle n’atteint FormData que lorsque vous invoquez internals.setFormValue(value) ; passez null et l’élément est exclu de la soumission. Le pattern setter garantit que chaque affectation à .value — depuis un script, un attribut ou une saisie utilisateur — resynchronise la valeur soumise. Une fois associé, l’élément se comporte comme un élément natif : la collection elements du formulaire inclut tous les <button>, <fieldset>, <input>, <object>, <output>, <select>, <textarea>, ainsi que les éléments personnalisés associés au formulaire.

Si vous avez seulement besoin d’injecter des données arbitraires dans une soumission — sans construire un contrôle réutilisable — l’événement formdata est suffisant : écoutez-le sur le formulaire et appelez event.formData.append(...). Recourez à ElementInternals lorsque vous construisez un contrôle qui doit valider, étiqueter, réinitialiser et restaurer.

Rendu et synchronisation de l’input interne

Le contrôle a besoin d’un <input> interne dont la valeur remonte via le setter public, et dont les événements input/change sont redispatchés depuis l’hôte afin que les écouteurs sur <text-field> se comportent comme sur un champ natif. Étant donné qu’un shadow root ne peut être attaché qu’une seule fois, la configuration est protégée pour qu’une déconnexion suivie d’une reconnexion n’appelle pas attachShadow() une seconde fois (ce qui lèverait une erreur) :

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;        // resynchronise setFormValue
      this.#validate();                      // validité en temps réel
    });
    this.input.addEventListener('change', (e) => {
      this.dispatchEvent(new e.constructor(e.type, e));
    });
  }

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

Les événements change natifs ne traversent pas la frontière du shadow, aussi cloner l’événement avec new e.constructor(e.type, e) et le redispatcher depuis l’hôte le réémet dans le DOM light où le code parent peut l’intercepter. Piloter this.value depuis l’écouteur input de l’input maintient la valeur soumise dans FormData à jour à chaque frappe.

Étiquetage, délégation du focus et tabindex

Un <label for="..."> natif doit donner le focus au contrôle lorsqu’on clique dessus. Pour un élément personnalisé associé à un formulaire, ce câblage provient de delegatesFocus: true sur le shadow root. Définir delegatesFocus: true est ce qui permet à un clic sur <label for> de déplacer le focus vers votre input interne, exactement comme un champ natif — sans cela, cliquer sur le label ne fait rien, et donner le focus à l’hôte n’atteint pas l’input. Puisqu’un FACE est un élément étiquetable, vous écrivez le balisage exactement comme pour un input natif :

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

L’association des labels pour les lecteurs d’écran avec les contrôles personnalisés a historiquement été incohérente selon les combinaisons navigateur/technologie d’assistance, aussi testez avec vos AT cibles et conservez un <label> de repli dans le shadow si vous devez prendre en charge une large matrice. Avec delegatesFocus activé, vous n’avez généralement pas besoin de gérer tabindex manuellement — le focus est délégué à l’input interne, qui est déjà dans l’ordre de tabulation.

Validation avec setValidity() et les ValidityStateFlags complets

ElementInternals.setValidity() reproduit le système natif de validation des contraintes. Vous passez un objet ValidityStateFlags, un message lisible par l’humain optionnel, et un élément d’ancrage optionnel ; le navigateur applique alors la pseudo-classe :invalid et bloque la soumission. Avec cette configuration, la pseudo-classe :invalid s’applique automatiquement à l’élément dès qu’un indicateur est activé. Passer un objet vide {} efface toutes les erreurs.

Les indicateurs correspondent un à un aux raisons de validité natives :

IndicateurDéclencheur natif analogue
valueMissingrequired sans valeur
typeMismatchvaleur incorrecte pour le type (ex. email)
patternMismatchvaleur ne correspondant pas au pattern
tooLong / tooShortdépasse maxlength / en dessous de minlength
rangeUnderflow / rangeOverflowen dessous de min / au-dessus de max
stepMismatchvaleur non alignée sur step
badInputsaisie non analysable
customErrorerreur définie par l’auteur via un message personnalisé

Validez à chaque événement input pour un retour en temps réel, et exposez les méthodes standard (checkValidity(), reportValidity(), validity, validationMessage) afin que les appelants puissent traiter votre contrôle comme n’importe quel champ natif :

#validate() {
  const flags = {};
  let message = '';
  if (this.hasAttribute('required') && !this.#value) {
    flags.valueMissing = true;
    message = 'Ce champ est obligatoire.';
  }
  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() ne représente que la moitié d’une validation accessible : le navigateur bloquera la soumission et appliquera :invalid, mais à moins de rendre également un message visible lié via aria-describedby, l’utilisateur se retrouve face à un formulaire qui refuse silencieusement de se soumettre. L’étape #renderError est traitée dans la section accessibilité.

Les quatre callbacks du cycle de vie du formulaire

Les éléments personnalisés associés aux formulaires disposent de quatre callbacks de réaction supplémentaires au-delà du cycle de vie standard des éléments personnalisés. C’est la pièce qui définit la complétude et que la plupart des tutoriels omettent. formAssociatedCallback(form) est appelé lorsque le formulaire associé change ; formResetCallback() est appelé lorsque le formulaire est réinitialisé et que l’élément doit effacer la valeur saisie par l’utilisateur ; formDisabledCallback(isDisabled) est appelé lorsque l’état désactivé change ; et formStateRestoreCallback(state, reason) est appelé lorsque le navigateur restaure l’état de l’élément (raison "restore") ou exécute le remplissage automatique (raison "autocomplete").

CallbackSe déclenche quandArgumentCorps typique
formAssociatedCallbackl’élément gagne/perd un propriétaire de formulairele nouveau form (ou null)câbler l’état dépendant du formulaire
formDisabledCallbackl’état désactivé de l’hôte ou d’un fieldset ancêtre basculebooleanrépercuter sur l’input interne
formResetCallbackle formulaire est réinitialiséaucunrestaurer la valeur par défaut
formStateRestoreCallbacknavigation arrière/avant, rechargement, remplissage automatiqueétat restauré, raisonréhydrater depuis l’état sauvegardé
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;
}

Les attributs disabled et readonly ont une signification spécifique dans la spécification : l’attribut disabled rend un élément personnalisé associé à un formulaire non interactif et empêche la soumission de sa valeur, tandis que l’attribut readonly indique que l’élément est exclu de la validation des contraintes.

Restauration d’état, conforme à la spécification

setFormValue() accepte un second argument optionnel — setFormValue(value, state) — où state est une représentation côté client uniquement qui n’est pas envoyée au serveur, utile pour restaurer un état interne plus riche que la simple valeur de soumission. La spécification est désormais explicite sur ce que reçoit le callback de restauration : lorsque l’agent utilisateur met à jour la valeur d’un élément personnalisé associé à un formulaire au nom d’un utilisateur ou dans le cadre d’une navigation, son formStateRestoreCallback est appelé avec le nouvel état et une chaîne indiquant la raison, "autocomplete" ou "restore". Une incohérence antérieure dans la spécification avait provoqué une vraie divergence — Chromium appelait le callback avec la valeur tandis que WebKit l’appelait avec l’état — mais cela a été résolu en faveur de l’état. Si vous prenez en charge des versions plus anciennes de Chromium/Edge, traitez l’argument de façon défensive.

Seul "restore" est fiable aujourd’hui. Firefox a livré la prise en charge de formStateRestoreCallback, mais note que 'autocomplete' pour les éléments personnalisés reste non pris en charge. Considérez "restore" (navigation arrière et rechargement) comme le seul mode sur lequel vous pouvez compter sur tous les moteurs.

L’accessibilité bien faite

Définir la validité n’est pas la même chose que la communiquer. Pour rendre les erreurs perceptibles, rendez un message dans le shadow root, liez-le à l’input interne avec aria-describedby, et marquez-le comme région live afin que les technologies d’assistance l’annoncent :

#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;
}

Étant donné que l’input et l’erreur résident dans le même shadow root, aria-describedby se résout correctement dans cet arbre. Les propres sémantiques ARIA de l’élément hôte peuvent être définies via les propriétés ElementInternals — et cette surface est désormais largement prise en charge, notamment ElementInternals.role et les propriétés de réflexion aria*.

Un mode d’échec courant en production est le contrôle à moitié terminé qui semble fonctionnel mais échoue silencieusement — et ce sont exactement les bugs que la relecture de session met en évidence, car ils ne génèrent aucune erreur dans la console. La relecture révèle un utilisateur cliquant à plusieurs reprises sur Soumettre face à un contrôle qui signale valueMissing sans message visible ; un clic sur un label qui n’aboutit nulle part parce que delegatesFocus n’a jamais été défini ; ou un payload soumis auquel manque un champ parce que le setter n’a jamais appelé setFormValue. Un autre piège qui mérite une note lors de la revue de code : insérer un input natif required dans un slot d’un élément personnalisé laisse le contrôle du DOM light actif dans le formulaire aux côtés de l’hôte, produisant une double participation. Évitez de placer des contrôles natifs dans des slots lorsque l’hôte est lui-même associé à un formulaire.

Utilisation dans un framework

Les intégrations framework pour les éléments personnalisés associés aux formulaires encapsulent exactement l’API ElementInternals brute présentée ici. Stencil offre une prise en charge de première classe depuis la v4.5.0 via un décorateur @AttachInternals, et son formStateRestoreCallback reçoit également un second argument mode, soit "restore" soit "autocomplete", indiquant la raison de la restauration. Lit n’intègre pas FACE nativement — vous ajoutez vous-même un petit mixin FormAssociated ou importez un mixin communautaire — mais ce mixin encapsule les mêmes indicateur formAssociated, attachInternals(), setFormValue() et callbacks de cycle de vie présentés ici. Maîtriser l’API de la plateforme directement vous permet de lire, déboguer ou remplacer n’importe lequel de ces wrappers.

Assemblez les pièces — le setter de valeur, la synchronisation de l’input interne, delegatesFocus, setValidity avec un message role="alert" lié, et les quatre callbacks de cycle de vie — et vous disposez d’un <text-field> unique qui soumet, valide, réinitialise et restaure de façon indiscernable d’un input natif. Intégrez-le dans un <form>, câblez un <label for>, et la prochaine étape concrète consiste à vérifier l’aller-retour : soumettez le formulaire, relisez la valeur depuis new FormData(form), et confirmez que la validation des contraintes bloque un champ obligatoire vide.

FAQ

Quelle est la différence entre l'événement formdata et ElementInternals pour ajouter des valeurs à un formulaire ?

L'événement formdata injecte des données arbitraires dans une soumission : vous l'écoutez sur le formulaire et appelez event.formData.append() pour ajouter des champs. ElementInternals construit un contrôle réutilisable qui participe pleinement au formulaire, notamment la validation des contraintes, l'étiquetage, la réinitialisation et la restauration d'état via setFormValue et setValidity. Utilisez l'événement formdata pour une injection de données ponctuelle ; recourez à ElementInternals lorsque vous avez besoin d'un contrôle qui se comporte comme un input natif.

Pourquoi attachInternals() lève-t-il une NotSupportedError ?

attachInternals() lève une NotSupportedError lorsque l'élément n'est pas un élément personnalisé autonome. Les éléments personnalisés associés aux formulaires doivent étendre HTMLElement directement, et non une sous-classe intégrée comme HTMLInputElement, et la classe doit être enregistrée via customElements.define avant l'instanciation. Appeler attachInternals() plus d'une fois sur le même élément lève également une erreur. Placez l'appel une seule fois dans le constructeur d'une classe qui étend HTMLElement pour éviter cette erreur.

delegatesFocus interfère-t-il avec la gestion manuelle du tabindex sur un élément personnalisé associé à un formulaire ?

Avec delegatesFocus défini à true sur le shadow root, vous n'avez généralement pas besoin de gérer tabindex manuellement. Le focus est délégué au premier élément focalisable à l'intérieur du shadow root, c'est-à-dire l'input interne déjà dans l'ordre de tabulation, de sorte que l'élément hôte transfère le focus automatiquement. Un clic sur label-for et la navigation au clavier atteignent tous deux l'input interne. Ajouter un tabindex manuel sur l'hôte peut créer un doublon dans l'ordre de tabulation, aussi omettez-le lorsque delegatesFocus gère le routage.

Que se passe-t-il lorsque vous appelez setFormValue avec null ?

Appeler internals.setFormValue(null) exclut totalement l'élément de la soumission du formulaire : son nom et sa valeur sont exclus de l'objet FormData produit par le formulaire. Cela diffère du passage d'une chaîne vide, qui soumet le champ avec une valeur vide. Utilisez null pour exclure délibérément un contrôle, par exemple un champ désactivé ou en lecture seule, et passez une chaîne de valeur réelle ou un FormData lorsque le contrôle doit contribuer à la soumission.

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.