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.

More Links

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 via this.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>
   `;
 }
}

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

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's set method with a param value of 'blue', which will set the attribute's value to 'blue'. On the other hand, when we execute this.color what is really being executed is a call to color's get 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();