Shadow DOM v1 – Composants Web autonomes

Shadow DOM permet aux développeurs Web de créer un DOM et un CSS compartimentés pour les composants Web.

Résumé

Le Shadow DOM élimine la fragilité de la création d'applications Web. La fragilité vient de la nature globale de HTML, CSS et JS. Au fil des ans, nous avons inventé un nombre exorbitant d'outils pour contourner ces problèmes. Par exemple, lorsque vous utilisez un nouvel ID/une nouvelle classe HTML, vous ne pouvez pas savoir s'il entrera en conflit avec un nom existant utilisé par la page. Des bugs subtils apparaissent, la spécificité CSS devient un énorme problème (!important tout le temps !), les sélecteurs de style deviennent incontrôlables et les performances peuvent en souffrir. Et cette liste ne s'arrête pas là.

Shadow DOM corrige les problèmes liés au CSS et au DOM. Il introduit les styles de portée dans la plate-forme Web. Sans outils ni conventions d'attribution de noms, vous pouvez grouper le CSS avec la balise, masquer les détails d'implémentation et créer des composants autonomes en JavaScript standard.

Introduction

Shadow DOM est l'une des trois normes Web Components : les modèles HTML, le Shadow DOM et les éléments personnalisés. Les importations HTML faisaient partie de la liste, mais sont désormais considérées comme obsolètes.

Vous n'avez pas besoin de créer des composants Web qui utilisent le DOM fantôme. Mais lorsque vous le faites, vous profitez de ses avantages (couverture CSS, encapsulation DOM, composition) et créez des éléments personnalisés réutilisables, qui sont résilients, hautement configurables et extrêmement réutilisables. Si les éléments personnalisés permettent de créer un code HTML (avec une API JavaScript), Shadow DOM permet de fournir son code HTML et CSS. Les deux API se combinent pour créer un composant avec du code HTML, CSS et JavaScript autonome.

Shadow DOM est conçu comme un outil permettant de créer des applications basées sur des composants. Il apporte donc des solutions aux problèmes courants du développement Web:

  • DOM isolé: le DOM d'un composant est autonome (par exemple, document.querySelector() ne renvoie pas de nœuds dans le Shadow DOM du composant).
  • CSS limité: le CSS défini dans le Shadow DOM est limité à celui-ci. Les règles de style ne s'échappent pas et les styles de page ne s'infiltrent pas.
  • Composition: concevez une API déclarative basée sur le balisage pour votre composant.
  • Simplifie le CSS : le DOM avec portée vous permet d'utiliser des sélecteurs CSS simples, des noms d'ID/de classe plus génériques et de ne pas vous soucier des conflits de dénomination.
  • Productivité : pensez aux applications en blocs de DOM plutôt qu'en une seule grande page (globale).

Démonstration fancy-tabs

Tout au long de cet article, je vais faire référence à un composant de démonstration (<fancy-tabs>) et à des extraits de code issus de celui-ci. Si votre navigateur est compatible avec les API, une démonstration en direct s'affiche juste en dessous. Sinon, consultez le code source complet sur GitHub.

Afficher la source sur GitHub

Qu'est-ce que le Shadow DOM ?

Informations sur le DOM

Le code HTML est le langage de base du Web, car il est facile à utiliser. En déclarant quelques balises, vous pouvez créer en quelques secondes une page qui présente à la fois une présentation et une structure. Cependant, le code HTML seul n'est pas très utile. Les humains comprennent facilement un langage basé sur du texte, mais les machines ont besoin de plus. Saisissez le Document Object Model (DOM).

Lorsque le navigateur charge une page Web, il effectue un certain nombre d'opérations intéressantes. L'une des tâches qu'il effectue est de transformer le code HTML de l'auteur en document en direct. En gros, pour comprendre la structure de la page, le navigateur analyse le code HTML (chaînes de texte statiques) dans un modèle de données (objets/nœuds). Le navigateur conserve la hiérarchie HTML en créant une arborescence de ces nœuds: le DOM. L'avantage du DOM est qu'il s'agit d'une représentation en direct de votre page. Contrairement au code HTML statique que nous créons, les nœuds produits par le navigateur contiennent des propriétés et des méthodes, et surtout, ils peuvent être manipulés par des programmes. C'est pourquoi nous pouvons créer des éléments DOM directement à l'aide de JavaScript:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

génère le balisage HTML suivant:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

Tout cela est bien joli. Qu'est-ce que le Shadow DOM ?

DOM… dans l'ombre

Shadow DOM n'est qu'un DOM normal avec deux différences: 1) la façon dont il est créé/utilisé et 2) son comportement par rapport au reste de la page. En règle générale, vous créez des nœuds DOM et les ajoutez en tant qu'enfants d'un autre élément. Avec le Shadow DOM, vous créez une arborescence DOM étendue qui est associée à l'élément, mais distincte de ses enfants réels. Ce sous-arbre ciblé est appelé arbre fantôme. L'élément auquel il est associé est son hôte fantôme. Tout ce que vous ajoutez dans les ombres devient local pour l'élément hôte, y compris <style>. C'est ainsi que le Shadow DOM gère le champ d'application des styles CSS.

Créer un DOM fantôme

Une racine d'ombre est un fragment de document qui est associé à un élément "hôte". L'élément obtient son Shadow DOM en associant une racine fantôme. Pour créer un Shadow DOM pour un élément, appelez element.attachShadow():

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

J'utilise .innerHTML pour remplir le root d'ombre, mais vous pouvez également utiliser d'autres API DOM. C'est le Web. Nous avons le choix.

La spécification définit une liste d'éléments qui ne peuvent pas héberger un arbre d'ombre. Plusieurs raisons peuvent expliquer la présence d'un élément dans la liste:

  • Le navigateur héberge déjà son propre Shadow DOM interne pour l'élément (<textarea>, <input>).
  • Il n'est pas logique que l'élément héberge un Shadow DOM (<img>).

Par exemple, cette solution ne fonctionne pas:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

Créer un Shadow DOM pour un élément personnalisé

Shadow DOM est particulièrement utile lorsque vous créez des éléments personnalisés. Utilisez Shadow DOM pour compartimenter le code HTML, CSS et JS d'un élément, créant ainsi un "composant Web".

Exemple : un élément personnalisé associe le Shadow DOM à lui-même, encapsulant son DOM/CSS :

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

Voici quelques points à noter : La première est que l'élément personnalisé crée son propre DOM d'ombre lorsqu'une instance de <fancy-tabs> est créée. Cette opération s'effectue dans constructor(). Deuxièmement, comme nous créons une racine d'ombre, les règles CSS dans <style> seront limitées à <fancy-tabs>.

Composition et emplacements

La composition est l'une des fonctionnalités les moins comprises du Shadow DOM, mais c'est sans doute la plus importante.

Dans le monde du développement Web, la composition est la façon dont nous construisons des applications, de manière déclarative à partir du code HTML. Différents blocs de base (<div>, <header>, <form> et <input>) se combinent pour former des applications. Certaines de ces balises fonctionnent même ensemble. C'est grâce à la composition que les éléments natifs tels que <select>, <details>, <form> et <video> sont si flexibles. Chacune de ces balises accepte certains éléments HTML en tant qu'enfants et effectue une action spéciale avec eux. Par exemple, <select> sait afficher <option> et <optgroup> dans des widgets déroulants et à sélection multiple. L'élément <details> affiche <summary> sous la forme d'une flèche extensible. Même <video> sait gérer certains enfants : les éléments <source> ne sont pas affichés, mais ils affectent le comportement de la vidéo. Quelle magie !

Terminologie: Light DOM et Shadow DOM

La composition Shadow DOM introduit de nombreux nouveaux principes fondamentaux dans le développement Web. Avant d'entrer dans le vif du sujet, définissons certains termes pour que nous parlions le même langage.

DOM léger

Le balisage écrit par un utilisateur de votre composant. Ce DOM se trouve en dehors du DOM de l'ombre du composant. Il s'agit des enfants réels de l'élément.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

DOM écrit par l'auteur d'un composant. Le Shadow DOM est local au composant et définit sa structure interne, son CSS étendu et encapsule les détails de votre implémentation. Il peut également définir comment afficher le balisage créé par le consommateur de votre composant.

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

Arbre DOM aplati

Résultat de la distribution du DOM léger de l'utilisateur dans votre DOM d'ombre, ce qui génère le produit final. L'arborescence aplatie est ce que vous voyez finalement dans les outils pour les développeurs et ce qui est affiché sur la page.

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

Élément <slot>

Le Shadow DOM compose différents arbres DOM à l'aide de l'élément <slot>. Les emplacements sont des espaces réservés dans votre composant que les utilisateurs peuvent remplir avec leur propre balisage. En définissant un ou plusieurs emplacements, vous invitez le balisage externe à s'afficher dans le DOM fantôme de votre composant. En substance, vous dites "Affichez la balise de l'utilisateur ici".

Les éléments sont autorisés à "franchir" la limite du DOM d'ombre lorsqu'un <slot> les invite. Ces éléments sont appelés nœuds distribués. D'un point de vue conceptuel, les nœuds distribués peuvent sembler un peu bizarres. Les emplacements ne déplacent pas physiquement le DOM. Ils l'affichent à un autre emplacement dans le DOM de l'ombre.

Un composant peut définir zéro ou plusieurs emplacements dans son DOM ombragé. Les emplacements peuvent être vides ou fournir un contenu de remplacement. Si l'utilisateur ne fournit pas de contenu Light DOM, l'emplacement affiche son contenu de remplacement.

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

Vous pouvez également créer des emplacements nommés. Les emplacements nommés sont des espaces vides spécifiques dans votre DOM d'ombre que les utilisateurs référencent par nom.

Exemple : les emplacements dans le Shadow DOM de <fancy-tabs> :

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

Les utilisateurs de composants déclarent <fancy-tabs> comme suit:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

Voici à quoi ressemble l'arborescence aplatie:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

Notez que notre composant peut gérer différentes configurations, mais que l'arborescence DOM aplatie reste la même. Nous pouvons également passer de <button> à <h2>. Ce composant a été créé pour gérer différents types d'enfants, tout comme <select>.

Attribuer un style

Il existe de nombreuses options pour styliser les composants Web. Un composant qui utilise Shadow DOM peut être stylisé par la page principale, définir ses propres styles ou fournir des crochets (sous la forme de propriétés personnalisées CSS) pour que les utilisateurs puissent remplacer les valeurs par défaut.

Styles définis par le composant

La fonctionnalité la plus utile du Shadow DOM est sans conteste le CSS à portée:

  • Les sélecteurs CSS de la page externe ne s'appliquent pas à l'intérieur de votre composant.
  • Les styles définis à l'intérieur ne s'étendent pas. Elles sont limitées à l'élément hôte.

Les sélecteurs CSS utilisés dans Shadow DOM s'appliquent localement à votre composant. En pratique, cela signifie que nous pouvons à nouveau utiliser des noms d'identifiants/de classe courants, sans nous soucier des conflits ailleurs sur la page. Il est recommandé d'utiliser des sélecteurs CSS plus simples dans Shadow DOM. Ils sont également bénéfiques pour les performances.

Exemple : les styles définis dans une racine d'ombre sont locaux

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Les feuilles de style sont également limitées à l'arborescence d'ombre:

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Vous êtes-vous déjà demandé comment l'élément <select> génère un widget de sélection multiple (plutôt qu'un menu déroulant) lorsque vous ajoutez l'attribut multiple ?

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select> peut s'appliquer un style différent en fonction des attributs que vous déclarez. Les composants Web peuvent également se styliser eux-mêmes à l'aide du sélecteur :host.

Exemple : un composant se stylisant lui-même

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

Un piège avec :host est que les règles de la page parente sont plus spécifiques que les règles :host définies dans l'élément. Autrement dit, les styles externes l'emportent. Cela permet aux utilisateurs de remplacer votre style de niveau supérieur de l'extérieur. De plus, :host ne fonctionne que dans le contexte d'une racine d'ombre. Vous ne pouvez donc pas l'utiliser en dehors du DOM d'ombre.

La forme fonctionnelle de :host(<selector>) vous permet de cibler l'hôte s'il correspond à un <selector>. C'est un excellent moyen pour votre composant d'encapsuler les comportements qui réagissent aux interactions utilisateur, à l'état ou au style des nœuds internes en fonction de l'hôte.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

Mise en forme basée sur le contexte

:host-context(<selector>) correspond au composant si lui-même ou l'un de ses ancêtres correspond à <selector>. Un cas d'utilisation courant est la thématisation en fonction de l'environnement d'un composant. Par exemple, de nombreuses personnes effectuent la thématisation en appliquant une classe à <html> ou <body>:

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) styliserait <fancy-tabs> lorsqu'il s'agit d'un descendant de .darktheme:

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() peut être utile pour la thématisation, mais une approche encore meilleure consiste à créer des crochets de style à l'aide de propriétés CSS personnalisées.

Mettre en forme des nœuds distribués

::slotted(<compound-selector>) correspond aux nœuds distribués dans un <slot>.

Imaginons que nous ayons créé un composant de badge de nom:

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

Le DOM de l'ombre du composant peut styliser les <h2> et .title de l'utilisateur:

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

Si vous vous souvenez, les <slot> ne déplacent pas le DOM léger de l'utilisateur. Lorsque les nœuds sont distribués dans un <slot>, le <slot> affiche leur DOM, mais les nœuds restent physiquement en place. Les styles appliqués avant la distribution continuent de s'appliquer après la distribution. Toutefois, lorsque le DOM léger est distribué, il peut adopter des styles supplémentaires (définis par le DOM sombre).

Voici un autre exemple plus détaillé de <fancy-tabs>:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

Dans cet exemple, il existe deux emplacements: un emplacement nommé pour les titres des onglets et un emplacement pour le contenu du panneau de l'onglet. Lorsque l'utilisateur sélectionne un onglet, nous mettons en gras sa sélection et affichons son panneau. Pour ce faire, sélectionnez les nœuds distribués qui disposent de l'attribut selected. Le code JavaScript de l'élément personnalisé (non affiché ici) ajoute cet attribut au bon moment.

Styliser un composant de l'extérieur

Il existe plusieurs façons de styliser un composant de l'extérieur. Le moyen le plus simple consiste à utiliser le nom de la balise comme sélecteur:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

Les styles externes l'emportent toujours sur les styles définis dans le Shadow DOM. Par exemple, si l'utilisateur écrit le sélecteur fancy-tabs { width: 500px; }, il remplace la règle du composant: :host { width: 650px;}.

Le style du composant lui-même ne vous mènera pas très loin. Mais que se passe-t-il si vous souhaitez styliser l'intérieur d'un composant ? Pour ce faire, nous avons besoin de propriétés CSS personnalisées.

Créer des crochets de style à l'aide de propriétés personnalisées CSS

Les utilisateurs peuvent modifier les styles internes si l'auteur du composant fournit des crochets de style à l'aide de propriétés CSS personnalisées. Du point de vue conceptuel, l'idée est semblable à <slot>. Vous créez des "espaces réservés de style" que les utilisateurs peuvent remplacer.

Exemple : <fancy-tabs> permet aux utilisateurs de remplacer la couleur d'arrière-plan :

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

Dans son Shadow DOM:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

Dans ce cas, le composant utilisera black comme valeur d'arrière-plan, car l'utilisateur l'a fournie. Sinon, la valeur par défaut est #9E9E9E.

Rubriques avancées

Créer des racines d'ombre fermées (à éviter)

Il existe une autre variante du Shadow DOM appelée mode "fermé". Lorsque vous créez un arbre d'ombre fermé, JavaScript externe ne peut pas accéder au DOM interne de votre composant. Ce fonctionnement est semblable à celui des éléments natifs tels que <video>. JavaScript ne peut pas accéder au Shadow DOM de <video>, car le navigateur l'implémente à l'aide d'une racine fantôme en mode fermé.

Exemple : création d'un arbre d'ombre fermé :

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

D'autres API sont également concernées par le mode fermé:

  • Element.assignedSlot / TextNode.assignedSlot renvoie null
  • Event.composedPath() pour les événements associés aux éléments du DOM de l'ombre, renvoie []

Voici pourquoi vous ne devez jamais créer de composants Web avec {mode: 'closed'}:

  1. Un sentiment artificiel de sécurité. Rien n'empêche un pirate informatique de pirater Element.prototype.attachShadow.

  2. Le mode fermé empêche le code de votre élément personnalisé d'accéder à son propre DOM ombragé. C'est un échec complet. À la place, vous devrez stocker une référence pour plus tard si vous souhaitez utiliser des éléments tels que querySelector(). Cela va à l'encontre de l'objectif initial du mode fermé.

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. Le mode fermé rend votre composant moins flexible pour les utilisateurs finaux. Lorsque vous créez des composants Web, il arrive que vous oubliiez d'ajouter une fonctionnalité. Option de configuration. Cas d'utilisation souhaité par l'utilisateur. Un exemple courant est d'oublier d'inclure des crochets de style appropriés pour les nœuds internes. En mode fermé, les utilisateurs ne peuvent pas remplacer les valeurs par défaut ni modifier les styles. La possibilité d'accéder aux composants internes est très utile. À terme, les utilisateurs vont forker votre composant, en trouver un autre ou en créer un s'il ne fait pas ce qu'ils veulent :(

Utiliser des emplacements en JavaScript

L'API Shadow DOM fournit des utilitaires pour travailler avec les emplacements et les nœuds distribués. Ils sont utiles lorsque vous créez un élément personnalisé.

Événement slotchange

L'événement slotchange se déclenche lorsque les nœuds distribués d'un emplacement changent. Par exemple, si l'utilisateur ajoute/supprime des enfants du DOM léger.

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

Pour surveiller d'autres types de modifications apportées au DOM léger, vous pouvez configurer un MutationObserver dans le constructeur de votre élément.

Quels éléments sont affichés dans un emplacement ?

Il est parfois utile de savoir quels éléments sont associés à un emplacement. Appelez slot.assignedNodes() pour identifier les éléments que le slot affiche. L'option {flatten: true} renvoie également le contenu de remplacement d'un emplacement (si aucun nœud n'est distribué).

Par exemple, supposons que votre DOM d'ombre se présente comme suit:

<slot><b>fallback content</b></slot>
UtilisationAppelerRésultat
<my-component>texte du composant</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

À quel emplacement un élément est-il attribué ?

Il est également possible de répondre à la question inverse. element.assignedSlot indique à quel emplacement de composant votre élément est attribué.

Modèle d'événement Shadow DOM

Lorsqu'un événement remonte du Shadow DOM, sa cible est ajustée pour maintenir l'encapsulation fournie par le Shadow DOM. Autrement dit, les événements sont re-ciblés pour donner l'impression qu'ils proviennent du composant plutôt que des éléments internes de votre DOM fantôme. Certains événements ne se propagent même pas en dehors du DOM ombragé.

Les événements qui traversent la limite de l'ombre sont les suivants:

  • Événements de mise au point: blur, focus, focusin et focusout
  • Événements de souris: click, dblclick, mousedown, mouseenter, mousemove, etc.
  • Événements de la molette: wheel
  • Événements d'entrée: beforeinput, input
  • Événements de clavier: keydown, keyup
  • Événements de composition: compositionstart, compositionupdate et compositionend
  • DragEvent: dragstart, drag, dragend, drop, etc.

Conseils

Si l'arborescence d'ombre est ouverte, l'appel de event.composedPath() renvoie un tableau de nœuds que l'événement a traversés.

Utiliser des événements personnalisés

Les événements DOM personnalisés déclenchés sur des nœuds internes d'un arbre d'ombre ne sortent pas de la limite de l'ombre, sauf si l'événement est créé à l'aide de l'indicateur composed: true:

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

Si la valeur est composed: false (par défaut), les consommateurs ne pourront pas écouter l'événement en dehors de votre racine d'ombre.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

Gérer la mise au point

Si vous vous souvenez du modèle d'événements du Shadow DOM, les événements déclenchés dans le Shadow DOM sont ajustés pour donner l'impression qu'ils proviennent de l'élément hôte. Par exemple, supposons que vous cliquiez sur un <input> dans une racine d'ombre:

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

L'événement focus semblera provenir de <x-focus>, et non de <input>. De même, document.activeElement sera <x-focus>. Si la racine d'ombre a été créée avec mode:'open' (voir mode fermé), vous pouvez également accéder au nœud interne qui a obtenu la sélection:

document.activeElement.shadowRoot.activeElement // only works with open mode.

Si plusieurs niveaux de Shadow DOM sont en jeu (par exemple, un élément personnalisé dans un autre élément personnalisé), vous devez explorer de manière récursive les racines fantômes pour trouver le activeElement:

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

L'option delegatesFocus: true est une autre option de mise au point, qui développe le comportement de mise au point des éléments dans un arbre d'ombre:

  • Si vous cliquez sur un nœud dans le DOM fantôme et que le nœud n'est pas une zone sélectionnable, la première zone sélectionnable est sélectionnée.
  • Lorsqu'un nœud du Shadow DOM est sélectionné, :focus s'applique à l'hôte en plus de l'élément sélectionné.

Exemple : comment delegatesFocus: true modifie le comportement de mise au point

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

Résultat

delegatesFocus: comportement &quot;true&quot;.

Vous trouverez ci-dessus le résultat lorsque <x-focus> est sélectionné (clic de l'utilisateur, focus(), etc.). L'utilisateur clique sur le "texte Shadow DOM cliquable" ou le <input> interne est sélectionné (y compris autofocus).

Si vous définissez delegatesFocus: false, vous verrez ce qui suit:

delegatesFocus: false et la saisie interne est sélectionnée.
delegatesFocus: false et le <input> interne sont mis au point.
delegatesFocus: false et x-focus acquiert la sélection (par exemple, il a tabindex=&#39;0&#39;).
delegatesFocus: false et <x-focus> acquiert la sélection (par exemple, il possède tabindex="0").
delegatesFocus: false et le texte &quot;Texte Shadow DOM cliquable&quot; est cliqué (ou une autre zone vide dans le Shadow DOM de l&#39;élément est cliquée).
delegatesFocus: false et le "texte Shadow DOM cliquable" est sélectionné (ou une autre zone vide du Shadow DOM de l'élément est sélectionnée).

Conseils et astuces

Au fil des années, j'ai appris quelques trucs et astuces sur la création de composants Web. Je pense que certains de ces conseils vous seront utiles pour créer des composants et déboguer le Shadow DOM.

Utiliser la structuration CSS

En règle générale, la mise en page/le style/la peinture d'un composant Web sont assez autonomes. Utilisez la conteneurisation CSS dans :host pour améliorer les performances:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

Réinitialiser les styles héritables

Les styles héritables (background, color, font, line-height, etc.) continuent d'être hérités dans Shadow DOM. Autrement dit, ils transpercent la limite du Shadow DOM par défaut. Si vous souhaitez repartir à zéro, utilisez all: initial; pour réinitialiser les styles héritables à leur valeur initiale lorsqu'ils traversent la limite de l'ombre.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

Rechercher tous les éléments personnalisés utilisés par une page

Il peut parfois être utile de trouver les éléments personnalisés utilisés sur la page. Pour ce faire, vous devez parcourir récursivement le DOM ombragé de tous les éléments utilisés sur la page.

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

Créer des éléments à partir d'un <template>

Au lieu de renseigner une racine d'ombre à l'aide de .innerHTML, nous pouvons utiliser un <template> déclaratif. Les modèles sont un espace réservé idéal pour déclarer la structure d'un composant Web.

Consultez l'exemple dans Éléments personnalisés: créer des composants Web réutilisables.

Historique et compatibilité avec les navigateurs

Si vous suivez les composants Web depuis quelques années, vous savez que Chrome 35/Opera propose depuis un certain temps une ancienne version du DOM ombragé. Blink continuera à prendre en charge les deux versions en parallèle pendant un certain temps. La spécification v0 proposait une méthode différente pour créer une racine fantôme (element.createShadowRoot au lieu de element.attachShadow de la version 1). L'appel de l'ancienne méthode continue de créer une racine fantôme avec la sémantique v0, de sorte que le code v0 existant ne sera pas endommagé.

Si vous êtes intéressé par l'ancienne spécification v0, consultez les articles html5rocks : 1, 2 et 3. Vous trouverez également une excellente comparaison des différences entre les versions 0 et 1 du DOM ombragé.

Prise en charge des navigateurs

Shadow DOM v1 est disponible dans Chrome 53 (état), Opera 40, Safari 10 et Firefox 63. Edge a commencé son développement.

Pour détecter le Shadow DOM, vérifiez l'existence de attachShadow:

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

Tant que la compatibilité avec les navigateurs n'est pas largement disponible, les polyfills shadydom et shadycss vous offrent la fonctionnalité v1. Shady DOM imite le champ d'application du DOM de Shadow DOM et les polyfills shadycss des propriétés personnalisées CSS et du champ d'application de style fourni par l'API native.

Installez les polyfills:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

Utilisez les polyfills:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

Consultez https://github.com/webcomponents/shadycss#usage pour savoir comment ajouter un shim ou définir le champ d'application de vos styles.

Conclusion

Pour la première fois, nous disposons d'une primitive d'API qui effectue un balisage CSS et DOM appropriés, et qui offre une véritable composition. Combiné à d'autres API de composants Web, comme les éléments personnalisés, Shadow DOM permet de créer des composants véritablement encapsulés sans avoir recours à des hacks ni à des éléments plus anciens, comme les <iframe>.

Ne vous méprenez pas. Le Shadow DOM est certainement un animal complexe. Mais c'est un outil qui vaut la peine d'être appris. Passez du temps avec elle. Apprenez-le et posez des questions.

Documentation complémentaire

Questions fréquentes

Puis-je utiliser la version 1 de Shadow DOM dès aujourd'hui ?

Avec un polyfill, oui. Consultez la page Compatibilité avec les navigateurs.

Quelles fonctionnalités de sécurité le Shadow DOM fournit-il ?

Shadow DOM n'est pas une fonctionnalité de sécurité. Il s'agit d'un outil léger permettant de définir le champ d'application du CSS et de masquer les arbres DOM dans le composant. Si vous souhaitez une véritable limite de sécurité, utilisez un <iframe>.

Un composant Web doit-il utiliser Shadow DOM ?

Non. Vous n'avez pas besoin de créer de composants Web qui utilisent le DOM ombragé. Toutefois, la création d'éléments personnalisés qui utilisent Shadow DOM vous permet de profiter de fonctionnalités telles que le champ d'application CSS, l'encapsulation DOM et la composition.

Quelle est la différence entre les racines d'ombre ouvertes et fermées ?

Consultez Racines d'ombre fermées.