When using Web Components it's often necessary to be able to distinguish between clicks inside the component from those outside of it.
For example you may want to show some content when a button is clicked, and then hide it when the user clicks outside the component but not when the user clicks inside the component:
The problem
What may seem a simple problem becomes more complicated when you consider how events are treated differently depending on whether an element is in the Shadow DOM or in the Light DOM.
Take the following component definition used in the above example:
<my-component>
#shadow-root
<button>Open</button>
<button>Button A</button>
<slot><slot>
<button>Button B</button>
</my-component>
The desired behaviour is:
- close when you click outside the component
- do not close when the component itself is clicked
- do not close when an element in the Shadow DOM is clicked (Button A)
- do not close when an element in the Light DOM is clicked (Button B)
A first attempt may be to add an event listener defined inside your component like this:
document.addEventListener('click', (event) => {
if (event.target !== this) {
this.close();
}
});
- ✅ For a click outside the component
event.target !== this
so the component will close. - ✅ For a click on the component itself
event.target === this
so the component will not close. - ✅ For a click on Button A inside the Shadow DOM,
event.target === this
so the component will not close. - ❌ For a click on Button B inside the Light DOM
event.target !== this
so the component will close.
But why is event.target === this
for elements inside the Shadow DOM, but not for elements inside the Light DOM?
Event retargeting
For elements inside the Shadow DOM events are automatically retargeted to the parent component. This means that event.target
is set to the parent component for any event originating from inside the Shadow DOM.
But for elements in the Light DOM, which are only projected to the inside of a component and are not physically moved there, the event.target
remains set to the element itself.
So how do you find out if an element in a component's Light DOM was clicked?
The solution
The event.composedPath()
method shows you each element the event passed through from the originating element right up to the Window
object at the top of the DOM tree. This works on the projected DOM tree so that the elements are included in the order that they appear when rendered rather than their physical position in the DOM:
document.addEventListener('click', (event) => {
console.log(event.composedPath());
/*
this will log an array containing the following
when a button in the Shadow DOM is clicked:
0: button.A
1: div#container
2: document-fragment
3: my-component <--
4: body
5: html
6: document
7: Window
and the following when a button in the Light DOM is clicked:
0: button.B
1: slot
2: div#container
3: document-fragment
4: my-component <--
5: body
6: html
7: document
8: Window
*/
});
In both cases you can see that my-component
appears in the composed path. Using this fact you can update the event listener to the following:
document.addEventListener('click', (event) => {
if (!event.composedPath().includes(this)) {
this.close();
}
});
- ✅ For a click outside the component
composedPath()
will not include the component (this
) so the component will close. - ✅ For a click on the component itself
composedPath()
will includethis
so the component will not close. - ✅ For a click on Button A inside the Shadow DOM,
composedPath()
will includethis
so the component will not close. - ✅ For a click on Button B inside the Light DOM
composedPath()
will includethis
so the component will not close.
🎆 Bingo, composedPath()
gives you all the information you need to see where the event originated!
You can view a full code example of the above component here.
Top comments (0)