In der Theorie können wir Webentwickler:innen mit Custom Elements alles erreichen, was der Browser mit nativen Elementen schafft. Eigene Tags, eigene Attribute, eigene Events, Kapselung mit Shadow DOM – an Werkzeugen herrscht kein Mangel. Selbst eigene Formular-Inputs sind machbar, aber wer auf Mastodon folgt, hat über diverse unzusammenhängende Posts mitbekommen, dass das gar nicht mal so einfach ist. Web-Formulare sind an sich schon kompliziert genug, aber eigene Formular-Elemente zu entwerfen, ist wahrlich noch ein dickes Stück komplexer. In diesem Artikel fasse ich ein paar der Dinge, die ich bis dato zu diesem Thema gelernt habe, etwas weniger unzusammenhängend als auf Mastodon zusammen. Weitere Artikel könnten folgen.

Begrifflichkeiten und Ziele

Das Ziel meiner Experimente ist, mit Custom Elements neue Formularfelder zu bauen, die im Hinblick auf Features, Verhalten und APIs den nativen Elementen in nichts nachstehen. Abstriche in auch nur einer dieser Kategorien zu machen, ruiniert die Kompatibilität der resultierenden Elemente. Nur Custom Elements, die sich 1:1 wie native Elemente verhalten, sind für sowohl für den Einsatz in handgeschriebenem HTML als auch in fetten Frontend-Frameworks als auch für Vanilla-DOM-Programmierung geeignet … und wenn diese Kompatibilität zu all dem nicht das Ziel ist, sollte man statt einer Web Component einfach eine Fette-Frontend-Framework-Komponente schreiben und sich sehr, sehr viel Arbeit ersparen. Keine Kompromisse!

Zuletzt möchte ich noch ein paar nicht offensichtliche Begriffe definieren, die ich im Folgenden zu Felde führen werde:

  • CFI: Custom Form Input, ein von mir soeben erfundener Begriff für Custom Elements, die im Allgemeinen form associated sind, im Speziellen den Use Case des Eingabefelds bedienen und vor allem die o.g. Kompatibilitäts-Ziele verfolgen.
  • Content-Attribut: via HTML oder setAttribute() gesetzter Attribut-Wert. Immer ein String und tritt praktisch immer zusammen mit einem entsprechenden IDL-Attribut auf.
  • IDL-Attribut: Per JavaScript Getter/Setter-Paar definierte API für ein Attribut. Kann andere Typen als string haben und tritt praktisch immer zusammen mit einem entsprechenden Content Attribut auf.
  • Constraint Validation: Auch als „HTML5-Validierungs-API“ bekannte eingebaute Validierungs-Features für Formulare.

Und nun stürzen wir uns einfach direkt in die nicht mehr ganz so unzusammenhängende Sammlung von Erkenntnissen zu Formular-Elementen, Teil 1 von N!

API-Boilerplate

Eine Custom-Element-Klasse kann sich per static formAssociated = true zum Form-Associated Element erklären und damit in den Club der Formular-Elemente <input>, <fieldset> und Co eintreten. Per attachInternals() erhalten wir Zugriff auf APIs für den Formular-Element-Zustand (v.a. setFormValue()). Wichtig hierbei ist, dass die Element Internals neben solchen privaten APIs auch allerlei öffentliche APIs enthalten: die checkValidity()-Methode, der form-Getter und alles Sonstige, was man an JS-APIs auf Inputs, Selects und Textareas so erwartet. Diese APIs muss eine CFI-Klasse mauell bereitstellen, in etwa wie folgt:

class CFI extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();

  get labels() {
    return this.#internals.labels;
  }

  get form() {
   return this.#internals.form;
  }

  get willValidate() {
    return this.#internals.willValidate;
  }

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

  reportValidity() {
    return this.#internals.reportValidity();
  }
}

Das implementiert alle JavaScript-Features, die man auf Formular-Inputs standardmäßig erwarten darf – abgesehen von jenen, die Wechselwirkungen mit Content- und IDL-Attributen haben, wie z. B. required, value und name. Und wo wir gerade bei name sind …

Formulare absenden

Damit ein CFI von einem Formular mit abgesendet werden kann, braucht es lediglich ein Content-Attribut name und absendbare Daten („submission value“). Letzteres kann über die setFormValue()-Methode der Element Internals definiert werden und ersteres benötigt streng genommen keinen IDL-Gegenpart und daher keine wirkliche Implementierung. Die minimale absendbare CFI-Klasse sieht also wie folgt aus:

class CFI extends HTMLElement {
  static formAssociated = true;
  constructor() {
    super();
    this.attachInternals().setFormValue("foobar");
  }
}

Die funktionierende Demo zeigt, dass es nicht nötig ist, name ordentlich (d. h. mit IDL-Attribut) zu definieren, aber im Rahmen der für uns definierten Ziele würde dieser Aufwand natürlich schon anfallen.

value State und Attribute

Wie gesehen wird der eigentliche Formularfeld-Wert über die setFormValue()-Methode der Element Internals festgelegt, wobei jedoch die Content- und IDL-Attribute value auch eine Rolle spielen. Der grobe Zusammenhang sieht wie folgt aus:

  1. Der form value initialisiert sich aus dem Content-Attribut value (sofern vorhanden). Standardwert ist der leere String.
  2. Ein dirty flag initialisert sich false.
  3. Der IDL-Getter value reflektiert den aktuellen form value, der IDL-Setter value setzt den aktuellen form value, aber nicht das Content-Attribut value.
  4. Solange das dirty flag false ist, führen Updates des Content-Attributes value zu Updates des form value. Wenn das dirty flag true ist, passiert das nicht mehr, denn getätigte Nutzereingaben sollten selbst bei sich veränderndem DOM erhalten bleiben.
  5. Das dirty flag wird true, sobald der IDL-Setter verwendet wird oder ein Nutzer auf eine Weise mit dem Input interagiert, die den form value verändert.
  6. Form-Resets (siehe unten) setzten das dirty flag zurück auf false und den form value auf den Wert des Content-Attributs value (sofern vorhanden). Standardwert ist auch hier der leere String.

Das dirty flag in der obigen Beschreibung stammt direkt aus den Spezifikationen und ist nicht mit dem user interacted flag zu verwechseln: letzteres erfährt beim Form-Reset selbst keinen Reset und ist ausschließlich dafür zuständig, dass die Pseudoklassen :user-valid und :user-invalid korrekt greifen.

Deaktivierung und Form-Resets

Standard-<input> und Co können mithilfe des boolschen disabled-Attribut (Content- und IDL-Attribut) deaktiviert werden, was für unsere Custom Elements genau so gelten sollte. Eine Deaktivierung kann aber auch ausgelöst werden, indem auf dem nächstgelegene Vorfahren-<fieldset> (sofern vorhanden) dessen disabled-Attribut (als Content- oder IDL-Attribut) gesetzt wird! Für Custom Elements bedeutet das: Der tatsächliche Aktiviertheits-Zustand eines CFI ergibt sich aus dem eigenen disabled-Attribut und dem disabled-Zustand des nächstgelegenen relevanten Fieldsets. Änderungen am letztgenannten Zustand sind über den Lifecycle-Callback formDisabledCallback() beobachtbar.

Der Aktiviertheits-Zustand eines Formularfelds ist nicht mit dem Mutability-Zustand zu verwechseln. Letzterer wird ausschließlich vom readonly-Attribut des betroffenen Formularfelds gesteuert und belässt das Element, anders als der Aktiviertheits-Zustand, validierbar und absendbar.

Einen Lifecycle-Callback gibt es auch für Formular-Resets. Dieser Tage sind Formular-Reset-Buttons nicht in Mode, aber sie können vorkommen! CFI sollten daher via formResetCallback() einen eigenen Reset-Algorithmus implementieren, der mindestens den formValue (auf den Wert des Content-Attributes value) und das dazugehörige dirty flag (auf false) zurücksetzt.

Formularvalidierung

Mit der setValidity()-Methode von Element Internals können wir unsere CFI im Rahmen der guten alten Contraint Validation API als ungültig ausgefüllt markieren. Das kann auf verschiedene Weisen nützlich sein, für die die drei möglichen Parameter von setValidity() gezielt jongliert werden wollen.

Parameter 1, validity, ist eine Sammlung von mit ValidityState-Objekten übereinstimmenden Flags, die die typischen Validierungsfehler anzeigen (valueMissing, tooLong etc.). Der Parameter bestimmt, wenig überraschend, den neuen ValidityState des CFI. Nicht angegebene Flags werden per Default auf false gesetzt, d. h. die entsprechenden Fehler liegen dann nicht vor.

Parameter 2, message, ist die anzuzeigende Fehlermeldung als String. Er muss (als nicht-leerer String) angegeben werden, wenn Parameter 1 mindestens einen Flag auf true setzt, kann aber weggelassen oder auf einen leeren String gesetzt werden, wenn kein Fehler vorliegt. Zusammen mit Parameter 1 ist es so ein Leichtes, setCustomValidity() für CFI zu implementieren (Demo):

class CFI extends HTMLElement {
  static formAssociated = true;
  #internals = this.attachInternals();
  setCustomValidity(msg) {
    this.#internals.setValidity({ customError: true }, msg);
  }
  get validationMessage() {
    return this.#internals.validationMessage;
  }
}

Parameter 3, anchor, hat einen extrem spezifischen, aber auch extrem nützlichen Use Case. Wenn unser CFI in seinem Shadow DOM andere Form-Associated Elements enthält und primär als Wrapper um diese Elemente fungiert (z. B. im Rahmen einer Pattern Library), muss der Validierungs-State des CFI den Validierungs-State der gewrappten Elemente widerspiegeln. Das ist nötig, da die Elemente im Shadow DOM vom Formular des CFI isoliert sind und somit selbst gar nicht der Constraint Validation unterliegen. Die Elemente können aber schon dafür genutzt werden, Validierungsfehler anzuzeigen, bei Auftreten eines Fehlers Fokus zu erhalten und ganz allgemein ist es im genannten Szenario korrekt, wenn die Verursacher-Elemente ihr Standard-Fehlerverhalten zeigen und das Wrapper-Element nur das Bindeglied zwischen Formular und Shadow DOM spielt.

In Konsequenz bedeutet das: wenn ein CFI ein Wrapper um andere Inputs ist und Validierungsfehler aus den gewrappen Inputs stammen, können wir mit dem anchor-Parameter den Validierungs-State des CFI als Fehler auf dem gewrappen Input anzeigen. Das ist z. B. im Pattern-Library-Szenario auch genau, was wir wollen. Umgekehrt folgt daraus, dass wenn das CFI kein Wrapper um andere Inputs ist, es keinen anchor und damit auch keine Standard-UX für Validierungsfehler-Anzeige gibt. Solche Non-Wrapper-CFI müssten hierfür eine ganz eigene UX erfinden.

Zwischenstand und Ausblick

Die in diesem Post gesammelten Erkenntnisse konnte ich in einem @formElement-Decorator so zusammenfassen, dass es damit (und mit Ornament) einigermaßen einfach möglich wird, simple Wrapper-CFI zu definieren. Die folgenden Zeilen implementieren eine Abstraktion über ein <input type="number">, die nur ganze Zahlen zulässt, aber u. a. required und disabled weiter als Attribute unterstützt:

import { render, html } from "uhtml";
import { define, attr, int, bool, reactive, debounce } from "@sirpepe/ornament";

@define("int-input")
@formElement()
export class IntInput extends HTMLElement {
  #root = this.attachShadow({
    mode: "closed",
    delegatesFocus: true
  });

  @attr(bool()) accessor required = false;
  @attr(int({ nullable: true })) accessor min = null;
  @attr(int({ nullable: true })) accessor max = null;

  @reactive()
  @debounce({ fn: debounce.raf() })
  render() {
    render(
      this.#root,
      html`
        <input
          value="${this.defaultValue}"
          min=${this.max ?? ""}
          min=${this.max ?? ""}
          step="1"
          type="number"
          ?disabled=${this.disabledState}
          ?required=${this.required} />`
    );
  }
}

Für das, was es tut, ist das fast eine akzeptable Code-Menge! Immerhin leisten diese paar Zeilen:

  1. Definition eines neuen Form-Associated Custom Element
  2. Implementierung der öffentlichen Standard-Formular-APIs (setCustomValidity() etc.)
  3. Implementierung der öffentlichen Standard-Formular-Attribute name, disabled und value (mit Content- und IDL-Attributen) mit dazugehörigem Verhalten bei Resets und deaktivierten Fieldset-Vorfahren
  4. Implementierung der zusätzlichen Attribute required, min, und max (mit Content- und IDL-Attributen, wobei min und max sich sogar die Mühe machen, ihre States als BigInt zu repräsentieren)
  5. Durchschleifen von Value- und Validity-Updates des gewrappten Inputs an den Wrapper, Meldung der Validity-Errors mit dem gewrappten Input als anchor

Die Dependency-Kosten belaufen sich allein auf die <4k von Ornament (theoretisch optional, würde aber die Komplexität des Rests explodieren lassen), die Rendering-Engine uhtml (theoretisch könnte auch jeder andere Render-Mechanismus verwendet werden) und die 250 Zeilen Form-Decorator.

Das ist ein brauchbarer Zwischenstand, aber gibt es noch viel mehr zu erforschen: CFI könnten mehr als nur ein Form-Element wrappen, was ein komplexeres Value-Composing notwendig machen würde. Um diverse Lifecycle-Events wie formStateRestoreCallback habe ich mich noch gar nicht gekümmert und das leidige Thema der TypeScript-Kompatibilität steht auch noch im Raum. Dazu (und zu bestimmt noch vielen anderen Features und Randaspekten) dann mehr in folgenden Artikeln.