Shadow DOM and Events

The main aim of the shadow tree is encapsulating the component’s internal implementation details.

The events occurring inside the shadow DOM take the host element as the target once caught out of the component.

Here is an example:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <site-card></site-card>
    <script>
      customElements.define('site-card', class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({
            mode: 'open'
          });
          this.shadowRoot.innerHTML = `<p>
            <button>Click on button</button>
          </p>`;
          this.shadowRoot.firstElementChild.onclick =
            e => alert("Inner target: " + e.target.tagName);
        }
      });
      document.onclick = e => alert("Outer target: " + e.target.tagName);
    </script>
  </body>
</html>

Once you click the button, the following messages will be shown:

  1. Inner target: BUTTON - the internal event handler receives the correct target, the element within the shadow DOM.
  2. Outer target:USER-CARD - the document event handler receives the shadow host as a target.

Event targeting is а very practical technique to have, as the outer document shouldn’t know about component internals. From the outer document point of view, that takes place on <user-card> .

If the event triggers on a slotted element physically living in the light DOM, retargeting won’t occur.

Let’s check out an example:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <site-card id="siteCard">
      <span slot="sitename">Welcome to W3Docs</span>
    </site-card>
    <script>
      customElements.define('site-card', class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({
            mode: 'open'
          });
          this.shadowRoot.innerHTML = `<div>
            <b>Name:</b> <slot name="sitename"></slot>
          </div>`;
          this.shadowRoot.firstElementChild.onclick =
            e => alert("Inner target: " + e.target.tagName);
        }
      });
      siteCard.onclick = e => alert(`Outer target: ${e.target.tagName}`);
    </script>
  </body>
</html>

Bubbling, Event.composedPath()

For the event bubbling purposes, it is recommended to use the flattened DOM.

So, when there is a slotted element, and an event triggers somewhere within it, then it will bubble up to the <slot> and upwards.

The entire path to the original event target may be obtained with event.composedPath(). That process is taken from the composition.

In the example demonstrated above, the flattened DOM looks like this:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <site-card id="siteCard">
      #shadow-root
      <div>
        <b>Name:</b>
        <slot name="sitename">
          <span slot="sitename">W3Docs</span>
        </slot>
      </div>
    </site-card>
  </body>
</html>

So, clicking on <span slot="username"> and calling event.composedPath() event.composedPath() returns an array [span, slot, div, shadow-root, user-card, body, html, document, window]. It is the parent chain. Please, also take into consideration that once the shadow tree is created using {mode: 'closed'}, then the composed path begins from the host: user-card and upwards.

Event Composed

Most of the events effectively bubble through shadow DOM boundary. Only a few events can’t do that.

It is governed by the composed event object property. When it’s true, then the event crosses the boundary. In the opposite case, it is only caught from inside the shadow DOM.

In the UI Events Specification, the majority of the events include composed: true. They are as follows:

  • blur, focus, focusin, focusout,
  • click, dblclick,
  • mousedown, mouseup mousemove, mouseout, mouseover,
  • wheel,
  • beforeinput, input, keydown, keyup.

All the touch and pointer events also include composed: true.

However, several events include composed: false. They are as follows:

  • mouseenter, mouseleave (they never bubble),
  • load, unload, abort, error,
  • select,
  • slotchange

You can only catch these events on elements inside the same DOM where the event target exists.

Custom Events

Once dispatching custom events, it is necessary to set both bubbles and composed properties to true. It should be done for bubbling up and out of the component.

The example is demonstrated below:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <div id="outer"></div>
    <script>
      outer.attachShadow({
        mode: 'open'
      });
      let inner = document.createElement('div');
      outer.shadowRoot.append(inner);
      /*
      div(id=outer)
        #shadow-dom
          div(id=inner)
      */
      document.addEventListener('test', event => alert(event.detail));
      inner.dispatchEvent(new CustomEvent('test', {
        composed: true,
        bubbles: true,
        detail: "composed"
      }));
      inner.dispatchEvent(new CustomEvent('test', {
        composed: false,
        bubbles: true,
        detail: "not composed"
      }));
    </script>
  </body>
</html>

Summary

Events are capable of crossing shadow DOM boundaries in case their composed is set to true.

Most of the built-in events encompass composed: true. Yet, some of them have composed: false. They can be caught merely on elements inside the same DOM. Dispatching a CustomEvent will explicitly set composed: true.




Do you find this helpful?

Related articles