DEV Community

Chris Haynes
Chris Haynes

Posted on • Originally published at lamplightdev.com on

How to detect clicks outside of a Web Component

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:

Screencast showing a button opening a component, clicking of buttons inside the component that do not close it, and a click outside the component which does close it

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>
Enter fullscreen mode Exit fullscreen mode

The desired behaviour is:

  1. close when you click outside the component
  2. do not close when the component itself is clicked
  3. do not close when an element in the Shadow DOM is clicked (Button A)
  4. 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();
  }
});
Enter fullscreen mode Exit fullscreen mode
  1. ✅ For a click outside the component event.target !== this so the component will close.
  2. ✅ For a click on the component itself event.target === this so the component will not close.
  3. ✅ For a click on Button A inside the Shadow DOM,event.target === this so the component will not close.
  4. ❌ 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
  */
});
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
Enter fullscreen mode Exit fullscreen mode
  1. ✅ For a click outside the component composedPath() will not include the component (this) so the component will close.
  2. ✅ For a click on the component itself composedPath() will include this so the component will not close.
  3. ✅ For a click on Button A inside the Shadow DOM,composedPath() will include this so the component will not close.
  4. ✅ For a click on Button B inside the Light DOM composedPath() will include this 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.

Subscribe to my mailing list to be notified of new posts about Web Components and building performant websites

Top comments (0)