Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Francine Brady
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Francine Brady@darkfeather )

Creating Custom DOM And Host Event Bindings In Angular 2 Beta 6

By Ben Nadel on

Yesterday, I demonstrated that attribute directive selectors and output events can have the same name in Angular 2 Beta 6. This is great for adding custom behavior to a view element, which I demonstrated with a "clickOutside" directive. But, this got me thinking - how could I add such a directive to a host element? I don't believe that I can dynamically apply directives to a host. But, it turns out that we can add custom DOM (Document Object Model) event bindings to a host through Angular 2's event plugin mechanism. To explore this, I am going to create a plugin that provides "clickOutside" bindings as a DOM event rather than as an attribute directive.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

Out of the box, you can hook into just about any DOM-based event using the native event name and the binding syntax:

  • (click)="handler( $event )" - Binds to the "click" event on the element.
  • (mouseup)="handler( $event )" - Binds to the "mouseup" event on the element.
  • (mousemove)="handler( $event )" - Binds to the "mousemove" event on the element.

... and so-on. And, you can even use this approach to bind to any DOM-based event that another directive might emit on the DOM tree (like a "close" event emitted by a modal widget or something to that effect).

But, this only works if Angular 2 knows how to capture said event. For something like a "clickOutside" event, Angular 2 doesn't know how to translate global "click" events into local "clickOutside" events. For that, we have to provide a custom event plugin.

It turns out, all of the event bindings are powered by an Event Manager which is, itself, powered by a number of plugins. Looking at the Angular 2 source code, Angular ships with plugins for DOM events, Key events, and Hammer.js gestures. To expose a set of "outside" events (ex, clickOutside, mousedownOutside) we have to provide Angular 2 with a plugin that knows how to translate event names, such as "mousemoveOutside," into actual event binding logic and Zone.js consumption.

Zone.js is the monkey-patching library that hooks into all the workflows that may require Angular 2 to run change-detection. When we bind our custom events, we don't actually want to trigger change-detection on all event instances - only those that translate into an event that may change the underlying view-model. As such, when we integrate our "outside" event bindings, we want to do so in a fork of the Angular 2 Zone.js instance. This way, our internal event bindings won't implicitly trigger change-detection.

If, however, one of our underlying event bindings needs to precipitate a call to the application's event handler, then and only then do we re-enter the Angular 2 zone and invoke the callback. This way, any view-model changes implemented by the application callback will be picked up by the change-detection mechanism.

To put it concretely, every "outside" event (ex, "clickOutside") is really just an "inside" event (ex, "click") at the document level. The document-level event binding checks the origin of each event and determines whether or not it originated from within the target element. The document-level event bindings are created in the fork of the Angular 2 zone. This way, not all document-level events will trigger change-detection - only those that require the "outside" event callback to be invoked.

One thing that I couldn't figure out, in this code, is how to keep the reference to "document" and "document.body" better encapsulated. I kept trying to reference "ng.platform.common_dom.DOM"; but, it always seemed to be undefined. I am not sure if I am just barking up the wrong tree; or, if this is a issue with the exported barrels and the deferred setting of the DOM adapter via the Browser adapter's .makeCurrent() call. In any case, I got around this (if you can call it that) by directly referencing the document node. Which I am pretty sure is frowned upon.

That said, let's take a look at the code. In this demo, I have a widget that I can toggle into and out of existence. This is done to ensure that the custom events can be bound, unbound, and then re-bound properly. If you look at the Widget implementation, you will see that I am binding to the host event, "clickOutside", and the global host event, "body:clickOutside".

  • <!doctype html>
  • <html>
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Creating Custom DOM And Host Event Bindings In Angular 2 Beta 6
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body>
  •  
  • <h1>
  • Creating Custom DOM And Host Event Bindings In Angular 2 Beta 6
  • </h1>
  •  
  • <my-app>
  • Loading...
  • </my-app>
  •  
  • <!--
  • Including extra padding / content to make sure that the BODY tag
  • is easy to reach as a target.
  • -->
  • <p style="padding: 50px 0px 50px 0px ; margin-bottom: 50px ;">
  • (body tag)
  • </p>
  •  
  •  
  • <!-- Load demo scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/6/es6-shim.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/6/Rx.umd.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/6/angular2-polyfills.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/6/angular2-all.umd.js"></script>
  • <!-- AlmondJS - minimal implementation of RequireJS. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/6/almond.js"></script>
  • <script type="text/javascript">
  •  
  • // Defer bootstrapping until all of the components have been declared.
  • // --
  • // NOTE: Not all components have to be required here since they will be
  • // implicitly required by other components.
  • requirejs(
  • [ "AppComponent", "DOMOutsideEventPlugin" ],
  • function run( AppComponent, DOMOutsideEventPlugin ) {
  •  
  • ng.platform.browser.bootstrap(
  • AppComponent,
  •  
  • // All of the DOM events are managed through an Event Manager that
  • // is, itself, backed by a series of plugins. We can add additional,
  • // custom DOM events and host bindings by providing plugins. While
  • // the plugins are registered in one order they are actually consumed
  • // in reverse order. This means that our custom plugins are actually
  • // given a higher precedence than the ones provided by Angular 2.
  • // This is why we have a chance to intercept a "native" DOM binding
  • // before it gets bound by Angular 2's core DOM plugin.
  • [
  • ng.core.provide(
  • ng.platform.common_dom.EVENT_MANAGER_PLUGINS,
  • {
  • useClass: DOMOutsideEventPlugin,
  • multi: true
  • }
  • )
  • ]
  • );
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide a browser plugin for custom "outside" DOM (Document Object Model)
  • // event bindings. The currently supported events are:
  • // --
  • // * clickOutside
  • // * mousedownOutside
  • // * mouseupOutside
  • // * mousemoveOutside
  • // --
  • // CAUTION: This plugin makes *** direct references *** to the DOCUMENT and BODY
  • // nodes because I could not figure out how to access the DOM Adapter or keep the
  • // DOM reference encapsulated.
  • define(
  • "DOMOutsideEventPlugin",
  • function registerDOMOutsideEventPlugin() {
  •  
  • return( DOMOutsideEventPlugin );
  •  
  •  
  • // I bind and unbind custom "outside" DOM events.
  • function DOMOutsideEventPlugin() {
  •  
  • var vm = this;
  •  
  • // Each "outside" event maps to a native event on the document node.
  • // I map the outside event to the document-level event.
  • // --
  • // NOTE: This map is also used to determine event support. Only
  • // events that are in this map will be flagged as supported.
  • var documentEventMap = {
  • "clickOutside": "click",
  • "mousedownOutside": "mousedown",
  • "mouseupOutside": "mouseup",
  • "mousemoveOutside": "mousemove"
  • };
  •  
  • // Expose the public methods.
  • // --
  • // CAUTION: Generally, I would return a new object with the exposed
  • // API. However, in this case, I am simply exposing the public methods
  • // so as to remove ambiguity when referencing the "zone", which would
  • // not be present on the method call this-binding otherwise (since it
  • // injected by the Angular 2 framework via "this.manager").
  • vm.addEventListener = addEventListener;
  • vm.addGlobalEventListener = addGlobalEventListener;
  • vm.supports = supports;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I add the given event handler to the given element and return the
  • // event de-registration method.
  • function addEventListener( element, eventName, handler ) {
  •  
  • // NOTE: The "manager" is injected by the Angular framework (via
  • // the Event Manager that aggregates the event plugins).
  • var zone = vm.manager.getZone();
  •  
  • // Each "outside" event is captured by an "inside" event at the
  • // document level. Translate the element-local event type to the
  • // document-local event type.
  • var documentEvent = documentEventMap[ eventName ];
  •  
  • // Zone.js patches event-target code. As such, when we attach the
  • // the document-level event handler, we want to do so outside of
  • // the change-detection zone so that our checkEventTarget()
  • // doesn't trigger more change-detection than it has to. Once we
  • // know that we have to parle the document-level event into an
  • // element-local event, we'll re-enter the Angular zone.
  • zone.runOutsideAngular( addDocumentEventListener );
  •  
  • return( removeDocumentEventListener );
  •  
  •  
  • // I attach the document-local event listener which will determine
  • // the origin of the bubbled-up events.
  • function addDocumentEventListener() {
  •  
  • document.addEventListener( documentEvent, checkEventTarget, true );
  •  
  • }
  •  
  • // I detach the document-local event listener, tearing down the
  • // "outside" event binding.
  • function removeDocumentEventListener() {
  •  
  • document.removeEventListener( documentEvent, checkEventTarget, true );
  •  
  • }
  •  
  • // I check to see if the given event originated from within the
  • // host element. If it did, the event is ignored. If it did NOT,
  • // then the "outside" event binding is invoked with the given event.
  • function checkEventTarget( event ) {
  •  
  • var current = event.target;
  •  
  • do {
  •  
  • if ( current === element ) {
  •  
  • return;
  •  
  • }
  •  
  • } while ( current.parentNode && ( current = current.parentNode ) );
  •  
  • // If we made it this far, we didn't bubble past the host
  • // element. As such, we know that the event was initiated
  • // from outside the host element. It is therefore an
  • // "outside" event and needs to be translated into a host-
  • // local event that integrates with change-detection.
  • triggerDOMEventInZone( event );
  •  
  • }
  •  
  • // I invoke the host event handler with the given event.
  • function triggerDOMEventInZone( event ) {
  •  
  • // Now that we know that the document-local event has to be
  • // translated into an element-local host binding event, we
  • // need to re-enter the Angular 2 change-detection zone so
  • // that view-model changes made within the event handler will
  • // trigger a new round of change-detection.
  • zone.run(
  • function runInZone() {
  •  
  • handler( event );
  •  
  • }
  • );
  •  
  • };
  •  
  • } // END: addEventListener().
  •  
  •  
  • // I register the event on the global target and return the event
  • // de-registration method.
  • function addGlobalEventListener( target, eventName, handler ) {
  •  
  • // For the purposes of an "outside" event, it will never be
  • // possible to actually click / mouse outside of the document
  • // or the window object. As such, simply ignore these global
  • // context, providing a no-op binding.
  • if ( ( target === "document" ) || ( target === "window" ) ) {
  •  
  • return( noop );
  •  
  • }
  •  
  • // If the target was not "document" or "window", it must be body
  • // (the only other "global" host binding). While not very likely,
  • // it is possible to click outside of the body tag (by clicking
  • // on the HTML tag). As such, let's add the event listener to the
  • // body tag directly.
  • return( addEventListener( document.body, eventName, handler ) );
  •  
  • }
  •  
  •  
  • // I check to see if the given event is supported by the plugin.
  • function supports( eventName ) {
  •  
  • // If the event can be mapped to a native event on the document,
  • // then we can support the event.
  • return( documentEventMap.hasOwnProperty( eventName ) );
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I perform a no-operation instruction.
  • function noop() {
  •  
  • // Nothing to see here, folks.
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide the root App component.
  • define(
  • "AppComponent",
  • function registerAppComponent() {
  •  
  • var Widget = require( "Widget" );
  •  
  • // Configure the App component definition.
  • ng.core
  • .Component({
  • selector: "my-app",
  • directives: [ Widget ],
  •  
  • // Here, we are going to toggle the Widget into and out of
  • // existence in order to ensure that the custom events can be
  • // bound, unbound, and re-bound properly.
  • template:
  • `
  • <p>
  • <a (click)="toggleWidget()">Toggle widget</a>.
  • </p>
  •  
  • <widget *ngIf="isShowingWidget">
  • Click, or click not, there is no mouse.
  • </widget>
  • `
  • })
  • .Class({
  • constructor: AppController
  • })
  • ;
  •  
  • return( AppController );
  •  
  •  
  • // I control the App component.
  • function AppController() {
  •  
  • var vm = this;
  •  
  • // I determine if the Widget is being linked in the DOM. The Widget
  • // is the element that is consuming the custom events.
  • vm.isShowingWidget = false;
  •  
  • // Expose the public methods.
  • vm.toggleWidget = toggleWidget;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I toggle the existence of the Widget component.
  • function toggleWidget( tagName ) {
  •  
  • vm.isShowingWidget = ! vm.isShowingWidget;
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide a Widget component that binds to custom DOM host events.
  • define(
  • "Widget",
  • function registerWidget() {
  •  
  • // Configure the Widget component definition.
  • ng.core
  • .Component({
  • selector: "widget",
  •  
  • // Notice that we are using a native event - click - alongside
  • // the custom DOM host event - clickOutside. We can bind the
  • // clickOutside event at both the local and the global levels.
  • host: {
  • "(click)": "handleClick( $event.target.tagName )",
  •  
  • // Provided by custom event plugin.
  • "(clickOutside)": "handleClickOutside( $event.target.tagName )",
  • "(body: clickOutside)": "handleClickOutsideBody()",
  • },
  • template:
  • `
  • <ng-content></ng-content>
  • `
  • })
  • .Class({
  • constructor: WidgetController
  • })
  • ;
  •  
  • return( WidgetController );
  •  
  •  
  • // I control the Widget component.
  • function WidgetController() {
  •  
  • var vm = this;
  •  
  • // Expose the public methods.
  • vm.handleClick = handleClick;
  • vm.handleClickOutside = handleClickOutside;
  • vm.handleClickOutsideBody = handleClickOutsideBody;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I handle the click internally to the bound target.
  • function handleClick( tagName ) {
  •  
  • console.log( "(click) -> Ouch!", tagName );
  •  
  • }
  •  
  •  
  • // I handle the click externally to the bound target.
  • function handleClickOutside( tagName ) {
  •  
  • console.log( "(clickOutside) -> Click outside!", tagName );
  •  
  • }
  •  
  •  
  • // I handle the global click externally to the BODY tag. This is here
  • // to test the global-host bindings.
  • function handleClickOutsideBody() {
  •  
  • console.log( "(body: clickOutside) -> You clicked outside the BODY tag!" );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, our host bindings do nothing more than log the target element's tagName to the console. And, when we run the above code and click both inside and outside of the Widget component, we get the following page output:


 
 
 

 
 Creating custom DOM events in Angular 2 allows us to bind to non-native DOM events like clickOutside and mousedownOutside. 
 
 
 

As you can see, our custom Angular 2 event plugin provided the Widget component with a means to hook into the "outside" events at the host level. But of course, this can also be used at any element level as well. Essentially, our event plugin makes the "clickOutside" event just as easy to bind to as the native "click" event.

When the Angular 2 Event Manager loads all of the event plugins, it does so in the reverse order in which they were defined. This gives our custom event plugins higher precedence than the core plugins, which in turn, allows us to intercept events before the core plugins do. This is why we can create custom DOM events even though Angular 2 ships with a DOM plugin provider. Very cool stuff!




Reader Comments

Great post. Would love to see a post on how zone.js integrates with angular. The web seems to be sparse on content on that subject.

Reply to this Comment

Ben, as usual great read!

I ran into a scenario where an event triggers the event registration which run's before the 1st event bubbled up to the document. This create's a false positive handling where the new registered event fire's on the same vm turn...

For example, a button that when clicked run's a handler that dynamically create a component, which has DOMOutsideEventPlugin on one of the element in it's template.

I can't force users to stop propagation on every event...
To workaround this I ran the document event registration on the next VM turn, something like this:
return zone.runOutsideAngular(() => {
var fn;
setTimeout(() => fn = addDocumentEventListener(), 0);
return () => fn();
});

This works, but ... do you have another idea?

Reply to this Comment

@Nikk,

I agree. I only vaguely understand what Zone.js is doing. Definitely worth some more exploration.

Reply to this Comment

@Shlomi,

Hmm, that's a really interesting problem. Let me say it back, just so we're on the same page:

* ComponentA listens for (click) eventA and creates ComponentB.
* ComponentB is listens for (clickOutside).
* ComponentB accidentally thinks eventB is relevant to it once it finally bubbles up to the root.

That is, indeed a tricky situation. I like your solution - I think deferring the binding of the document event listener is probably the cleanest since it fixes it in a single place. Plus, since this event binding necessarily relies on event "delegation" to the document, I don't think its an unfair expectation that the binding is not active in the current loop.

Awesome stuff!

Reply to this Comment

Hey, Ben, this is a great article.

I'm new to Angular2 and I'm wondering how one would implement such a plugin without putting the plugin in a script tag.

Obviously, this is not part of their document. Actually I didn't know you can even do that but this is great.

Reply to this Comment

Since there is no way to edit comments, I just sort of figure out how to do this in a typescript manner :D

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
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.