12k
All articles

Wiederverwendbare Formularsteuerelemente mit Web Components erstellen

Erstellen Sie mit Web Components und ElementInternals ein wiederverwendbares Formularfeld. Mit nativer Übermittlung, Validierung, Labels, Reset und Restore.

OpenReplay Team
OpenReplay Team
Wiederverwendbare Formularsteuerelemente mit Web Components erstellen

Ein benutzerdefiniertes Element, das ein <input> innerhalb eines Shadow DOM kapselt, ist für das übergeordnete <form> unsichtbar: Sein Wert gelangt nie in FormData, die native Validierung überspringt es, und ein Reset lässt es unberührt. Die Lösung besteht aus zwei Zeilen — static formAssociated = true sowie this.attachInternals() im Konstruktor — und der ElementInternals-API, die diese Zeilen freischalten. Dieser Artikel erstellt ein vollständiges <text-field>-Steuerelement, das wie ein natives Input übermittelt, validiert, beschriftet, zurückgesetzt und wiederhergestellt wird — ausschließlich mit der nativen Plattform-API.

Mit der Veröffentlichung von Safari 16.4 im März 2023 erreichten Web Components einen Meilenstein in ihrer Fähigkeit, über die ElementInternals-API mit dem Formularelement zu interagieren; zuvor waren Input-Elemente innerhalb eines Shadow DOM für Formulare nicht auffindbar, was bedeutete, dass sie bei der Übermittlung nicht validiert wurden und ihre Daten nicht im FormData-Objekt enthalten waren. Diese Kompatibilitätsfrage ist nun geklärt, weshalb der Artikel das übliche „Kann ich das schon verwenden?”-Zögern überspringt und sich auf das vollständige Rezept konzentriert, das keines der älteren Tutorials von Anfang bis Ende zusammenstellt.

Wichtigste Erkenntnisse

  • Zwei Zeilen verwandeln jedes autonome benutzerdefinierte Element in ein Formularelement: static formAssociated = true deklariert die Absicht, und this.attachInternals() gibt das ElementInternals-Objekt zurück, das Ihr Element mit dem übergeordneten Formular verbindet.
  • Ab September 2025 sind formularassoziierte benutzerdefinierte Elemente als Baseline Widely Available eingestuft — unterstützt in Chrome seit Version 77, Edge seit 79, Firefox seit 98 und Safari seit 16.4 — sodass der alte Vorbehalt „Wird das schon unterstützt?” nicht mehr gilt.
  • Der Wert eines Steuerelements gelangt nur dann in FormData, wenn Sie internals.setFormValue(value) aufrufen; die Übergabe von null schließt das Element vollständig aus der Übermittlung aus.
  • setValidity() blockiert die Übermittlung und wendet :invalid an, aber eine barrierefreie Validierung erfordert zusätzlich eine sichtbare, per aria-describedby verknüpfte Fehlermeldung — andernfalls verweigert das Formular stillschweigend die Übermittlung.
  • Von den zwei im Standard definierten Zustandswiederherstellungsmodi wird nur "restore" zuverlässig in aktuellen Browsern ausgelöst; "autocomplete" ist spezifiziert, aber nicht zuverlässig implementiert.

Warum Shadow DOM die native Formularteilnahme unterbricht

Ein natives <input> nimmt nur dann an einem Formular teil, wenn es im selben DOM-Baum vom <form> abstammt. Shadow DOM ist ein separater Baum, sodass ein Input, der im Shadow Root eines benutzerdefinierten Elements gerendert wird, für das Formular strukturell unsichtbar ist: Der Browser fügt es nie zu form.elements hinzu, erfasst seinen Wert nie in FormData und führt keine Constraint-Validierung dagegen durch. Dies ist der bewusst eingegangene Kompromiss der Kapselung — und genau das Problem, für dessen Lösung formularassoziierte benutzerdefinierte Elemente (FACE) entwickelt wurden.

Die Plattformantwort lautet ElementInternals. Die Methode HTMLElement.attachInternals() gibt ein ElementInternals-Objekt zurück; diese Methode ermöglicht es einem benutzerdefinierten Element, an HTML-Formularen teilzunehmen, und die Schnittstelle stellt Hilfsmittel bereit, um mit diesen Elementen genauso zu arbeiten wie mit jedem standardmäßigen HTML-Formularelement, während sie dem Element gleichzeitig das Accessibility Object Model zugänglich macht. FACE ist kein experimentelles Feature mehr: Formularassoziierte benutzerdefinierte Elemente sind seit dem 27. September 2025 als Baseline Widely Available eingestuft, unterstützt in Chrome 77 (2019), Edge 79 (2020), Firefox 98 (2022) und Safari 16.4, veröffentlicht am 27. März 2023. Ein Community-Paket element-internals-polyfill existiert weiterhin für ältere Engines, aber angesichts der Baseline-Unterstützung in allen Evergreen-Browsern benötigen die meisten Projekte es nicht mehr.

Eine harte Einschränkung: Das Hinzufügen einer statischen formAssociated-Eigenschaft mit dem Wert true macht ein autonomes benutzerdefiniertes Element zu einem formularassoziierten benutzerdefinierten Element. Das Element muss direkt von HTMLElement erben — nicht von einer eingebauten Unterklasse — und attachInternals() wirft einen NotSupportedError, wenn das Element kein benutzerdefiniertes Element ist.

Das minimale formularassoziierte benutzerdefinierte Element

Das kleinste sinnvolle Steuerelement deklariert formAssociated, holt sich seine Internals und übergibt seinen Wert über einen Setter an das Formular. Alles andere baut auf diesem Grundgerüst auf.

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() ist hier der entscheidende Aufruf. Der Wert eines Steuerelements gelangt nur dann in FormData, wenn Sie internals.setFormValue(value) aufrufen; übergeben Sie null, wird das Element vollständig aus der Übermittlung ausgeschlossen. Das Setter-Muster stellt sicher, dass jede Zuweisung an .value — aus Skript, einem Attribut oder Benutzereingabe — den übermittelten Wert neu synchronisiert. Nach der Assoziation verhält sich das Element wie ein eingebautes: Die elements-Sammlung des Formulars enthält alle <button>, <fieldset>, <input>, <object>, <output>, <select>, <textarea> sowie formularassoziierte benutzerdefinierte Elemente, die dem Formular zugeordnet sind.

Wenn Sie nur beliebige Daten in eine Übermittlung einspeisen möchten — ohne ein wiederverwendbares Steuerelement zu erstellen — reicht das leichtgewichtigere formdata-Event aus: Lauschen Sie darauf am Formular und rufen Sie event.formData.append(...) auf. Greifen Sie auf ElementInternals zurück, wenn Sie ein Steuerelement erstellen, das validieren, beschriften, zurücksetzen und wiederherstellen muss.

Das interne Input rendern und synchronisieren

Das Steuerelement benötigt ein internes <input>, dessen Wert über den öffentlichen Setter nach außen fließt und dessen input/change-Events vom Host neu ausgelöst werden, sodass Listener auf <text-field> sich genauso verhalten wie bei einem nativen Feld. Da ein Shadow Root nur einmal angehängt werden kann, wird die Einrichtung abgesichert, damit ein Trennen und erneutes Verbinden attachShadow() nicht ein zweites Mal aufruft (was einen Fehler werfen würde):

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-syncs setFormValue
      this.#validate();                      // real-time validity
    });
    this.input.addEventListener('change', (e) => {
      this.dispatchEvent(new e.constructor(e.type, e));
    });
  }

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

Native change-Events überschreiten die Shadow-Grenze nicht, daher wird das Event durch new e.constructor(e.type, e) geklont und vom Host neu ausgelöst, um es im Light DOM zu emittieren, wo übergeordneter Code es empfangen kann. Das Steuern von this.value über den input-Listener des Inputs hält den übermittelten FormData-Wert bei jedem Tastendruck aktuell.

Beschriftung, Fokusdelegierung und Tabindex

Ein natives <label for="..."> sollte das Steuerelement fokussieren, wenn es angeklickt wird. Bei einem formularassoziierten benutzerdefinierten Element kommt diese Verknüpfung von delegatesFocus: true am Shadow Root. Das Setzen von delegatesFocus: true bewirkt, dass ein Klick auf <label for> den Fokus auf Ihr internes Input verschiebt — genau wie bei einem nativen Feld. Ohne diese Einstellung bewirkt ein Klick auf das Label nichts, und das Fokussieren des Hosts erreicht das Input nicht. Da ein FACE ein beschriftbares Element ist, schreiben Sie das Markup genau so, wie Sie es für ein natives Input tun würden:

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

Die Verknüpfung von Screen-Reader-Labels mit benutzerdefinierten Steuerelementen war historisch gesehen über verschiedene Browser- und Hilfstechnologie-Kombinationen hinweg inkonsistent. Testen Sie daher mit Ihren Ziel-Hilfstechnologien und behalten Sie ein <label> im Shadow Root als Fallback, wenn Sie eine breite Gerätematrix unterstützen. Mit gesetztem delegatesFocus müssen Sie tabindex in der Regel nicht manuell verwalten — der Fokus wird an das interne Input delegiert, das sich bereits in der Tab-Reihenfolge befindet.

Validierung mit setValidity() und den vollständigen ValidityStateFlags

ElementInternals.setValidity() spiegelt das native Constraint-Validation-System wider. Sie übergeben ein ValidityStateFlags-Objekt, eine optionale lesbare Fehlermeldung und ein optionales Ankerelement; der Browser wendet dann die Pseudoklasse :invalid an und blockiert die Übermittlung. Mit dieser Einrichtung wird die Pseudoklasse :invalid automatisch auf das Element angewendet, sobald ein Flag gesetzt ist. Die Übergabe eines leeren Objekts {} löscht alle Fehler.

Die Flags entsprechen eins zu eins den nativen Validierungsgründen:

FlagAnaloger nativer Auslöser
valueMissingrequired ohne Wert
typeMismatchWert passt nicht zum type (z. B. email)
patternMismatchWert erfüllt pattern nicht
tooLong / tooShortüberschreitet maxlength / unterschreitet minlength
rangeUnderflow / rangeOverflowunter min / über max
stepMismatchWert nicht auf step ausgerichtet
badInputnicht parsbarer Eingabewert
customErrorautorenseitig definierter Fehler über benutzerdefinierte Meldung

Validieren Sie bei jedem input-Event für Echtzeit-Feedback und stellen Sie die Standardmethoden (checkValidity(), reportValidity(), validity, validationMessage) bereit, damit Aufrufer Ihr Steuerelement wie ein natives Feld behandeln können:

#validate() {
  const flags = {};
  let message = '';
  if (this.hasAttribute('required') && !this.#value) {
    flags.valueMissing = true;
    message = 'This field is required.';
  }
  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() ist nur die halbe Miete bei barrierefreier Validierung: Der Browser blockiert zwar die Übermittlung und wendet :invalid an, aber ohne eine sichtbare, per aria-describedby verknüpfte Fehlermeldung erhält der Benutzer ein Formular, das stillschweigend die Übermittlung verweigert. Der #renderError-Schritt wird im Abschnitt zur Barrierefreiheit behandelt.

Die vier Formular-Lifecycle-Callbacks

Formularassoziierte benutzerdefinierte Elemente erhalten vier zusätzliche Reaktions-Callbacks über den standardmäßigen Custom-Element-Lifecycle hinaus. Dies ist das vollständigkeitsdefinierende Element, das die meisten Tutorials auslassen. formAssociatedCallback(form) wird aufgerufen, wenn sich das zugehörige Formular ändert; formResetCallback() wird aufgerufen, wenn das Formular zurückgesetzt wird und das Element den vom Benutzer gesetzten Wert löschen soll; formDisabledCallback(isDisabled) wird aufgerufen, wenn sich der Deaktivierungsstatus ändert; und formStateRestoreCallback(state, reason) wird aufgerufen, wenn der Browser den Zustand des Elements wiederherstellt (Grund "restore") oder Autofill ausführt (Grund "autocomplete").

CallbackWird ausgelöst, wennArgumentTypischer Inhalt
formAssociatedCallbackElement erhält/verliert einen Formulareigentümerdas neue form (oder null)formularabhängigen Zustand verknüpfen
formDisabledCallbackDeaktivierungsstatus des Hosts oder eines übergeordneten fieldset wechseltbooleanauf das interne Input spiegeln
formResetCallbackFormular wird zurückgesetztkeineStandardwert wiederherstellen
formStateRestoreCallbackVor-/Zurück-Navigation, Neuladen, Autofillwiederhergestellter Zustand, Grundaus gespeichertem Zustand rehydrieren
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;
}

Die Attribute disabled und readonly haben hier eine spezifizierte Bedeutung: Das disabled-Attribut macht ein formularassoziiertes benutzerdefiniertes Element nicht interaktiv und verhindert, dass sein Übermittlungswert übertragen wird, während das readonly-Attribut festlegt, dass das Element von der Constraint-Validierung ausgeschlossen ist.

Zustandswiederherstellung gemäß Spezifikation

setFormValue() akzeptiert ein optionales zweites Argument — setFormValue(value, state) — wobei state eine clientseitige Repräsentation ist, die nicht an den Server gesendet wird. Sie ist nützlich, um einen reichhaltigeren internen Zustand wiederherzustellen als den bloßen Übermittlungswert. Die Spezifikation ist nun eindeutig darüber, was der Restore-Callback empfängt: Wenn der User Agent den Wert eines formularassoziierten benutzerdefinierten Elements im Namen eines Benutzers oder im Rahmen der Navigation aktualisiert, wird sein formStateRestoreCallback aufgerufen und erhält den neuen Zustand sowie einen String, der den Grund angibt: "autocomplete" oder "restore". Eine frühere Inkonsistenz in der Spezifikation führte zu einer echten Divergenz — Chromium rief den Callback mit dem Wert auf, während WebKit ihn mit dem Zustand aufrief — was jedoch zugunsten des Zustands gelöst wurde. Wenn Sie ältere Chromium/Edge-Versionen unterstützen, behandeln Sie das Argument defensiv.

Nur "restore" ist heute zuverlässig. Firefox hat die Unterstützung für formStateRestoreCallback ausgeliefert, merkt jedoch an, dass 'autocomplete' für benutzerdefinierte Elemente weiterhin nicht unterstützt wird. Betrachten Sie "restore" (Zurück-Navigation und Neuladen) als den einzigen Modus, auf den Sie browserübergreifend zuverlässig setzen können.

Barrierefreiheit richtig umgesetzt

Validität zu setzen ist nicht dasselbe wie sie zu kommunizieren. Um Fehler wahrnehmbar zu machen, rendern Sie eine Meldung innerhalb des Shadow Root, verknüpfen Sie sie über aria-describedby mit dem internen Input und markieren Sie sie als Live-Region, damit Hilfstechnologien sie ankündigen:

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

Da sowohl das Input als auch die Fehlermeldung im selben Shadow Root liegen, wird aria-describedby korrekt innerhalb dieses Baums aufgelöst. Die eigenen ARIA-Semantiken des Host-Elements können über ElementInternals-Eigenschaften gesetzt werden — diese Schnittstelle wird nun breit unterstützt, einschließlich ElementInternals.role und der aria*-Reflektionseigenschaften.

Ein häufiges Fehlermuster in der Produktion ist das halb fertige Steuerelement, das fertig aussieht, aber stillschweigend versagt — und genau diese Fehler deckt Session Replay auf, da sie keinen Konsolenfehler werfen. Replay zeigt einen Benutzer, der wiederholt auf „Absenden” klickt bei einem Steuerelement, das valueMissing meldet, aber keine sichtbare Fehlermeldung anzeigt; einen Label-Klick, der ins Leere geht, weil delegatesFocus nie gesetzt wurde; oder eine übermittelte Nutzlast, der ein Feld fehlt, weil der Setter nie setFormValue aufgerufen hat. Eine weitere Falle, die in Code-Reviews einen Hinweis verdient: Das Einbetten eines nativen required-Inputs in ein benutzerdefiniertes Element über einen Slot lässt das Light-DOM-Steuerelement aktiv im Formular neben dem Host, was zu doppelter Formularteilnahme führt. Halten Sie native Steuerelemente aus Slots heraus, wenn der Host selbst formularassoziiert ist.

Verwendung innerhalb eines Frameworks

Framework-Integrationen für formularassoziierte benutzerdefinierte Elemente kapseln exakt die hier gezeigte rohe ElementInternals-API. Stencil liefert ab Version 4.5.0 erstklassige Unterstützung über einen @AttachInternals-Decorator, und sein formStateRestoreCallback empfängt ebenfalls ein zweites mode-Argument — entweder "restore" oder "autocomplete" — das den Grund der Wiederherstellung angibt. Lit baut FACE nicht ein — Sie fügen selbst ein kleines FormAssociated-Mixin hinzu oder importieren ein Community-Mixin — aber dieses Mixin kapselt dasselbe formAssociated-Flag, attachInternals(), setFormValue() und die hier gezeigten Lifecycle-Callbacks. Die Plattform-API direkt zu kennen bedeutet, dass Sie jeden dieser Wrapper lesen, debuggen oder ersetzen können.

Fügen Sie die Bausteine zusammen — den Value-Setter, die Synchronisierung des internen Inputs, delegatesFocus, setValidity mit einer verknüpften role="alert"-Meldung und alle vier Lifecycle-Callbacks — und Sie haben ein einzelnes <text-field>, das ununterscheidbar von einem nativen Input übermittelt, validiert, zurücksetzt und wiederherstellt. Binden Sie es in ein <form> ein, verknüpfen Sie ein <label for>, und der nächste konkrete Schritt ist die Überprüfung des vollständigen Durchlaufs: Übermitteln Sie das Formular, lesen Sie den Wert aus new FormData(form) zurück und bestätigen Sie, dass die Constraint-Validierung ein leeres Pflichtfeld blockiert.

Häufig gestellte Fragen

Was ist der Unterschied zwischen dem formdata-Event und ElementInternals beim Hinzufügen von Werten zu einem Formular?

Das formdata-Event injiziert beliebige Daten in eine Übermittlung: Sie lauschen darauf am Formular und rufen event.formData.append() auf, um Felder hinzuzufügen. ElementInternals erstellt ein wiederverwendbares Steuerelement, das vollständig am Formular teilnimmt, einschließlich Constraint-Validierung, Beschriftung, Reset und Zustandswiederherstellung über setFormValue und setValidity. Verwenden Sie das formdata-Event für schnelle einmalige Dateneinspeisungen; greifen Sie auf ElementInternals zurück, wenn Sie ein Steuerelement benötigen, das sich wie ein natives Input verhält.

Warum wirft attachInternals() einen NotSupportedError?

attachInternals() wirft einen NotSupportedError, wenn das Element kein autonomes benutzerdefiniertes Element ist. Formularassoziierte benutzerdefinierte Elemente müssen direkt von HTMLElement erben, nicht von einer eingebauten Unterklasse wie HTMLInputElement, und die Klasse muss vor der Instanziierung über customElements.define registriert sein. Auch das mehrfache Aufrufen von attachInternals() auf demselben Element wirft einen Fehler. Platzieren Sie den Aufruf einmalig im Konstruktor einer Klasse, die HTMLElement erweitert, um den Fehler zu vermeiden.

Beeinträchtigt delegatesFocus die manuelle Tabindex-Verwaltung bei einem formularassoziierten benutzerdefinierten Element?

Wenn delegatesFocus auf true am Shadow Root gesetzt ist, müssen Sie tabindex in der Regel nicht manuell verwalten. Der Fokus wird an das erste fokussierbare Element innerhalb des Shadow Root delegiert, also das interne Input, das sich bereits in der Tab-Reihenfolge befindet, sodass das Host-Element den Fokus automatisch weiterleitet. Sowohl ein Klick auf label-for als auch die Tastaturnavigation per Tab erreichen das interne Input. Das Hinzufügen eines manuellen tabindex am Host kann einen doppelten Tab-Stop erzeugen, daher sollte er weggelassen werden, wenn delegatesFocus das Routing übernimmt.

Was passiert, wenn Sie setFormValue mit null aufrufen?

Der Aufruf von internals.setFormValue(null) entfernt das Element vollständig aus der Formularübermittlung: Name und Wert werden aus dem FormData-Objekt ausgeschlossen, das das Formular erzeugt. Dies unterscheidet sich von der Übergabe eines leeren Strings, der das Feld mit einem leeren Wert übermittelt. Verwenden Sie null, um ein Steuerelement bewusst auszuschließen — etwa ein deaktiviertes oder schreibgeschütztes Feld — und übergeben Sie einen tatsächlichen Wert-String oder FormData, wenn das Steuerelement zur Übermittlung beitragen soll.

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.