Prendre en compte les Web Components dans vos scripts
Les Web Components sont de plus en plus présents dans nos interfaces. Des design systems populaires comme Carbon d’IBM, Spectrum d’Adobe ou encore Polaris de Shopify ont aussi pris le parti de les utiliser pour leurs composants. Ce choix répond souvent à une volonté d’être agnostique, c’est-à-dire de proposer des éléments réutilisables quel que soit l’écosystème utilisé : React, Vue, Angular, ou même sans framework.
Techniquement, un Web Component repose sur trois autres standards : les custom elements 1 qui permettent de définir de nouvelles balises ; les HTML templates 2 qui servent de base à leur structure ; et le shadow DOM 3 qui encapsule et isole le style et la logique interne du composant.
Cette isolation est une force, elle garantit qu’un composant ne sera pas impacté par les styles externes. Mais elle complique également la tâche des scripts qui veulent interagir avec le contenu encapsulé.
Lors de ma conférence « Les Web Components et l’accessibilité » à Paris Web, j’ai évoqué le fait que certains outils d’accessibilité ne détectent pas les éléments présents dans le shadow DOM.
Plusieurs personnes m’ont ensuite demandé comment faire pour corriger ça dans leurs propres scripts. Cet article est l’occasion d’y répondre plus en détail.
Les particularités du shadow DOM
Le contenu d’un Web Component n’est pas directement accessible avec des sélecteurs habituels comme document.querySelector.
Pour accéder au contenu d’un Web Component, il faut d’abord cibler l’élément, puis explorer son shadowRoot :
// Pour accéder au shadow DOM du custom element <24-jours-de-web>
const element = document.querySelector('24-jours-de-web');
const shadow = element.shadowRoot;
Mais attention, cela ne fonctionne que si le shadow DOM a été créé en mode open. En mode closed le contenu restera inaccessible. 4
Dans la suite de l’article, nous partirons donc du principe que les composants utilisent un shadow DOM open.
Parcourir les Web Components dans un script
Pour illustrer la prise en compte des Web Components dans vos scripts, partons d’un cas simple : récupérer tous les champs <input/> présents dans une page.
Le cas classique
Quand aucun Web Component n’est utilisé, les éléments sont directement présents dans le DOM principal.
Dans ce contexte, un sélecteur CSS classique suffit :
const inputs = document.querySelectorAll('input');
Avec des Web Components
Les choses se compliquent lorsque des input se retrouvent encapsulés dans un shadow DOM.
Dans ce cas, document.querySelectorAll('input') ne les verra pas.
Pour résoudre le problème, il faut d’abord être capable d’identifier les Web Components présents dans la page.
Identifier les Web Components
Pour éviter les conflits avec les balises HTML natives, le nom d’un Custom Element doit obligatoirement contenir un tiret (-).
C’est une règle du standard et c’est aussi un moyen simple et fiable de les détecter.
const findCustomElements = () => {
// On recherche tous les éléments de la page
return [...document.querySelectorAll('*')].filter(elm => {
// On ne retourne que ceux dont la balise contient un tiret
return elm.tagName.includes('-');
});
}
Cette fonction renvoie tous les Web Components présents dans le DOM principal mais pas leur contenu, qui reste encapsulé dans leur shadowRoot.
Parcourir leur shadow DOM
Une fois un Custom Element détecté, il faut vérifier qu’il possède un shadow DOM, puis s’assurer qu’il est en mode open, condition indispensable pour y accéder depuis un script externe :
const findInputsInShadow = (element) => {
// Si pas de shadowRoot (ou en mode closed), on s’arrête là
if (!element.shadowRoot) return [];
// À partir d’ici, on peut interroger librement le contenu du shadow DOM
return [...element.shadowRoot.querySelectorAll('input')];
}
Cette approche fonctionne, mais uniquement à un niveau d’imbrication.
C’est une situation plutôt courante, un composant peut en contenir un autre, qui lui-même en contient un autre, etc. Et chacun possède potentiellement son propre shadow DOM.
Il faut donc répéter ce travail à chaque niveau, ce qui impose une approche récursive.
Récupérer tous les input, dans le DOM et dans tous les shadow DOM
Pour couvrir tous les cas, il faut récupérer les champs présents dans le DOM principal et ceux encapsulés dans les shadow DOM accessibles.
On peut commencer par écrire une première fonction capable de descendre dans tous les niveaux d’imbrication :
const findAllInputs = (root = document) => {
// Inputs visibles à ce niveau du DOM
const inputs = [...root.querySelectorAll('input')];
// Web Components présents à ce même niveau
const customElements = findCustomElements(root);
// Pour chaque Web Component, on accède au shadowRoot
for (const element of customElements) {
if (!element.shadowRoot) continue;
inputs.push(...findAllInputs(element.shadowRoot));
}
return inputs;
};
Cette première étape permet déjà de retrouver tous les input de la page, même ceux encapsulés dans des Web Components.
Mais on peut aller plus loin et généraliser le principe, pourquoi se limiter aux champs ?
Vers un deepQuerySelectorAll()
L’idée suivante découle assez naturellement, écrire un équivalent de document.querySelectorAll(), mais capable de traverser tous les Shadow DOM, quels que soient leur profondeur ou leur nombre.
La structure va être la même que précédemment, mais on remplace simplement le sélecteur 'input' par n’importe quel sélecteur CSS passé en paramètre :
const deepQuerySelectorAll = (selector, root = document) => {
// Éléments trouvés dans le DOM courant
const results = [...root.querySelectorAll(selector)];
// Détection des Custom Elements déjà écrite plus haut
const customElements = findCustomElements(root);
// On explore chaque Shadow DOM accessible
for (const el of customElements) {
if (!el.shadowRoot) continue;
results.push(...deepQuerySelectorAll(selector, el.shadowRoot));
}
return results;
}
Avec cette nouvelle fonction, on peut par exemple récupérer tous les boutons d’une page, y compris ceux encapsulés dans le Shadow DOM. Exactement comme on le ferait naturellement avec document.querySelectorAll().
const buttons = deepQuerySelectorAll('button');
Et le querySelectorDeep() ?
La suite logique serait de proposer l’équivalent de document.querySelector(), qui s’arrête au premier résultat.
Je ne vais pas m’attarder dessus, le principe est le même et je pense que l’idée générale est désormais claire.
Pour rendre tout cela plus pratique, j’ai regroupé ces fonctions dans une petite librairie dédiée : @dume/webcomp-utils.
Voici à quoi cela ressemble :
import { deepQuerySelectorAll, deepQuerySelector } from '@dume/webcomp-utils';
const emailInput = deepQuerySelector('#email');
const fields = deepQuerySelectorAll('.field');
Conclusion
Maintenant que vous avez toutes les clés pour parcourir les Web Components, n’oubliez pas qu’un shadow DOM n’est accessible que s’il est en mode open. En closed son contenu reste totalement isolé et aucun script extérieur ne pourra le toucher.
L’isolation apportée par le shadow DOM est volontaire, elle protège le composant et garantit qu’il ne sera pas perturbé par des styles ou scripts externes. Les fonctions que nous avons vues permettent d’accéder au contenu encapsulé, mais elles ne sont pas destinées à le modifier. Utilisez-les avec précaution.
Avec ces outils en main, vous pouvez désormais aller mettre à jour vos scripts et interagir avec vos Web Components comme si l’encapsulation n’existait pas. 😉
Il n’est plus possible de laisser un commentaire sur les articles mais la discussion continue :