Just Add Code
(Part 2)

Using GitHub Catalyst for Progressive Enhancement with Web Components

The last post in this series established that Web Components are a good way to ship markup and logic to a browser in order to provide progressive enhancement. In this part I will show how GitHub Catalyst supports us in developing Web Components that make progressive enhancement easy.

Dieser Blogpost ist auch auf Deutsch verfügbar

Previously on Just Add Code

In the first part of this post, we looked at a simple example of progressive enhancement on a webpage and how to implement it using Stimulus. In this part I will show how to implement the same example with GitHub Catalyst to see where the two libraries are taking different approaches.

As a reminder let us revisit the example we’re implementing. We are using a simple <input> and, assuming the browser loads our code, allow it to be filled with the text from the clipboard or to transfer the content of the fields to the clipboard with simple button presses. We are using the Clipboard API for this interaction. As not all browsers provide the same level of support for this relatively new API yet, we follow the progressive enhancement idea and only show the functionalities that the browser really supports.

<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>
The Basic HTML Setup

By default the two buttons are hidden through CSS, since we only want them to be visible when they can be used.

.hidden {
  display: none;
}

The complete example is published on GitHub so you can easily run and inspect it locally.

Catalyst

Catalyst is a typescript-based library that supports you in developing custom elements. In contrast to Stimulus, it is geared towards using the existing W3C standards, so the result is a “real” custom element. Catalyst aims at reducing the amount of boilerplate you have to do yourself to let you concentrate on implementing your functionality.

Catalyst relies heavily on typescript decorators to provide its functionality and reduce the amount of code a developer has to write. However, decorators are still an experimental typescript feature that needs to be specifically enabled for a project. If your project setup makes it impossible to do so, you can still employ Catalyst. The documentation always also shows how the functionality of a decorator can be recreated manually.

The Element

The main part of an implementation using Catalyst is a class that extends the generic HTMLElement as it is defined by the DOM API. You annotate this class with the @controller decorator provided by Catalyst in order to trigger the necessary registration in the browser.

import { controller } from "@github/catalyst"

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

The name of our new custom element is derived by Catalyst from the name we gave our class. A trailing Element (if it is part of the class name) is removed and CamelCasing is turned into kebab-casing. This way we are in full control of the element’s name. In our example the class name EnhancedInputElement is turned into <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>
Using the New Custom Element

Because we’ve implemented connectedCallback(), the default callback method from the custom element API that the browser calls whenever a custom element has been attached to the DOM, we should see the message EnhancedInputElement connected in the browser console.

Binding Existing Markup

The concept of binding HTML child elements to properties in the controller is called “Targets” in Catalyst as well. In contrast to Stimulus however, we don’t get auto-generated properties but need to declare them ourselves. This gives us the additional benefit of being able to use the correct element types from the DOM API and thus get IDE support for their properties and methods. To create the binding we annotate the properties with @target or @targets and Catalyst does the necessary search within the scope of our custom element.

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 Declaring Targets

You complete the binding by adding data-target attributes to the HTML elements that should be targeted. The content of this attribute is written in the form {controller-name}.{targetName} and declares the controller and the property to which it should be bound.

<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>
Declaring the Binding on the Target Elements

Besides using @target as shown above, you can also annotate a property with @targets which will bind an array of elements instead of a single one (internally the decorator performs querySelectorAll instead of querySelector). This also means that the same controller/property combination can be used in multiple data-target attributes.

Implicitly Catalyst scopes the search for targets ‘within the custom element’. This allows us to nest custom elements and allows elements to belong to more than one controller by declaring multiple values in their data-target attribute.

<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>
Nested Custom Elements

Having successfully created the binding between our properties in the controller and the HTML elements, we can now adapt our controller to show the buttons that we know the browser provides support for.

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');
      }
    }
  }
}
Activating the Buttons

Listening to Events

To handle events, we just have to define the necessary methods in our controller. There are no specific constraints on that – we can use all the flexibility that TypeScript provides. In our example we declare both our handler methods as async in order not to have to deal with promises while using the clipboard API.

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");
  }
}
Adding Handler Methods to the Controller

This example showcases one of the differences between Catalyst and Stimulus. Because we’re defining all methods and properties ourselves (instead of having some of them generated), we have to make sure that we’ve got concise names and no duplications. But that also leaves us with the freedom to choose more speaking names and call the references to the <button> elements xxxButton instead of xxxValue as Stimulus would.

The ability to bind events from elements in the DOM to methods on a controller by declaration is also called “Actions” in Catalyst. There is only a single attribute used for that: data-action (much the same as it was for targets). The value of the data-action attribute follows the scheme {event}:{controller}#{method} which defines

  1. which event of the element ({event}) we bind (:)
  2. to which method (#{method})
  3. of which controller ({controller})

If you need to listen to multiple events, you define them in a list separated by spaces data-action="{event-1}"{controller-1}#{method-1} {event-2}:{controller-2}#{method-2}". Actions also support the notion of nested custom elements, so the controller you pick to handle an event can be any controller that is a parent of the element you annotate.

<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 Actions to Elements

Using Attributes for Configuration

We want to make our “Enhanced Input” component more flexible to use, by allowing control of which functions (copy and paste) are enabled. This should be possible in a declarative way when the custom element is used in an HTML file.

Catalyst supports this with its notion of “Attrs”. Attrs provides another decorator that is aptly named @attr which you again use to annotate properties in the controller. Once decorated, Catalyst does the binding to HTML attributes for us, looking for attributes following the naming scheme 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");
  }
}
Attr Bindings Added to the Controller

Currently you can only use string, Boolean and number as data types for properties annotated with @attr. One of the advantages of using @attr is that you will never need to check for null or undefined – Catalyst ensures that a default is set in every case. In addition you can use the declaration of your properties in TypeScript to set the default that you want.

Using Boolean attributes is a little trickier than you would expect. This is because Catalyst is not simply getting the value of the attribute and then trying to interpret it as “true” or “false”. Instead it uses hasAttribute to check if the attribute is present at all. If it is, the value of the property is always true, regardless of the contents of the attribute – so Boolean attributes work more like required on form elements.

We see the result of that in our example, where we add enabled attributes whenever we want to switch on one of the functions.

<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>
Producing Different Setups Using the Attributes

If you want to be notified about changes to attributes, there’s no specific mechanism with Catalyst. You can however implement attributeChangedCallback(), which is defined by the custom elements API. Note that when you do so, you also have to implement a getter for observedAttributes to announce which attributes you’re interested in. Another catch of this call is that there is no guarantee that the old and the new values actually differ, so if your handling code isn’t idempotent you have to handle that explicitly.

Further Features

Catalyst keeps close to the features offered natively by the web platform and tries to make the use of them easier. Out of that approach follows the support for templates, which allows you to add markup to your custom element that will only be rendered when the controller code is loaded. To do so, you define the markup inside a <template data-shadowroot> element that by default isn’t rendered by the browser. When the controller starts up it takes the contents of this element and adds them to the element’s shadow root for display. Applying this functionality to our example looks like this:

<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>
Using Inline Templating

The trouble with this simple setup is that now we’re back to rendering an empty element if our JavaScript Code does not run. This is also why the Catalyst developers document this feature as one you should only use cautiously and only for parts that absolutely make no sense without Javascript loaded.

The current implementation also does not allow the mixing of contents written in the <template> with any that are outside – by default the contents of the <template> replace all other contents of the element. For us to keep our progressive enhancement we need to double the <input> element:

<h1>Catalyst Controller Demo</h1>
<section class="demo">
  <enhanced-input data-copy-enabled data-paste-enabled>
    <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>
Allowing Progressive Enhancement and Inline Templating

One other effect of Catalyst having this feature is that all functionality I’ve shown so far is transparently supporting shadow DOM on elements. You can add your content directly or to the shadow root and Catalyst will make the necessary traversal.

Summary on Catalyst

Using Catalyst feels slick and efficient. Defining your own custom elements is easy and interacting with present markup or adding attributes for some flexibility never needs a lot of code or feels unintuitive (Boolean attributes notwithstanding). The names of the decorators are verbose enough to tell you what they do, and since all properties are always there you never lose track in the code. I personally also like the short and consistent names of the data-attributes (as opposed to the need for different names in Stimulus).

Additionally you never feel far away from the actual custom element standard. You don’t have to learn and understand a new and separate API, but you can take your web platform knowledge and apply it, without having to write all the boilerplate.

Having the library based on TypeScript is an additional bonus, as it lets you assign types to all other elements you are working with, so you get proper IDE support for their methods and properties.

The area where Catalyst does not shine as much is whenever you’re looking for support. Just searching for Catalyst on GitHub yields quite a number of other repositories with the same name. On Stack Overflow the tag is taken by a perl web framework. And with the first version released on March 12, 2020, there also isn’t much information out there in the rest of the internet. On the other hand there are only nine source files currently in the repository, each of them pretty readable – so just looking up how things work might be the easiest solution.

Comparing Catalyst and Stimulus

As the example has shown, Catalyst and Stimulus are pretty similar. They follow the same basic approach and use the same names for the same concepts in a number of places (the Catalyst makers openly admit that Stimulus was a big influence for them). So the main differences are in the details.

Catalyst bases itself on a typed language and tries to keep as close to the standard as possible. The main goal is taking over the tedious bits of browser integration to let developers focus on their functionality. At runtime there isn’t any difference between a Catalyst class and a plain custom element. As a developer you have to make sure though that your users' browsers support all necessary standards or supply the necessary polyfills.

Stimulus on the other hand tries to lower the entry barrier as much as possible and offers everything it does as a simple, no-dependencies plain vanilla JavaScript library. It also ensures that support for the necessary standards it bases itself upon (mostly MutationObserver) is present. In this way it also lowers the barrier of entry on the client side.

Comparing the most important features of the two libraries
Feature Catalyst Stimulus
Language TypeScript JavaScript
Size of the bundle 9kB 80kB
Features Element binding, Action binding, Attribute binding, Shadow DOM templates Element binding, Action binding, Attribute binding, Logical CSS class names
API W3C Custom Element API Stimulus API
Support GitHub issues, Stack Overflow Dedicated Hotwire community, GitHub issues

Both libraries do an excellent job in supporting you in developing custom elements (or something close enough to it to not make a difference). Both ensure that supporting progressive enhancement not only is easy, but also the most obvious path to take. As you probably can see from my comparison I do think that Catalyst is the slightly nicer solution of the two. Just because it tries to stay close to the standard and thus produces know-how that you can use in the long term – besides being the much smaller implementation too.

I want to thank my colleague Robert Glaser for his feedback on earlier versions of this post. The image in the title is by Yancy Min on Unsplash.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more