Aller au contenu

Web components et JavaScript⚓︎

Un gabarit de Web Component⚓︎

🐣 2023-11

Il y a pas mal de façons de faire et c’est une combinaison de ce qu’on pu partager récemment les influenceurs tech de ma bulle (surtout Zach Leat et Chris Ferdinandi). Merci à Anthony pour la partie sur l’écoute des attributs !

web-components-template.js
// (1)!
const template = document.createElement('template')
template.innerHTML = `<button>Hello world</button>`

class MyComponent extends HTMLElement {
  static register(tagName = 'my-component') {
    // (2)!
    if ('customElements' in window && !customElements.get(tagName)) {
      customElements.define(tagName, this)
    }
  }

  // (3)!
  static observedAttributes = ['color', 'size']

  constructor() {
    super()
    this.attachShadow({ mode: 'open' }) // (4)!
  }

  handleEvent(event) {
    // (5)!
    this[`on${event.type}`](event)
  }

  onclick(event) {
    // Do something when the component is clicked.
  }

  oninput(event) {
    // Same with the input event.
  }

  connectedCallback() {
    this.shadowRoot.appendChild(template.content.cloneNode(true))
    this.addEventListener('click', this)
    this.addEventListener('input', this)
  }

  disconnectedCallback() {
    // (6)!
    this.removeEventListener('click', this)
    this.removeEventListener('input', this)
  }

  // (7)!
  attributeChangedCallback(name, oldValue, newValue) {
    console.log(
      `Attribute ${name} has changed from ${oldValue} to ${newValue}.`
    )
  }
}

MyComponent.register()
  1. Il s’agit d’un exemple avec un template dynamique, si le HTML peut être ajouté directement dans le Web Component c’est mieux car plus résilient si le JS plante au chargement par exemple…
  2. J’aime bien le fait que le composant puisse s’auto-enregistrer tout en laissant la faculté de personnaliser le nom. C’est optionnel.
  3. On définit les attributs dont on veut observer les changements.
  4. Ça va automagiquement ajouter un this.shadowRoot, voir cet article détaillé de Lee James Reamsnyder. On le met ouvert pour pouvoir jouer avec en JavaScript par la suite.
  5. Astuce pour dispatcher les évènements vers des méthodes dédiées. Voir l’article de Andrea Giammarchi pour comprendre la méthode standard handleEvent sur un objet.
  6. C’est une bonne pratique de déconnecter les écouteurs rendus inutiles, a fortiori sur des web components !
  7. Si l’attribut color ou size change sur le composant, on passe là-dedans, magique !

Par ici un exemple assez brut car c’est toujours chouette de jouer avec en direct :

Ouvrir dans un nouvel onglet

Chargement des Web Components⚓︎

🐣 2022-08

Les Web Components étant chargés en JavaScript, il y a un moment où il peut y avoir un flash lors du chargement. Cette astuce proposée par Cory LaViska permet de donner une impression de chargement synchrone à la page.

Plutôt que de le faire en CSS :

1
2
3
:not(:defined) {
  visibility: hidden;
}

On va le faire en JavaScript (combiné à du CSS) :

web-components-loading.html
<style>
  body {
    opacity: 0;
  }

  body.ready {
    opacity: 1;
    transition: 0.25s opacity;
  }
</style>

<script type="module">
  await Promise.allSettled([
    customElements.whenDefined('my-button'),
    customElements.whenDefined('my-card'),
    customElements.whenDefined('my-rating'),
  ])

  // Button, card, and rating are registered now! Add
  // the `ready` class so the UI fades in.
  document.body.classList.add('ready')
</script>

On attend que chacun des composants soit chargé pour afficher le <body> avec une transition sur l’opacité. Selon la portée de vos composants, il n’est pas forcément nécessaire d’appliquer cela à la page entière mais ça donne des idées.

Styler des Web Components⚓︎

🐣 2022-11

Il est possible d’appliquer des styles à un composant web directement avec des variables CSS et Manuel Matuzovic nous le démontre :

web-components-styling.js
class Alert extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })

    const styles = document.createElement('style')
    styles.textContent = `
      div {
        background-color: var(--alert-bg, rgb(136 177 255 / 0.5));
        color: var(--alert-color, rgb(0 0 0));
        font-weight: bold;
        padding: var(--alert-spacing, 1rem);
      }
    `

    const content = document.createElement('div')
    content.innerHTML = `
      <slot></slot>
    `

    this.shadowRoot.append(styles)
    this.shadowRoot.append(content)
  }
}

customElements.define('custom-alert', Alert)

On peut ensuite l’utiliser ainsi par défaut :

1
2
3
<custom-alert>
  Votre demande est bien arrivée sur nos serveurs. 😊
</custom-alert>

Et ajouter une classe avec les bons styles — hors du web component — pour un autre type de messages :

1
2
3
<custom-alert class="error">
  Votre demande était un peu trop cavalière ! 🐎
</custom-alert>
1
2
3
4
.error {
  --alert-bg: rgb(255 119 119);
  --alert-spacing: 2rem;
}

Stylé !

Styler des Web Components, encore⚓︎

🐣 2023-11

Tiré et adapté d’un article et de son dépôt associé.

web-components-styling-suite.js
class W3CBanner extends HTMLElement {
  static register(tagName = 'w3c-banner') {
    if ('customElements' in window && !customElements.get(tagName)) {
      customElements.define(tagName, this)
    }
  }

  // (1)!
  static style = `
:host {
  --_wb-color: var(--wb-color, #00599b);
  background: var(--_wb-color);
  display: block;
  padding: 1rem;
}
:host([color="green"]) {
  --wb-color: #00a400;
}
:host([color="red"]) {
  --wb-color: #ff0000;
}
:host([color="black"]) {
  --wb-color: #000;
}
`

  connectedCallback() {
    // (2)!
    if (!('replaceSync' in CSSStyleSheet.prototype) || this.shadowRoot) {
      return
    }
    this.attachShadow({ mode: 'open' })
    // (3)!
    let sheet = new CSSStyleSheet()
    sheet.replaceSync(W3CBanner.style)
    this.shadowRoot.adoptedStyleSheets = [sheet]
    this.shadowRoot.innerHTML = `<slot>Hello</slot>`
  }
}

W3CBanner.register()
  1. Oui c’est bien une variable CSS qui définit une autre variable CSS pour ajouter du dynamisme. Neat!
  2. On coupe la moutarde sur replaceSync ou si les styles sont déjà appliqués.
  3. Combinaison de CSSStyleSheet + replaceSync + adoptedStyleSheets, wow. Sympa comme découverte.

🤙 Des évènements pour les Web Components⚓︎

🐣 2023-12

Une manière générique d’émettre des évènements, inspiré par ce que propose Chris Ferdinandi :

web-components-events.js
class Modem56k extends HTMLElement {
  // (1)!
  emit(type, detail = {}) {
    const event = new CustomEvent(`${this.localName}:${type}`, {
      bubbles: true,
      cancelable: true,
      detail: detail,
    })
    return this.dispatchEvent(event)
  }

  connectedCallback() {
    // (2)!
    this.emit('connected', { message: 'At last!' })
  }
}

customElements.define('modem-56k', Modem56k)

class OldPhone extends HTMLElement {
  connectedCallback() {
    // (3)!
    this.addEventListener('modem-56k:connected', (event) => {
      console.log(event.detail)
    })
  }
}

customElements.define('old-phone', OldPhone)
  1. C’est cette méthode qui est réutilisable et qui envoie des évènements relativement standardisés.
  2. On l’utilise ainsi en passant ce que l’on veut dans l’objet.
  3. On l’écoute ensuite, par exemple dans un autre composant web.

Des mixins pour les Web Components⚓︎

🐣 2023-12

Si vous reprenez deux des astuces précédentes pour l’enregistrement et les évènements, il peut être intéressant de ne pas avoir à les répéter pour chaque définition de composant !

Ici on crée des mixin qui vont permettre d’étendre le comportement par défaut de HTMLElement. Merci à Luke Secomb.

web-components-mixins.js
const SelfRegister = (BaseClass) =>
  class extends BaseClass {
    static register(tagName) {
      if ('customElements' in window && !customElements.get(tagName)) {
        customElements.define(tagName, this)
      }
    }
  }

const WithEvents = (BaseClass) =>
  class extends BaseClass {
    emit(type, detail = {}) {
      const event = new CustomEvent(`${this.localName}:${type}`, {
        bubbles: true,
        cancelable: true,
        detail: detail,
      })
      return this.dispatchEvent(event)
    }
  }

class OldPhone extends WithEvents(SelfRegister(HTMLElement)) {
  connectedCallback() {
    this.emit('connected', { message: 'At last!' })
  }
}

OldPhone.register('old-phone')

Laisser le choix de définir un Web Component⚓︎

🐣 2024-10

Une idée de Nathan Knowler qui utilise un paramètre dans l’URL du fichier JS pour charger ou pas le Custom Element par défaut. Malin !

web-components-loading.js
export class DefinedElement extends HTMLElement {
  constructor() {
    super()
    // Do something.
    console.log('Constructed.')
  }
}

if (new URL(import.meta.url).searchParams.has('define')) {
  customElements.define('defined-element', DefinedElement)
}

Ce qui permet ensuite de charger le composant avec une auto-définition :

<script type=module src=./web-components-loading.js?define></script>

… ou le définir à sa sauce, avec son propre nom :

<script type="module">
  import DefinedElement from './web-components-loading.js'
  customElements.define('super-element', DefinedElement)
</script>

Charger dynamiquement des Web Components⚓︎

🐣 2024-10

Une astuce de Chris Ferdinandi pour ne pas charger des Custom Elements qui ne sont même pas utilisés :

web-components-dynamic.js
1
2
3
if (document.querySelector('simple-toc')) {
  import('/path/to/simple-toc.js')
}

Simple et efficace (vérifier le support de import() pour votre convenance bien sûr).

Un innerHTML moderne avec évaluation du shadow DOM⚓︎

🐣 2024-10

Une nouvelle méthode découverte en explorant le code derrière cet article qui permet de faire en sorte que le declarative shadow DOM soit interprété depuis une chaîne de texte renvoyée par le serveur :

set-html-unsafe-dsd.js
const fetchPartial = async (path) => {
  const response = await fetch(`/${path}`)
  const html = await response.text()

  const tmpl = document.createElement('template')

  // This is a new API, see: https://thathtml.blog/2024/01/dsd-safety-with-set-html-unsafe/
  // and: https://caniuse.com/mdn-api_element_sethtmlunsafe
  if ('setHTMLUnsafe' in tmpl) {
    tmpl.setHTMLUnsafe(html)
  } else {
    // Fall back on using innerHTML for older browsers.
    tmpl.innerHTML = html
  }

  return tmpl.content
}

C’est du baseline 2024 mais si vous travaillez avec des Web Components + shadow DOM c’est plutôt essentiel.

Ça peut s’utiliser ensuite ainsi :

const fetchAndProcess = async (path, elOrSelector) => {
  const partial = await fetchPartial(path)
  const el =
    (typeof elOrSelector === "string")
      ? document.querySelector(elOrSelector)
      : (elOrSelector || document.body)
  el.append(...partial.children)
}

Aller plus loin/différemment⚓︎