Maîtriser les Web Components : Créez des Composants Réutilisables et Interopérables
Maîtriser les Web Components : Créez des Composants Réutilisables et Interopérables

Les Slots : Distribution de Contenu dans vos Composants

Introduction : La Flexibilité au Cœur des Web Components

Dans notre parcours pour maîtriser les Web Components, nous avons exploré la création de composants réutilisables et encapsulés grâce au Shadow DOM. Cependant, l'encapsulation, bien que puissante pour isoler les styles et la structure interne d'un composant, présente un défi : comment permettre aux utilisateurs de notre composant d'injecter leur propre contenu ou de personnaliser des sections spécifiques sans compromettre l'encapsulation ?

C'est précisément là qu'interviennent les Slots. Les slots sont une fonctionnalité essentielle des Web Components qui permettent de créer des "trous" ou des "placeholders" dans le Shadow DOM de votre composant, où du contenu externe peut être projeté. Ils offrent une solution élégante pour combiner la robustesse de l'encapsulation avec la flexibilité nécessaire à la composition de l'interface utilisateur.

Imaginez un composant de carte générique. Sa structure (bordure, ombres) est fixe, mais le contenu (titre, image, texte, boutons d'action) doit être fourni par l'utilisateur de la carte. Les slots nous permettent de définir ces zones flexibles à l'intérieur du Shadow DOM de notre composant <my-card>, sans forcer l'utilisateur à manipuler la structure interne de <my-card>.

Qu'est-ce qu'un Slot ?

Un slot est un élément HTML spécial, représenté par la balise <slot>, que vous placez dans le Shadow DOM de votre Web Component. Il agit comme un point d'insertion pour le contenu que l'utilisateur de votre composant placera entre les balises de votre élément personnalisé.

Lorsque le navigateur rencontre un <slot> dans le Shadow DOM, il recherche le contenu "distribué" depuis le Light DOM (le DOM externe) de l'élément personnalisé et le "projette" à l'emplacement du slot. Il est important de noter que le contenu n'est pas déplacé dans le Shadow DOM ; il est simplement rendu à cet endroit. Le contenu reste techniquement dans le Light DOM de l'élément personnalisé, ce qui a des implications pour le style et la manipulation JavaScript.

Un Aperçu Simple

<!-- Utilisation du composant -->
<my-custom-element>
  <p>Ceci est le contenu projeté dans le slot.</p>
</my-custom-element>
// Définition du composant
class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.innerHTML = `
      <style>
        div { border: 1px solid blue; padding: 10px; }
      </style>
      <div>
        <h2>Contenu interne du composant</h2>
        <!-- Le slot attend le contenu externe -->
        <slot></slot>
      </div>
    `;
  }
}
customElements.define('my-custom-element', MyCustomElement);

Dans cet exemple, le paragraphe <p>Ceci est le contenu projeté dans le slot.</p> sera affiché à l'intérieur du div bordé de bleu, sous le titre <h2>Contenu interne du composant</h2>.

Les Slots Non Nommés (ou par défaut)

Un slot non nommé est un élément <slot> qui n'a pas d'attribut name. Il y a une seule règle : un seul slot non nommé peut exister par Shadow DOM.

Ce slot unique capture tout le contenu enfant de votre élément personnalisé qui n'a pas été explicitement assigné à un slot nommé. C'est le slot "fourre-tout" par excellence.

Exemple : Un Composant de Carte Simple

Considérons un composant <simple-card> qui affiche un titre et un contenu.

<!-- index.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Exemple de Slot par Défaut</title>
    <script src="simple-card.js" defer></script>
</head>
<body>
    <h1>Ma Page avec des Cartes</h1>

    <simple-card>
        <h2>Mon Titre de Carte</h2>
        <p>Ceci est le corps principal de la carte. C'est un contenu flexible qui sera projeté dans le slot par défaut.</p>
        <button>En savoir plus</button>
    </simple-card>

    <simple-card>
        <h3>Autre Carte</h3>
        <ul>
            <li>Élément 1</li>
            <li>Élément 2</li>
        </ul>
    </simple-card>
</body>
</html>
// simple-card.js
class SimpleCard extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({ mode: 'open' });
        shadowRoot.innerHTML = `
            <style>
                :host {
                    display: block;
                    border: 1px solid #ccc;
                    border-radius: 8px;
                    padding: 15px;
                    margin-bottom: 20px;
                    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
                    background-color: #fff;
                }
                h2 {
                    color: #333;
                    margin-top: 0;
                    border-bottom: 1px solid #eee;
                    padding-bottom: 10px;
                    margin-bottom: 15px;
                }
                /* Style pour le contenu inséré via le slot */
                ::slotted(p) {
                    line-height: 1.6;
                    color: #555;
                }
                ::slotted(button) {
                    background-color: #007bff;
                    color: white;
                    padding: 8px 15px;
                    border: none;
                    border-radius: 4px;
                    cursor: pointer;
                    margin-top: 10px;
                }
            </style>
            <div>
                <!-- Le slot non nommé recevra tout le contenu enfant de <simple-card> -->
                <slot></slot> 
            </div>
        `;
    }
}
customElements.define('simple-card', SimpleCard);

Explication : Dans cet exemple, tous les éléments (<h2>, <p>, <button>, <h3>, <ul>, <li>) placés à l'intérieur de <simple-card> dans le index.html sont automatiquement projetés et rendus à l'emplacement de <slot> dans le Shadow DOM. Le ::slotted() pseudo-élément (expliqué plus loin) est utilisé pour appliquer des styles spécifiques au contenu projeté.

Les Slots Nommés

Les slots nommés vous permettent de définir plusieurs points d'insertion spécifiques dans votre Shadow DOM. Pour cela, vous utilisez l'attribut name sur l'élément <slot>.

Pour que le contenu soit projeté dans un slot nommé, il doit posséder l'attribut slot avec la même valeur que l'attribut name du slot cible.

Syntaxe

  • Dans le Shadow DOM de votre composant : <slot name="nom-du-slot"></slot>
  • Dans le Light DOM (lors de l'utilisation du composant) : <element slot="nom-du-slot">Contenu</element>

Exemple : Un Composant de Carte Avancé

Créons un composant <advanced-card> avec des zones dédiées pour un en-tête, un corps et un pied de page.

<!-- index.html -->
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Exemple de Slots Nommés</title>
    <script src="advanced-card.js" defer></script>
</head>
<body>
    <h1>Ma Page avec des Cartes Avancées</h1>

    <advanced-card>
        <h2 slot="header">Titre de la Carte Avancée</h2>
        <img slot="body" src="https://via.placeholder.com/150" alt="Image de placeholder">
        <p slot="body">Ceci est le corps principal de la carte. Il peut contenir divers éléments comme des images et du texte.</p>
        <button slot="footer">Action 1</button>
        <button slot="footer">Action 2</button>
    </advanced-card>

    <advanced-card>
        <h3 slot="header">Une Autre Carte</h3>
        <p slot="body">Juste un petit texte ici.</p>
        <a href="#" slot="footer">Voir plus</a>
    </advanced-card>

    <!-- Carte sans en-tête ni pied de page, utilisant le fallback -->
    <advanced-card>
        <p slot="body">Contenu uniquement pour le corps. L'en-tête et le pied de page utiliseront leur contenu de secours.</p>
    </advanced-card>
</body>
</html>
// advanced-card.js
class AdvancedCard extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({ mode: 'open' });
        shadowRoot.innerHTML = `
            <style>
                :host {
                    display: block;
                    border: 1px solid #ddd;
                    border-radius: 8px;
                    margin-bottom: 25px;
                    box-shadow: 0 4px 8px rgba(0,0,0,0.1);
                    background-color: #f9f9f9;
                    overflow: hidden; /* Pour border-radius sur les images */
                }
                .card-header {
                    background-color: #007bff;
                    color: white;
                    padding: 15px 20px;
                    font-size: 1.2em;
                    border-bottom: 1px solid #0056b3;
                }
                .card-body {
                    padding: 20px;
                }
                .card-footer {
                    background-color: #eee;
                    padding: 10px 20px;
                    border-top: 1px solid #ddd;
                    display: flex;
                    justify-content: flex-end;
                    gap: 10px;
                }

                /* Styles pour le contenu slotté */
                ::slotted(h2), ::slotted(h3) {
                    margin: 0;
                    color: inherit; /* Utilise la couleur du parent .card-header */
                }
                ::slotted(img) {
                    max-width: 100%;
                    height: auto;
                    border-radius: 4px;
                    margin-bottom: 15px;
                    display: block;
                }
                ::slotted(p) {
                    margin-top: 0;
                    margin-bottom: 15px;
                    color: #444;
                }
                ::slotted(button), ::slotted(a) {
                    background-color: #6c757d;
                    color: white;
                    padding: 8px 15px;
                    border: none;
                    border-radius: 4px;
                    cursor: pointer;
                    text-decoration: none;
                    font-size: 0.9em;
                }
                ::slotted(button:hover), ::slotted(a:hover) {
                    background-color: #5a6268;
                }
            </style>
            <div class="card-header">
                <!-- Slot nommé pour l'en-tête -->
                <slot name="header">Titre par Défaut</slot> 
            </div>
            <div class="card-body">
                <!-- Slot nommé pour le corps -->
                <slot name="body">Contenu par défaut du corps.</slot>
            </div>
            <div class="card-footer">
                <!-- Slot nommé pour le pied de page -->
                <slot name="footer">Actions par Défaut</slot>
            </div>
        `;
    }
}
customElements.define('advanced-card', AdvancedCard);

Explication :

  • Le Shadow DOM de <advanced-card> contient trois slots nommés : header, body, et footer.
  • Dans le index.html, nous utilisons l'attribut slot="header", slot="body", ou slot="footer" sur les éléments que nous voulons placer dans ces zones spécifiques.
  • Les éléments sans attribut slot (ou avec un nom de slot qui ne correspond à aucun slot nommé dans le Shadow DOM) seraient ignorés par les slots nommés et seraient capturés par un éventuel slot non nommé si celui-ci existait.

Les Slots de Remplacement (Fallback Content)

Que se passe-t-il si l'utilisateur d'un composant ne fournit pas de contenu pour un slot donné ? Par défaut, le slot resterait vide. C'est là que le contenu de remplacement (ou fallback content) entre en jeu.

Le contenu de remplacement est simplement le contenu que vous placez entre les balises du <slot> lui-même dans votre Shadow DOM. Ce contenu sera rendu si, et seulement si, aucun contenu n'est projeté dans ce slot depuis le Light DOM.

Dans l'exemple de l'<advanced-card> ci-dessus, j'ai déjà intégré du contenu de remplacement :

  • <slot name="header">Titre par Défaut</slot>
  • <slot name="body">Contenu par défaut du corps.</slot>
  • <slot name="footer">Actions par Défaut</slot>

Ainsi, si vous utilisez <advanced-card> sans spécifier de contenu pour header (comme dans le troisième exemple du index.html), le texte "Titre par Défaut" sera affiché. C'est idéal pour les sections optionnelles ou pour fournir des valeurs par défaut utiles.

Style des Slots : Le Pseudo-Élément ::slotted()

Une question fréquente est : "Comment puis-je styliser le contenu qui est projeté dans un slot depuis l'intérieur de mon Shadow DOM ?"

La réponse est le pseudo-élément ::slotted(). Il vous permet de cibler des éléments qui ont été distribués dans un slot.

Syntaxe

::slotted(sélecteur) {
  /* Vos styles ici */
}

Points importants sur ::slotted() :

  1. Portée Limitée : ::slotted() ne peut cibler que les enfants directs du slot. Il ne peut pas cibler les descendants de ces enfants. Par exemple, si vous avez <div slot="body"><span>Contenu</span></div>, ::slotted(div) fonctionnera, mais ::slotted(span) ne fonctionnera pas directement (car span n'est pas un enfant direct du slot, mais un enfant du div qui est slotté). Pour styliser le span, le style devrait venir du Light DOM où le span réside.
  2. Styles du Light DOM vs. Shadow DOM : C'est une distinction cruciale.
    • Les styles appliqués dans le Light DOM (là où votre composant est utilisé) peuvent styliser le contenu projeté avant qu'il ne soit projeté. Ces styles s'appliquent car le contenu reste techniquement dans le Light DOM.
    • Les styles appliqués dans le Shadow DOM de votre composant, en utilisant ::slotted(), peuvent également styliser le contenu projeté. C'est utile pour s'assurer que le contenu s'intègre visuellement dans le design global du composant.
  3. Héritage : Certaines propriétés CSS (comme color, font-family) sont héritées. Donc, si vous définissez color: blue; sur le conteneur <div> de votre Shadow DOM qui contient le slot, le texte projeté héritera de cette couleur, sauf s'il a sa propre règle plus spécifique.

Dans nos exemples précédents, nous avons déjà utilisé ::slotted(p), ::slotted(button), ::slotted(img), etc., pour appliquer des styles cohérents au contenu inséré, garantissant qu'il s'intègre bien à l'esthétique de la carte.

Interaction avec les Slots (JavaScript)

Les slots ne sont pas seulement des placeholders passifs. Vous pouvez interagir avec eux en JavaScript pour des logiques plus avancées.

L'événement slotchange

L'événement slotchange est déclenché sur un élément <slot> lorsque le contenu qui lui est attribué change. Cela peut être utile pour réagir dynamiquement à l'ajout, la suppression ou la modification de contenu projeté.

class MyDynamicSlotComponent extends HTMLElement {
    constructor() {
        super();
        const shadowRoot = this.attachShadow({ mode: 'open' });
        shadowRoot.innerHTML = `
            <style>
                div { border: 2px dashed green; padding: 10px; margin-top: 10px; }
            </style>
            <div>
                <h3>Contenu du slot :</h3>
                <slot name="dynamic-content"></slot>
            </div>
            <p>Nombre d'éléments dans le slot: <span id="slot-count">0</span></p>
        `;

        // Attacher un écouteur d'événement au slot
        const slot = shadowRoot.querySelector('slot[name="dynamic-content"]');
        slot.addEventListener('slotchange', this._handleSlotChange);
    }

    _handleSlotChange(event) {
        const slot = event.target;
        // Obtenir les nœuds assignés au slot
        const assignedNodes = slot.assignedNodes({ flatten: true }); // flatten: true inclut les descendants de slots imbriqués

        // Filtrer pour obtenir uniquement les éléments (exclure les nœuds de texte vides, commentaires)
        const assignedElements = assignedNodes.filter(node => node.nodeType === Node.ELEMENT_NODE);
        
        console.log(`Le contenu du slot a changé !`);
        console.log(`Nouveaux éléments assignés :`, assignedElements);

        const countSpan = this.shadowRoot.getElementById('slot-count');
        if (countSpan) {
            countSpan.textContent = assignedElements.length;
        }
    }
}
customElements.define('my-dynamic-slot-component', MyDynamicSlotComponent);
<!-- Utilisation -->
<my-dynamic-slot-component>
    <p slot="dynamic-content">Premier élément</p>
    <div slot="dynamic-content">Deuxième élément</div>
</my-dynamic-slot-component>

<button onclick="addContent()">Ajouter un paragraphe</button>
<script>
    let count = 0;
    function addContent() {
        const component = document.querySelector('my-dynamic-slot-component');
        const newP = document.createElement('p');
        newP.setAttribute('slot', 'dynamic-content');
        newP.textContent = `Nouvel élément ${++count}`;
        component.appendChild(newP);
    }
</script>

Explication : Quand vous cliquez sur le bouton "Ajouter un paragraphe", un nouveau paragraphe est ajouté au composant. Cela déclenche l'événement slotchange sur le slot nommé dynamic-content. La fonction _handleSlotChange est appelée, elle logue les éléments et met à jour le compteur d'éléments dans le composant.

Méthodes assignedNodes() et assignedElements()

Ces méthodes, appelées sur l'élément <slot> lui-même, vous permettent d'accéder au contenu réel qui a été projeté dans le slot.

  • slot.assignedNodes({ flatten: true }) : Retourne un tableau de tous les nœuds (éléments, texte, commentaires) qui sont actuellement assignés au slot. L'option flatten: true est utile si vous avez des slots imbriqués.
  • slot.assignedElements() : C'est une version plus spécifique qui ne retourne que les éléments (nœuds de type Node.ELEMENT_NODE), excluant les nœuds de texte et les commentaires. C'est souvent plus utile pour manipuler le DOM.

Dans l'exemple ci-dessus, slot.assignedNodes() a été utilisé pour démontrer comment récupérer le contenu projeté. Ces méthodes sont essentielles pour implémenter des logiques basées sur le contenu réel (par exemple, compter les éléments, valider leur type, ou appliquer des transformations).

Avantages des Slots

  1. Flexibilité et Composition : Les slots permettent de créer des composants génériques et réutilisables qui peuvent être adaptés à une multitude de cas d'utilisation en leur fournissant simplement différents contenus.
  2. Séparation des Préoccupations : La logique et la structure internes du composant restent encapsulées dans le Shadow DOM, tandis que le contenu spécifique à l'application est géré par le parent du composant dans le Light DOM.
  3. Amélioration de la Lisibilité : L'utilisation de slots rend le code HTML d'utilisation du composant plus déclaratif et facile à comprendre, car il ressemble à l'utilisation d'éléments HTML natifs.
  4. Interopérabilité : Les Web Components avec slots peuvent être utilisés dans n'importe quel framework ou bibliothèque JavaScript, ou même sans aucun framework, car ils reposent sur des standards web.

Limites et Considérations

  1. Contrôle du Style : Bien que ::slotted() permette de styliser le contenu projeté, le contrôle total des styles de ce contenu reste du ressort du Light DOM où le contenu est défini. Si vous avez besoin d'un contrôle de style très fin, il peut parfois être plus simple de passer des propriétés via des attributs ou des propriétés JavaScript.
  2. Accessibilité (A11y) : Assurez-vous que l'ordre logique du contenu (par exemple, pour la navigation au clavier ou les lecteurs d'écran) est maintenu lorsque vous utilisez des slots, surtout avec des slots nommés qui peuvent modifier l'ordre visuel. L'ordre source dans le Light DOM est souvent l'ordre par défaut pour l'accessibilité.
  3. Complexité : L'abus de slots nommés (trop de slots ou des imbrications complexes) peut rendre la structure de votre composant difficile à comprendre et à maintenir. Utilisez-les judicieusement pour les zones clairement définies.

Conclusion

Les slots sont une brique fondamentale et indispensable pour la construction de Web Components véritablement flexibles, modulaires et réutilisables. Ils résolvent le paradoxe entre l'encapsulation protectrice du Shadow DOM et le besoin de personnalisation du contenu.

En comprenant et en maîtrisant :

  • Les slots non nommés pour le contenu principal.
  • Les slots nommés pour des zones spécifiques.
  • Le contenu de remplacement pour la robustesse.
  • Le pseudo-élément ::slotted() pour l'intégration visuelle.
  • L'événement slotchange et les méthodes assignedNodes()/assignedElements() pour l'interactivité.

Vous serez en mesure de concevoir des composants Web puissants qui s'adaptent élégamment aux besoins de n'importe quelle application. Les slots sont la clé pour passer de composants statiques à des blocs de construction dynamiques et hautement composables.