Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at dev.Objective() 2015 (Bloomington, MN) with: Kitt Hodsden
Ben Nadel at dev.Objective() 2015 (Bloomington, MN) with: Kitt Hodsden@kitt )

Creating A Direct-Click DOM Event Plug-in In Angular 4.4.6

By Ben Nadel on

One of the most compelling features of Angular is that you can define custom DOM (Document Object Model) event plug-ins that can run arbitrary event logic outside of the main Angular Zone. This means that you can both encapsulate complex event logic and ensure that Angular's change-detection algorithm is only triggered if certain conditions are met. In the past, I've used this feature to create "clickoutside" event bindings and multi-priority global-key bindings. This morning, I want to use the same approach to create a "directclick" event to capture clicks on a given element but only if the click event did not originate from within a descendant element.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

Angular already has the ability to bind to (click) events. But, with the way the DOM (Document Object Model) is structured, a (click) event binding on an element will intercept direct clicks on the given element as well as any clicking on any descendant node within the given element. If we didn't want to respond to descendant-node events, we could theoretically inject the ElementRef into our Angular component and then check to see if the (click) target matches the ElementRef.nativeElement; but, that's wicked janky and overly DOM-focused.

Instead, we can define a custom DOM events plug-in, (directclick), that performs all of that logic for us. And, does so in a way that can be used globally (by other elements) and reduces the number of change-detection cycles that our application has to run. To do this, we just need to implement the EventManagerPlugin API and add our implementation to the EVENT_MANAGER_PLUGINS provider collection at application bootstrap.

Inside of our custom DOM Events plug-in, it's worth considering Angular's interaction with Zone.js. Each of our Angular components contains a view-model and a template. As the view-model changes throughout the life-cycle of the application, Angular reconciles the template against the view-model using change-detection.

By default, all of the core DOM-event bindings, like (click), will trigger a change-detection cycle because the core event handlers are bound inside of Angular's Zone.js instance. This makes sense when there is a one-to-one mapping of events to event-handler invocations. However, in the case of something like a (directclick), where not every "click" event will precipitate an associated "directclick" event, it doesn't make sense to trigger a change-detection cycle for every click.

Instead, we want to trigger a change-detection cycle only after a component's view-model may have been changed. To do this, we need to bind the base "click" event-handler outside of Angular's Zone.js instance; then, only re-enter Angular's Zone.js instance when the base "click" event will lead to a "directclick" event which may lead to a change in a component's view-model which may need to be reconciled with said component's template.

Here is my implementation of a (directclick) DOM event plug-in:

  • // Import the core angular services.
  • import { EventManager } from "@angular/platform-browser";
  • import { NgZone } from "@angular/core";
  •  
  • // Import these modules for their side-effects.
  •  
  • export class DirectClickPlugin {
  •  
  • // CAUTION: This property is automatically injected by the EventManager service. It
  • // will be available by the time the addEventListener() method is called.
  • public manager: EventManager;
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I bind the "directclick" event to the given object.
  • public addEventListener(
  • element: HTMLElement,
  • eventName: string,
  • handler: Function
  • ) : Function {
  •  
  • // By default, when we bind an event using the .addEventListener(), the event is
  • // bound inside Angular's Zone. This means that when the event handler is
  • //invoked, Angular's change-detection algorithm will get automatically triggered.
  • // When there is a one-to-one mapping of events to event handler calls, this
  • // makes sense. However, in this case, for the "directclick" event, not all
  • // "click" events will lead to an event handler invocation. As such, we want to
  • // bind the base "click" hander OUTSIDE OF THE ANGULAR ZONE, inspect the event,
  • // then RE-ENTER THE ANGULAR ZONE only in the case when we're about to invoke the
  • // event handler. This way, the "click" events WILL NOT trigger change-detection;
  • // but, the "directclick" events WILL TRIGGER change-detection.
  • var zone: NgZone = this.manager.getZone();
  •  
  • zone.runOutsideAngular( addClickHandler );
  •  
  • return( removeClickHandler );
  •  
  • // ---
  • // LOCALLY-SCOPED FUNCTIONS.
  • // ---
  •  
  • // I handle the base "click" event OUTSIDE the Angular Zone.
  • function addClickHandler() {
  •  
  • element.addEventListener( "click", clickHandler, false );
  •  
  • }
  •  
  • // I remove the base "click" event.
  • function removeClickHandler() {
  •  
  • element.removeEventListener( "click", clickHandler, false );
  •  
  • }
  •  
  • // I handle the base "click" event.
  • function clickHandler( event: Event ) : void {
  •  
  • if ( event.target !== element ) {
  •  
  • return;
  •  
  • }
  •  
  • // If the target of the click event is the bound element, then this "click"
  • // event is a "directclick" event. At this point, we need to invoke the
  • // event-handler. So, we're going to RE-ENTER THE ANGULAR ZONE so that the
  • // change-detection algorithm will be triggered.
  • zone.run(
  • function runInZoneSoChangeDetectionWillBeTriggered() {
  •  
  • handler( event );
  •  
  • }
  • );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I bind the "directclick" event to the given global object.
  • // --
  • // CAUTION: Not currently supported - not sure it would even make sense.
  • public addGlobalEventListener(
  • element: string,
  • eventName: string,
  • handler: Function
  • ) : Function {
  •  
  • throw( new Error( `Unsupported event target ${ element } for event ${ eventName }.` ) );
  •  
  • }
  •  
  •  
  • // I determine if the given event name is supported by this plugin. For each event
  • // binding, the plugins are searched in the reverse order of the EVENT_MANAGER_PLUGINS
  • // multi-collection. Angular will use the first plugin that supports the given event.
  • public supports( eventName: string ) : boolean {
  •  
  • return( eventName === "directclick" );
  •  
  • }
  •  
  • }

There's not too much going on here. As you can see inside of our addEventListener() method, we're using zone.runOutsideAngular() to bind the base "click" event-handler. This will ensure that this plug-in's click events won't trigger a change-detection digest by default. Then, when the proper conditions have been met - when the event target is the same as the bound element - we re-enter Angular's Zone.js instance using zone.run(). This will ensure that a change-detection digest is triggered after the (directclick) event-handler is invoked.

To use this custom DOM event plug-in, we have to add it to the EVENT_MANAGER_PLUGINS multi-provider in our root application module:

  • // Import the core angular services.
  • import { BrowserModule } from "@angular/platform-browser";
  • import { EVENT_MANAGER_PLUGINS } from "@angular/platform-browser";
  • import { NgModule } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { AppComponent } from "./app.component";
  • import { DirectClickPlugin } from "./direct-click.plugin";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @NgModule({
  • bootstrap: [
  • AppComponent
  • ],
  • imports: [
  • BrowserModule
  • ],
  • declarations: [
  • AppComponent
  • ],
  • providers: [
  • {
  • provide: EVENT_MANAGER_PLUGINS,
  • useClass: DirectClickPlugin,
  • multi: true
  • }
  • ]
  • })
  • export class AppModule {
  • // ...
  • }

Once we've registered our DOM events plug-in, we can start to use it within our Angular components. To demonstrate, I'm going to use it in my root component's host bindings. This way, we can respond to direct-clicks on the root component, but ignore clicks on any elements contained within the root component. And, to drive home the interaction between event-bindings and change-detection digests, I'm going to log all calls to the ngDoCheck() life-cycle method so we can see which kind of click events trigger change-detection.

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { DoCheck } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • host: {
  • "(directclick)": "handleDirectClick( $event )"
  • },
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • This text is contained directly within the root component.
  • <p>
  • This text is contained within a <code>P</code> tag.
  • </p>
  • This text is contained directly within the root component.
  • `
  • })
  • export class AppComponent implements DoCheck {
  •  
  • // I get called as part of the change-detection algorithm.
  • public ngDoCheck() : void {
  •  
  • console.warn( "Change-detection algorithm at", Date.now() );
  •  
  • }
  •  
  •  
  • // I handle the direct-clicks to the current component.
  • public handleDirectClick( event: Event ) : void {
  •  
  • console.log( "Direct click." );
  •  
  • }
  •  
  • }

In this case, I'm using (directclick) in the component's host bindings. But, we could have just as easily been using (directclick) directly within the component's template as well - there's nothing special about host bindings. That said, if we run this Angular app and click around the root component, we get the following output:


 
 
 

 
 Creating a directclick custom DOM event plug-in in Angular 4.4.6. 
 
 
 

As you can see from this screenshot, our (directclick) event binding was able to invoke our component's event handler. And, we can see from the ngDoCheck() life-cycle hook that change-detection was triggered as a result of the event. However, what you can't really see unless you watch the video is that no change-detection cycle was triggered if I clicked on the P-tag (since it didn't constitute a "direct" click on the host element).

For the most part, Angular's change-detection algorithm "just works." This is because Angular uses Zone.js to register callbacks that may produce a view-model change. When we create custom DOM events, like (directclick), we can step outside of Angular's Zone.js instance in order to make sure that the overhead of change-detection is only incurred when it is necessary. And, we get to encapsulate a whole lot of boiler-plate logic as well, which is hella sweet!



Looking For A New Job?

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

Hi Ben,

This is a cool feature. I tried to create a plugin for capture resize on the root component but it's not getting triggered. Not sure what I'm missing, there're no errors.

Running angular 5
This is what I have in the plugin:
export class ElementResizePlugin {
public manager: EventManager;
private static EVENT_NAME: string = 'elementResize';

public addEventListener(
element: HTMLElement,
event: string,
handler: Function
) : Function {
let zone: NgZone = this.manager.getZone();
zone.runOutsideAngular(addElementResizeHandler);

return (removeElementResizeHandler);

function addElementResizeHandler() {
element.addEventListener('onresize', elementResizeHandler, false);
}

function removeElementResizeHandler() {
element.removeEventListener('onresize', elementResizeHandler, false);
}

function elementResizeHandler(event: Event) {
if(event.target !== element) {
return;
}
zone.run(() => {
handler(event);
});
}
}

supports(eventName: string) : boolean {
return eventName === ElementResizePlugin.EVENT_NAME;
}
}

In the component:

host: {
'(elementResize)': 'handlerElementResize($event)'
},

Reply to this Comment

@Jeff,

The problem is likely that the "resize" event isn't raised on every element. In fact, I believe the "resize" event is only raised on the Window object. Looking at the size of an element is very difficult because it could change for a whole lot of reasons, including, but not limited to:

* Window resize.
* Change in content.
* Change in content styles.
* Change in content visibility.

As such, it's not as easy as just listening to a given event, unfortunately. I think I read somewhere that a future specification might add some sort of resize event to the Element nodes; but, I don't believe that time is now.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.