javascripttypescriptlit-elementlit-html

Implementing outside clicks on Lit-Elements


I'm making a custom dropdown web component using LitElements and while implementing the click-outside-dropwdown-to-close feature I'm noticing some unexpected behavior that is blocking me. Below in the async firstUpdated() function I'm creating the event listener as recommended in their documentation. Logging out dropdown works as expected, but logging out target returns the root <custom-drop-down>...</custom-drop-down> element every time, which while accurate to an extent, is not specific enough. If there's a better way to handle outside click events I'm all ears.

For reference, here's something that's already built (although the source is hidden) that I'm trying to replicate: https://www.carbondesignsystem.com/components/dropdown/code/

async firstUpdated() {
  await new Promise((r) => setTimeout(r, 0));
  this.addEventListener('click', event => {
    const dropdown = <HTMLElement>this.shadowRoot?.getElementById('dropdown-select');
    const target = <HTMLElement>event.target;
    if (!dropdown?.contains(target)) {
      console.log('im outside');
    }
  });
}    


@customElement('custom-drop-down')
public render(): TemplateResult {
  return html`
    <div>
      <div id="dropdown-select" @action=${this.updateValue}>
        <div class="current-selection" @click=${this.toggleDropdown}>
          ${this.value}
        </div>
        <div class="dropdown-options" id="dropdown-options">
          ${this.dropdownItems(this.options)}
        </div>
      </div>
    </div>
  `;
}

Solution

  • The click event's target property is set to the topmost event target.

    The topmost event target MUST be the element highest in the rendering order which is capable of being an event target.

    Because a shadow DOM encapsulates its internal structure, the event is retargeted to the host element. In your example, the click event's target is retargeted to the custom-drop-down because that is the first and highest element capable of being a target.

    If you need to target an element inside your custom element, you can take one of the following approaches:

    1. Register an event listener inside your element.
    2. Use the <slot> element to fill your custom element with elements that are declared in the light DOM.
    3. Use the composedPath on events with composed: true to determine which internal element your event bubbled from.

    Here's an example of the functionality I believe you are looking for. If you click on the Custom Element it toggles its active state (similar to toggling a dropdown). If you click on any other element on the page, the custom element deactivates itself (similar to hiding a dropdown when it loses focus).

    // Define the custom element type
    class MySpan extends HTMLSpanElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        
        const text = 'Custom Element.';
        const style = document.createElement('style');
        style.textContent = `
          span {
            padding: 5px;
            margin: 5px;
            background-color: gray;
            color: white;
          }
          
          .active {
            background-color: lightgreen;
            color: black;
          }      
        `;
        this.span = document.createElement('span');
        this.span.innerText = text;
        this.span.addEventListener('click', (event) => { 
          console.log('CUSTOM ELEMENT: Clicked inside custom element.'); 
          this.handleClick(event);
        });
        
        this.shadowRoot.append(style, this.span);
      }
      
      handleClick(event) {
        // If the event is from the internal event listener,
        // the target will be our span element and we can toggle it.
        // If the event is from outside the element, 
        // the target cannot be our span element and we should
        // deactiviate it.
        if (event.target === this.span) {
          this.span.classList.toggle('active');
        }
        else {
          this.span.classList.remove('active');
        }
      }
    }
    customElements.define('my-span', MySpan, { extends: 'span' });
    
    // Insert an instance of the custom element
    const div1 = document.querySelector('#target');
    const mySpan = new MySpan();
    div1.appendChild(mySpan);
    
    // Add an event listener to the page
    function handleClick(event) {
      // If we didn't click on the custom element, let it
      // know about the event so it can deactivate its span.
      if (event.target !== mySpan) {
        console.log(`PAGE: Clicked on ${ event.target }. Notifying custom element.`);
        mySpan.handleClick(event);
      }
    }
    document.body.addEventListener('click', (event) => handleClick(event));
    p {
      padding: 5px;
      background-color: lightgray;
    }
    <p id="target">This is a paragraph.</p>
    <p>This is a second paragraph.</p>
    <p>This is a third paragraph.</p>
    <p>This is a fourth paragraph.</p>