Nous pouvons créer des éléments HTML personnalisés, définis par nos classes, avec leur propres méthodes et propriétés, gestionnaires d’événement, etc.
Une fois qu’un élément personnalisé est définit, nous pouvons l’utiliser au même titre qu’un élément HTML classique.
C’est parfait, sachant que le dictionnaire HTML est riche, mais pas infini. Il n’y a pas de <easy-tabs>
, <sliding-carousel>
, <beautiful-upload>
… Pensez un instant à toute les balises dont nous pourrions avoir besoin.
Nous pouvons les définir avec une classe spéciale, et les utiliser comme des balises HTML classique.
Il y a deux sortes d’éléments personnalisés :
- Éléments personnalisés autonome – les nouveaux éléments, qui étendent la classe abstraite
HTMLElement
.
Éléments personnalisés intégrés – ils étendent les éléments déjà intégrés au navigateur, comme un bouton personnalisé, basé sur HTMLButtonElement
.
Nous allons voir les éléments personnalisés autonome dans un premier temps, puis nous passerons aux éléments personnalisés déjà intégrés au navigateur.
Pour créer un élément personnalisé, nous allons devoir donner quelques détails au navigateur : Comment le montrer, que faire lorsque cet élément est chargé dans le DOM, ect…
C’est possible en créant une classe avec des méthodes spéciales. C’est facile, sachant qu’il n’y seulement que quelques méthodes, et elles sont toutes optionnelles.
Voici la classe et toute ses méthodes :
class MyElement extends HTMLElement {
constructor() {
super();
// créer l'élément
}
connectedCallback() {
// le navigateur appelle cette méthode lorsque l'élément est ajouté au document
// elle peut-être appelé autant de fois que lélément est ajouté ou supprimé)
}
disconnectedCallback() {
// le navigateur appelle cette méthode lorsque l'élément est supprimé du document
// elle peut-être appelé autant de fois que lélément est ajouté ou supprimé)
}
static get observedAttributes() {
return [/* tableau listant les attributs dont les changements sont à surveiller */];
}
attributeChangedCallback(name, oldValue, newValue) {
// appelé lorsque l'un des attributs listé par la méthode ci-dessus est modifié
}
adoptedCallback() {
// méthode appelé lorsque l'élément est envoyé vers un nouveau document
// (utilisé très rarement avec document.adoptNode)
}
// vous pouvez ajouter d'autres méthodes ou propriétées
}
Après ça, nous devons enregistrer cet élément :
// let the browser know that <my-element> is served by our new class
customElements.define("my-element", MyElement);
Now for any HTML elements with tag <my-element>
, an instance of MyElement
is created, and the aforementioned methods are called. We also can document.createElement('my-element')
in JavaScript.
-
Custom element name must have a hyphen -
, e.g. my-element
and super-button
are valid names, but myelement
is not.
That’s to ensure that there are no name conflicts between built-in and custom HTML elements.
Example: “time-formatted”
For example, there already exists <time>
element in HTML, for date/time. But it doesn’t do any formatting by itself.
Let’s create <time-formatted>
element that displays the time in a nice, language-aware format:
<script>
class TimeFormatted extends HTMLElement { // (1)
connectedCallback() {
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
}
customElements.define("time-formatted", TimeFormatted); // (2)
</script>
<!-- (3) -->
<time-formatted datetime="2019-12-01"
year="numeric" month="long" day="numeric"
hour="numeric" minute="numeric" second="numeric"
time-zone-name="short"
></time-formatted>
- The class has only one method
connectedCallback()
– the browser calls it when<time-formatted>
element is added to page (or when HTML parser detects it), and it uses the built-in Intl.DateTimeFormat data formatter, well-supported across the browsers, to show a nicely formatted time. - We need to register our new element by
customElements.define(tag, class)
. - And then we can use it everywhere.
If the browser encounters any <time-formatted>
elements before customElements.define
, that’s not an error. But the element is yet unknown, just like any non-standard tag.
Such “undefined” elements can be styled with CSS selector :not(:defined)
.
When customElement.define
is called, they are “upgraded”: a new instance of TimeFormatted
is created for each, and connectedCallback
is called. They become :defined
.
To get the information about custom elements, there are methods:
customElements.get(name)
– returns the class for a custom element with the givenname
,customElements.whenDefined(name)
– returns a promise that resolves (without value) when a custom element with the givenname
becomes defined.
connectedCallback
, not in constructor
In the example above, element content is rendered (created) in connectedCallback
.
Why not in the constructor
?
The reason is simple: when constructor
is called, it’s yet too early. The element is created, but the browser did not yet process/assign attributes at this stage: calls to getAttribute
would return null
. So we can’t really render there.
Besides, if you think about it, that’s better performance-wise – to delay the work until it’s really needed.
The connectedCallback
triggers when the element is added to the document. Not just appended to another element as a child, but actually becomes a part of the page. So we can build detached DOM, create elements and prepare them for later use. They will only be actually rendered when they make it into the page.
Observing attributes
In the current implementation of <time-formatted>
, after the element is rendered, further attribute changes don’t have any effect. That’s strange for an HTML element. Usually, when we change an attribute, like a.href
, we expect the change to be immediately visible. So let’s fix this.
We can observe attributes by providing their list in observedAttributes()
static getter. For such attributes, attributeChangedCallback
is called when they are modified. It doesn’t trigger for other, unlisted attributes (that’s for performance reasons).
Here’s a new <time-formatted>
, that auto-updates when attributes change:
<script>
class TimeFormatted extends HTMLElement {
render() { // (1)
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
static get observedAttributes() { // (3)
return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
}
attributeChangedCallback(name, oldValue, newValue) { // (4)
this.render();
}
}
customElements.define("time-formatted", TimeFormatted);
</script>
<time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted>
<script>
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
</script>
- The rendering logic is moved to
render()
helper method. - We call it once when the element is inserted into page.
- For a change of an attribute, listed in
observedAttributes()
,attributeChangedCallback
triggers. - …and re-renders the element.
- At the end, we can easily make a live timer.
Rendering order
When HTML parser builds the DOM, elements are processed one after another, parents before children. E.g. if we have <outer><inner></inner></outer>
, then <outer>
element is created and connected to DOM first, and then <inner>
.
That leads to important consequences for custom elements.
For example, if a custom element tries to access innerHTML
in connectedCallback
, it gets nothing:
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(this.innerHTML); // empty (*)
}
});
</script>
<user-info>John</user-info>
If you run it, the alert
is empty.
That’s exactly because there are no children on that stage, the DOM is unfinished. HTML parser connected the custom element <user-info>
, and is going to proceed to its children, but just didn’t yet.
If we’d like to pass information to custom element, we can use attributes. They are available immediately.
Or, if we really need the children, we can defer access to them with zero-delay setTimeout
.
This works:
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
setTimeout(() => alert(this.innerHTML)); // John (*)
}
});
</script>
<user-info>John</user-info>
Now the alert
in line (*)
shows “John”, as we run it asynchronously, after the HTML parsing is complete. We can process children if needed and finish the initialization.
On the other hand, this solution is also not perfect. If nested custom elements also use setTimeout
to initialize themselves, then they queue up: the outer setTimeout
triggers first, and then the inner one.
So the outer element finishes the initialization before the inner one.
Let’s demonstrate that on example:
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(`${this.id} connected.`);
setTimeout(() => alert(`${this.id} initialized.`));
}
});
</script>
<user-info id="outer">
<user-info id="inner"></user-info>
</user-info>
Output order:
- outer connected.
- inner connected.
- outer initialized.
- inner initialized.
We can clearly see that the outer element finishes initialization (3)
before the inner one (4)
.
There’s no built-in callback that triggers after nested elements are ready. If needed, we can implement such thing on our own. For instance, inner elements can dispatch events like initialized
, and outer ones can listen and react on them.
Customized built-in elements
New elements that we create, such as <time-formatted>
, don’t have any associated semantics. They are unknown to search engines, and accessibility devices can’t handle them.
But such things can be important. E.g, a search engine would be interested to know that we actually show a time. And if we’re making a special kind of button, why not reuse the existing <button>
functionality?
We can extend and customize built-in HTML elements by inheriting from their classes.
For example, buttons are instances of HTMLButtonElement
, let’s build upon it.
-
Extend
HTMLButtonElement
with our class:class HelloButton extends HTMLButtonElement { /* custom element methods */ }
-
Provide the third argument to
customElements.define
, that specifies the tag:customElements.define('hello-button', HelloButton, {extends: 'button'});
There may be different tags that share the same DOM-class, that’s why specifying
extends
is needed. -
At the end, to use our custom element, insert a regular
<button>
tag, but addis="hello-button"
to it:<button is="hello-button">...</button>
Here’s a full example:
<script>
// The button that says "hello" on click
class HelloButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', () => alert("Hello!"));
}
}
customElements.define('hello-button', HelloButton, {extends: 'button'});
</script>
<button is="hello-button">Click me</button>
<button is="hello-button" disabled>Disabled</button>
Our new button extends the built-in one. So it keeps the same styles and standard features like disabled
attribute.
References
- HTML Living Standard: https://html.spec.whatwg.org/#custom-elements.
- Compatiblity: https://caniuse.com/#feat=custom-elementsv1.
Summary
Custom elements can be of two types:
-
“Autonomous” – new tags, extending
HTMLElement
.Definition scheme:
class MyElement extends HTMLElement { constructor() { super(); /* ... */ } connectedCallback() { /* ... */ } disconnectedCallback() { /* ... */ } static get observedAttributes() { return [/* ... */]; } attributeChangedCallback(name, oldValue, newValue) { /* ... */ } adoptedCallback() { /* ... */ } } customElements.define('my-element', MyElement); /* <my-element> */
-
“Customized built-in elements” – extensions of existing elements.
Requires one more
.define
argument, andis="..."
in HTML:class MyButton extends HTMLButtonElement { /*...*/ } customElements.define('my-button', MyElement, {extends: 'button'}); /* <button is="my-button"> */
Custom elements are well-supported among browsers. There’s a polyfill https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs.