The topic of custom element event names comes up every now and then, especially from Shoelace users who get confused when events of the same name are emitted from different components.
Take <sl-details>
, <sl-dialog>
, and <sl-dropdown>
, for example. They all emit sl-show
and sl-hide
events, allowing you to hook into their lifecycle as they open and close. Occasionally, someone will nest a component inside another, listen for an event such as sl-hide
, and wonder why the callback executes at the "wrong time."
In this example, you can see that we have an <sl-dropdown>
inside of an <sl-dialog>
. Both of these components emit sl-show
and sl-hide
events.
<sl-dialog label="Dialog">
<!-- Note how the dropdown is nested inside the dialog -->
<sl-dropdown>
<sl-button slot="trigger" caret>Open and close me</sl-button>
<sl-menu>
<sl-menu-item>Option 1</sl-menu-item>
<sl-menu-item>Option 2</sl-menu-item>
<sl-menu-item>Option 3</sl-menu-item>
</sl-menu>
</sl-dropdown>
</sl-dialog>
<sl-button id="open">Open Dialog</sl-button>
<script>
const dialog = document.querySelector('sl-dialog');
const openButton = document.getElementById('open');
// Show the dialog when the button is clicked
openButton.addEventListener('click', () => dialog.show());
// Listen for the sl-show event
dialog.addEventListener('sl-show', event => {
console.log('The dialog has opened…or has it?')
});
// Listen for the sl-hide event
dialog.addEventListener('sl-hide', event => {
console.log('The dialog has closed…or has it?')
});
</script>
The logic above is faulty because it runs when the dialog or any of the dialog's children emit the sl-show
or sl-hide
event. This occurs due to event bubbling, which is an incredibly useful feature of the platform. When events bubble up, listeners on ancestor elements will execute. This enables an extremely useful feature called event delegation.
Alas, this is also a source of confusion.
Fortunately, there are a couple easy ways to work around it.
Checking the Target
Events are emitted with a target
property that you can inspect to determine which element emitted it. Thus, we can modify the handlers from the previous example to ensure the target is the same dialog we attached the listener to.
// Listen for the sl-show event
dialog.addEventListener('sl-show', event => {
if (event.target === dialog) {
console.log('The dialog has opened')
}
});
// Listen for the sl-hide event
dialog.addEventListener('sl-hide', event => {
if (event.target === dialog) {
console.log('The dialog has closed')
}
});
Checking the Current Target
Another way is to compare Event.currentTarget
, which effectively does the same thing.
// Listen for the sl-show event
dialog.addEventListener('sl-show', event => {
if (event.target === event.currentTarget) {
console.log('The dialog has opened')
}
});
// Listen for the sl-hide event
dialog.addEventListener('sl-hide', event => {
if (event.target === event.currentTarget) {
console.log('The dialog has closed')
}
});
Checking the Event Phase
Here's another way to ensure the event was emitted by the expected element. It's a bit more verbose, but it doesn't require a reference. Instead, we can inspect Event.eventPhase
to determine which phase the event is in. In this example, the listener is attached directly to the dialog, so we're interested in the AT_TARGET
phase.
// Listen for the sl-show event
dialog.addEventListener('sl-show', event => {
if (event.eventPhase === Event.AT_TARGET) {
console.log('The dialog has opened')
}
});
// Listen for the sl-hide event
dialog.addEventListener('sl-hide', event => {
if (event.eventPhase === Event.AT_TARGET) {
console.log('The dialog has closed')
}
});
Why Not Use Unique Event Names?
It has been proposed numerous times that custom elements should emit events with unique names to avoid such confusion. For example, instead of emitting sl-show
for multiple components, event names might named be based on their tag, e.g. sl-dialog-show
and sl-dropdown-show
.
I appreciate the idea, but this just isn't how events works. If you click a <button>
you get a click
event, not a button-click
event. If you set focus to an <input>
, you get a focus
event, not an input-focus
event.
For the sake of consistency with the platform, I consider this suggestion an anti-pattern that should be avoided in custom elements. Instead, developers must understand that many events bubble — including native ones — and they should be doing these checks any time elements are nested to avoid unexpected behaviors.
This problem isn't exclusive to custom elements.
Top comments (0)