A few years back I watched Joe Gregorio’s 2015 OSCON talk and was deeply impressed by the core message: “Stop using JS frameworks, start writing reusable, orthogonally-composable units of HTML+CSS+JS.” Earlier this year I asked Joe to reflect on what he’s since learned about applying the newish suite of web standards known collectively as web components. He replied with a blog post that convinced me to try following in his footsteps.
I’ve been thinking about how software components can be built and wired together since the dawn of the web, when the state-of-the-art web components were Java applets and Netscape browser plugins. Since then we’ve seen newer plugin mechanisms (Flash, Silverlight) rise and fall, and the subsequent rise of JavaScript frameworks (jQuery, Angular, React) that are now the dominant way to package, reuse, and recombine pieces of web functionality.
The framework-centric approach has never seemed right to me. My touchstone example is the tag editor in the Hypothesis web annotator. When the Hypothesis project started, Angular was the then-fashionable framework. The project adopted a component, ngTagsInput, which implements the capable and attractive tag editor you see in Hypothesis today. But Angular has fallen from grace, and the Hypothesis project is migrating to React — more specifically, to the React-API-compatible preact. Unfortunately ngTagsInput can’t come along for the ride. We’ll either have to find a React-compatible replacement or build our own tag-editing component.
Why should a web component be hardwired to an ephemeral JavaScript framework? Why can’t the web platform support framework-agnostic components, as Joe Gregorio suggests?
I’ve been exploring these questions, and my provisional answers are: “It shouldn’t!” and “It can!”
The demo I’ll describe here comes from an app used by biocurators working for the ClinGen Consortium. They annotate scientific literature to provide data that will feed machine learning systems. While these biocurators can and do use off-the-shelf Hypothesis to highlight words and phrases in papers, and link them to search results in databases of phenotypes, genes, and alleles, the workflow is complex and the requirements for accurate tagging are strict. The app provides a guided workflow, and assigns tags automatically. In the latest iteration of the app, we’ve added support for a requirement to tag each piece of evidence with a category — individual, family, or group — and an instance number from one to twenty.
The demo extracts this capability from the app. It’s live here, or you can just download index.html and index.js and open index.html locally. This is plain vanilla web software: no package.json, no build script, no transpiler, no framework.
To create a widget that enables curators to declare they are annotating in the context of individual 1 or family 3 or group 11, I started with IntegerSelect, a custom element that supports markup like this:
<select is="integer-select" type="individual" count="20"></select>
Here I digress to clarify a point of terminology. If you look up web components on Wikipedia (or anywhere else) you will learn that the term refers to a suite of web technologies, primarily Custom Elements, Shadow DOM, and HTML Template. What I’m showing here is only Custom Elements because that’s all this project (and others I’m working on) have required.
(People sometimes ask: “Why didn’t web components take off?” Perhaps, in part, because nearly all the literature implies that you must adopt and integrate a wider variety of stuff than may be required.)
The thing being extended by IntegerSelect is a native HTML element, HTMLSelectElement. When it renders into the DOM, it will have empty select markup which the element’s code then fills. It inherits from HTMLSelectElement, so that code can use expressions like this.options[this.selectedIndex].value.
Two key patterns for custom elements are:
-
Use attributes to pass data into elements.
-
Use messages to send data out of elements.
You can see both patterns in IntegerSelect’s class definition:
class IntegerSelect extends HTMLSelectElement { type constructor() { super() this.type = this.getAttribute('type') } relaySelection() { const e = new CustomEvent('labeled-integer-select-event', { detail: { type: this.type, value: this.options[this.selectedIndex].value } }) // if in a collection, tell the collection so it can update its display const closestCollection = this.closest('labeled-integer-select-collection') if (closestCollection) { closestCollection.dispatchEvent(e) } // tell the app so it can save/restore the collection's state dispatchEvent(e) } connectedCallback() { const count = parseInt(this.getAttribute('count')) let options = '' const lookupInstance = parseInt(getLookupInstance(this.type)) for (let i = 1; i < count; i++) { let selected = ( i == lookupInstance ) ? 'selected' : '' options += `<option ${selected}>${i}` } this.innerHTML = options this.onclick = this.relaySelection } } customElements.define('integer-select', IntegerSelect, { extends: "select" })
As you can see in the first part of the live demo, this element works as a standalone tag that presents a picklist of count choices associated with the given type. When clicked, it creates a message with a payload like this:
{"type":"individual","value":"8"}
And it beams that message at a couple of targets. The first is an enclosing element that wraps the full hierarchy of elements that compose the widget shown in this demo. The second is the single-page app that hosts the widget. In part 1 of the demo there is no enclosing element, but the app is listening for the event and reports it to the browser’s console.
An earlier iteration relied on a set of radio buttons to capture the type (individual/family/group), and combined that with the instance number from the picklist to produce results like individual 1 and group 3. Later I realized that just clicking on an IntegerSelect picklist conveys all the necessary data. An initial click identifies the picklist’s type and its current instance number. A subsequent click during navigation of the picklist changes the instance number.
That led to a further realization. The radio buttons had been doing two things: identifying the selected type, and providing a label for the selected type. To bring back the labels I wrapped IntegerSelect in LabeledIntegerSelect whose tag looks like this:
<labeled-integer-select type="individual" count="20"></labeled-integer-select>
Why not a div (or other) tag with is="labeled-integer-select"? Because this custom element doesn’t extend a particular kind of HTML element, like HTMLSelectElement. As such it’s defined a bit differently. IntegerSelect begins like so:
class IntegerSelect extends HTMLSelectElement
And concludes thusly:
customElements.define('integer-select', IntegerSelect, { extends: "select" })
LabeledIntegerSelect, by contrast, begins:
class LabeledIntegerSelect extends HTMLElement
And ends:
customElements.define('labeled-integer-select', LabeledIntegerSelect)
As you can see in part 2 of the demo, the LabeledIntegerSelect picklist is an IntegerSelect picklist with a label. Because it includes an IntegerSelect, it sends the same message when clicked. There is still no enclosing widget to receive that message, but because the app is listening for the message, it is again reported to the console.
Finally we arrive at LabeledIntegerSelectCollection, whose markup looks like this:
<labeled-integer-select-collection> <labeled-integer-select type="individual" count="20"></labeled-integer-select> <labeled-integer-select type="family" count="20"></labeled-integer-select> <labeled-integer-select type="group" count="20"></labeled-integer-select> </labeled-integer-select-collection>
When this element is first connected to the DOM it visits the first of its LabeledIntegerSelects, sets its selected attribute true, and bolds its label. When one of its LabeledIntegerSelects is clicked, it clears that state and reestablishes it for the clicked element.
The LabeledIntegerSelectCollection could also save and restore that state when the page reloads. In the current iteration it doesn’t, though. Instead it offloads that responsibility to the app, which has logic for saving to and restoring from the browser’s localStorage.
You can see the bolding, saving, and restoring of choices in this screencast:
I’m still getting the hang of custom elements, but the more I work with them the more I like them. Joe Gregorio’s vision of “reusable, orthogonally-composable units of HTML+CSS+JS” doesn’t seem far-fetched at all. I’ll leave it to others to debate the merits of the frameworks, toolchains, and package managers that so dramatically complicate modern web development. Meanwhile I’ll do all that I can with the basic web platform, and with custom elements in particular. Larry Wall has always said of Perl, which enabled my earliest web development work, that it makes easy things easy and hard things possible. I want the web platform to earn that same distinction, and I’m more hopeful than ever that it can.
Tried the live demo on Firefox for Android and also Firefox Aurora for Android, it only shows one empty dropdown. On Chrome for Android it shows all three. I’ve tried other WebComponents demos on Firefox Aurora for Android and they work. Is your example Chrome-specific?
Apparently so. I didn’t check because Chromium was enough for my use case, and I wouldn’t have thought Firefox would require polyfilling, but evidently it does. I’ll look into it, thanks.
Update: It wasn’t a problem with Firefox’s support for web components, but rather for a not-yet-implemented language feature: class fields. I removed the use of that feature and the demo works in Firefox now too.
Here’s the diff between the Chromium-only version and the Firefox-also version: diff.