Dieser Blogpost ist auch auf Deutsch verfügbar

What we’re trying to achieve

Web Components are an easy way to add custom functionality to a webpage while isolating the scope. This is achieved by introducing Custom Elements that we can use to define our own markup elements: <my-element>. This new element forms an isolated scope in which we can easily control all its contents and define handlers for any event that might happen within its boundaries. As long as the browser isn’t aware of the definition for our element it will simply ignore it – so no harm is done by adding specialized elements to any markup that we already have. (From here on,I will only refer to Custom Elements instead of Web Components, since we won’t be using Templating or Shadow DOM).

Usual examples for writing Custom Elements showcase the usage of such a newly defined element as something like this: <my-element></my-element>. The actual contents of the element are only created and rendered when the new element has been registered with the browser and the necessary code has been run. Popular frameworks like Stencil or LitElement showcase this as the ‘normal’ way.

Let’s consider this example, where instead we do not simply declare empty elements, but we use them to decorate existing markup and add additional features. We could try an approach such as this one:

<my-element>
  <p>Your API Keys</p>
  <input type="text" name="api-key" value="A8DAF06123B7"/>
</my-element>

In the above setup, we would for example be able to add additional code that make it possible for the user to easily copy the text to the clipboard in order to then paste it somewhere else. However, in case the code for our <my-element> doesn’t run for some reason, the information is still visible to the user

The showcase

We will use this example to show how Stimulus and Catalyst (in part 2 of this blogpost) provide support in developing Custom Elements, all the while nudging the Developer to make use of progressive enhancement by providing easy access to existing markup.

For our showcase, we start off with a simple HTML Fragment. We plan to allow the user to simply copy the content of the text-field to the clipboard, or vice-versa fill the text-field from the clipboard. To do so, we are using a relatively new JavaScript API – so not all browsers provide the same level of support yet. We use this challenge to further the progressive enhancement idea and only add the functionality that the browser already 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.

What are Progressive Enhancement and Custom Elements?

Stimulus

I’ve shortly introduced Stimulus in my Hotwire introduction post. As mentioned there, Stimulus makes use of existing ‘normal’ HTML-Markup and isn’t technically using Custom Elements at all. So in the end, we will not build a Custom Element in this post. We will, however, look at all the ways in which Stimulus provides the same functionality that Custom Elements do.

The Controller

Main part of the implementation in Stimulus is a Controller – a plain old JavaScript class that derives from the Controller defined and implemented in Stimulus:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  connect() {
    console.log('EnhancedInputController connected');
  }
}
The Stimulus Controller

As soon as we’ve created this controller and registered it with Stimulus, we’re able to bind it to an HTML element by adding a data-controller attribute to the respective element. Stimulus then instantiates a controller instance and binds it to the element.

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input">
        <input type="text" name="input" />
        <button class="hidden">Copy</button>
        <button class="hidden">Paste</button>
      </div>
    </section>
  </main>
</body>
Binding the controller to a DOM element

Because we’ve also implemented the connect() lifecycle method that Stimulus invokes, whenever a controller instance is bound to a DOM element we should see EnhancedInputController connected in the browser console.

Binding existing markup

As mentioned, Stimulus is built to easily interact with existing markup. Stimulus calls this concept “Targets”. To define your targets, you add a static list of element names in the controller

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];

  connect() {
    console.log('EnhancedInputController connected');
  }
}
Targets are defined in the Controller

Once this is done, we can use the names from that String-Array to add additional data attributes to HTML elements. Those attributes follow the naming scheme {controller-name}-target and have one of the names that we defined previously in targets:

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste">Paste</button>
      </div>
    </section>
  </main>
</body>
Binding targets in HTML

It’s also possible to reuse a target’s name as attributes in multiple elements, even though the example does not illustrate this. Reusing a target’s name is necessary, when not one but multiple elements need to be addressed with a target in a controller (as an example, imagine all items of a list). Furthermore, an element can be bound to more than one controller, hence have multiple data-*-target attributes.

Stimulus utilizes the values defined in static targets and creates synthetic properties from them, that can then be accessed via this.{name}Target – so, in our example, our controller would have the “magic” properties this.inputTarget, this.copyTarget and this.pasteTarget. In addition, Stimulus will generate this.has{Name}Target methods that can be used to check if a target has been bound to an element or not.

Using this knowledge, we can extend the controller to enable the buttons that we know should work:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }
}
Enabling the buttons supported by the browser

Besides navigator.clipboard as the general gatekeeper of our JavaScript API, we’re also specifically checking for the existence of writeText and readText, as their support hasn’t landed in all browsers yet. My current Firefox 85.0.2, for example, only supports writeText out of the box, while Chromium already supports both methods.

Listening to events

However, our showcase’s current status is not yet sufficiently satisfying – since we’re showing the buttons, but not actually doing anything when the user clicks them.

Firstly, let’s define two methods to handle the necessary Copy and Paste functionality with the backing of the Clipboard API. Since the API is asynchronous we define our handler methods as async and then use await to write simple code without Promises:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

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

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Adding the handler code

Secondly, to bind these methods to events in the DOM, we use Stimulus “Actions”. These actions get defined by extending the element whose event we want to capture with a data-action attribute.

The value of this attribute is a string following the pattern {event}->{controller-name}#{method-name} declaring

  1. which event of the element ({event}) we want to bind (->)
  2. to which method (#{method-name})
  3. of which controller. ({controller-name})
<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
  </main>
</body>
Wiring up actions in the markup

In the situation where we want to listen to multiple events of any one element, all of the necessary bindings can be defined in a space separated list in the data-action attribute data-action="{event-1}->{controller-name-1}#{method-name-1} {event-2}->{controller-name-2}#{method-name-2}"

Besides the previous simple syntax, Stimulus knows a couple of other tricks. For example, “shorthand” in which you can leave out the name of the event for “obvious” bindings (like the click event of a button). Or additional features like once that are provided by the DOM Eventlistener API.

Using attributes for configuration

In order to make our “Enhanced Input” component even more flexible, we want to add the functionality to selectively enable copy or paste and not only let the browser determine the options. Of course we don’t want to have to modify our code every time we want the element configured differently – we want to specify it in the HTML.

In this case, Stimulus supports us with the concept of “Values”. Again, values are defined as a static field in the controller. Using the name values we provide a static definition of an Object, in which the keys are the names of our attributes, and the values are their data type.

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

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

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Controller with a 'values' definition
In line with the previous features, Stimulus utilizes this declaration to again create synthetic properties. This time, they follow the naming scheme `this.{name}Value`, so we get `this.copyValue` and `this.pasteValue`, which we can read and write. In addition, we get a `this.has{Name}Value` method to check if a value has been set at all.

Our example only uses Boolean since we simply want to switch functionality on and off. However, Stimulus supports Array, Boolean, Number, Object and String as possible data types. Stimulus does the necessary conversion between the string in the attribute in the DOM and the internal representation in JavaScript.

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText && this.copyValue === true) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText && this.pasteValue === true) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

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

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Using the values in the activation checks

Whenever you need to be aware of changes, you can also implement a method following the {name}ValueChangednaming scheme to be invoked by Stimulus any time the content of the value changes.

In HTML we define values by adding yet another data attribute to the element that does the controller binding. This specific attribute follows the data-{controller-name}-{valueName}-value naming scheme.

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Copy disabled</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-copy-value="false"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Paste disabled</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="false">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
  </main>
</body>
Setting different content for the values

Stimulus defines default values for each type of data it supports. With the given form of defining the values as an object you can’t override those settings for your own initial defaults. Should the Stimulus default value not be the value you want, you must specify the attribute. This is shown in the previous example, because the default for Boolean values is false, however, obviously, we usually want to show all buttons.

further Features

As an additional method to make one’s component more flexible to use in different situations, Stimulus offers “CSS Classes”. This feature aims to decouple behavior controlled by CSS from classnames hard coded into the component.

Imagine you have a component that visualizes its validation state to the user. The easiest way this can be done is through adding valid or error CSS classes to the element. Or, as in our example, we use CSS to hide and show the buttons that the user is able to use (by adding and removing the hidden CSS class).

Instead of hard coding the class name in the source code (as previously shown) and thus requiring everyone who uses the component to provide CSS with exactly those classes, CSS Classes allow us to input the name of the actual class to use into our controller via the HTML Markup.

We start out by adding another static field definition to our controller:

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };
  static classes = ["hidden"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText && this.copyValue === true) {
            this.copyTarget.classList.toggle('hidden');
        }
        if (navigator.clipboard.readText && this.pasteValue === true) {
            this.pasteTarget.classList.toggle('hidden');
        }
    }
  }

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

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Controller with added CSS Classes definition

Having this definition makes Stimulus create another synthetic property named this.{name}Class that can be accessed and used as a logical value in the code.

import { Controller } from "stimulus"

export default class EnhancedInputController extends Controller {

  static targets = ["input", "copy", "paste"];
  static values = { copy: Boolean, paste: Boolean };
  static classes = ["hidden"];

  connect() {
    console.log('EnhancedInputController connected');

    if (navigator.clipboard) {
        if (navigator.clipboard.writeText && this.copyValue === true) {
            this.copyTarget.classList.toggle(this.hiddenClass);
        }
        if (navigator.clipboard.readText && this.pasteValue === true) {
            this.pasteTarget.classList.toggle(this.hiddenClass);
        }
    }
  }

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

  async paste() {
      console.log("pasting to the input field");
      const content = await navigator.clipboard.readText();
      this.inputTarget.value = content;
      console.log("✅ done");
  }
}
Using CSS Classes instead of a hard coded class name

In the markup we again declare a data attribute on our controller and in its value define the concrete name of the CSS class to use. In this example we pass in hidden, but in a different project we could be using no-display and the component would continue to function the same way. The necessary attribute is named data-{controller-name}-{css-class-name}-class.

<body>
  <main>
    <h1>Stimulus Controller Demo</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-hidden-class="hidden" 
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Copy disabled</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-hidden-class="hidden" 
           data-enhanced-input-copy-value="false"
           data-enhanced-input-paste-value="true">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
    <h1>Paste disabled</h1>
    <section class="demo">
      <div data-controller="enhanced-input"
           data-enhanced-input-hidden-class="hidden" 
           data-enhanced-input-copy-value="true"
           data-enhanced-input-paste-value="false">
        <input type="text" name="input" data-enhanced-input-target="input"/>
        <button class="hidden" data-enhanced-input-target="copy" data-action="click->enhanced-input#copy">Copy</button>
        <button class="hidden" data-enhanced-input-target="paste" data-action="click->enhanced-input#paste">Paste</button>
      </div>
    </section>
  </main>
</body>
adding the necessary definition for the 'hidden' class

Watching it work

Once we’ve added all these features, we get to our final version of the component that is now built in a way that it can be simply adjusted to its surroundings.

The complete example
The complete example

As you can see also in browsers that support both copy and paste there are additional permissions to obtain. This is due to security considerations, as your clipboard content could also be the password you’ve just loaded from your Password manager and you definitely wouldn’t want that to be accessible to any website. Our benefit is that we added all the additional functionality as progressive enhancement. Even if we wouldn’t have the permission to paste, we can still offer the copy functionality. Should JavaScript not work at all, we still show the <input> field to provide the primary function to the user.

Conclusion&Preview for part 2

As stated in my first Hotwire post: all the tools from the Hotwire bundle feel very solid. Their functionality is well thought out and includes the necessary flexibility you need to implement proper custom functionality. The API is on par with what native Custom Elements provide. Usually you can live well within the abstractions that Stimulus provides and leave all the implementation complexity to it – all you need to know is plain vanilla JavaScript and HTML.

Memorizing the different incantations for the magic synthetic properties takes some time, but at least the properties follow a conclusive naming scheme. Deriving the naming of all the different data attributes from that is a harder task – I do love the feature and the flexibility they provide but I still have to look up the naming schemes every time. And – driven by the fact that a lot of those names need to include name of the controller – I find the attribute name to get rather verbose once you name your controller something a little more complicated than copy.

The thing I’m finding least appealing is the fact that Stimulus redefines functionality that already exists in the browser. Sure, there’s an advantage of being able to start off with the ‘HTML I already have’ and not needing to learn about Custom Elements first, but why exactly shouldn’t I invest the time? In the end Web Components and its APIs are the basis the Web Platform provides and as such promise to be around and with widespread support for a long time. And having my markup extended with speaking element names instead of <div data-controller="foo"> sounds like proper improvement to me.

This doesn’t mean that Stimulus is bad, not at all. The library is solid, the documentation is very good and if you still have questions after looking at the Code (that is small and well structured) you can ask in the User-Community that is run by Basecamp (so the developers are active there too). However, I think you could also use the time and learn the Custom Element basics instead.

The next part will take a closer look at Catalyst as another library supporting the development of Custom Elements that provide Progressive Enhancement. The features are pretty similar, but Githubs developers took a couple different design decisions that make these two libraries interesting to compare. See you there!

I want to thank my colleagues Robert Glaser and Daniel Westheide for their feedback to earlier versions of this post. The image in the title is by Claudio Schwarz on Unsplash.

TAGS

Comments