Jan 29, 2020 Javascript

Maîtriser Javascript : web components (partie 2)

Le shadow DOM est une technologie récente qui permet d’attacher du DOM isolé à une page. Les navigateurs utilisent ça pour certains composants comme les date pickers ou les sliders. Le shadow DOM permet ainsi d’encapsuler tout ce qui concerne un composant, y compris le DOM et le style.

Les bases du shadow DOM

Pour découvrir le shadow DOM on va repartir de l’exemple de bouton compteur du précédent article. Dans ce composant l’initialisation est effectuée dans la méthode connectedCallback :

connectedCallback() {
  this._value = parseInt(this.getAttribute('value'));
  this._increment = parseInt(this.getAttribute('increment'));
  this.innerHTML = `<button>${ this._value }</button>`;
  this.addEventListener('click', () => this.onButtonClick());
}

On va modifier ce code pour créer un shadow DOM :

connectedCallback() {
    ...
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<button>${ this._value }</button>`;
    ...
}

En premier on crée un shadow DOM et on l’attache au composant :

this.attachShadow({mode: 'open'});

On voit qu’on définit un mode comme ouvert (open). C’est une option obligatoire qui sur un plan pratique est toujours réglée ainsi. L’idée à la base est de mieux sécuriser le composant en le rendant plus difficilement accessible avec l’option sur close. Mais l’expérience a montré que c’était relativement facile à contourner avec les prototypes et qu’on se complique en fait la vie pour rien. Il serait sans doute plus simple de fixer une valeur par défaut à open.

Ensuite on crée le HTML du shadow DOM :

this.shadowRoot.innerHTML = `<button>${ this._value }</button>`;

Si on lance l’application on peut aller voir le code généré :

On trouve un shadow-root et le contenu prévu.

Ici un peu de vocabulaire est sans doute utile. Sur le site de Mozilla il y a cette illustration intéressante :

  • Shadow host : le noeud auquel est relié le shadow DOM
  • Shadow tree : le contenu du shadow DOM
  • Shadow root : le parent du shadow tree
  • Shadow boundary : la limite entre le shadow host et le shadow tree

Donc en gros le shadow DOM est un fragment du DOM, un peu comme celui créé avec la balise template, mais la différence c’est que celui du shadow DOM est directement visible sur la page.

Pour atteindre un élément du shadow tree il faut passer par le shadow root, comme on l’a fait ci-dessus pour remplir le contenu :

this.shadowRoot.innerHTML = `<button>${ this._value }</button>`;

Quand on lance l’application on constate aussi une différence dans l’apparence du bouton :

On a perdu le style ! Maintenant que notre bouton est dans le shadow tree la classe n’est plus active. On a voulu l’isolation et là on l’a !

D’autre part quand on clique sur le bouton on obtient une erreur :

En effet on a écrit ce code lorsque le bouton est cliqué :

onButtonClick() {
  this._value += this._increment;
  this.querySelector('button').textContent = this._value;
}

Mais maintenant pour atteindre le bouton il faut tenir compte du fait qu’il est isolé, alors il faut aussi passer par le shadow root :

this.shadowRoot.querySelector('button').textContent = this._value;

Et maintenant ça fonctionne ! Il faudra juste régler la question du style…

Dans tous les exemples précédents j’ai toujours mis l’initialisation dans la méthode connectedCallback. En effet on a besoin que le composant soit dans le DOM quand on veut attacher du HTML directement. Mais avec le shadow DOM c’est différent. Notre DOM est détaché de la page et on peut en disposer immédiatement. La conséquence de cela est qu’on peut faire l’initialisation au niveau du constructeur :

constructor() {
  super();
  this._value = parseInt(this.getAttribute('value'));
  this._increment = parseInt(this.getAttribute('increment'));
  this.attachShadow({mode: 'open'});
  this.shadowRoot.innerHTML = `<button>${ this._value }</button>`;
  this.addEventListener('click', () => this.onButtonClick());
}

Les éléments dans le shadow DOM sont protégés des styles externes. On le voit si on crée un autre bouton sur la page :

<div class="paper container">
  <button>10</button>
  <momo-button-counter value="10" increment="10"></momo-button-counter>    
</div>

Le premier bouton est mis en forme par les classes du framework, ce qui n’est pas le cas du bouton du composant. Toutefois on hérite quand même des règles générale de la page :

Ce qui semble logique pour maintenir une cohérence globale.

Évidemment rien n’empêche au niveau du composant d’appeler du style externe :

this.shadowRoot.innerHTML = `
  <link rel="stylesheet" href="https://unpkg.com/papercss/dist/paper.min.css">
  <button>${ this._value }</button>`;

On va voir qu’on peut affiner la gestion des styles.

Les slots

Voyons à présent comment améliorer l’aspect template. On va reprendre ce projet vu dans le précédent article :

J’ai créé un nouveau plunk pour la nouvelle version de l’application.

L’idée est de créer un shadow DOM comme on l’a vu ci-dessus et en plus d’utiliser des slots. Un slot est un emplacement dans du HTML qui permet une insertion.

Mais on va commencer par changer la page HTML :

<!doctype html>
<html lang="fr">

<head>
  <meta charset="utf-8">
  <title>Les web components</title>
  <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css">
  <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css">
  <script type="module" src="./components/cardsList.js"></script>
</head>

<body>

  <br>
  
  <div class="container">
    <form>    
      <fieldset>
        <label for="title">Titre</label>
        <input id="title" name="title" type="text">
        <label for="content">Contenu</label>
        <textarea id="content" name="content"></textarea>
        <button class="button-primary" type="submit">Ajouter</button>
      </fieldset>  
    </form>
    <cards-list></cards-list>
  </div>

  <script>
    document.querySelector('form').addEventListener('submit', e => {
      e.preventDefault();
      const formData = new FormData(document.forms[0]);
      document.querySelector('cards-list').add({
        title: formData.get('title'), 
        content: formData.get('content')
      });
    });
  </script>
</body>

</html>

J’ai opté pour un autre framework CSS minimaliste : Milligram. On a donc le chargement du framework CSS, le formulaire adapté et le Javascript pour traiter la soumission du formulaire avec ce nouvel aspect plus classique :

Mis à part l’aspect formel on a rien changé d’autre.

Pour le composant CardsList on a ce nouveau code :

import CardsCard from './cardsCard.js';

export default class CardsList extends HTMLElement {

  constructor() {
    super();
    this.cards = [];
    this.addEventListener('delete', this.handleDelete);
  }
  
  add(card) {
    this.cards.push(card);
    this.render();
  }

  render() {
    let i = 0;
    this.innerHTML = `
      ${ this.cards.map(card => `
        <cards-card index=${ i++ }>
          <span slot="title">${ card.title }</span>
          ${ card.content }        
        </cards-card>
      `).join('') }
    `;
  }

  handleDelete(e) {
    this.cards.splice(e.detail.index, 1);
    this.render();
  }
}  

if (!customElements.get('cards-list')) {
  customElements.define('cards-list', CardsList);
}

La seule différence réside dans la définition du contenu HTML :

this.innerHTML = `
  ${ this.cards.map(card => `
    <cards-card index=${ i++ }>
      <span slot="title">${ card.title }</span>
      ${ card.content }        
    </cards-card>
  `).join('') }
`;

Le titre et le contenu sont maintenant insérés avec une syntaxe particulière en pointant un slot par son nom et un autre sans nommage. On a deux slots :

  • title : pour l’emplacement réservé du titre
  • sans nom : pour l’emplacement réservé du contenu

En gros on sait qu’il y a ces deux emplacement réservés dans le template du composant, on les repère par leur nom ou sans nom pour la valeur par défaut. Ici c’est une donnée simple qui est transmise mais on pourrait envoyer tout un DOM particulier de la même façon.

C’est au niveau du composant CardsCard que va se passer la partie la plus intéressante :

export default class CardsCard extends HTMLElement {
  
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <style>
        .card {
          box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
          transition: 0.3s;
          margin-bottom: 1em; 
        }
        .card:hover {
          box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
        }
        .container {
          padding: 2px 16px;
        }
        button {
          background-color: red;
          border: none;
          border-radius: 5px;
          color: white;
          padding: 10px 20px;
          text-align: center;
          text-decoration: none;
          display: inline-block;
          font-size: 16px;
          margin-bottom: 10px;
          cursor: pointer;
        }
      </style>
      <div class="card">
        <div class="container">
          <h4><strong><slot name="title"></slot></strong></h4>
          <p><slot></slot></p>
          <button>Supprimer</button>
        </div>
      </div>
    `;
    this.shadowRoot.querySelector('button').addEventListener('click', this.handleClick);
  }

  get index() {
    return this.getAttribute('index');
  }

  set index(val) {
    this.setAttribute('index', val);
  }

  handleClick = () => {
    this.dispatchEvent(new CustomEvent('delete', { 
      bubbles: true,
      detail: { index: this.index }
    }));
  }
}

if (!customElements.get('cards-card')) {
  customElements.define('cards-card', CardsCard);
}

Le style des fiches est défini dans le composant et a cet aspect :

On crée un shadow DOM dans le constructeur :

this.attachShadow({mode: 'open'});

On voit aussi dans le code un changement pour le contenu HTML :

this.shadowRoot.innerHTML = `
  <style>
    // Style spécifique au composant
  </style>
  <div class="card">
    <div class="container">
      <h4><strong><slot name="title"></slot></strong></h4>
      <p><slot></slot></p>
      <button>Supprimer</button>
    </div>
  </div>
`;

Le style est défini à ce niveau et est isolé par rapport à celui de la page principale. D’autre part on définit les deux slots :

<h4><strong><slot name="title"></slot></strong></h4>
<p><slot></slot></p>

Pour bien montrer l’isolement du style j’ai utilisé un classe interne au composant nommée container :

.container {
  padding: 2px 16px;
}

Et le framework Milligram utilise aussi une classe avec le même nom :

Mais comme je l’avais évoqué précédemment on hérite quand même du style de base pour garder une cohérence visuelle :

Le template

Maintenant je vous propose de modifier encore le code en utilisant un template. Voici le nouveau plunk.

Cette fois on crée un template dans la page HTML :

<template id="card">
  <style>
    .card {
      box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
      transition: 0.3s;
      margin-bottom: 1em; 
    }
    .card:hover {
      box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
    }
    .container {
      padding: 2px 16px;
    }
    button {
      background-color: red;
      border: none;
      border-radius: 5px;
      color: white;
      padding: 10px 20px;
      text-align: center;
      text-decoration: none;
      display: inline-block;
      font-size: 16px;
      margin-bottom: 10px;
      cursor: pointer;
    }
  </style>
  <div class="card">
    <div class="container">
      <h4><strong><slot name="title"></slot></strong></h4>
      <p><slot></slot></p>
      <button>Supprimer</button>
    </div>
  </div>
</template>

Du coup le composant CardsCard devient plus léger, voici le nouveau constructeur :

constructor() {
  super();
  const template = document.getElementById('card');
  this.attachShadow({mode: 'open'}).appendChild(template.content.cloneNode(true));
  this.shadowRoot.querySelector('button').addEventListener('click', this.handleClick);
}

On référence le template. Ensuite on crée le shadow DOM en lui ajoutant un clone du contenu du template. Pour le reste il n’y a aucun changement.

Le style

On a vu que le style du shadow DOM est isolé et que ce qui parvient de la page englobante est très limité. On a donc une frontière assez étanche mais il existe des moyens pour passer au travers si le besoin s’en fait sentir.

Dans la dernière version ci-dessus avec le template on va effectuer cette modification dans le style :

<style>
  :host { 
    display: block;
    border: 3px solid;
    padding: 20px;
    margin-bottom: 10px;
  }
  .card {
    box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
    transition: 0.3s;
  }

Donc on enlève la marge inférieur de la classe .card qui servait à séparer les fiches. A la place on crée de nouvelles règles avec la pseudo-classe :host. celle-ci pointe l’élément englobant le composant, donc l’hôte du shadow DOM. On agit donc sur un élément extérieur au composant :

En cas de besoin on peut mieux cibler en passant un sélecteur en paramètre :

:host(.wrapper) {

Si l’élément ne possède pas cette classe les règles ne seront pas appliquées.

On peut même définir un comportment différent selon qu’un composant qui peut se trouver dans des composant différents doit aussi appliquer des règles différentes avec :host-context.  Il suffit de passer en paramètre le nom du composant concerné.

On a aussi le cas du HTML transmis à travers un slot. On va modifier ainsi le code de notre composant CardsList :

<span slot="title"><h2>${ card.title }</h2></span>

Ainsi que l’insertion du titre dans le template :

<slot name="title"></slot>

Donc on n’envoie plus seulement le texte brut mais inséré dans une balise h2 :

Maintenant si on ajoute un règle au template pour styliser ce titre :

h2 { color: red; }

Il ne va rien se passer parce que ce contenu est envoyé par le parent. Il faut donc utiliser une autre stratégie…

Il existe le pseudo-élément ::slotted qui va nous servir :

::slotted(*) { color: red; }

Là on dit que tous les élément (*) insérés dans un slot doivent avoir application des règles énoncées :

Le titre est bien devenu rouge mais pas le contenu parce que celui-ci est seulement du texte et du coup les règles ne s’appliquent pas. On obtiendrait le même résultat en spécifiant la balise avec ::slotted(span).

Je pense que tout ça va encore évoluer, il faudra surveiller les spécifications…

Il existait le pseudo-élément ::shadow pour traverser la barrière à partir du parent il a été supprimé récemment. Il ne nous reste plus grand chose pour le faire. Mais on peut utiliser les variables CSS. Dans notre page globale on définit une vriable CSS :

:root { --text-color: red; }

Et on peut utiliser cette variable dans notre composant CardsCard :

::slotted(*) { color:var(--text-color); }

La variable a pu franchir la barrière !

Conclusion

On a vu dans cet article qu’il est possible de créer un composant pratiquement totalement isolé de son environnement. Ce n’est pas toujours ce qu’on recherche mais c’est une possibilité qui peut s’avérer utile. L’utilisation de slots est intéressant et il est dommage que ce soit limité au shadow DOM.

 

Laisser un commentaire