Making that local-time custom element
I did it, I made the local-time
custom element (aka web component) I said I was going to. Here’s how.
Making a basic custom element
This will not be a post on all the ins and outs of creating a web component. Instead, this is the minimum amount of information you need to make a basic web component. Outside of the normal documentation on MDN and web.dev articles about web components, I took a lot of inspiration from the work Zach Leatherman has been doing with the web components he’s been making over the years as well as the web components that Heydon Pickering and Andy Bell released as part of Every Layout.
Registering
Custom elements are ES6 classes that extend from the HTMLElement
class. You then need to register them using window.customElements.define()
. There can only be one definition for a given tag name on a page. I like how Zach uses a static register
method on the class to handle registering the component. I could see this being extra useful if you wanted to create your own base class that all your custom elements extend from.
static register(tagName) {
if ("customElements" in window) {
customElements.define(tagName || "local-time", LocalTime);
}
}
Rendering
The connectedCallback
lifecycle hook is called each time the element is added to the DOM. This is where we do all our setup and rendering logic. There is also an attributeChangedCallback
, which runs whenever an attribute defined in the observedAttributes
array changes. This is also often a time when you want to re-render your component. The components in Every Layout implement a render
method that gets called in both of these lifecycle hooks (Lit also uses a render
method).
The render of local-time
is very simple: it sets the textContent
.
render() {
this.textContent = LocalTime.format(this.datetime, this.options);
}
Attributes
I mentioned responding to attribute changes with attributeChangedCallback
, and alluded to some of those attributes in my render
method. How do you define, set, and get attributes on a custom element? With setters and getters, of course.
For this first iteration of the local-time
component, I’m targeting just the attributes I need for my site. To make this more generic, I’d add more to cover more use cases.
I liked an approach I saw in Zach’s line-numbers
component, where he defines a static attrs
object on the class. He uses this to map a more JavaScript-friendly variable name to a more HTML-friendly name (e.g., manualRender
-> manual-render
). I’ve found that most of the time, HTML attributes don’t include hyphens (except aria-_ and data-_) but rather are lowercased (tabindex
, autocomplete
, maxlength
, etc.). I made my attributes
field an array, then used it in observedAttributes
.
static attributes = ["datetime", "day", "month", "timezone", "year"];
static get observedAttributes() {
return LocalTime.attributes;
}
I’m not really using attributes
outside of observedAttributes
, but I feel like it makes the source more readable to have a field saying “these are the attributes you can apply.”
Likewise, I added a default
field to show what few defaults the component has.
static defaults = {
day: "numeric",
month: "long",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
year: "numeric",
};
You might have noticed these attributes all correspond to options for Intl.DateTimeFormat
. For now, I just support attributes I care about. If and when I release this on npm, then I’ll support any possible option to DateTimeFormat
as an attribute.
Setting and getting the attributes is basic and boring, but here’s a taste:
get day() {
return this.getAttribute("day") || LocalTime.defaults.day;
}
set day(val) {
this.setAttribute("day", val);
}
The logic
You might have noticed in the render
method that the real work was being done by a LocalTime.format
class method. If you read the last post, you’ll be familiar with its implementation.
static format(dateTime, options) {
const dateTimeFormat = new Intl.DateTimeFormat(
window.navigator.language,
options
);
return dateTimeFormat.format(new Date(dateTime));
}
To get the options
as an object to pass to Intl.DateTimeFormat
, I wrote a special getter.
get options() {
return {
day: this.day,
month: this.month,
timeZone: this.timezone,
year: this.year,
};
}
Accessibility
This is a really simple custom element. It replaces the built-in time
element with a useful set of features to format the visible date per a user’s time zone and locale. The time
element has semantic meaning. Why else would we be using it? Custom elements don’t have semantic meaning by default. We need to give them that meaning using ARIA. Thankfully, there is an ARIA role that directly corresponds to the time
element. It’s time
! That works out well, because in the past I’ve made custom elements that should take on the roles of description lists, terms, and details and there are no corresponding ARIA roles. Frustrating.
1
Next steps
You can view the full source by opening your web inspector and looking at the script tag at the bottom of the document of this page. Next, I plan to release this on npm as a standalone package.
As I was writing this, I tripled checked. According to MDN, there are structural roles for description list elements:
associationlist
,associationlistitemkey
, andassociationlistitemvalue
. But, W3C’s Authoring Practices Guide does not list them, nor can I find them in the WAI-ARIA spec. ↩︎