The bright future of Web Components - Massimo Artizzu - Web architect - Codemotion Online Tech Conference - Italian Edition - 24-25-26 Novembre 2020 Hello folks and welcome to this online talk about an argument that is dear to me as a lover of the Web Platform: Web Components.
My face

Massimo Artizzu

Web dev & architect
at Antreem

Twitter logo / GitHub logo / dev.to logo @MaxArt2501

You can find these slides at

QR Code for the presentation's link maxart2501.github.io/web-components-talk/codemotion-rm20/

So…
Web Components.

Let's be honest

And also a slew of other issues that have been plaguing Web Components since their conception. Let's see how awkward and verbose is defining a Web Component.
class MyBox extends HTMLElement {
  constructor() {
  super();
  // ...
}
  connectedCallback() {}
disconnectedCallback() {}
attributeChangedCallback() {}
adoptedCallback() {}
  static get observedAttributes() {
  return ['foo'];
}
}
You know what a front-end developer is tempted to say?

“I'll just stick to React!”

(Angular)

(Vue)

(Ember)

(jQuery)

So maybe Web Components are in a difficult position, but… let's see how all these problems have been tackled in the last months and will be solved in the future.

The problem with styles (and Shadow DOM)

Style encapsulation is kind of the whole point of Shadow DOM, but it makes it hard to create a coherent look & feel for the application. The >>> operator would defy the purpose of style encapsulation The @apply function needs separate declarations for pseudo-classes/elements and isn't clear about some more complex uses.

CSS Shadow Parts

#shadow-root
  <header part="box-header">...</header>
  <div part="box-content">...</div>
/* styles.css */
::part(box-header) {
  font: bold 2em system-ui, sans-serif;
}
The proposal that actually gained traction is called CSS Shadow Parts. With this specifications, elements inside the Shadow DOM may define a part attribute with a name of our choice And that can be used in an external stylesheet to style elements inside the Shadow DOM. And that works with pseudo-classes/elements too.
Shadow Parts developer.mozilla.org/en-US/docs/Web/CSS/::part But what about styling WC from the inside, instead? The problem here is that…

“Loading .css files is a pain!”

constructor() {
  ...
  const styleEl = document.createElement('style');
  styleEl.textContent = 'div { color: red; }';
  this.shadowRoot.appendChild(styleEl);
}
constructor() {
  ...
  const linkEl = document.createElement('link');
  linkEl.rel = 'stylesheet';
  linkEl.href = 'component.css';
  this.shadowRoot.appendChild(linkEl);
}

A new way to apply styles

const styles = new CSSStyleSheet();
styles.replaceSync('div { color: red; }');

styles.replace('div { color: red; }')
  .then(sheet => { ... });
.replaceSync isn't really different than creating a <style> element and defining textContent, just nicer… maybe. On the other hand, .replace returns a promise that resolves when the stylesheet is fully loaded, so we have more control over it. But what can we do with these CSSStyleSheet objects? Let's see.
import { styles } from './styles.js';

class MyComponent extends HTMLElement {
  constructor() {
    ...
    this.shadowRoot.adoptedStyleSheets = [ styles ];
  }
}
We define this .adoptedStyleSheets property of the .shadowRoot as an array containing our styles, and we're done: our Web Component is now styled. The main point here is that these stylesheets can now be shared among Web Components and their instances. No need to reload, no need to re-parse.

…The heck is .adoptedStyleSheets?

Frozen Array as in Object.freeze
Jonathan Swan's famous focused face while reading a paper handed by Donald Trump
document.adoptedStyleSheets = [
  ...document.adoptedStyleSheets,
  sheet
];
Jonathan Swan's famous confused face after reading a paper handed by Donald Trump
This is pretty unusual for DOM API, as we're used to deal with methods like add or define, push or whatever to add new "things" to collections, while we're just creating new plain arrays. But it works…
Constructable Stylesheets

“Better, but…”

CSS Modules

(No, not those CSS Modules…)

Not CSS Modules that's being used as the default CSS-in-JS library by create-react-app

These CSS Modules…

import sheet from './styles.css';

sheet is a CSSStyleSheet

Now, again, this looks like using the library CSS Modules, where webpack with its loader transforms the import in actual JavaScript, but this will actually be JavaScript! (And I honestly don't know how this will be handled by the mentioned library.)
import sheet from './styles.css';

class MyComponent extends HTMLElement {
  constructor() {
    ...
    this.shadowRoot.adoptedStyleSheets = [ sheet ];
  }
}
Now this looks an easy way to load styles for Web Components!
CSS Modules github.com/w3c/webcomponents/issues/759

What about HTML?

As some of you might remember, when Web Components were initially conceived, they comprised four parts, and one of them was called "HTML Imports". But it suffered from several problems. Still needs JS Modular, but not ES6 modular Blocking loading Global object pollution

Once upon a time…

HTML Imports

<link rel="import" href="/template.html">

HTML Modules

import templateDoc from './template.html';
This is a proposal from Microsoft, and not Google for once

A simple case

module.html
<blockquote>
  640K is more memory than anyone will ever need.
</blockquote>
main.js
import quoteDoc from './module.html';
console.log(quoteDoc.constructor.name); // HTMLDocument

How is that better than this?

const xhr = new XMLHttpRequest();
xhr.responseType = 'document';
xhr.open('GET', './module.html');
xhr.send();
xhr.onload = () => console.log(xhr.response);
This works in IE6 too

HTML Modules are not inert!

scripts are executed

module.html
<template>Today is <time></time></template>
<script type="module">
  const doc = import.meta.document;
  export const content = doc.querySelector('template').content;
</script>
main.js
import { content } from './module.html';
That sounds bad, bud actually it's great since the JS part can make its own named exports That also means that only scripts of type="module" work
<template>Today is <time></time></template>
<script type="module">
  import sheet from './today-date.css';
  export class TodayDate extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({ mode: 'open' });
      this.shadowRoow.adoptedStyleSheets = [ sheet ];
      this.shadowRoot.appendChild(import.meta.document
        .querySelector('template').content.cloneNode(true)
      );
    }
  }
</script>
… meaning that a single HTML file can export a whole Web Component out of the box!

There's still ugliness!

What about templates?

We have… <template>s

BUT…

<template>s are static

<template>
  Today is <time></time>
</template>

<script>
  const { content } = document.querySelector('template');
  const copy = content.cloneNode(true);
  copy.querySelector('time').textContent = new Date();
</script>
We still have manually copy values to elements. <template>s are not "string" templates, but rather DOM structure templates, useful to create complex structures fast.

Is this what we're given? 😭

Community to the rescue 🙌

lit-html is by Justin Fagnani, from Google. They know templates aren't up to the task!

lit-html

import { html } from 'lit-html';
function MyDate() {
  return html`<div>
    Today is
    <time>${ new Date() }</time>
  </div>`;
}

Notable alternatives: hyperHTML, nanohtml

Hold up! ✋
There's something!

Template Instantiation

<template>
  Today is
  <time datetime="{{ iso }}">
    {{ date }}
  </time>
</template>
const date = new Date();
const instance =
  template.createInstance({
    date: date.toString(),
    iso: date.toISOString()
  });
It would introduce a Mustache-like syntax for <template> elements and a way to replace the placeholders with the data provided. So we wouldn't have to clone the tree, target the nodes and so on.

… but it'll have to wait

🤷‍♀️

Because introducing a Mustache-like syntax would be asking to deal of a lot of possible conflicts on the Web Platform

A brand new proposal

<p>Today is <time></time></p>

const todayPart = new ChildNodePart(timeEl);
todayPart.value = new Date().toDateString();
todayPart.commit();

<p>Today is <time>Wed Nov 25 2020</time></p>
This proposal is still in its early stages, and got out of the strawman phase very recently (October 2020) Similar entities are defined for attributes The names are still tentative

Accessibility concerns

We have role, aria-*

What else?

Doing things right…

… is hard!

Setting up all the accessibility requirements even for basic elements can be long, tedious and error-prone
better-button {
  box-sizing: border-box;
  min-width: 5.14em;
  margin: 0 0.29em;
  font: inherit;
  text-transform: uppercase;
  outline-width: 0;
  border-radius: 3px;
  user-select: none;
  cursor: pointer;
  padding: 0.7em 0.57em;
  transition: box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1);
  display: inline-block;
  overflow: hidden;
  position: relative;
  contain: content;
}
better-button[raised]:not([disabled]),
better-button:not([disabled]):hover {
  box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14),
              0 1px 5px 0 rgba(0, 0, 0, 0.12),
              0 3px 1px -2px rgba(0, 0, 0, 0.2);
}
better-button[disabled] {
  background: #eaeaea;
  color: #a8a8a8;
  cursor: auto;
  pointer-events: none;
  box-shadow: none;
}
better-button:not([disabled]):focus {
  font-weight: 500;
  box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.14),
              0 1px 18px 0 rgba(0, 0, 0, 0.12),
              0 3px 5px -1px rgba(0, 0, 0, 0.4);
}
better-button .ripple {
  position: absolute;
  transform: scale3d(0,0,0);
  opacity: 0.6;
  transition: all 800ms cubic-bezier(0.4, 0, 0.2, 1);
  border-radius: 50%;
  width: 150px;
  height: 150px;
  will-change: opacity, transform;
  pointer-events: none;
  z-index: -1;
}
better-button .ripple.run {
  opacity: 0;
  transform: none;
}
better-button:not(:defined) { color: red; }

class BetterButton extends HTMLElement {
  static get observedAttributes() {
    return ['disabled'];
  }

  get disabled() {
    return this.hasAttribute('disabled');
  }

  set disabled(val) {
    if (val) {
      this.setAttribute('disabled', '');
    } else {
      this.removeAttribute('disabled');
    }
  }

  constructor() {
    super();
    this.addEventListener('keydown', e => {
      if (e.keyCode === 32 || e.keyCode === 13) {
        this.dispatchEvent(new MouseEvent('click',
          { bubbles: true, cancelable: true }));
      }
    });

    this.addEventListener('click', e => {
      if (this.disabled) {
        e.preventDefault();
        e.stopPropagation();
      }
      this.drawRipple(e.offsetX, e.offsetY);
    });
  }

  connectedCallback() {
    this.setAttribute('role', 'button');
    this.setAttribute('tabindex', '0');
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (this.disabled) {
      this.setAttribute('tabindex', '-1');
      this.setAttribute('aria-disabled', 'true');
    } else {
      this.setAttribute('tabindex', '0');
      this.setAttribute('aria-disabled', 'false');
    }
  }

  drawRipple(x, y) {
    let div = document.createElement('div');
    div.classList.add('ripple');
    this.appendChild(div);
    div.style.top = `${y - div.clientHeight/2}px`;
    div.style.left = `${x - div.clientWidth/2}px`;
    div.style.backgroundColor = window
      .getComputedStyle(this).color;
    div.classList.add('run');
    div.addEventListener('transitionend', e => div.remove());
  }
}
window.customElements.define('better-button', BetterButton);

Extending built-in elements

class MyButton extends HTMLButtonElement {…}
customElements.define('my-button', MyButton, { extends: 'button' });
<button is="my-button">…</button>
This is why Custom Elements, by specification, can extend native elements. All we have to do is to extend the built-in element class… then refence the extended element when defining the Custom Element… then actually use the built-in element but with the is= attribute referencing our Custom Element. Kinda awkward, but there's even worse news.
Customized built-in element support

… so.
WebKit.

Excerpt of a comment by NIWA, Ryosuke:
  I'll note that we've vocally and repeatedly objected to having is=, and in fact, stated
  publicly that we won't implement this feature github.com/w3c/webcomponents/issues/509 And it has even a justification
Excerpt of a comment by NIWA, Ryosuke:
  We won't be supporting extends either.
  One fundamental problem is that subclassing a subclass of HTMLInputElement or HTMLImageElement
  often leads to a violation of the Liskov substitution principle over time...
  ... we don't have any hooks for the processing models and internal states of builtin elements...
  Finally, the whole design of is= is a hack, and it would harm the long term health of the Web platform. Among Us' 'emergency meeting' banner If the W3C decides to extend a built-in element, then every Custom Element that extends that element has to support the new feature, or it would break. NIWA, Ryosuke also points out that the is attribute is a hack and it's a sentiment shared by the proponents themselves. He suggests using a system based on mixins; others proposed the introduction of "behaviors" (again, reminiscent of Internet Explorer back in the day). But at this point, we have no solution!

Accessible Object Model (AOM)

element.ariaLabel = 'This is the result';
element.ariaLive = 'polite';

<output aria-label="This is the result"
  aria-live="polite">...</output>
Internet Explorer logo This specification doesn't just involve Web Components, but the platform as a whole. This isn't actually new, as this was actually implemented in Internet Explorer since, like, version 6. But, of course, there's way more to it.
class AlertModal extends HTMLElement {
  constructor() {
    super();
this.setAttribute('role', 'alertdialog'); this.setAttribute('aria-expanded', 'false');
#internals = this.attachInternals(); #internals.role = 'alertdialog'; #internals.ariaExpanded = false; } }
We won't need to use .setAttribute to define the custom element's accessible properties, and that's good because those attributes could be removed or changed from outside. Instead, we can define an ElementInternals object with this new method .attachInternals. This allows us to define accessible semantics internally, so they can't be touched externally. And it gets better.
element.ariaDescribedBy = 'boxTitle';
// ... implies
element.ariaDescribedByElements = [
  boxTitleElement
];
For every attribute that takes an id or a list of id's, there's a corresponding property with an Element(s) suffix, that gets filled with the elements pointed by those id's. And we can also do the opposite: define the properties ending with Element(s) to get the same result, whether or not they have an id. Let's see why it's so important for Web Components
#shadow-root (custom-combobox)
  <input aria-owns="optList" aria-activedescendant="opt1">
  <slot></slot>

<custom-combobox>
  <custom-optionlist id="optList">
    <custom-option id="opt1">Option 1</custom-option>
    ...
  </custom-optionlist>
</custom-combobox>
If we only had attributes to define accessible semantics, not only we'd have the elements in the Shadow DOM with hard-coded id's, but they actually cannot point to elements in the "light" DOM. So this doesn't work.
connectedCallback() {
  const input = this.shadowRoot.querySelector('input');
  const list = this.querySelector('custom-optionlist');
  input.ariaOwnsElements = [ list ];
  input.ariaActiveDescendantElement = list.firstChild;
}
ElementInternals for custom elements ElementInternals yields other nice things too, as it was actually conceived to make Custom Elements as form elements. So we wouldn't actually need to extend the HTMLInputElement or similar classes. Meme of the two astronauts, looking at some JS code, the first saying 'Wait, it's all JavaScript?', the other replying 'Always has been' Web Components have always heavily relied on JavaScript. And that's a problem, because they're quite opaque to search engines and social media sharing, as they might not execute the JavaScript bits to hydrate the laconic tags that we put in the markup. So here it comes a new proposal…

Declarative Shadow DOM

<daily-question>
  <template shadowroot="open">
    <h2>Question of the day</h2>
    <slot></slot>
  </template>
  <p>Is the cat inside?</p>
</daily-question>
All we have to do is to define the content of the WC using the good ol' <template> element with this new shadowroot attribute, that takes "open" or "closed" as values like the possible modes of .attachShadow. So this could be done by a server!
class DailyQuestion extends HTMLElement {
  constructor() {
    super();
    // A custom element now *might* have
    // a shadow root already attached
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' });
      this.shadowRoow.innerHTML = '...';
    }
    ...
  }
  ...
}
Now when the class gets instantiated, it might already have a shadow root defined by the DSD, so we'd have to check its presence. This is also retro-compatible, meaning that re-declaring a shadow root with .attachShadow() will just remove the one created by the Declarative Shadow DOM, letting old Web Components still work.

Easy styles!

<retro-banner>
  <template shadowroot="open">
    <link rel="stylesheet" href="/eigthies.css">
    <h1><slot></slot></h1>
  </template>
  Bring the `80s back!
</retro-banner>
DSD also makes it easy (again) to load stylesheets for Web Components. All you need to do is to insert a <link> element inside the template, as we used to do with JavaScript. The stylesheet gets cached too, so it won't be loaded again for all the other instances of the same component.
Declarative Shadow DOM

Scoped Custom Elements Registries

const registry = new CustomElementRegistry();
registry.define('daily-question', DailyQuestion);

const root = document.querySelector('#app');
root.attachShadow({ mode: 'open', registry });

root.shadowRoot.innerHTML =
  '<daily-question>Tabs or spaces?</daily-question>';
We get to create a new CustomElementRegistry, use it to define our custom elements and attach it to a shadow root. Attaching a scoped registry to a Shadow DOM because it provides an encapsulation mechanism out of the box
A chart showing how a custom element inside a shadow root can be resolved in the shadow root's own custom element registry, and then the global registry Custom elements resolution is of course changed. If a CE is inside a shadow DOM, the browser will check if that has a registry and if the CE is defined there; otherwise, it will check the global registry.
Drake 'bad' meme
Drake 'bad' meme
Now Web Components libraries, instead of definining custom elements on their own on the global registry, can take a registry as a dependency and use it instead.
Scoped Custom Elements Registries

Honorable mentions

github.com/WICG/webcomponents/tree/gh-pages/proposals ElementInternals was actually conceived with the initial intent of making Custom Elements as form controls. Another nice thing that should come to ElementInternals, allows to add, toggle and remove custom states and use them as pseudo-classes in CSS. ISDD because <slot> elements aren't enough, we sometimes need more control in placing projected content inside the Shadow DOM. A way to notify asynchronous tasks

A look at the ecosystem

lit-element

@customElement('simple-greeting')
export class SimpleGreeting extends LitElement {
  @property() name = 'World';

  render() {
    return html`

Hello, ${this.name}!

`; } }

stencil

@Component({ tag: 'my-first-component' })
export class MyComponent {
  @Prop() name: string;

  render() {
    return <p>
      My name is {this.name}
    </p>;
  }
}
SkateJS too uses TSX

webcomponents.org

A good ol' library directory

That's all, folks!
👋

for (const question of questions) {
  await answer(question);
}