Just add Code (Teil 2)

Github Catalyst für Progressive Enhancement mit Web Components

Dass Web Components ein gutes Mittel sind, um in sich gekapselt Logik für Progressive Enhancement an den Browser auszuliefern hatten wir uns ja schon angeschaut. In diesem Teil schauen wir uns an, wie GitHubs Catalyst uns dabei unterstützt, Web Components zu entwickeln, die Progressive Enhancement einfach machen.

This blog post is also available in English

Previously on ‚just add Code‘

Der erste Teil der Vorstellung hat in einem einfachen Beispiel gezeigt wie wir Progressive Enhancement mit Stimulus umsetzen. In diesem Teil werden wir das gleiche Beispiel mit Hilfe von Catalyst durchspielen und sehen, wo die Unterschiede und Gemeinsamkeiten liegen.

Zur Erinnerung noch einmal unser Beispiel: wir nehmen ein einfaches <input> Feld und geben diesem – wenn der Browser unseren Code lädt – die Möglichkeit, den Inhalt des Textfelds entweder automatisch in die Zwischenablage zu kopieren, oder den Inhalt der Zwischenablage in das Textfeld einzufügen. Da das nötige JavaScript API noch vergleichsweise neu ist und noch nicht in allen Browsern gleich gut funktioniert nutzen wir dies als Chance, Progressive Enhancement noch weiter zu ziehen und nur an Funktion anzubieten, was auch wirklich funktioniert.

<body>
  <main>
    <h1>Progressive Custom Element Demo</h1>
    <section class="demo">
      <input type="text" name="input" />
      <button class="hidden">Copy</button>
      <button class="hidden">Paste</button>
    </section>
  </main>
</body>
Unser HTML Grundgerüst

Die beiden Buttons sind dabei initial ausgeblendet, weil wir sie nur anbieten wollen, wenn sie auch etwas sinnvolles tun können:

.hidden {
  display: none;
}

Das komplette Beispiel steht als öffentliches GitHub-Repository zur Verfügung und kann damit einfach lokal nachvollzogen werden.

Catalyst

Catalyst ist eine auf Typescript basierende Library, die einem bei der Erstellung von Custom Elements hilft. Im Gegensatz zu Stimulus ist das Ergebnis von Catalyst Code also immer ein neues Custom Element – allerdings ohne, dass man selbst die Definition oder Registrierung beim Browser macht. Man kann sich also voll auf die Funktionalität konzentrieren.

Catalyst setzt recht weitgehend auf Typescript Decorators, um die Menge an Boilerplate klein zu halten. Da diese in Typescript selbst noch ein experimentelles Feature sind, muss man sie explizit in seiner tsconfig.json aktivieren, wenn man das Projekt aufsetzt. Sollte das aus irgendeinem Grund nicht möglich sein, zeigt die Catalyst-Dokumentation auch immer, was hinter den Kulissen passiert, so dass man die notwendige Funktionalität nachbauen kann – eben einfach mit mehr Code.

Die Element Klasse

In Catalyst ist die zentrale Implementierung somit eine Ableitung des generischen HTMLElement, wie es in der DOM API definiert ist. Dies erweitert man noch mit dem von Catalyst zur Verfügung gestellten @controller Decorator um die notwendige Registrierung im Browser zu triggern:

import { controller } from "@github/catalyst"

@controller
export class EnhancedInputElement extends HTMLElement {
  connectedCallback() {
    console.log('EnhancedInputElement connected');
  }
}
Der Catalyst Controller

Den Namen für unser neues Custom Element leitet Catalyst für uns aus dem Namen der Klasse ab. Ein allfällig vorhandenes Element wird entfernt und das CamelCasing wird in kebab-casing umgewandelt. So haben wir selbst Einfluss auf das Naming. Im Beispiel wird aus EnhancedInputElement der Element-Name <enhanced-input>.

<body>
  <main>
    <h1>Catalyst Controller Demo</h1>
    <section class="demo">
      <enhanced-input>
        <input type="text" name="input" />
        <button class="hidden">Copy</button>
        <button class="hidden">Paste</button>
      </enhanced-input>
    </section>
  </main>
</body>
Verwendung des neuen Custom Elements

Da wir connectedCallback() – den Standard-Callback des Custom Element API, den der Browser aufruft wenn Custom Elements in den DOM gehängt werden – implementiert haben, sollten wir nun die Meldung EnhancedInputElement connected in der Browser-Konsole sehen.

Binding von vorhandenem Markup

Auch in Catalyst heisst das Konzept, mit dem man Child-Elemente innerhalb seines Custom Elements an Properties bindet „Targets“. Mit Catalyst definieren wir die notwendigen Properties aber selbst und können so auch die Typen aus dem DOM API nutzen. Um das Binding zu erzeugen annotiert man diese Properties mit @target oder @targets. Catalyst übernimmt dann das Suchen innerhalb des Scopes des Custom Elements und die Definition der notwendigen Getter (die Dokumentation zeigt recht schön, wie Targets ohne Decorators funktionieren).

import { controller, target } from "@github/catalyst"

@controller
export class EnhancedInputElement extends HTMLElement {
  @target copyButton: HTMLButtonElement;
  @target pasteButton: HTMLButtonElement;
  @target input: HTMLInputElement;

  connectedCallback() {
    console.log('EnhancedInputElement connected');
  }
}
Catalyst Controller mit Targets

Im HTML vervollständigt man dieses Binding, indem man den entsprechenden Elementen ein data-target Attribut hinzufügt, dass in der Form {controller-name}.{targetName} angibt, an welche Property das Element gebunden werden soll.

<body>
  <main>
    <h1>Catalyst Controller Demo</h1>
    <section class="demo">
      <enhanced-input>
        <input data-target="enhanced-input.input" type="text" name="input" />
        <button data-target="enhanced-input.copyButton" class="hidden">Copy</button>
        <button data-target="enhanced-input.pasteButton" class="hidden">Paste</button>
      </enhanced-input>
    </section>
  </main>
</body>
Binding der Target Elemente

Neben dem hier gezeigten @target gibt es auch die Annotation @targets, die anstelle eines einzelnen Elements ein Array an Elementen bindet (also intern querySelectorAll anstelle von querySelector abbildet). Demzufolge kann der gleiche Name innerhalb von mehreren data-target Attributen verwendet werden.

Außerdem bezieht sich Catalyst mit Targets immer implizit auf den Scope ‚innerhalb des Elements‘. Damit lassen sich Custom Elements schachteln und Elemente können zu mehreren Controllern gehören, indem mehr als eine Deklaration als Wert von data-target angegeben wird.

<body>
  <main>
    <h1>Catalyst Controller Demo</h1>
    <my-list>
      <enhanced-input>
        <input data-target="enhanced-input.input my-list.inputs" type="text" name="input" />
        <button data-target="enhanced-input.copyButton" class="hidden">Copy</button>
        <button data-target="enhanced-input.pasteButton" class="hidden">Paste</button>
      </enhanced-input>
      <enhanced-input>
        <input data-target="enhanced-input.input my-list.inputs" type="text" name="input" />
        <button data-target="enhanced-input.copyButton" class="hidden">Copy</button>
        <button data-target="enhanced-input.pasteButton" class="hidden">Paste</button>
      </enhanced-input>
    </my-list>
  </main>
</body>
Beispiel für geschachtelte Controller

Nun, da die Elemente effektiv mit unseren Properties verbunden sind, können wir diese nutzen, um aus unserem Controller heraus die Buttons basierend auf den Fähigkeiten des Browsers zu aktivieren.

import { controller, target } from "@github/catalyst"

@controller
export class EnhancedInputElement extends HTMLElement {
  @target copyButton: HTMLButtonElement;
  @target pasteButton: HTMLButtonElement;
  @target input: HTMLInputElement;

  connectedCallback() {
    console.log('EnhancedInputElement connected');
    if (navigator.clipboard) {
      if (navigator.clipboard.writeText) {
        this.copyButton.classList.toggle('hidden');
      }
      if (navigator.clipboard.readText) {
        this.pasteButton.classList.toggle('hidden');
      }
    }
  }
}
Aktivieren der Buttons

Reagieren auf Events

Zum Behandeln von Events definieren wir als erstes unsere Handler als eigenständige Methoden des Controllers. Dabei stehen uns alle Features von Typescript offen. Wir nutzen zum Beispiel async um einfach und ohne Promises mit dem Clipboard API umgehen zu können.

import { controller, target } from "@github/catalyst"

@controller
export class EnhancedInputElement extends HTMLElement {
  @target copyButton: HTMLButtonElement;
  @target pasteButton: HTMLButtonElement;
  @target input: HTMLInputElement;

  connectedCallback() {
    console.log('EnhancedInputElement connected');
    if (navigator.clipboard) {
      if (navigator.clipboard.writeText) {
        this.copyButton.classList.toggle('hidden');
      }
      if (navigator.clipboard.readText) {
        this.pasteButton.classList.toggle('hidden');
      }
    }
  }

  async copy(): Promise<void> {
    console.log("copying from the input field");
    await navigator.clipboard.writeText(this.input.value);
    console.log("✅ done. Go try pasting somewhere.");
  }

  async paste(): Promise<void> {
    console.log("pasting to the input field");
    const content = await navigator.clipboard.readText();
    this.input.value = content;
    console.log("✅ done");
  }
}
Controller mit Handler-Methoden

Hier zeigt sich einer der Unterschiede von Catalyst zu Stimulus. Da alle Properties und Methoden in der gleichen Klasse sind und somit im gleichen Namensraum existieren muss ich selbst für Eindeutigkeit und sprechende Namen sorgen, also die Referenzen auf die <button> Elemente z.B. mit xxxButton benennen.

Das Konzept um Events aus dem DOM an Methoden des Controllers zu binden heisst in Catalyst ebenfalls „Actions“. Wie vorher für die Targets gibt es nur ein Attribut, mit dem man alle Elemente erweitert von denen man Events behandeln will: data-action.

In data-action schreibt man in der Form {event}:{controller}#{method}

  1. Welches Event ({event}) des Elements man an
  2. welche Methode (#{method})
  3. welches Controllers ({controller}) binden (:) will

Wiederum sind mehrere Events definierbar, indem man sie durch Leerzeichen trennt data-action="{event-1}"{controller-1}#{method-1} {event-2}:{controller-2}#{method-2}". Analog zu Targets kann der Controller dabei irgendein Controller sein, der im DOM-Tree oberhalb des annotierten Elements steht, das Schachteln von Controllern ist also wieder unterstützt.

<body>
  <main>
    <h1>Catalyst Controller Demo</h1>
    <section class="demo">
      <enhanced-input>
        <input data-target="enhanced-input.input" type="text" name="input" />
        <button data-action="click:enhanced-input#copy" data-target="enhanced-input.copyButton" class="hidden">Copy</button>
        <button data-action="click:enhanced-input#paste" data-target="enhanced-input.pasteButton" class="hidden">Paste</button>
      </enhanced-input>
    </section>
  </main>
</body>
Binding der Actions

Konfiguration über Attribute

Wir wollen unsere „Enhanced Input“ Komponente wieder flexibler gestalten, indem wir es erlauben, deklarativ über Attribute zu definieren, ob Copy oder Paste enabled sein sollen (wenn der Browser dies auch unterstützt).

Catalyst nennt das Konzept für das notwendige Binding „Attrs“. Mit dem dabei definierten @attr Decorator kann man wieder einfach Properties in seinem Controller annotieren und Catalyst übernimmt das notwendige Binding auf ein HTML-Attribut. Die Attribute folgen dem Namens-Schema data-{attr-name}.

import { controller, target, attr } from "@github/catalyst"

@controller
export class EnhancedInputElement extends HTMLElement {
  @target copyButton: HTMLButtonElement;
  @target pasteButton: HTMLButtonElement;
  @target input: HTMLInputElement;
  @attr copyEnabled = false;
  @attr pasteEnabled = false;

  connectedCallback() {
    console.log('EnhancedInputElement connected');
    if (navigator.clipboard) {
      if (navigator.clipboard.writeText && this.copyEnabled) {
        this.copyButton.classList.toggle('hidden');
      }
      if (navigator.clipboard.readText && this.pasteEnabled) {
        this.pasteButton.classList.toggle('hidden');
      }
    }
  }

  async copy(): Promise<void> {
    console.log("copying from the input field");
    await navigator.clipboard.writeText(this.input.value);
    console.log("✅ done. Go try pasting somewhere.");
  }

  async paste(): Promise<void> {
    console.log("pasting to the input field");
    const content = await navigator.clipboard.readText();
    this.input.value = content;
    console.log("✅ done");
  }
}
Controller mit Attribute Bindings

Als Datentypen unterstützt @attr bisher nur string, boolean und number, garantiert dafür aber, dass diese immer einen Wert haben (jeder Datentyp hat also auch einen Default) und sie nie null oder undefined sind. Ausserdem kann man in der Typescript Definition einfach selber einen Default als Teil der Deklaration setzen.

Der Support für boolean Attribute ist im Detail ein wenig tricky. Denn Catalyst macht nicht etwa eine Konvertierung der Strings „true“ und „false“ aus einem Attribut in ein boolean, sondern es verwendet hasAttribute zu prüfen, ob das Attribut existiert. Sobald das entsprechende Attribut auf dem Element definiert ist, ist der Wert der Property im Controller immer true – unabhängig vom Inhalt. Erst wenn man das Attribut entfernt ist der Wert der Property false – also analog, wie z.B. required auf Form-Elementen funktioniert.

In unserem Beispiel bedeutet dies, das wir unsere enabled Attribute immer dann hinschreiben müssen, wenn wir die Funktion aktivieren wollen.

<body>
  <main>
    <h1>Catalyst Controller Demo</h1>
    <section class="demo">
      <enhanced-input data-copy-enabled data-paste-enabled>
        <input data-target="enhanced-input.input">
        <button class="hidden" data-target="enhanced-input.copyButton"  data-action="click:enhanced-input#copy">Copy</button>
        <button class="hidden" data-target="enhanced-input.pasteButton" data-action="click:enhanced-input#paste">Paste</button>
      </enhanced-input>
    </section>

    <h2>Copy disabled</h2>
    <section class="demo">
      <enhanced-input data-name="foo" data-paste-enabled>
        <input data-target="enhanced-input.input">
        <button class="hidden" data-target="enhanced-input.copyButton" data-action="click:enhanced-input#copy">Copy</button>
        <button class="hidden" data-target="enhanced-input.pasteButton" data-action="click:enhanced-input#paste">Paste</button>
      </enhanced-input>
    </section>

    <h2>Paste disabled</h2>
    <section class="demo">
      <enhanced-input data-copy-enabled>
        <input data-target="enhanced-input.input">
        <button class="hidden" data-target="enhanced-input.copyButton" data-action="click:enhanced-input#copy">Copy</button>
        <button class="hidden" data-target="enhanced-input.pasteButton" data-action="click:enhanced-input#paste">Paste</button>
      </enhanced-input>
    </section>
  </main>
</body>
Beispiel für unterschiedliche Attribute

Möchte man über Änderungen von Attributen benachrichtigt werden gibt es keinen speziellen Mechanismus, sondern man muss attributeChangedCallback() implementieren, so wie es vom Custom Elements API definiert wird. Damit man korrekt benachrichtigt wird muss man dann auch den observedAttributes Getter implementieren und die Attribute angeben, für die man sich interessiert. Ausserdem gilt es zu beachten, dass bei diesem Callback der alte und der neue Wert des Attributs nicht unbedingt unterschiedlich sind.

weitere Goodies

Catalyst bleibt auch mit weiteren Features sehr nah an dem, was die Webplattform bietet und versucht dessen Nutzung einfacher zu machen. So unterstützt Catalyst mit Templates eine Möglichkeit innerhalb seines Custom Elements Elemente zu definieren, die erst angezeigt werden, wenn auch das JavaScript geladen und Interaktivität gegeben ist. Die entsprechenden Inhalte werden in einem <template data-shadowroot> Element definiert und dann automatisch in den ShadowDOM des Custom Elements eingehängt. Unser Beispiel sähe dann so aus:

<h1>Catalyst Controller Demo</h1>
<section class="demo">
  <enhanced-input data-copy-enabled data-paste-enabled>
    <template data-shadowroot>
      <input data-target="enhanced-input.input">
      <button class="hidden" data-target="enhanced-input.copyButton"  data-action="click:enhanced-input#copy">Copy</button>
      <button class="hidden" data-target="enhanced-input.pasteButton" data-action="click:enhanced-input#paste">Paste</button>
    </template>
  </enhanced-input>
</section>
Beispiel mit inline Templating

Der komplette interaktive Teil würde also im Normalfall nicht gerendert, sondern erst wenn das Custom Element geladen wurde. Wird JavaScript nicht ausgeführt produzieren wir also ein leeres Element!

Die Catalyst-Macher dokumentieren auch (absolut zu Recht), dass man mit diesem Feature sparsam umgehen sollte, weil es natürlich den Gedanken von Progressive Enhancement kaputt machen kann. Sie sehen es als Möglichkeit komplexeres HTML erst einzubinden, wenn man wirklich weiss, dass das dazugehörige JavaScript auch funktioniert.

Mit der aktuellen Implementierung ist es leider nicht möglich Inhalte innerhalb und ausserhalb eines <template> Elements zu definieren um diese zu kombinieren. Der Inhalt des <template> Elements ersetzt immer den gesamten Inhalt unseres Custom Element. Um immer noch progressive Enhancement zu erlauben müsste unser Beispiel also so aussehen:

<h1>Catalyst Controller Demo</h1>
<section class="demo">
  <enhanced-input data-copy-enabled data-paste-enabled>
    <input data-target="enhanced-input.input">
    <template data-shadowroot>
      <input data-target="enhanced-input.input">
      <button class="hidden" data-target="enhanced-input.copyButton" data-action="click:enhanced-input#copy">Copy</button>
      <button class="hidden" data-target="enhanced-input.pasteButton" data-action="click:enhanced-input#paste">Paste</button>
    </template>
  </enhanced-input>
</section>
Beispiel mit inline Templating und progressive Enhancement

Der positive Seiteneffekt von diesem Feature ist, dass alle Funktionen, die ich oben erwähnt habe auch transparent auf einem Element mit ShadowDOM funktionieren. Catalyst übernimmt die notwendige Traversierung, so dass es aus Nutzersicht egal ist, wo der eigene Content zu Hause ist.

Fazit Catalyst

Catalyst fühlt sich in der Verwendung schlank und effizient an. Ich kann einfach meine Custom Elements definieren und wichtige Elemente und Attribute binden, ohne dass es für mich viel Code zu schreiben gilt – im Gegenteil, die wichtigen Dinge verstecken sich hinter einfachen Decorators. Auch die einheitlichen und kurzen Namen der data Attribute sind schön (und das Handling von boolean Attributen stört nicht allzusehr).

Gleichzeitig ist Catalyst die ganze Zeit nah am Standard. Ich muss nicht ein neues, eigenständiges API begreifen, sondern kann mein Wissen über Custom Elements 1:1 weiterverwenden und einfach an der einen oder anderen Stelle auf meine eigene Implementierung verzichten.

Dass die ganze Library auf Typescript basiert hilft insofern weiter, als dass man somit auch den Rest des DOM APIs typisiert nutzt und damit Editor Support für Methoden und Properties bekommt.

Wenn man offene Fragen hat, wird es etwas schwieriger Antworten zu finden. Denn bei GitHub selbst existieren eine ganze Reihe anderer Repositories unter dem gleichen Namen. Bei Stack Overflow ist der Tag bereits für ein Perl Webframework belegt. Und mit da die erste Version erst am 12. März 2020 schienen ist, gibt es auch sonst nicht viele Erklärungen im Internet. Auf der anderen Seite ist der eigentliche Quellcode mit nur 9 Source-Dateien auch mehr als überschaubar. Direkt im Source nachzusehen ist also nicht die schlechteste Lösung.

Vergleich Catalyst und Stimulus

Wie wir gesehen haben, sind die beiden Libraries sehr ähnlich – sie folgen den gleichen Ideen und haben weitgehend sogar die gleichen Namen für die unterstützten Konzepte (Catalyst hat sich hier nach eigener Aussage weitgehend von Stimulus inspirieren lassen).

Lediglich der Ansatz ist leicht unterschiedlich. Catalyst setzt auf eine Sprache mit Typ-Support und versucht wirklich nur vom bestehenden Web Components Standard ein wenig zu abstrahieren und Boilerplate Code einzusparen. Zur Laufzeit im Browser ist das Ergebnis von einem handgeschriebenen Custom Element nicht zu unterscheiden. Als Entwickler muss man allerdings selber sicherstellen, dass der Browser alle nötigen Features unterstützt, oder selber die nötigen Polyfills inkludieren.

Stimulus dagegen senkt die Eintrittsbarriere weiter und bietet seine ganze Funktionalität in plain vanilla JavaScript. Außerdem verlässt es sich nicht darauf, dass der Browser Custom Elements oder Mutation Observer unterstützt, sondern bringt diese Funktionen selber mit. Es setzt also auch auf der Client-Seite möglichst wenig voraus

Die wichtigsten Features im direkten Vergleich
Feature Catalyst Stimulus
Sprache TypeScript JavaScript
Grösse im Browser 9kB 80kB
Features Element-Binding, Action-Binding, Attribut-Binding, Shadow-DOM Templates Element-Binding, Action-Binding, Attribut-Binding, logische CSS-Klassennamen
API Analog dem W3C Custom Element API Durch Stimulus definiert
Support Github Issues, Stack Overflow dedizierte Hotwire Community, GitHub Issues

Beide Libraries sind hervorragend geeignet, um einen dabei zu unterstützen interaktive Elemente in Form von (oder zumindest analog zu) Custom Elements zu implementieren und dabei Progressive Enhancement als Konzept nicht aus den Augen zu verlieren. Wie man aus der Zusammenfassung sicherlich schon herauslesen kann, finde ich dabei Catalyst die schönere Lösung, weil sie sich näher an dem bewegt, was der Browser nativ implementiert und damit direkter Wissen schafft, dass sich langfristig und anderweitig verwenden lässt. Und die deutlich kleinere Implementierung ist es obendrein.

Ich danke meinem Kollegen Robert Glaser für sein Feedback zu einer älteren Version dieses Texts. Das Titelbild ist von Yancy Min auf Unsplash.

TAGS

Kommentare

Um die Kommentare zu sehen, bitte unserer Cookie-Vereinbarung zustimmen. Mehr lesen