Cut the noise: Declarative web components with HTMLElementSugar
TL;DR
HTMLElementSugar provides declarative attribute definitions, template
handling, component aliases, and configuration inheritance — all while staying
close to vanilla web components. In this blog I’ll explain the why, the how,
and the because. You can find it on npm.
Sugar for my honey, sugar for my web components.
Webcomponents solve a really fun problem, they only introduce a new one: BOILERPLATE much?
When I wrote a HTML mixtape I
didn’t really care about the boilerplate. But afterwards I realized I could
actually write a toolset that implements most of the components I used to
create a mixtape library with predefined web components. My initial reaction
was to utilize UltiSnips but I soon
realized that this was not really the thing I was needing.
For observedAttributes you need a list of attributes. For attributeMap, you
need to define optional parsers, and for defaultConfig, you need to define if
you need default values and what they are. This can probably be done with a
good snippet, but it needs too much coding on the wrong side of my editor.
So I decided to tackle the problem on the javascript side. I started writing
HTMLElementSugar. It had a different name when I started, but it is now
called HTMLElementSugar. It’s important to note that all my web components
operate at the light DOM. I don’t operate in the shadow DOM, as I don’t have a
compelling reason to do so at this moment. Besides this reason, it is also very
SEO and crawler-friendly. Well… in theory at least, search engines and SEO
tools have a lot of issues with pages that are written with webcomponents.
Cutting the boilerplate, declaring the attribute
So what does HTMLElementSugar do? Well, a lot. It removes the “need” to write
observedAttributes, attributeMap, and defaultConfig. You only need to
declare attribute definitions: attributeDefs. This is essentially a mapping
that transforms the data into observedAttributes, attributeMap, and
defaultConfig. It is a small hash/map/dictionary/associative array — every
language I know has a different term for what I, a perl dev, name a hash, so
pick one you like ;)) — where you define the name and a specification. By
default, attributes are observed, unless you add observed in a falsey manner.
You can define a default and a parser, all are optional:
class MyNewElement extends HTMLElementSugar {
static tag = 'my-new-element';
static attributeDefs = {
foo: { default: 'bar' },
'bar': { parser: parseBoolean },
'baz': null,
'qux': {},
'toto': { observed: "" }, // not observed
'titi': { observed: 0 }, // not observed
'tata': { observed: false }, // not observed
'tete': { observed: null }, // not observed
'yes': { observed: true },
'yez': { observed: 1 },
'yep': { observed: "here be dragons" },
};
}
Compare that to this:
export class TrackItem extends HTMLElement {
static tag = 'track-item';
static observedAttributes = ['name', 'tagline', 'audioBase', 'videoBase',
'artBase'];
static attributeMap = {
name: true,
tagline: true,
hidden: parseBoolean,
playHidden: parseBoolean,
};
static defaultConfig = {
name: null,
tagline: null,
audioBase: './audio/',
videoBase: './video/',
artBase: './art/',
};
}
We simplified everything by declaring it once, and we then populated
observedAttributes, attributeMap, and defaultConfig. Let me show you by
using our test file:
t.test('test sunny day for attributeDefs', async t => {
MyNewElement.init();
t.same(MyNewElement.tag, 'my-new-element', 'tag set');
t.same(
MyNewElement.observedAttributes,
['foo', 'bar', 'baz', 'qux', 'yes', 'yez', 'yep'],
'observedAttributes set'
);
t.same(MyNewElement.attributeMap, { bar: parseBoolean }, 'Parser on bar');
t.same(MyNewElement.defaultConfig, { foo: 'bar' }, 'default applied');
});
Simple, effective, and no repetition.
Pre-v0.1.0 we allowed authors to write their own
observedAttributes,attributeMap, anddefaultConfigand opt out of usingattributeDefs. This is no longer the case due to inheritance problems.
So now our mixtape component will look like this:
export class TrackItem extends HTMLElementSugar {
static tag = 'track-item';
static attributeDefs = {
name: { default: null },
tagline: { default: null },
audioBase: { default: './audio' },
videoBase: { default: './video' },
artBase: { default: './art' },
hidden: { observed: 0, parser: parseBoolean },
playHidden: { observed: 0, parser: parseBoolean },
}
}
Now you can register the component:
TrackItem.register()
This will run some sanity checks, e.g., does your tag exist, and transforms the
data. It will also fail fast once an error is detected. And to make your
development experience nicer, you don’t need to register() your component in
a test suite, you can call init() and all the checks are done for you.
But this is not all…
Writing templates
Web components also use templates. If you have written web components before,
you know that you can write them in HTML directly, or you can include them in
your web component. Because HTMLElementSugar operates in the light DOM its
template handling is flexible. HTMLElementSugar supports four, or five, use
cases, depending on how you look at them. You can use standard HTML template
references, javascript template references, a regular JS function or via a
tuple. Or you write none. The tuple is the one I use most often as it allows
overrides that one may want to have in a project. It allows you to add some
hooks for CSS targetting.
class TypeComponent extends HTMLElementSugar {
static tag = 'tuple-based';
static HtmlTemplate = [
'some-id-in-html',
() => {
const t = document.createElement('template');
t.innerHTML =
`<div class="track-row"><div class="track-info">from fn</div></div>`;
return t;
}
];
}
Other features of HTMLElementSugar
Besides the sugar for attribute definitions and templates, several other features are available: aliases and configuration.
Calling it the beauty or the beast: aliases
The beautiful thing about web components (I’m being sarcastic here) is that
they require you to define them as foo-bar and you cannot have foobar; it
needs a hyphen. This ensures you’ll never clash with newly introduced or future
HTML elements. Understandable, but it limits some expressive ways to write your
web components. So… this is actually one of the reasons I added alias
support. I wanted proper web components — spec-compliant, hyphenated,
well-behaved — but I also wanted the freedom to write more semantic,
human-friendly HTML.
Under the hood, everything stays compliant. We register a canonical element
like track-item. But at authoring time, we can allow expressive tags like
<song> or <book>. A small rewrite layer transforms those into their
hyphenated counterparts before the browser even complains.
You get the discipline of the spec. But you also get to write HTML that reads like English.
- You can test your code with jsdom, and
see if everything works as intended (
jsdombreaks when you register a web component without hyphens). - You can bend the spec because you added an alias and HTMLElementSugar rewrites the DOM for you.
- You can still reach the element via its official name, e.g., track-item.
- You can alias valid use-cases with optional default attributes
To see this in action:
TrackItem.alias('song', { type: 'song' });
YourBook.alias('book');
This adds the alias and sets some attributes on the track-item, although I
realize that while writing, type doesn’t exist in the track-item example. But
you can see where I’m going with this. Instead of your-book, you could use
book.
So this allows you to write the following HTML:
<song></song>
<!-- song here is sugar for -->
<track-item type="song"></track-item>
<!-- or in case of book -->
<book attributes="here"></book>
<!-- is alternative syntax for -->
<your-book attributes="here"></your-book>
Aliases enable you to be expressive while remaining spec-compliant. And allows
you to pass in often-used attributes or bend the spec when you absolutely want
to, because you feel the semantics vibe better with mixtape instead of
some-mixtape.
You do need to add one small utility helper in your HTML:
@import '@skirbi/sugar/aliases-register';
Otherwise it won’t work.
Configuration (without inheritance)
In my mixtape project, I found that I needed configuration from a parent
component. So I decided to create a way to be able to select configuration
details from a parent, but it expanded into something more. You can actually
target specific elements. We do this with getNearestConfig().
You can define how configuration is found: via a CSS selector or a more complex
XPath expression. It uses the first node that is found and checks if the
element supports a getConfig() call. It will then return that configuration.
With a CSS pattern, it looks like this:
class TrackItem extends HTMLElementSugar {
static getConfigSelector = 'track-config';
}
Which you can optionally override in HTML:
<track-item get-config-selector="track-config"></track-item>
This means your components don’t need to duplicate configuration. You only need to define the configuration once, thus making it leaner.
You could also opt for a more fine-grained search by using xpath:
<track-item get-config-xpath="//config-provider[@role='primary']"></track-item>
Sample the mixtape, remix your work
I was writing a mixtape. And once I got tired of having to type a lot of the
same boilerplate, I wrote HTMLElementSugar, and it wasn’t trying to solve a
complex problem. It tried to reduce noise and repetition. If you also want to
avoid repetition and focus on writing just enough code to make it happen in a
meaningful way: Sample it. Use it. Remix it.
You can install it using:
npm install @skirbi/sugar
Or check it out on npm: @skirbi/sugar
The hidden track
When I wrote this blogpost, sugar was alone. Since then it has grown,
significantly. It now works under Livewire without having to use wire:ignore,
it is resistant against morphing. And is now part of something bigger I call
skirbi. The full ecosystem will most likely be
featured in future blogposts.
Sugar, via skirbi, powers this blog via a hugo theme called @tatuahe/hugo. And the same suite powers the customer facing side of the Aruba Beach Tennis Open Championships management system.