This will be an educational article, showcasing one of the cooler Angular features that not many people pay enough attention to. But it will also be a shameless plug of our open source library because you might not know you want it, although I'm sure you do. For a mere 1kB gzip it will improve your DX across many different scenarios, which we will explore here. If you know this library already, don't worry, there are some new features I'll announce here.

What is event management in Angular? It is what the framework does when you write (click) in your template. Have you ever wondered, what magic goes into listening to an Escape key with (keydown.esc)? In this article we will dive a bit into the source code to explore this lesser known public API and how we can leverage it for our benefit.

EventManager

In Angular, templates are rendered with what is called a Renderer. We will not go deep into how it operates, we will just take a look at this little method:

listen(
  target: 'window' | 'document' | 'body' | any,
  event: string,
  callback: (event: any) => boolean,
): () => void {
  (typeof ngDevMode === 'undefined' || ngDevMode) &&
    this.throwOnSyntheticProps &&   
    checkNoSyntheticProp(event, 'listener');
  if (typeof target === 'string') {
    target = getDOM().getGlobalEventTarget(this.doc, target);
    if (!target) {
      throw new Error(`Unsupported event target ${target} for event ${event}`);
    }
  }

  return this.eventManager.addEventListener(
    target,
    event,
    this.decoratePreventDefault(callback),
  ) as VoidFunction;
}

When you write (window:resize) or (keydown.esc) this method gets called. In the first case, the target is going to be window string, in second — it will be the element you write this listener on. As you can see, after resolving the actual target from string all it does is delegate the event listening to eventManager. What is this mysterious beast? It is a global Angular service that has this one relevant method:

addEventListener(
  element: HTMLElement,
  eventName: string,
  handler: Function
): Function {
  const plugin = this._findPluginFor(eventName);
  return plugin.addEventListener(element, eventName, handler);
}

It is a one-liner that gets a target, event name (as you typed it, for example keydown.esc) and a callback. It returns a cleanup function for Renderer to call when the element is destroyed. By now you can see that the meat of this mechanism is in plugins. What are they? Let's find out.

EventManagerPlugin

While the list of available plugins is provided with a public token EVENT_MANAGER_PLUGINS the abstract class itself became public only in Angular 17. This doesn't mean we cannot leverage this in earlier versions, but it's just easier in 17+ in terms of types. The interface is pretty simple though. If we want to add our own plugin, all we need to implement is 2 methods:

abstract supports(eventName: string): boolean;
abstract addEventListener(element: HTMLElement, eventName: string, handler: Function): Function;

What plugins do we have out of the box? There are 3:

  1. DomEventsPlugin — this is your one size fits all guy. It's a fallback plugin that just uses native addEventListener with the given event name as is.
  2. KeyEventsPlugin — this plugin is responsible for (keydown.esc) kind of events. It listens to all keydown events outside of zone.js so that change detection is not triggered for irrelevant key presses. Then if the key matches esc it triggers callback inside zone.js.
  3. HammerGesturesPlugin — that plugin is optional and is enabled if you add HammerModule. It simplifies using Hammer.js gesture events for touch interactions.

Since EVENT_MANAGER_PLUGINS is a multi token, nothing is stopping us from extending the set with our own plugins, just like HammerModule does. Doing so is easy. Let's create our first specimen to get a taste of what we can achieve and how.

Writing our own plugins

How often do you pass $event object to callback just to call .stopPropagation()? If you work with DOM a lot, like me, you probably did so here and there. Wouldn't it be nice if we could just declaratively write (click.stop) and have Angular take care of it for us? It's really easy to achieve with plugins. Have a look:

export class StopEventPlugin extends EventManagerPlugin {
  supports(eventName: string): boolean {
    return eventName.split('.').includes('stop');
  }

  addEventListener(element: HTMLElement, eventName: string, handler: Function): Function {
    const wrapped = (event: Event) => {
      event.stopPropagation();
      handler(event);
    }

    return this.manager.addEventListener(element, eventName.replace('.stop', ''), wrapped)
  }
}

Then we can add this constant to providers in our app bootstrap so that this plugin becomes available for EventManager:

export const STOP_PLUGIN = {
  provide: EVENT_MANAGER_PLUGINS,
  multi: true,
  useClass: StopEventPlugin,
};

So what have we done here? We investigated the event name for .stop block and reported that our plugin supports an event if it has that modifier. Then in the addEventListener function we did 3 things:

  1. Dropped that modifier from the name.
  2. Added a little arrow function wrapper that calls .stopPropagation() on the event, before passing it on to the original callback.
  3. Passed this data back to EventManager so it would determine the right plugin for this particular event

This is a very non-intrusive way to extend Angular logic. We barely wrote any code, so we most likely will not introduce potential bugs. And we also just plugged our logic inside the existing mechanism. That means we can write something like (keydown.esc.stop) and it would still work as expected. Very handy if you want to close a dropdown but keep a modal dialog with it open.

One awesome feature that WebStorm has in this regard is Web Types. They allow you to extend autocomplete and type check your custom events against a JSON schema, so you can have this:

And then your callback will still know that $event is MouseEvent. The list above foreshadows what we will talk about next. You probably guessed that we can also create a similar plugin to call .preventDefault(), but this is just a tip of the iceberg. This simple API opens up many doors and for some time we've been gathering all the helpful plugins under one little library. Let's see what we have so far.

@taiga-ui/event-plugins

Recently we released the next major version of our library to tie-in with Taiga UI 4. It has a few new features, as well as a bumped Angular version and a slight refactor here and there. Besides already mentioned .stop and .prevent plugins, what else do we have ready for you?

SilentEventPlugin

Remember how built-in KeyEventsPlugin exits the zone.js for irrelevant keys so it doesn't trigger change detection? While we all patiently wait for stable zoneless Angular you might also want to be able to execute some callbacks without triggering change detection. For example, if you do not want to move focus away on click — you can achieve this with the following line in host of your component/directive: '(mousedown.prevent.silent)': '0'. It prevents the default behavior of mousedown event, which is to move the focus, and does so outside on NgZone. 0 is just the shortest possible empty callback. Most of those plugins can be combined like that.

SelfEventPlugin

Sometimes you want to ignore bubbled events. You would typically do it by checking if currentTarget equals the target of the event in question. It's easy to do using the same technique of callback wrapping described above. With this plugin just write (transitionend.self) and be sure you will not react to some nested DOM element transition that you might not even have control of.

OptionsEventPlugin

This is one of the most important plugins in the set, as it does not just improve DX, but also brings a feature previously not possible with traditional Angular event listeners. You know you can pass options object as the last argument for addEventListener? Most importantly it allows you to listen to the events in the capturing phase. Read more about it if you are not familiar with the term. Basically, it is the opposite of bubbling and goes from top to bottom of the DOM tree. Not only it allows you to listen to events from child nodes that do not bubble, it also serves as a "first responder" callback as it will be triggered before all others. This is a really helpful feature giving you some control over the order of execution.

ResizeEventPlugin (new)

We all know there's a resize event on window, but what if we want to listen to size changes of a DOM element? There's a tool for that, called ResizeObserver. While we have Web APIs for Angular, our open source initiative to bring Web APIs into Angular in idiomatic form, it still requires you to import a directive that uses an observer under the hood. Event plugin, on the other hand, is added only once, to the global providers, and after that you are able to just write (resize) on any element and be notified if its size changes.

GlobalEventPlugin (new)

Remember in the first code snippet from Angular sources we saw that it resolved global objects, such as windowdocument or body? But these are not the only global objects that implement the EventTarget interface. For example, you might want to detect screen keyboard or rotation with resize of visualViewport. With this plugin you can write (visualViewport>resize) similar to built-in solution, except > instead of :, and it will try to find that global object on globalThis.

Putting it to good use!

Now that we have all those tools ready let's see what we can achieve with them in a simple app with 2 components. Imagine we have a form with an input that automatically grows in size and a submit button. While we patiently wait for field-sizing to ship, we can implement such an input using a little trick. We will make the actual input invisible and absolutely positioned and show text underneath in a span. Take a look at this demo and let's explore each use of our new plugins:

First of all, we have (mousedown.prevent.silent): "0" on the submit button. We do this so that when we tap the button on mobile, focus does not leave the input. Otherwise the screen keyboard will disappear, layout will shift and click event might not even happen. At the time of writing, this issue can be seen on Airbnb chat web app.

Second case for plugins is (click.capture.silent) inside our button. We use capturing phase to react to this event first. If our button is in loading state we stop propagation of the click event. This stops our form from being submitted multiple times while we wait for the response. You might ask why not just disable the button? This solution has accessibility concerns. Disabled button will lose focus and will not indicate to the screen reader what is going on. In our implementation it would read out loud that the button label is "Loading" and we can also use aria-disabled to let user know that this button does not do anything at the moment.

Lastly, in our auto-grow input, when we type enough text for it to overflow — native input will start to scroll. We need to compensate for its scroll position in our span, which is easy to do using text-indent CSS, because it can be negative. However, scroll event does not bubble, so there is no way for us to listen to it in the parent. This is a great use for .capture event plugin because it allows us to react to this event in capturing phase, when it propagates from top to bottom, even for events that do not bubble.

With this little example you can see that custom event plugins can improve your DX. They allow you to write concise declarative statements for common situations, such as preventing default event behaviors. But they also can achieve in one line what otherwise was not that easy to do in Angular, such as listening to events in capturing phase which can come in really handy!

Conclusion

Event management in Angular, as usual, is something we can augment with Dependency Injection — arguably the best part of the framework. With the introduction of EventManagerPlugin abstract class to public API it has now become first class citizen, and it's a good idea to familiarize yourself with the mechanism. @taiga-ui/event-plugins is a good starting point, with several ready-to-use quality of life improvements. But it is definitely not exhaustive. You might come up with a clever and ergonomic solution for your particular needs. And if you think your idea could be helpful to others — it's a good reason to contribute to our library. Similarly, if you have a solid idea, but you are unsure how to implement it best — a feature request could bring in collective mind and help develop the library further.


Last Update: June 25, 2024