1

In my LWC, I am needing to detect when a particular element is clicked, or more specifically, not clicked. This is a child LWC and I need the click event scope to cover the entire document. However, when setting click listeners at the window or document level, the click event doesn't seem to be able to tell what exactly was clicked. The event.target just returns "SecureElement: [object HTMLElement]". So then whenever I try to use something like "!container.contains(event.target)" it returns true 100% of the time regardless of where the click occurred.

This is presumably because of the shadow boundaries, based on the fact that this problem doesn't exist when the listener is attached to this.template (which itself is too limited in scope).

connectedCallback() {
    document.addEventListener('click',this.clickHandler);
}

disconnectedCallback() {
    document.removeEventListener('click',this.clickHandler);
}

clickHandler = (event) => {
    let container = this.template.querySelector('.container');

    alert(event.target); // always returns the generic HTMLElement

    if (this.flag && container && !container.contains(event.target))
        this.flag = false; // this happens 100% of the time due to generic HTMLElement
}

I've tried this solution: LWC -- How to check if user click is outside of a component inside the component's JS?

However this is problematic because I have several such child components on the page, and this solution allows for a user to click on any such component to the effect of preventing the propagation of the listener and thus not handling the previously-flagged component properly.

Is this shadow boundary ironclad as far as event listeners are concerned? Any solutions would be greatly appreciated!

2 Answers 2

2

You're correct, the Shadow DOM is getting in the way. That's why I wrote the original code the way I did. We can only get either "it was us" or "it was not us". We can adapt my previous solution by intoducing a shared variable.

First, create a service component that will house our shared data.

export default { target: null };

Now, for both the parents that want to listen, and for the children who want to announce themselves, we import this variable:

import shared from 'c/shared';

The child component will set a variable temporarily:

async function clickHandler(event) {
  shared.target = event.target.dataset.name;
  // Let the event finish
  await Promise.resolve();
  // Then reset.
  shared.target = null;
}

Now, we just need to attach the event listener. However, we need the click handler to still go first, so we set to the bubble phase:

connectedCallback() {
  document.addEventListener('click',this.clickHandler, false);
}

disconnectedCallback() {
  document.removeEventListener('click',this.clickHandler, false);
}

All that's left is to check the variable to see if our instance was hit or not:

clickHandler = (event) => {
  let container = this.template.querySelector('.container');
  if (this.flag && container?.dataset?.name === shared.target) {
    this.flag = false;
  }
}

Of course, we need to assign a unique Id (which I called name), such as:

<div class="container" data-name={name}>...</div>

There might be a better way to do this, but this is at least fairly efficient given our limitations.

2
  • thank you as always for a fast and effective response. I put together a solution for this before seeing your answer (see my own answer post). Though I was interested in your use of dataset. Though when I did a little experiment on my document's click listener, event.target.dataset did not have the expected data-name property I'd set. When I stringified this object all that turned up was "{"dataRenderingServiceUid":"208","auraRenderedBy":"519:0"}". Any idea why? I've never been able to pull dataset from event.target successfully, only event.currentTarget. Apr 4, 2023 at 5:08
  • @BrigLarimer Perhaps it was unclear; the child itself reads the local event, which is how it can read the dataset attribute. The parent cannot, of course, read the dataset from the document/window handler, which was the original problem. Your solution is actually basically the same as mine, but without the extra shared variable.
    – sfdcfox
    Apr 4, 2023 at 13:10
1

I found what seems to be a reliable solution for this. I can register two listeners, one on this.template and one on document. I can then share information from the template listener with the document listener so they can make a coordinated decision. Of course, this requires that the listeners reliably fire in sequential order, which I've assured by setting useCapture on the template listener since capture events occur before those in the bubbling phase. (They seemed to be firing in registration order even when I didn't set useCapture anyway, but I need to be sure).

connectedCallback() {
    this.template.addEventListener('click',this.templateClickHandler,true);
    document.addEventListener('click',this.documentClickHandler);
}

disconnectedCallback() {
    this.template.removeEventListener('click',this.templateClickHandler);
    document.removeEventListener('click',this.documentClickHandler);
}

targetComponentClicked = false;

templateClickHandler = (event) => {
    this.targetComponentClicked = false;
    
    let select = this.template.querySelector('.container');
    
    if (!container.contains(event.target))
        this.flag = false;
    else
        this.targetComponentClicked = true;
}

documentClickHandler = () => {
    if (this.targetComponentClicked) {
        this.targetComponentClicked = false;
        return;
    }
    
    this.flag = false;
}

This has a similar concept with sfdxfox's answer which I imagine would work equally well but haven't tried. Seems the bottom line is that information from the local component needs to be shared out to any other components of interest one way or the other since that shadow boundary seems quite impenetrable when working purely from the DOM.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .