Skip to main content
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Zach Stepek
Ben Nadel at BFusion / BFLEX 2009 (Bloomington, Indiana) with: Zach Stepek ( @zstepek )

Creating A Vue.js Inspired Event-Modifier DOM Plug-In In Angular 7.1.4

By on

Over the holiday break, I took my first look at Vue.js by reading Vue.js Up and Running by Callum Macrae. One Vue.js feature that caught my eye was the ability to add modifiers to event-bindings that influenced the way in which the underlying event was managed. For example, you could add ".prevent" to the end of a click-binding in order to get Vue.js to call .preventDefault() on the triggered event object. That's cool. And, since Angular's DOM-event system is built on top of a plug-in architecture, this should be something that we can easily build into Angular as well.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Hooking into the DOM (Document Object Model) event system is something that I've looked at a number of times before. In fact, one of the Vue.js event modifiers - ".self" - is even a feature that I've built before using a "directclick" plug-in. As such, there's nothing terribly new about this post. Mostly, it's just a fun exploration and an opportunity to cross-pollinate ideas between the two front-end frameworks.

That said, here are the Vue.js event-modifiers that I'm interesting in:

  • .stop - calls .stopPropagation().
  • .prevent - calls .preventDefault().
  • .capture - binds to the capturing phase.
  • .self - requires event-target to be the host element.
  • .once - unbinds itself after first invocation.
  • .passive - binds a passive event-handler (not supported in all browsers).

To be clear, this is not the entirety of the Vue.js event-modifiers; but, these are the ones that pertain to non-key-based DOM events. And, these are the ones that I'm accounting for in my Event Manager Plugin.

In Vue.js, the order in which these modifiers are applied is meaningful. However, in my implementation, the order is irrelevant. Which means that the following event-bindings are equivalent:

  • (click**.prevent.self**)="handleClick()"
  • (click**.self.prevent**)="handleClick()"

In all cases, the event-modifier simply enables a Boolean flag in an underlying configuration object which is then used to drive the event-binding logic.

With that said, let's look at my VueEventModifiersPlugin class. This plug-in will support any event that is defined as a normal event-name followed by one-or-more of the aforementioned modifiers:

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

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

type EventTarget = Window | Document | HTMLElement;

interface EventConfig {
	name: string;
	isStop: boolean;
	isPrevent: boolean;
	isCapture: boolean;
	isSelf: boolean;
	isOnce: boolean;
	isPassive: boolean;
}

// I provide support for DOM event-modifiers that are inspired by Vue.js. These allow
// for events to be qualified with the following suffixes:
// --
// - .stop
// - .prevent
// - .capture
// - .self
// - .once
// - .passive
// --
export class VueEventModifiersPlugin {

	private supportsPassive: boolean;

	// I initialize the event plug-in.
	constructor() {

		this.supportsPassive = this.detectPassiveSupport();

	}

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

	// I bind the given event handler to the given element. Returns a function that
	// tears-down the event binding.
	public addEventListener(
		element: HTMLElement,
		higherOrderEventName: string,
		handler: Function
		) : Function {

		var eventConfig = this.parseHigherOrderEventName( higherOrderEventName );

		return( this.setupEventBinding( element, eventConfig, handler ) );

	}


	// I bind the given event handler to the given global element selector. Returns a
	// function that tears-down the event binding.
	public addGlobalEventListener(
		higherOrderElement: string,
		higherOrderEventName: string,
		handler: Function
		) : Function {

		var target = this.parseHigherOrderElement( higherOrderElement );
		var eventConfig = this.parseHigherOrderEventName( higherOrderEventName );

		return( this.setupEventBinding( target, eventConfig, handler ) );

	}


	// I determine if the given event name is supported by this plug-in. For each event
	// binding, the plug-ins are tested in the reverse order of the EVENT_MANAGER_PLUGINS
	// multi-collection. Angular will use the first plug-in that supports the event.
	public supports( eventName: string ) : boolean {

		var eventPattern = /^[a-z]+(?:\.(?:stop|prevent|capture|self|once|passive))+$/;

		return( eventPattern.test( eventName ) );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I determine if the current environment supports Passive event handlers.
	private detectPassiveSupport() : boolean {

		var support = false;

		// This approach is more-or-less taken from the Mozilla Developer Network:
		// --
		// READ MORE: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support
		try {

			var handler = function(){};

			var options: any = {
				get passive() {

					return( support = true );

				}
			};

			window.addEventListener( "test", handler, options );
			window.removeEventListener( "test", handler, options );

		} catch( error ) {

			// ...

		}

		return( support );

	}


	// I parse the "higher order" element selector into an actual browser DOM reference.
	private parseHigherOrderElement( selector: string ) : EventTarget {

		switch( selector ) {
			case "window":
				return( window );
			break;
			case "document":
				return( document );
			break;
			case "body":
				return( document.body );
			break;
			default:
				throw( new Error( `Element selector [${ selector }] not supported.` ) );
			break;
		}

	}


	// I parse the "higher order" event name into the event configuration that will be
	// used to bind the underlying event handler.
	private parseHigherOrderEventName( eventName: string ) : EventConfig {

		var parts = eventName.split( "." );

		var config = {
			name: <string>parts.shift(), // Telling TypeScript not to worry.
			isStop: false,
			isPrevent: false,
			isCapture: false,
			isSelf: false,
			isOnce: false,
			isPassive: false
		};

		// While this is different in Vue.js, we're not going to care about the order in
		// which the event modifiers are defined. Each modifier will just act as an
		// independent flag to be consumed when configuring the subsequent event-handler.
		while ( parts.length ) {

			switch ( parts.shift() ) {
				case "stop":
					config.isStop = true;
				break;
				case "prevent":
					config.isPrevent = true;
				break;
				case "capture":
					config.isCapture = true;
				break;
				case "self":
					config.isSelf = true;
				break;
				case "once":
					config.isOnce = true;
				break;
				case "passive":
					config.isPassive = true;
				break;
				default:
					throw( new Error( `Event config [${ eventName }] not supported.` ) );
				break;
			}

		}

		return( config );

	}


	// I bind the given event handler to the given event target using the given event
	// configuration. I can be used for both local and global targets. Returns a function
	// that tears-down the event binding.
	private setupEventBinding(
		target: EventTarget,
		eventConfig: EventConfig,
		handler: Function
		) : Function {

		var options: any = eventConfig.isCapture;
		// If the event requires a "passive" modifier, then we have to change the way
		// that we define the event-phase. Passive mode requires an EventListerOptions
		// object that is only supported in some browsers.
		if ( this.supportsPassive && eventConfig.isPassive ) {

			options = {
				passive: true,
				capture: eventConfig.isCapture
			};

		}

		// NOTE: We are remaining inside the Angular Zone (if it is loaded).
		addProxyFunction();

		return( removeProxyFunction );

		// -- Hoisted Functions -- //

		function addProxyFunction() {

			target.addEventListener( eventConfig.name, proxyFunction, options );

		}

		function removeProxyFunction() {

			target.removeEventListener( eventConfig.name, proxyFunction, options );

		}

		function proxyFunction( event: Event ) {

			// NOTE: If the target is not Self, the handler won't be called. But, a
			// change-digest will still be triggered. This is because we're not bothering
			// to bind the handler outside of the Angular Zone (since most cases will be
			// a one-to-one mapping of event-to-handler invocation).
			if ( eventConfig.isSelf && ( event.target !== target ) ) {

				return;

			}

			// If the handler is only intended to be invoked once, let's unbind before
			// we call the underlying handler.
			if ( eventConfig.isOnce ) {

				removeProxyFunction();

			}

			if ( eventConfig.isStop ) {

				event.stopPropagation();

			}

			if ( eventConfig.isPrevent ) {

				event.preventDefault();

			}

			handler( event );

		}

	}

}

As you can see, each one of the event-modifiers flips a flag on the EventConfig interface. This EventConfig object is then passed to the .setupEventBinding() method where it is used to drive the logic of the event-binding.

Now, we just have to add this plugin to the EVENT_MANAGER_PLUGINS multi-collection:

// 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 { VueEventModifiersPlugin } from "./vue-event-modifiers.plugin";

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

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

Once the Angular EventManager knows about our Vue.js inspired plug-in, we can start using it our component templates and directive host bindings. To see this in action, I've setup a number of click-handlers in my root component using various combinations of event-modifiers:

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

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

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<ul
			(click.capture)="logEvent( $event )"
			(click)="logEvent( $event )">

			<li>
				<a (click.once)="logClick( 'Testing .once' )">
					Testing <code>.once</code>
				</a>
			</li>
			<li>
				<a (click.self)="logClick( 'Testing .self' )">
					Testing <code>.self</code>
				</a>
			</li>
			<li>
				<a (click.once.self)="logClick( 'Testing .once.self' )">
					Testing <code>.once.self</code>
				</a>
			</li>
			<li>
				<a (click.stop)="logClick( 'Testing .stop' )">
					Testing <code>.stop</code>
				</a>
			</li>
			<li>
				<a href="https://google.com" (click.prevent)="logClick( 'Testing .prevent' )">
					Testing <code>.prevent</code>
				</a>
			</li>
			<li>
				<a
					href="https://google.com"
					target="_blank"
					(click.once.self.prevent)="logClick( 'Testing .once.self.prevent' )">
					Testing <code>.once.self.prevent</code>
				</a>
			</li>
			<li>
				<a (click.prevent.passive)="logClick( 'Testing .prevent.passive' )">
					Testing <code>.prevent.passive</code>
				</a>
			</li>
		</ul>
	`
})
export class AppComponent {

	// I log the click on the Anchor tag.
	public logClick( message: string ) : void {

		console.group( "Local Event Log" );
		console.log( message );
		console.groupEnd();

	}


	// I log the click at the parent (list) level.
	public logEvent( event: MouseEvent ) : void {

		console.group( "Parent Event Log" );
		console.log( "type:", event.type );
		console.log( "default prevented:", event.defaultPrevented );
		console.log( "phase:", event.eventPhase );
		console.log( "target:", event.target );
		console.groupEnd();

	}

}

As you can see, I'm binding to the click event at two levels (list and anchor elements). Furthermore, my list element has two different click-handlers - one bound to the Capture phase and one bound to the Bubble phase. This two-phase approach will help us see when event propagation and behavior is modified.

Most of this behavior will be easier to see in the demo; but, if we click the ".once.self" link twice, you will see that the local event-handler doesn't fire on the subsequent click:

Vue.js inspired event-modifier plug-in for DOM events in Angular 7.1.4.

As you can see, the event-handler fires on the first click and is then implicitly unbound thanks to the ".once" event-modifier. The second click is therefore captured on the list-element, but not on the anchor-element.

No matter how many times I use Angular's event manager plug-in system, it never gets old. I absolutely love how flexible and unopinionated Angular is about most things. It just gives us hooks and then allows us to augment the runtime in magical ways. In this case, we can easily add Vue.js inspired event-modifiers to an Angular 7.1.4 application.

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

Reader Comments

15,663 Comments

@Aleksandr,

Yeah, these are pretty interesting. It would also be interesting to be able to denote certain handlers as being bound outside of the Angular Zone, for cases where you want more control over when change-detection is triggered. You can do it manually, but I feel like there's an opportunity to make some of this more declarative.

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