Web Components
Web components are praised for being framework independent (good) and relatively easy to create (just JavaScript). (Great introduction by @gomakething What are Web Components (and why would you use them)? - YouTube)
So far I never head a real usage for them, but I played with them here and there and I think they are here to stay, so I think it's worth to know the basics.
- Web Components - Web APIs | MDN
- Web Components At Work
- From Web Component to Lit Element | Google Codelabs
- 7 Web Component Tricks | daverupert.com
- lamplightdev #webcomponents - Blog
More Links
- HTML with Superpowers | daverupert.com
- carbon-design-system/carbon-for-ibm-dotcom: Carbon for IBM.com is based on the Carbon Design System for IBM
- IBM.com Web Standards
- Carbon Custom Elements
- shoelace-style/shoelace: A collection of professionally designed, every day UI components built on Web standards. Works with all framework as well as regular HTML/CSS/JS. 🥾
- Installation
- Web Components
- Custom Element Lifecycle
- Shadow DOM
- HTML Templates
- Observing Attributes
- lit-html and LITElement
- Internationalization
- Use Cases
- Server-Side Rendered Data
- All the ways to make a Web Component (native and Alpine.js)
Custom Element Lifecycle
Custom Elements come with a set of lifecycle hooks. They are:
constructor
connectedCallback
disconnectedCallback
attributeChangedCallback
adoptedCallback
The constructor
is called when the element is first created: for example, by calling document.createElement(‘rating-element')
or new RatingElement()
. The constructor is a good place to set up your element, but it is typically considered bad practice to do DOM manipulations in the constructor for element "boot-up" performance reasons.
The connectedCallback
is called when the custom element is attached to the DOM. This is typically where initial DOM manipulations happen.
The disconnectedCallback
is called after the custom element is removed from the DOM.
The attributeChangedCallback(attrName, oldValue, newValue)
is called when any of the user-specified attributes change.
The adoptedCallback
is called when the custom element is adopted from another documentFragment
into the main document via adoptNode
such as in HTMLTemplateElement
.
Shadow DOM
Use it for encapsulation, so CSS styles can't interfere with the page and vice versa.
The
open
mode means that the shadow content is inspectable and makes the shadow root accessible viathis.shadowRoot
as well.
class RatingElement extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
}
</style>
<button class="thumb_down" ></button>
`;
}
}
- Shadow DOM
- Styling a Web Component | CSS-Tricks - CSS-Tricks
- ::part and ::theme, an ::explainer – Monica Dinculescu
HTML Templates
Using innerHTML
and template literal strings with no sanitization may cause security issues with script injection. <template>
elements provide inert DOM, a highly performant method to clone nodes, and reusable templating for security and reusability.
<template id="template">
<style>
:host {}
</style>
<button></button>
</template>
class RatingElement extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const shadowRoot = this.attachShadow({mode: 'open'});
const templateContent = document.getElementById('rating-element-template').content;
const clonedContent = templateContent.cloneNode(true);
shadowRoot.appendChild(clonedContent);
}
}
Observing Attributes
- 6. Adding Functionality
- lamplightdev - What's the difference between Web Component attributes and properties?
Let's suppose that we have a custom element whose class has a property called
color
that we want to reflect its namesake property. Give this scenario, the code would be the following :
//'this' is pointing to the custom-element declared class
// that extends from HTMLElement, that's why has the
// 'setAttribute' and 'getAttribute' methods available
set color(value){
this.setAttribute('color', value)
}
get color(){
this.getAttribute('color')
}
Behind the scenes, what is happening is that when we execute
this.color = 'blue'
what is really being executed is a call to color'sset
method with a param value of 'blue', which will set the attribute's value to 'blue'. On the other hand, when we executethis.color
what is really being executed is a call to color'sget
method, which return the attribute's value.(Source: Web Components API: Definition, Attributes And Props)
lit-html and LITElement
While lit-html seems to have some helpers to simplify a component (declarative style for click events and stuff)appears to me right now like using something like React etc. with a new syntax for templates. Not sure if that's a good thing.
Internationalization
const FALLBACK_LANG = "en";
class MyBookmark extends HTMLElement {
constructor() {
super();
this._saved = false;
}
getDocumentLang() {
return (
document.body.getAttribute("xml:lang") ||
document.body.getAttribute("lang") ||
document.documentElement.getAttribute("xml:lang") ||
document.documentElement.getAttribute("lang") ||
FALLBACK_LANG
);
}
render() {
const lang = this.getDocumentLang();
const state = this._saved ? 'bookmarks.button.bookmarked' : 'bookmarks.button.bookmark';
const messages = {
'de': {
'bookmarks.button.bookmark': 'merken',
'bookmarks.button.bookmarked': 'gemerkt'
},
'en': {
'bookmarks.button.bookmark': 'save',
'bookmarks.button.bookmarked': 'saved'
},
};
const html = `${messages[lang][state]}`;
}
}
Use Cases
Server-Side Rendered Data
The following example can be achieved with JSON and VueJS or similar frameworks, however use cases could be:
- wish for minimal enhancement of server-rendered data (with the text being present directly in the document for search engines for example)
- instead of sending a large collection of cards, styled with 5000 tailwind css class names over the wire, send the skeleton data and enrich it with the web component template (not sure if that makes sense given that we gzip documents)
Render a skeleton structure
<div class="placelist js-drag-container">
<place-element color="#BA423D">
<img slot="cover" src="" alt="" />
<span slot="title">Mushroom Beach House</span>
<span slot="author">A futuristic-looking beach house ...</span>
</place-element>
<place-element color="#BA423D">
<img slot="cover" src="" alt="" />
<span slot="title">Parsons Reserve</span>
<span slot="author">Former private property that ...</span>
</place-element>
</div>
and add a complex template
<template id="place-template">
<style>
/* Layout Container */
:host {
display: block;
contain: layout inline-size;
perspective:1000px;
}
/* Base Styles */
::slotted(img) {
display:block;
width:100%;
height: auto;
border-radius: 0px 2px 2px 0px;
background-color: var(--cover-color);
}
h2{
font-size: 1rem;
}
.meta{
margin: .75rem;
}
</style>
<article class="place">
<div class="front">
<slot name="cover"></slot>
</div>
<div class="meta">
<h2 class="title"><slot name="title"></slot></h2>
<p class="author"><slot name="author"></slot></p>
</div>
</article>
</template>
// Custom Element Definition
class PlaceElement extends HTMLElement {
constructor() {
super()
this.template = document.getElementById('place-template')
if (this.template) {
this.attachShadow({ mode: "open" }).appendChild(this.template.content.cloneNode(true))
}
}
static get observedAttributes() {
return ['color'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (newValue !== oldValue) {
this[attrName] = this.getAttribute(attrName);
this.update();
}
}
connectedCallback() {
this.update();
}
update() {
if (this.color) {
this.style.setProperty('--cover-color', this.color)
}
}
}
if ('customElements' in window) {
customElements.define('place-element', PlaceElement)
}
All the ways to make a Web Component (native and Alpine.js)
#webcomponents #alpinejs
https://webcomponents.dev/blog/all-the-ways-to-make-a-web-component/
const template = document.createElement('template');
template.innerHTML = `
<style>
* {
font-size: 200%;
}
span {
width: 4rem;
display: inline-block;
text-align: center;
}
button {
width: 4rem;
height: 4rem;
border: none;
border-radius: 10px;
background-color: seagreen;
color: white;
}
</style>
<button id="dec">-</button>
<span id="count"></span>
<button id="inc">+</button>`;
class MyCounter extends HTMLElement {
constructor() {
super();
this.count = 0;
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.appendChild(template.content.cloneNode(true));
this.shadowRoot.getElementById('inc').onclick = () => this.inc();
this.shadowRoot.getElementById('dec').onclick = () => this.dec();
this.update(this.count);
}
inc() {
this.update(++this.count);
}
dec() {
this.update(--this.count);
}
update(count) {
this.shadowRoot.getElementById('count').innerHTML = count;
}
}
customElements.define('my-counter', MyCounter);
import Alpine from 'alpinejs'
const template = document.createElement("template");
template.innerHTML = `
<style>
span, button {
font-size: 200%;
}
span {
width: 4rem;
display: inline-block;
text-align: center;
}
button {
width: 4rem;
height: 4rem;
border: none;
border-radius: 10px;
background-color: seagreen;
color: white;
}
</style>
<div x-data="$el.parentElement.data()">
<button @click="dec()">-</button>
<span x-text="count"></span>
<button @click="inc()">+</button>
</div>
`;
export class MyCounter extends HTMLElement {
connectedCallback() {
this.append(template.content.cloneNode(true));
}
data() {
return {
count: 0,
inc() {
this.count++;
},
dec() {
this.count--;
},
};
}
}
customElements.define("my-counter", MyCounter);
Alpine.start();