Shadow DOM et événements
Apprenez comment les événements se comportent dans le Shadow DOM : bulles, reciblage, event.composedPath(), event.composed et événements personnalisés.
Un composant web construit avec Shadow DOM garde sa structure interne cachée derrière une frontière shadow. Cette encapsulation modifie la façon dont les événements se propagent : certains franchissent la frontière et d'autres non, et ceux qui la franchissent sont reciblés afin que le monde extérieur ne voie jamais vos éléments internes privés. Ce chapitre explique ces règles pour que vos composants déclenchent des événements que la page hôte peut réellement utiliser.
Vous aborderez quatre points : comment les événements se propagent par bulles à travers la frontière shadow, le reciblage d'événements, event.composedPath(), l'indicateur event.composed, et le déclenchement d'événements personnalisés qui sortent de l'arbre shadow.
Ce chapitre suppose que vous connaissez déjà les bases du Shadow DOM et la propagation d'événements par bulles et par capture. Si les événements personnalisés sont nouveaux pour vous, lisez d'abord Déclencher des événements personnalisés.
Propagation par bulles dans le Shadow DOM
La propagation par bulles décrit comment un événement remonte l'arbre DOM : il se déclenche sur l'élément cible, puis sur chaque ancêtre successivement, jusqu'à atteindre document. (Pour une vue d'ensemble complète, consultez Bubbling and Capturing.)
Dans le Shadow DOM, la question devient : l'événement continue-t-il à remonter une fois qu'il atteint la racine shadow, en entrant dans le light DOM de l'hôte ? Cela dépend du fait que l'événement est composed ou non :
- Les événements composed franchissent la frontière shadow et continuent de se propager par bulles dans le light DOM. La plupart des événements natifs orientés utilisateur sont composed :
click,mousedown,keydown,input,pointermove, etc. - Les événements non-composed s'arrêtent à la racine shadow et n'atteignent jamais l'hôte. Exemples :
focus(utilisezfocusin/focusoutsi vous avez besoin d'événements de focus composed),scroll,mouseenteretload.
Pour arrêter la propagation d'un événement à n'importe quel moment — qu'il soit composed ou non — appelez event.stopPropagation().
Reciblage d'événements
C'est la partie qui surprend les développeurs. Lorsqu'un événement composed franchit la frontière, le navigateur le recible : pour les écouteurs dans le light DOM, event.target pointe sur l'élément hôte, et non sur l'élément interne sur lequel vous avez réellement cliqué.
C'est délibéré. L'encapsulation n'aurait aucun sens si le code extérieur pouvait lire les nœuds privés de votre composant via event.target. Ainsi, la page hôte voit « quelque chose à l'intérieur de <my-widget> a été cliqué », et non « le troisième <button> de votre arbre shadow a été cliqué ». À l'intérieur de l'arbre shadow, event.target pointe toujours sur l'élément réel.
Si vous avez besoin du chemin réel à travers l'arbre shadow, utilisez event.composedPath() — abordé ensuite.
Utiliser event.composedPath()
Puisque le reciblage masque l'élément interne de event.target, vous avez besoin d'un autre moyen d'inspecter le chemin de propagation réel. event.composedPath() retourne un array des nœuds par lesquels l'événement est passé, y compris les nœuds à l'intérieur de tout arbre shadow traversé, ordonnés de la cible la plus profonde vers window.
C'est le moyen fiable de répondre à « quel élément interne a réellement été cliqué ? » depuis un écouteur dans le light DOM — mais uniquement pour les composants dont la racine shadow a mode: 'open'. Pour une racine mode: 'closed', composedPath() s'arrête à l'hôte et les nœuds internes sont omis, préservant la confidentialité du composant fermé.
Voici comment event.composedPath() peut être utilisé pour suivre la propagation des événements dans le Shadow DOM :
<div id="outer"></div>
<script>
const outer = document.getElementById('outer');
const shadow = outer.attachShadow({ mode: 'open' });
const inner = document.createElement('div');
inner.textContent = 'Click me';
inner.addEventListener('click', event => {
const composedInfo = document.createElement('p');
composedInfo.textContent = 'The event composedPath contains the following elements:';
shadow.appendChild(composedInfo);
const path = event.composedPath();
path.forEach((e) => {
const pathItem = document.createElement('p');
pathItem.textContent = e.tagName;
shadow.appendChild(pathItem);
});
});
shadow.appendChild(inner);
</script>Cliquer sur la <div> interne liste chaque nœud dans le chemin composed : il commence par la DIV sur laquelle vous avez cliqué, puis DIV (l'hôte #outer), puis BODY, HTML, et enfin des entrées pour document et window (qui s'affichent comme undefined car ils n'ont pas de tagName). Les premières entrées sont exactement ce que event.target cache aux écouteurs du light DOM.
Comprendre event.composed
La propriété event.composed en lecture seule est un boolean : true si l'événement peut franchir les frontières shadow, false s'il est confiné à son arbre shadow. Vous ne pouvez pas le modifier après coup — pour les événements natifs, sa valeur est fixée par la spécification, et pour les événements personnalisés, vous la définissez lors de la construction de l'événement via l'option composed.
Cet indicateur est surtout important lorsque vous construisez un composant et devez décider si vos événements personnalisés doivent s'en échapper. Les événements d'interaction natifs comme click sont composed par défaut ; vos propres CustomEvent ne le sont pas, sauf si vous l'activez explicitement.
Voici comment event.composed peut être utilisé en pratique :
<div id="outer"></div>
<script>
const outer = document.getElementById('outer');
const shadow = outer.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.textContent = 'Click me';
button.addEventListener('click', event => {
const composedInfo = document.createElement('p');
composedInfo.textContent = `Composed: ${event.composed}`;
shadow.appendChild(composedInfo);
});
shadow.appendChild(button);
</script>Dans cet exemple, cliquer sur le bouton dans le Shadow DOM déclenche un événement click. Nous créons dynamiquement un élément <p> pour afficher la propriété event.composed à l'intérieur du Shadow DOM.
Événements personnalisés dans le Shadow DOM
Les événements personnalisés permettent à un composant d'annoncer des choses au monde extérieur — « valeur modifiée », « élément sélectionné », « boîte de dialogue fermée » — sans exposer ses éléments internes. C'est la façon standard dont un composant web communique avec la page qui l'utilise. (Voir Déclencher des événements personnalisés pour l'API en détail.)
Pour qu'un événement personnalisé atteigne un écouteur sur l'élément hôte dans le light DOM, vous avez besoin de deux options définies :
composed: true— permet à l'événement de franchir la frontière shadow.bubbles: true— lui permet de remonter l'arbre pour atteindre les écouteurs ancêtres.
Définir uniquement bubbles et l'événement se propage par bulles à l'intérieur de l'arbre shadow mais s'arrête à la racine shadow. Définir uniquement composed et il franchit la frontière mais ne remontera pas vers les ancêtres. Vous voulez presque toujours les deux.
Créons et déclenchons un événement personnalisé dans un Shadow DOM :
<div id="container"></div>
<script>
const container = document.getElementById('container');
const shadow = container.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.textContent = 'Click me';
button.addEventListener('click', () => {
const event = new CustomEvent('customEvent', { bubbles: true, composed: true });
button.dispatchEvent(event);
});
shadow.appendChild(button);
container.addEventListener('customEvent', () => {
const composedInfo = document.createElement('p');
composedInfo.textContent = `Custom Event Triggered!`;
container.appendChild(composedInfo);
});
</script>Cliquer sur le bouton déclenche customEvent avec à la fois bubbles: true et composed: true, ce qui lui permet de franchir la frontière shadow et de remonter jusqu'à l'écouteur sur l'hôte (container) dans le light DOM. Pour transmettre des données avec l'événement, utilisez la propriété detail :
button.dispatchEvent(new CustomEvent('customEvent', {
bubbles: true,
composed: true,
detail: { value: 42 }
}));
container.addEventListener('customEvent', (event) => {
console.log(event.detail.value); // 42
});Notez que même si l'événement atteint l'hôte, le reciblage s'applique toujours : dans l'écouteur container, event.target est l'élément hôte, pas le button interne. Utilisez event.composedPath()[0] si vous avez besoin de la cible d'origine.
Référence rapide
| Propriété / méthode | Ce qu'elle indique |
|---|---|
event.composed | true si l'événement peut franchir les frontières shadow (lecture seule). |
event.composedPath() | Array des nœuds que l'événement traverse, y compris les arbres shadow ouverts, du plus profond vers l'extérieur. |
event.target (depuis le light DOM) | L'élément hôte, en raison du reciblage — jamais le nœud interne privé. |
option bubbles | Permet à un événement personnalisé de remonter l'arbre. |
option composed | Permet à un événement personnalisé de quitter l'arbre shadow. |
Pièges courants
- Oublier
composed: truesur les événements personnalisés. Un événement personnalisé avec uniquementbubblesmeurt silencieusement à la racine shadow et n'atteint jamais la page hôte — un bug fréquent de type « mon écouteur ne se déclenche pas ». - Lire
event.targetdepuis l'extérieur. Il est reciblé vers l'hôte. Utilisezevent.composedPath()lorsque vous avez besoin de la vraie cible interne. focusne se compose pas. Utilisezfocusin/focusoutsi vous avez besoin que les changements de focus atteignent l'hôte.- Racines shadow
closed.composedPath()ne révèle pas les nœuds à l'intérieur d'une racinemode: 'closed', donc ne comptez pas dessus pour inspecter des composants fermés.
Chapitres connexes
- JavaScript Shadow DOM — ce qu'est l'arbre shadow et comment en attacher un.
- Shadow DOM Slots and Composition — projeter le light DOM dans l'arbre shadow.
- Shadow DOM Styling — styles scopés à l'intérieur d'un composant.
- Custom Elements — définir vos propres éléments HTML.
Conclusion
Les événements dans le Shadow DOM suivent quelques règles claires : les événements composed franchissent la frontière, les non-composed ne la franchissent pas, et les événements composed sont reciblés vers l'hôte afin que vos éléments internes restent privés. Utilisez event.composed pour vérifier la capacité de franchissement, event.composedPath() pour récupérer le chemin réel, et CustomEvent avec bubbles: true et composed: true pour permettre à vos composants de communiquer avec la page qui les héberge.