How to Handle Click Events Not Bubbling Up to Document
Replacing event listeners on document with custom event streams in Angular & RxJS for greater control
The Initial, Happy Scenario
An interesting scenario I recently encountered was a list of items, each of which contained a dropdown, like so:
Each list item is an instance of a component called ListItemComponent
.
The dropdown is a PrimeNG Dropdown, and, like all good-hearted component developers, PrimeNG makes sure that clicking outside the dropdown menu triggers it to close.
They do this via the common practice of attaching a click event listener on document
that closes the menu upon click outside of the menu.
That was all well and good until the scenario got a slight change.
The Troublemaker Scenario
Our happy scenario turned into a bit of a troublemaker when the list items were extended to allow for an expandable/collapsible detail row, like so:
Why troublemaker, you ask? Great question! Here’s why:
Clicks on the dropdowns bubble up to the list item summary rows and trigger the expand/collapse of the detail rows.
Very bad user experience. Very, very, very bad.
The Search for a Fix
The most obvious solution is to stopPropagation
of the dropdown clicks to prevent the bubble-up.
Here’s what that gives us:
This fixes the unwanted expand/collapse of the detail rows, but introduces a new problem: multiple dropdowns can now be open at the same time!
When we stop propagation of clicks on the dropdowns, the click listeners on document
never receive the click events and therefore don’t close their associated dropdowns.
A Different Approach
To fix this, we need to build a more reliable event notification system that can’t be thwarted by an innocently inserted stopPropagation
.
Since we need to communicate between components that might not be part of the same parent-child tree structure, we need to look to an Angular service.
Angular Service & RxJS to the Rescue!
We can easily build a service to act as the communications center for all events related to this interface.
Click events on the dropdowns can be reported to the service, and the service, via Observables, can notify subscribers of those click events.
A simple version of the service would look like this:
Points of interest:
- The service exposes an Observable called
events$
that emits anEventNotification
object whenever the service’snotify
method is called. - We’re using a Subject internally within the service (
_events$
) to emit the events but we’re not exposing it directly, we’re keeping itprivate
and exposing its Observable form because, in general, subscribers should not have access to the raw Subject unless they’ll also be emitting events from it, and that’s not the case here. - We created an enum called
EventType
that currently has just one member event,ListItemDropdownOpened
, but will help keep things organized as we add other events. - We created a type called
EventNotification
that has atype
property for theEventType
and an optionaldetails
property. - The optional
details
property is of typeEventDetails
, which currently has one optional property,componentInstance
, that will indicate whichListItemComponent
instance was involved in the event. Subscribers will need this information so they know if they’re receiving a notification about themselves or about a differentListItemComponent
instance.
Subscribing to the Events
Here’s how our ListItemComponent
can subscribe to our new service’s events$
Observable and react accordingly:
Points of interest:
- We created an Observable called
otherDropdownOpened$
that filters the service’s event stream for events of typeListItemDropdownOpened
that are associated with instances ofListItemComponent
other than this one. - Upon receiving one of those filtered events, we close our dropdown by calling
this.dropdown.hide()
. (This happens to be PrimeNG’s API for programmatically closing its dropdown, but others could be different, of course.)
Triggering the Events
In the previous ListItemComponent
code sample, you probably noticed that I left in a spoiler giving away this part of the article: a method called onShowDropdown
that calls the EventNotificationService
's notify
method.
Here it is:
The notify
method accepts an EventNotification
object. On that object we set the type
to ListItemDropdownOpened
(using the EventType
enum) and in the details
we set the componentInstance
to this
, a reference to our instance of ListItemComponent
.
The onShowDropdown
method is invoked when the component’s dropdown is opened. In our scenario, it’s a PrimeNG dropdown, so our component’s template looks like this, hooking into the dropdown’s onShow
event:
But the basic idea is that your component would have some way of knowing when its dropdown has been opened so it can then notify the service.
Extending the Service
As you extend the service with more event types, you could enhance it by, for example, creating useful pre-defined event bundles, like this:
The allComponentOpenedEvents$
Observable would be useful for certain components, like dropdowns, to subscribe to if they need to react to any type of component opening itself.
Then if you someday add a new component that also “opens,” you could simply add its EventType
to that filter rather than having to find and update every subscriber listening for component “open” events.
Conclusion
Here’s a StackBlitz with the working code:
I hope this provided some useful information and ideas.
Go forth and stream!