Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ken Auenson
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Ken Auenson ( @KenAuenson )

Using Data Attributes To Pass Configuration Into An Event Plug-in In Angular 5.1.1

By on

One of my favorite features of Angular is the ability to provide custom event bindings using the EVENT_MANAGER_PLUGINS collection. This feature allows you to create a clean abstraction around how event-listeners are attached to the DOM (Document Object Model); and, how those event-listeners interact with Angular's change-detection algorithm. In most cases, the plug-in is powered by the event-binding in an Angular template. The other day, however, I ran into a situation in which I needed to provide additional configuration to the underlying event-listeners. Since these event plug-ins don't have "inputs" like a traditional Directive, I had to use a different means of conveying the information. In the end, I used a Data attribute (data-*) to pass additional configuration into the event plug-in abstraction.

Run this demo in my JavaScript Demos project on GitHub.

The event-bindings in Angular are very flexible. And, in fact, we can use this flexibility to add configuration data directly into the event-binding syntax. For example, I used this flexibility to add key-code configuration to the keydown events before I realized that this keydown functionality was actually native to the Angular framework:

<input (keydown**.Meta.Enter**)="submit()" />

Here, the ".Meta.Enter" portion of the event-binding is "configuration" for the underlying "keydown" event-listener. This is super powerful; but, there are limitations in the syntax. For example, the other day, I needed to create a "mousedown outside" event-plugin that would ignore certain DOM element targets. At first, I tried to bake that configuration information into the event-binding:

(mousedownOutside**:not( p.skip-me )**)="handleMousedown()"

But, this doesn't compile. Either because of the nested parenthesis; or, perhaps because ":" usually indicates some sort of document name-spacing. I tried to come up with a different syntax; but, nothing seemed to look or feel right. That's when it occurred to me that I might be able to use data-attributes as a means to pass this information into the event plug-in:

(mousedownOutside)="handleMousedown()" data-ignoreMousedownOutside="p.skip-me"

Since the event plug-in receives the current Element as part of its invocation, it implicitly provides the DOM as a source of information. This means that data-* attributes in the Angular template implicitly get exposed as dataset properties (or getAttribute() properties in older browsers) in the plug-in event-handler.

For my mousedownOutside event plug-in, I'm using the "data-ignoreMousedownOutside" attribute as the means to convey which elements - if any - should be ignored when evaluating the underlying "mousedown" event:

// Import the core angular services.
import { EventManager } from "@angular/platform-browser";
import { NgZone } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

export class MousedownOutsidePlugin {

	// CAUTION: This property is automatically injected by the EventManager. It will be
	// available by the time the addEventListener() method is called.
	public manager: EventManager;

	// ---
	// PUBLIC METHODS.
	// ---

	// I bind the "mousedownOutside" 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 be triggered automatically.
		// When there is a one-to-one mapping of events to event handler calls, this
		// makes sense. However, in this case, for the "mousedownOutside" event, not all
		// "mousedown" events will lead to an event handler invocation. As such, we want
		// to bind the base "mousedown" hander OUTSIDE OF THE ANGULAR ZONE, inspect the
		// event, then RE-ENTER THE ANGULAR ZONE in the case when we're about to invoke
		// the event handler. This way, the "mousedown" events WILL NOT trigger change-
		// detection; but, the subsequent "mousedownOutside" events (if any) WILL TRIGGER
		// change-detection.
		var zone: NgZone = this.manager.getZone();

		zone.runOutsideAngular( addMousedownHandler );

		return( removeMousedownHandler );

		// ---
		// LOCALLY-SCOPED FUNCTIONS.
		// ---

		// I handle the base "mousedown" event OUTSIDE the Angular Zone.
		function addMousedownHandler() {

			document.addEventListener( "mousedown", mousedownHandler, false );

		}

		// I remove the base "mousedown" event.
		function removeMousedownHandler() {

			document.removeEventListener( "mousedown", mousedownHandler, false );

		}

		// I handle the base "mousedown" event.
		function mousedownHandler( event: Event ) : void {

			var ignoreTargets: any[] | null = null;

			// By default, the mousedownOutside handler will respond to any mousedown
			// events outside the current element. However, some of those events can be
			// ignored if the "data-ignoreMousedownOutside" attribute is provided (as
			// a list of CSS selectors).
			// --
			// CAUTION: .dataset is not supported in IE10 (but getAttribute() would be
			// if you needed to support older browsers).
			if ( element.dataset.ignoremousedownoutside ) {

				ignoreTargets = Array.from( document.querySelectorAll( element.dataset.ignoremousedownoutside ) );

			}

			var target = <Node>event.target;

			// Since we're looking for events that originate outside of the current
			// element (or any of the "ignore" elements), we have to walk up the DOM
			// (Document Object Model) tree in order to ensure that the event target is
			// not a descendant of the white-listed elements.
			while ( target ) {

				// If we've reached the element reference, this is an internal event and
				// we can safely ignore it.
				if ( target === element ) {

					return;

				}

				// If we've reached one of the ignorable elements, this is an internal
				// event and we can safely ignore it.
				if ( ignoreTargets && ( ignoreTargets.indexOf( target ) !== -1 ) ) {

					console.warn( "Ignoring mousedown in target:", target );
					return;

				}

				target = target.parentNode;

			}

			// If we've made it this far, it means that the mousedown event was outside
			// of the current element AND outside of any elements that we need to ignore.
			// At this point, we need to invoke the event-handler; as such, we're going
			// to RE-ENTER THE ANGULAR ZONE so that the change-detection algorithm will
			// be triggered after our handler is invoked.
			zone.runGuarded(
				function runInZoneSoChangeDetectionWillBeTriggered() {

					handler( event );

				}
			);

		}

	}


	// I bind the "mousedownOutside" 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 === "mousedownOutside" );

	}

}

As you can see, the "data-ignoreMousedownOutside" attribute is treated as a CSS selector for .querySelectorAll(). Then, for each mousedown event, I look to see if the target node exists within one of the elements matched by the "data-ignoreMousedownOutside" selector. And, if it does, I ignore the event.

To see this in action, let's add it to the AppComponent:

// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p
			(mousedownOutside)="handleMousedown()"
			data-ignoreMousedownOutside="h1, p.omit"
			class="origin">

			I listen for "mousedown outside" events &mdash; but ignore the
			<code>h1</code> and the <code>p.omit</code> tags.
		</p>

		<p class="omit">
			Outside origin, but will <em>not</em> trigger event.
		</p>
	`
})
export class AppComponent {

	public handleMousedown() : void {

		console.log( "(mousedownOutside) of origin." );

	}

}

In this case, I'm listening for "mousedown" events that happen outside of that first paragraph tag. But, as you can see from the "data-ignoreMousedownOutside" attribute, I want to ignore the event if it originates from within the h1 tag or the p.omit tag.

Now, if we run this Angular application and click around, we get the following output:

Creating a mousedown outside event plugin in Angular 5.

As you can see, some of the "mousedown" events get translated into "mousedownOutside" events within the AppComponent. And, some of the "mousedown" events get ignored because they originate from within elements that are matched by the "data-ignoreMousedownOutside" CSS selector. We've successfully used data-* attributes to configure the event-listener within our Angular events plug-in.

And, of course, we have to let Angular know about this plugin by adding it to the EVENT_MANAGER_PLUGINS collection when boostrapping the app 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 { MousedownOutsidePlugin } from "./mousedown-outside.plugin";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@NgModule({
	bootstrap: [
		AppComponent
	],
	imports: [
		BrowserModule
	],
	declarations: [
		AppComponent
	],
	providers: [
		{
			provide: EVENT_MANAGER_PLUGINS,
			useClass: MousedownOutsidePlugin,
			multi: true
		}
	]
})
export class AppModule {
	// ...
}

A Note On Attribute Directives

It can feel like a fine line when it comes to deciding if your functionality should be encapsulated in an Attribute Directive or in an Event Plug-in. With an attribute directive, this behavior may have been exposed like this:

(mousedownOutside)="handleMousedown()" ignoreMousedownOutside="p.skip-me"

... where the "mousedownOutside" attribute acts as both the directive selector and as the output binding for the event. Compare this to our plug-in syntax:

(mousedownOutside)="handleMousedown()" data-ignoreMousedownOutside="p.skip-me"

These two approaches accomplish the same thing. But, they each have their own limitations. For example, there's no obvious way to attach an attribute directive in a "host" binding. And, with an attribute directive, the outputs have to be known ahead of time. Compare this with an event plug-in, which can be defined easily in a "host" binding; and, which can leverage syntax that is parsed and consumed at runtime-time, not compile-time.

When it comes to behavior that is based solely on native interactions (like "click" and "mouseenter"), I tend to lean more on Angular's event plug-in system. And, for behavior that is more complex, involves state, or needs to expose multiple inputs or outputs, I tend to lean more on Angular's attribute directive capabilities. There's no right or wrong answer.

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel