When it comes to designing robust systems, laziness can be an asset. Anyone who knows me knows I tend to recoil at re-implementing basic interaction patterns or functionality - why should I code something native HTML elements can handle already? Not only is it relatively boring, it's more code to maintain that is, as with any code, susceptible to bugs and gaps in support. Who needs the headache?
Pushing native HTML and CSS too far can get you into trouble too, (I had to re-write my tabs pattern tutorial a few times as my clever tricks weren't exactly universally compatible), but their functionality is a good foundation to build on top of. Many complex patterns can be, at least in part, constructed out of more atomic components.
One such example is a multi-select listbox. Listboxes have a lot of ergonomic interactivity like type-ahead and a single tab stop, but, boiled down to base, a listbox is a group (or a set of groups) of selectable options. A <fieldset> of checkboxes affords that; it just lacks some of the convieniences.
When I was relatively new at my job, I heard the phrase graceful degredation for the first time. This was in the context of browser support - if something we were designing couldn't be handled by Internet Explorer, how could we offer contingencies that provided users a good-enough experience? We ended up writing more code, specifically for this case that didn't naturally fit with our base design. When we dropped support some years later, the result was technical debt we had to shlep around.
Progressive enhancement is a design philosophy which flips graceful degredation on its head. Instead of starting at a jet and working backwards to handling unpowered flight, you design from basic avation principles up to supersonic capabilities.
Progressive enhancement is fantastic for maintainability and fault tolerance since, irrespective of whatever situation you're in, you know the bare bones of your design are solid.
Web Components
As you may be able to tell by the rest of this site, I'm a fan of Web Components and custom elements. They're great for making reusable, flexible controls, or even just for being able to repeat content around a site. One approach I've been using more lately is to wrap some existing markup in a custom element which enhances its existing structure with new capabilities. As previously alluded to, one example I've made is the gw-check-listbox, which turns a set of checkboxes into a listbox.
This is one way progressive enhancment can work well, since the basic strucutre is apparent and will render on the page as-is should the custom element fail in some way. If the custom element can use the underlying markup directly as its state, surrounding code won't even need to know that the custom element is present or account for it. In this way, the enhancement can be invisible to surrounding code - so clear in how it operates that it can simply be ignored.
We can run into a bit of a snag here, though, in that changing the state of native HTML elements is not always something that can be easily tapped into. While clicking a checkbox bubbles up an event the custom element can listen to and setting attributes can be observed, modifying a property directly with code can happen silently. For example, if another bit of code sets the checked property from false to true, the custom element may never know. Getting out of sync like this is often worse than the custom element failing outright.
Nuts & Bolts
Property Descriptors
What is the checked property anyway? To understand that, we need to talk about objects in JavaScript for a moment.
JavaScript objects are mutable collections of properties[MDN]. They're often represented as key-value pairs enclosed in curly braces, e.g. {Name: "Vera", Cats: 3}. Here, Name and Cats are properties. The values of these properties may changed by assignment, for example obj.Cats = 4. Easy, right? There is a bit more going on here, though.
Properties are not simple string keys that point directly to values; they each have a descriptor which defines their behavior. Let's take a look at the descriptors on our example object.
Each descriptor is represented by its own object with a set of properties and values. Does anyone else hear the Inception theme playing? Fortunately, these are just representations of the descriptors, so we don't have an infinite chain.
You can probably guess what these descriptor properties mean by their names, but I'll go through them briefly here. You can also read about them in detail at MDN.
configurable
This determines whether this descriptor, or its properties, can be changed or deleted. Once set to true, this descriptor is set in stone for the lifetime of the object. Trying to re-define it will throw an error, and deleting its property off the object will fail.
enumerable
This determines whether the property is included when code enumerates over the object. For example, if a property is not enumerable, an Object.keys call will not surface it. However, it is otherwise still editable, so this is akin to hiding it from plain sight but not locking it away.
value
This determines the value of the property - it's the most visible part of the descriptor. This can be all kinds of things: string, number, object, function, etc.
writable
This determines whether the value can be changed by assignment. If not writable, assigning a property a new value will silently not work.
Interesting stuff, no? Now that we know what to expect, let's check out the properties on an <input type="checkbox">.
//Pretend there's a checkbox on the page somewhere
const cbx = document.querySelector(`input[type="checkbox"]`);
Object.getOwnPropertyDescriptors(cbx);
⬅️Object { } //gasp!
Console output
Wait, our checkbox has no properties? How could that be?
Prototypes
or, how that could be
As you undoubtedly know, JavaScript is magical. What you may not know is that a major component of that magic is prototypal inheritance, a concept that is woven through all objects and binds them together (like the force). Through careful study of the sacred texts, you, too, can tap into the power of the prototype chain and bend reality to your whims, good or evil! Muahaha!
Sorry, I got a little distracted. We need to briefly touch on prototypes to understand why our checkbox looks like it doesn't have any properties.
As previously mentioned, JavaScript objects are collections of properties. As not previously mentioned, each object has a special property that links to its prototype. This is often __proto__, but the correct way to get it is to use Object.getPrototypeOf.
A prototype is just another object. The special thing about it is that its properties will be used if code tries to act on a property which doesn't exist on an object that links to it. So, if an object doesn't have a property called checked, but its prototype does, the property from its prototype will be used. The other notable thing about prototypes is that they can have prototypes of their own, and those prototypes can have prototypes, and so on. These links form what's called the prototype chain.
The reason we're not seeing checked on our object is because it's not directly on the object, but is instead further up the prototype chain. A key word in Object.getOwnPropertyDescriptors is own - it won't go up the chain to find more properties.
Okay, so let's have a look at the descriptors on the prototype of our checkbox.
There are a lot of property descriptors in there, but checked is among them. It looks a little different than those we've examined so far though; there's no value or writable properties, instead we have get and set.
This is because there are actually two kinds of descriptors: data and accessor. We've looked at data descriptors so far, but checked has an accessor descriptor. Instead of maintaining data with the value property, accessor descriptors instead manage the value by providing get and set functions which are invoked when the property is read from and written to, respectively.
Overriding a Setter
To enable our custom element to know when other code changes the checked property on its checkbox, we need to inject a callback into the setter function. This demands caution - the built-in setter is a black box. We don't know exactly what it's up to, so it's important we not disrupt its functionality.
What that means, in practice, is that we can't simply override the setter and define entirely custom behavior. We need to preserve the built-in functionality by invoking the default setter in addition to our own handling. So, to invoke it later, we need to save that setter off. Let's grab it:
Great, now we've got it in a local variable, originalSet. Next, we're going to make our own setter. It has three goals:
Call the original setter.
Call a handler on the custom element.
Ensure the setter is in a state to do this again the next time it's invoked.
The fun thing about these setter functions is that they, very reasonably, assume this is the element the property is being mutated upon. That means that our easiest approach will be to create a setter that:
Restores the checkbox's setter to its original value.
Invokes the setter ourselves as though we are the calling code.
Replaces the checkbox's setter to our overridden setter for next time.
Invokes a handler to update the state of the custom element.
For this purpose, we need a way to define a setter function that has the necessary context, i.e. the checked descriptor, the original setter, the handler, and has this set to the checkbox.
One way I like to do this is to create a delegate function with pre-specified parameters. It's easier to show than describe:
The function returned by #createDelegate will, when invoked, will execute method with this set to context and with args passed as the first arguments with any additionally included arguments trailing.
We can use this to create a delegate which has all the needed information:
Our setter function, when invoked, will have this set to cbx and all of the needed information as arguments, including the value the caller has specified as the final argument in the list.
Now, with all of the pieces in place, let's define the contents ouf our custom setter.
This setter function briefly redefines the setter on the checkbox to its original state, invokes it, then puts itself back and calls the custom handler.
The last thing we have to do is put this new property descriptor onto the checkbox. We can't put it where we found it, though, which is on the checkbox's prototype. The reason for this is that the prototype's checked descriptor is shared by every input element on the page - we only want to override ours. We can do that by defining it directly on our checkbox's object, so that when checked is interacted with, we won't look up the prototype chain for a descriptor and will instead just use what we've defiend here. That looks like this:
That's it, phew! I know that last part got a little hairy, but it's a technique that's working in the field! Armed with the ability to observe setters on native HTML elements, you will be better able to use them as state for custom elements and keep them in sync, allowing you to progressively enhance with flexibility!
If you have questions or found this useful, feel free to get in touch. Cheers!