Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Nathan Deneau
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Nathan Deneau

Tracking Click Events Outside The Current Component In Angular 2 Beta 1

By Ben Nadel on

With Angular 2, there is a big push to not have to know anything about the browser's DOM (Document Object Model). Angular 2 even got rid of the "link function," which used to be the "glue" that explicitly bound the DOM to the AngularJS 1.x components. While this might make the rendering more flexible, in a cross-platform sense, I find that the lack of direction in the documentation leaves me with a lot of confusion as to when I can and cannot reference the native element safely. This makes seemingly trivial tasks - like tracking click events outside of the current component - feel a bit awkward.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

In Angular 2, you can inject the current platform Renderer and the ElementRef into the host component. And, you can use the ElementRef to access the underlying "nativeElement". Now, for the browser platform, that nativeElement is a DOM (Document Object Model) node; but, can I depend on that being true? What if the app is running as some sort of Native Script implementation? What is the nativeElement at that point? What API does it expose?

These are questions to which I have no answers (yet).

Now, you might assume that I know which platform my app is going to be run on. And that might be true. But, what if I'm building a 3rd-party consumable Angular 2 component? Some widget that anyone can use. In that case, I have no idea how my component will be used or on which platforms. As such, is there now a higher degree of de-coupling that I must assume as a "component author" in a framework that is no longer tightly-coupled to any particular platform?

I have no idea (yet).

That said, on the browser platform, we do know that we can access the nativeElement using the ElementRef. So, let's take a look at how we can track click events outside of the current element using a component's underlying DOM node. In the following code, we're using the host bindings to track mouse events at the Document level. We're then inspecting the target of those events to see if they originated inside the host component:

  • <!doctype html>
  • <html>
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Tracking Click Events Outside The Current Component In Angular 2 Beta 1
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body>
  •  
  • <h1>
  • Tracking Click Events Outside The Current Component In Angular 2 Beta 1
  • </h1>
  •  
  • <h2>
  • Using nativeElement
  • </h2>
  •  
  • <my-app>
  • Loading...
  • </my-app>
  •  
  • <!-- Load demo scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/es6-shim.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/Rx.umd.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/angular2-polyfills.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/angular2-all.umd.js"></script>
  • <!-- AlmondJS - minimal implementation of RequireJS. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/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" ],
  • function run( AppComponent ) {
  •  
  • ng.platform.browser.bootstrap( AppComponent );
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide the root application component.
  • define(
  • "AppComponent",
  • function registerAppComponent() {
  •  
  • var ClickTarget = require( "ClickTarget" );
  •  
  • // Configure the App component definition.
  • var AppComponent = ng.core
  • .Component({
  • selector: "my-app",
  • directives: [ ClickTarget ],
  • template:
  • `
  • <click-target
  • (click)="handleClick()"
  • (clickOutside)="handleClickOutside()"
  • (mousedownOutside)="handleMousedownOutside()"
  • (mouseupOutside)="handleMouseupOutside()">
  •  
  • Quit staring and mouse me!
  •  
  • </click-target>
  • `
  • })
  • .Class({
  • constructor: AppController
  • })
  • ;
  •  
  • return( AppComponent );
  •  
  •  
  • // I control the App component.
  • function AppController() {
  •  
  • var vm = this;
  •  
  • // Expose the public methods.
  • vm.handleClick = handleClick;
  • vm.handleClickOutside = handleClickOutside;
  • vm.handleMousedownOutside = handleMousedownOutside;
  • vm.handleMouseupOutside = handleMouseupOutside;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I log the click events, internal to the target.
  • function handleClick() {
  •  
  • console.log( "Clicked component!" );
  •  
  • };
  •  
  •  
  • // I log the click events, external to the target.
  • function handleClickOutside() {
  •  
  • console.warn( "Clicked - outside - component!" );
  •  
  • };
  •  
  •  
  • // I log the mousedown events, external to the target.
  • function handleMousedownOutside() {
  •  
  • console.warn( "Moused-Down - outside - component!" );
  •  
  • };
  •  
  •  
  • // I log the mouseup events, external to the target.
  • function handleMouseupOutside() {
  •  
  • console.warn( "Moused-Up - outside - component!" );
  •  
  • };
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide a component that tracks mouse events (mousedown, mouseup, click)
  • // outside of itself and emits them as they occur.
  • define(
  • "ClickTarget",
  • function registerClickTarget() {
  •  
  • // Configure the ClickTarget component definition.
  • var ClickTargetComponent = ng.core
  • .Directive({
  • selector: "click-target",
  • outputs: [
  • "clickOutside",
  • "mousedownOutside",
  • "mouseupOutside"
  • ],
  • host: {
  • // Track mouse events at the global level.
  • "(document: click)": "handleEvent( $event )",
  • "(document: mousedown)": "handleEvent( $event )",
  • "(document: mouseup)": "handleEvent( $event )"
  • }
  • })
  • .Class({
  • constructor: ClickTargetController
  • })
  • ;
  •  
  • ClickTargetComponent.parameters = [
  • new ng.core.Inject( ng.core.ElementRef )
  • ];
  •  
  • return( ClickTargetComponent );
  •  
  •  
  • // I control the ClickTarget component.
  • function ClickTargetController( elementRef ) {
  •  
  • var vm = this;
  •  
  • // Setup the output event streams.
  • // --
  • // NOTE: We are using convention to define the output streams as
  • // ( eventType + "Outside" ). This will make the events easier to
  • // trigger within the handleEvent() method.
  • vm.clickOutside = new ng.core.EventEmitter();
  • vm.mousedownOutside = new ng.core.EventEmitter();
  • vm.mouseupOutside = new ng.core.EventEmitter();
  •  
  • // Expose the public methods.
  • vm.handleEvent = handleEvent;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I handle the given global event to see if it should be translated
  • // into an output event (if it occurred outside the bounds of the
  • // host component).
  • function handleEvent( globalEvent ) {
  •  
  • // We are only concerned with mouse events that were triggered
  • // outside of the current host component.
  • if ( eventTriggeredInsideHost( globalEvent ) ) {
  •  
  • return;
  •  
  • }
  •  
  • // Now that we know the event was initiated outside of the host,
  • // we can emit the output event. By convention above, we know
  • // that we can simply use the event type to reference the
  • // correct output event stream.
  • vm[ globalEvent.type + "Outside" ].emit( globalEvent );
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I determine if the given event was triggered somewhere within the
  • // local host component DOM (Document Object Model) tree.
  • function eventTriggeredInsideHost( event ) {
  •  
  • var current = event.target;
  •  
  • // Reach under the hood to get the actual DOM element that is
  • // being used to render the component.
  • var host = elementRef.nativeElement;
  •  
  • // Here, we are going to walk up the DOM tree, checking to see
  • // if we hit the "host" node. If we hit the host node at any
  • // point, we know that the target must reside within the local
  • // tree of the host.
  • do {
  •  
  • // If we hit the host node, we know that the target resides
  • // within the host component.
  • if ( current === host ) {
  •  
  • return( true );
  •  
  • }
  •  
  • current = current.parentNode;
  •  
  • } while ( current );
  •  
  • // If we made it this far, we never encountered the host
  • // component as we walked up the DOM tree. As such, we know that
  • // the target resided outside of the host component.
  • return( false );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, we're binding to the relevant mouse events at the document level using the global host bindings:

  • (document: click)
  • (document: mousedown)
  • (document: mouseup)

We then walk up the DOM tree to see if the target element resides inside the host component's local DOM tree. If it doesn't, we know the event was triggered outside of the current component and therefore we turn around and emit the appropriate "outside" event.

On a personal note, when I see DOM-traversal like this happening, it really makes me lament the growing anti-jQuery sentiment among the "vocal minority" (and Angular's complete dropping of jqLite). Such traversal could have been done with one line of code using jQuery's .closest() API method. Of course, without jQuery's battle-tested and extensively hardened API, I have to re-implement such logic without the benefit of historical knowledge around edge-cases and cross-browser "gotchas."

That said, when we run this page and click in and out of the ClickTarget component, we get the following output:


 
 
 

 
 Tracking click events outside the current component in Angular 2. 
 
 
 

As you can see, we successfully differentiated between mouse events that were triggered both inside and outside of the component, logging each one to the console.

The nice thing about using the global host bindings is that we don't have to worry about binding and then unbinding event handlers during the component life-cycle, which is great. And, if we stop and think about the way events bubble up in the DOM, we can actually use host bindings to drive our inside/outside logic. In the following refactoring, we're still going to track click events that happen outside of our host component; but, we're going to do so without the dependence on the nativeElement.

When a DOM event is broadcast from a particular DOM node, the event travels up the DOM tree, starting at the target element and traveling up through each parent node until it reaches the document root. Therefore, if an event is triggered within our component, we know that it will eventually pass through our host element before it reaches the document root. As such, in a given bubbling phase, if the Event object presents the same reference at the host level that it does at the global level, we know that the event originated from within the host component.

Using our understanding of DOM event bubbling, we can replace the nativeElement reference with local host bindings. We can then compare the Event object that is present at each binding to determine if the event originated from within or without the host component:

  • <!doctype html>
  • <html>
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Tracking Click Events Outside The Current Component In Angular 2 Beta 1
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body>
  •  
  • <h1>
  • Tracking Click Events Outside The Current Component In Angular 2 Beta 1
  • </h1>
  •  
  • <h2>
  • Without Using nativeElement
  • </h2>
  •  
  • <my-app>
  • Loading...
  • </my-app>
  •  
  • <!-- Load demo scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/es6-shim.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/Rx.umd.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/angular2-polyfills.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/angular2-all.umd.js"></script>
  • <!-- AlmondJS - minimal implementation of RequireJS. -->
  • <script type="text/javascript" src="../../vendor/angularjs-2-beta/1/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" ],
  • function run( AppComponent ) {
  •  
  • ng.platform.browser.bootstrap( AppComponent );
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide the root application component.
  • define(
  • "AppComponent",
  • function registerAppComponent() {
  •  
  • var ClickTarget = require( "ClickTarget" );
  •  
  • // Configure the App component definition.
  • var AppComponent = ng.core
  • .Component({
  • selector: "my-app",
  • directives: [ ClickTarget ],
  • template:
  • `
  • <click-target
  • (click)="handleClick()"
  • (clickOutside)="handleClickOutside()"
  • (mousedownOutside)="handleMousedownOutside()"
  • (mouseupOutside)="handleMouseupOutside()">
  •  
  • Quit staring and mouse me!
  •  
  • </click-target>
  • `
  • })
  • .Class({
  • constructor: AppController
  • })
  • ;
  •  
  • return( AppComponent );
  •  
  •  
  • // I control the App component.
  • function AppController() {
  •  
  • var vm = this;
  •  
  • // Expose the public methods.
  • vm.handleClick = handleClick;
  • vm.handleClickOutside = handleClickOutside;
  • vm.handleMousedownOutside = handleMousedownOutside;
  • vm.handleMouseupOutside = handleMouseupOutside;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I log the click events, internal to the target.
  • function handleClick() {
  •  
  • console.log( "Clicked component!" );
  •  
  • };
  •  
  •  
  • // I log the click events, external to the target.
  • function handleClickOutside() {
  •  
  • console.warn( "Clicked - outside - component!" );
  •  
  • };
  •  
  •  
  • // I log the mousedown events, external to the target.
  • function handleMousedownOutside() {
  •  
  • console.warn( "Moused-Down - outside - component!" );
  •  
  • };
  •  
  •  
  • // I log the mouseup events, external to the target.
  • function handleMouseupOutside() {
  •  
  • console.warn( "Moused-Up - outside - component!" );
  •  
  • };
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // --------------------------------------------------------------------------- //
  • // --------------------------------------------------------------------------- //
  •  
  •  
  • // I provide a component that tracks mouse events (mousedown, mouseup, click)
  • // outside of itself and emits them as they occur.
  • define(
  • "ClickTarget",
  • function registerClickTarget() {
  •  
  • // Configure the ClickTarget component definition.
  • // --
  • // NOTE: We are tracking the relevant DOM events at both the host and
  • // the global level. This will allow us to compare event objects as
  • // they bubble-up through the DOM tree.
  • var ClickTargetComponent = ng.core
  • .Directive({
  • selector: "click-target",
  • outputs: [
  • "clickOutside",
  • "mousedownOutside",
  • "mouseupOutside"
  • ],
  • host: {
  • // Track mouse events at the global level.
  • "(document: click)": "compareEvent( $event )",
  • "(document: mousedown)": "compareEvent( $event )",
  • "(document: mouseup)": "compareEvent( $event )",
  •  
  • // Track mouse events at the host (component) level.
  • "(click)": "trackEvent( $event )",
  • "(mousedown)": "trackEvent( $event )",
  • "(mouseup)": "trackEvent( $event )"
  • }
  • })
  • .Class({
  • constructor: ClickTargetController
  • })
  • ;
  •  
  • return( ClickTargetComponent );
  •  
  •  
  • // I control the ClickTarget component.
  • function ClickTargetController() {
  •  
  • var vm = this;
  •  
  • // I keep a reference to the most recent host event (ie, the bound-
  • // event triggered on the current host component).
  • var hostEvent = null;
  •  
  • // Setup the output event streams.
  • // --
  • // NOTE: We are using convention to define the output streams as
  • // ( eventType + "Outside" ). This will make the events easier to
  • // trigger within the compareEvent() method.
  • vm.clickOutside = new ng.core.EventEmitter();
  • vm.mousedownOutside = new ng.core.EventEmitter();
  • vm.mouseupOutside = new ng.core.EventEmitter();
  •  
  • // Expose the public methods.
  • vm.compareEvent = compareEvent;
  • vm.trackEvent = trackEvent;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // When the event has bubbled its way up to the global event handler,
  • // I check to see if the given event is the same event that was
  • // tracked by the host.
  • function compareEvent( globalEvent ) {
  •  
  • // If the last known host event and the given global event are
  • // the same reference, we know that the event originated within
  • // the host (and then bubbled up out of the host and eventually
  • // hit the global binding). As such, it can't be an "outside"
  • // event and therefore we should ignore it.
  • if ( hostEvent === globalEvent ) {
  •  
  • return;
  •  
  • }
  •  
  • // Now that we know the event was initiated outside of the host,
  • // we can emit the output event. By convention above, we know
  • // that we can simply use the event type to reference the
  • // correct output event stream.
  • vm[ globalEvent.type + "Outside" ].emit( globalEvent );
  •  
  • }
  •  
  •  
  • // I start tracking the new host event triggered by one of the core
  • // DOM event bindings.
  • function trackEvent( newHostEvent ) {
  •  
  • hostEvent = newHostEvent;
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, we no longer need the nativeElement at all. We simply need to track the platform-specific Event object in order to determine from whence the event originated.


 
 
 

 
 There is no DOM, only Zuul. 
 
 
 

Now, can I safely depend on the same event-bubbling algorithm in a cross-platform way? Again, I have no idea. And the seemingly pluggable nature of platforms might make that a non-deterministic question?

When we knew that AngularJS was executing as part of a client-side browser-based application, we could make all kinds of assumptions about how it could be used because, there was only one way to use it - in the browser. Now that Angular 2 has decoupled itself from the concept of a browser DOM, we can no longer make those assumptions. And, in certain cases, like tracking click events outside of the current component, we have to come up with alternate, unassuming approaches to age-old problems. Like compare Event object references rather than walking the DOM tree.




Reader Comments

Maybe Angular's de-coupling for the browser is aimed at someday creating a cleaner version what React did with React-Native. Also, think about swapping DOM for WebGL one day... I mean that's not insignificant if you think about the kind of apps you can create in the browser with a framework like Angular directly powering WebGL. There's also, of course, the whole isomorphic thing.

As an aside to your lamentations of jQuery's decline; If performance is anything to care about, jQuery's closest is probably about 96% slower than what you came up with. https://jsperf.com/closest-parent-with-class/16

Reply to this Comment

@Jonathan,

I know that part of the driving force behind the DOM abstraction is that they can render on the server-side; but, I haven't looked into that yet. It's also a concept I can't fully wrap my head around yet. Since I've really only built apps at the extremes - single page vs. request-response - it's hard for me to understand how one would think about building an app that renders on the server-side AND is response on the client. I feel like somewhere, something's gotta give - there's gotta be some caveat that I'm just not seeing :D

I think jQuery will always be slower. But, I think part of that is the fact that it handles so many edge-cases. I remember watching a John-David Dalton talk and he mentioned why lodash can often be faster than native implementations - because it doesn't necessarily have to implement the "spec" fully.

I think the real irony is when people start to build a lot of custom DOM methods and then they look up and realize they have duplication... so they move them into a "DomUtils" library or something ... always just feels like we keep trying to get away from jQuery and then end up re-creating it in our own way.

Or maybe I'm just too stuck in my ways :)

Reply to this Comment

@Ben,

I guess I'm just thinking about some of the last few projects I've done without jQuery and I just never needed it. React and/or Angular take care of 99% of what we need to do. There's actually one piece of code in the custom drag/drop implementation that still uses $.closest, and after running that benchmark, I really want to remove it in favor of the quick little parent/loop trick.

Even lodash is being less depended on, although still a great utility. Every _.ma, _.assign, and _.forEach has been replaced by native version. Even Array.find is now native. By simply telling babel to polyfill, we achieve browser compatibility, i.e. edge cases.

That's the cool thing about having a transpiler like Babel, it can fill in the gaps that jQuery used to do so that you don't need a dom-utils... you just have reliable native functions. You code how you always thought you should be able to and let your toolchain worry about the rest.

Reply to this Comment

Always love seeing these posts. One thing though - you should really start looking and typescript, because it removes a lot of boilerplate and duplication for angular 2 apps.

Reply to this Comment

@Jonathan,

That is a good point about the build tools - it is nice to be able to polyfill towards a standard. And, I will definitely agree that I am trying to use native functions and more when they make sense. It's actually surprising how many workflows can be boiled down to Filter and Map operations :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.