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>
`;
}
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:
<slot>
element to fill your custom element with elements that are declared in the light DOM.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>