1

In my real world case, I'm dealing with a flyout-component, which I want to close once the any element outside of it receives focus. This flyout-component is used as part of the shadow-dom template of other elements. And of course, my real world case, there are a lot more components involved. I reduced the case to a bare minimum.

In the following example, we have an outer and an inner-component. The inner one is little more than a plain slot and a focusout-listener as a demonstrator for my problem. The outer-component contains an unnamed slot and an inner-component, which contains a named slot.

So the inner-component will select the slot outer-nested-slot from the outer-component with its inner-plain-slot. Finally, it will contain the div with the two buttons.

Now here comes the question itself: The buttons are no real children of inner-component. Nevertheless the focusout-event is received since they are slotted into inner-component. Is there a way to programmatically check if a Element is logically a child of another component, even though there is no parent-child-relation in the light-dom?

Of course slot.assignedNodes({flatten:true}) came into my mint. But this would return only the wrapping div around my buttons. Thus I would have to iterate over all the returned nodes in order to check if any of them contains the element in question. And of course, if the assigned node was not a simple div, but again a webcomponent, everything gets ridiculous complex.

So, in a nutshell: Given a webcomponent w-a and a node b, is there a way to programmatically check if there is a logical parent-child-relation (implying an event could bubble from b to w-a )?

const stamp = (node, name) => {
  const template = document.getElementById(name);
  const templateContent = template.content;

  const shadowRoot = node.attachShadow({
    mode: 'open'
  });
  shadowRoot.appendChild(templateContent.cloneNode(true));
}


customElements.define('inner-element',
  class extends HTMLElement {
    constructor() {
      super();
      stamp(this, 'inner-element');

      this.addEventListener('focusout', this.onFocusOut);
    }

    onFocusOut(focusevent) {
      const newFocus = focusevent.relatedTarget;
      const oldFocus = focusevent.target;
      document.getElementById('log').innerHTML += `<div>${oldFocus?oldFocus.id:'null'} -> ${newFocus?newFocus.id:'null'}; oldFocus is Child? ${this.contains(oldFocus)}. newFocus is Child? ${this.contains(newFocus)}</div>`;
    }

  }
);

customElements.define('outer-element',
  class extends HTMLElement {
    constructor() {
      super();
      stamp(this, 'outer-element');
    }
  }
);
<template id="inner-element">
  <style>
    :host {
      border: 2px solid hotpink;
      margin:2px;
      padding: 2px;
    }
  </style>
  <slot id="inner-plain-slot"></slot>
</template>

<template id="outer-element">
  <style>
    :host {
      border: 2px solid rebeccapurple;
      margin:2px;
      padding: 2px;
      display:flex;
      flex-direction:row
    }
  </style>
  <inner-element>  <slot id="outer-nested-slot" name="nested"></slot>  </inner-element>
  <slot id="outer-plain-slot"></slot>
</template>


<outer-element>
  <div style="display:flex;flex-direction:row" slot="nested">
    <button id="nest1">nested-1</button>
    <button id="nest2">nested-2</button>
  </div>
  <button id="not-nest1">not-nested1</button>
  <button id="not-nest2">not-nested2</button>
</outer-element>

<div>
  <code id=log></code>
</div>

I found one possible solution using events themself. The solution seems rather obvious after condensing the question. But I would still prefer a more native way to perform that check.

const checkChildParentRelation = (potentialChild, potentialParent) => {
  if (!potentialChild || !potentialParent) {
    return false;
  }
  let result = false;
  const listener = e => {
    result = true;
    e.stopImmediatePropagation();
  };

  const eventName = `parent-child-test-event-${Date.now()}`;

  potentialParent.addEventListener(eventName, listener);
  potentialChild.dispatchEvent(
    new CustomEvent(eventName, {
      bubbles: true,
      composed: true,
      cancelable: true,
    })
  );
  potentialParent.removeEventListener(eventName, listener);
  return result;
};
samjaf
  • 1,033
  • 1
  • 9
  • 19
  • Did you try `parentElement.contains(childElement)` ? It might not work as expected with Shadow DOM but is worth trying ... – IVO GELOV Apr 14 '22 at 05:56
  • `contains` is used in the example. It was the first thing I tried in my real world case. And it is not working indeed. – samjaf Apr 14 '22 at 06:26

2 Answers2

0

One big issue with the shadow DOM and how it relates with events is that the event target for events with a shadow DOM will be the host element when caught outside of that shadow DOM. This is "retargeting." That means that if you attach an event listener inside of the Shadow DOM, the target will work like normal. If you attach the same event listener to something like window, then the target will be the shadow host element for any element within the shadow DOM.

I couldn't find an easy way to do what you're doing using just the DOM API -- something like Node#contains will always return false when the event listener is attached to a Node outside of the shadow DOM and you're trying to determine if the "target" (which will be retargeted) of the shadow DOM contains another node inside of the shadow DOM. So I wrote this:

// Find the document-fragment or document node which contains the given node
export function findRootNode(n: Node): Node {
    if (n.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
        if (n instanceof ShadowRoot) {
            return n.host;
        }
        // I'm not sure when this could happen, but I don't expect it.
        throw ("found document fragment that is not a shadow root");
    }
    if (n.nodeType === Node.DOCUMENT_NODE) {
        return n;
    }
    return findRootNode(n.parentNode);
}

// findEventTarget will find what the event target (with respect to Window)
// should be for this element:
// - if in a shadow DOM then the shadow host element
// - otherwise, the element itself
export function findEventTarget(n: Node): Node {
    const rootNode = findRootNode(n);
    if (rootNode === document) {
        // not in shadow dom
        return n;
    }
    return rootNode;
}

These can be used to find which node you should compare your event's target to. If you had some event on node A inside a shadow DOM S, then you can use const et = findEventTarget(A) and compare et to the target of your event. This will work in between components in different shadow DOMs. If you want to extend it to work both between shadow DOMs and inside of one shadow DOM, then you can use a combination of Node.contains with the "real" nodes and, if that returns null, then use Node.contains with the Node returned from findEventTarget().

Hut8
  • 6,080
  • 4
  • 42
  • 59
-1

For loose coupling Events are best.

Sounds to me like you want to proces the array of DOM Elements you get from executing the event.composedPath() function

Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
  • Sorry, this question is neither about coupling nor about the path of the event as such. All I want is to check wether a component is logically a child of another component, even though there is no parent-child relation in respect to light dom. Please have a closer look into my example. – samjaf Apr 15 '22 at 13:31
  • If ``b`` is a child of ``w-a``, then ``w-a`` is in the ``composedPath`` Array, when you dispatch an Event from ``b``, that is _loose_ coupling. You can do it with a [recursive ``closestElement``](https://stackoverflow.com/questions/54520554/custom-element-getrootnode-closest-function-crossing-multiple-parent-shadowd), but that would _tight_ coupling – Danny '365CSI' Engelman Apr 15 '22 at 15:15
  • Thank you for your input. As you can see in my addition, I already found a solution using events. There is no need for me to inspect the composed path. If I can receive the event, the question is answered already. I am looking for a more straight forward way to verify (or falsify) the relation. I was hoping for a native API. But the more I investigate, the less I'm confident in finding such an API. – samjaf Apr 15 '22 at 19:03