Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Experimenting With Chained Keyboard Events Using Event Plug-ins In Angular 9.1.0

By Ben Nadel on

Over the weekend, I tried to showcase the awesome power of multiple cursors in Sublime Text 3 (ST3). As I was doing that, I thought about two of the ST3 key combinations that I use all the time: CMD+K followed by either CMD+U (uppercase) or CMD+L (lowercase). Which, in turn, got me thinking about how I might implement such a key-combination in Angular. Out of the box, Angular has effortless support for standard DOM and Keyboard event-bindings; but, to make something more complex, we can use Angular's Event Plug-in system to orchestrate higher-level events. These custom events can then be used directly within Angular's template syntax. The following is an experiment in creating a chained keyboard event plug-in in Angular 9.1.0.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

In Angular templates, DOM (Document Object Model) and Keyboard events can be bound using simple parenthesis notation. Examples being:

  • (keydown)="handleKeydown( $event )"
  • (keydown.Shift.S)="handleSave( $event )"
  • (keydown.Meta.Enter)="handleSubmit( $event )"
  • (click)="handleClick( $event )"
  • (mouseenter)="handleActivation( $event )"

When Angular encounters this notation, it will automatically wire the given event up to the given handler; and then, automatically unbind the event when the calling context is torn-down. It's part of what makes Angular such a joy to work with.

But, while the syntax for keyboard events allows for key-combination (ex, keydown.Meta.Enter), it doesn't have any notation of chained keyboard event sequences. So, what I want to do is create a plug-in for Angular's Event Manager that uses the + symbol to denote keyboard sequences.

Going back to my Sublime Text 3 example, I'd like to be able to define event sequences in an Angular template as such:

  • (keydown.Meta.K+Meta.L)="handleLowercase( $event )"
  • (keydown.Meta.K+Meta.U)="handleUppercase( $event )"

Notice that after the keydown. portion, I have Meta.K+Meta.L and Meta.K+Meta.U. This would tell my plug-in to look for the keydown event of CMD+K followed by CMD+L; or, CMD+K followed by CMD+U.

Before we look at how this is implemented, let's look at how it would be consumed. For this demo, I've set up a very simple App component that lists for host-bindings that include chained keyboard sequences. I then log the event to the console:

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

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

@Component({
	selector: "app-root",
	host: {
		"(window:keydown.Meta.K+Meta.U)": "handleUppercase( $event )",
		"(window:keydown.Meta.K+Meta.L)": "handleLowercase( $event )",
		"(window:keydown.ArrowUp+ArrowUp+ArrowDown+ArrowDown+ArrowLeft+ArrowRight+ArrowLeft+ArrowRight+Space)": "handleContra()"
	},
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			Possible key-combinations (watch console logging):
		</p>

		<ul>
			<li><code>CMD-K + CMD-U</code> - Upper-casing</li>
			<li><code>CMD-K + CMD-L</code> - Lower-casing</li>
		</ul>
	`
})
export class AppComponent {

	// I handle the Konami Conta code ... shhhhhh!
	public handleContra() : void {

		console.group( "Key Combination Used" );
		console.log( "Contra code unlocked" );
		console.log( "30-free lives!" );
		console.groupEnd();

	}


	// I handle the lower-case key-combination.
	public handleLowercase( event: KeyboardEvent ) : void {

		console.group( "Key Combination Used" );
		console.log( "K+L" );
		console.log( "Perform lower-case command." );
		console.groupEnd();

	}


	// I handle the upper-case key-combination.
	public handleUppercase( event: KeyboardEvent ) : void {

		console.group( "Key Combination Used" );
		console.log( "K+U" );
		console.log( "Perform upper-case command." );
		console.groupEnd();

	}

}

As you can see, I'm binding to the window object so that the user doesn't have to be interacting with any specific elements. The keyboard combinations that I've defined are the two key-casing short-cuts from Sublime Text 3 plus the secret Konomi code from my youth :D

Now, if we open this Angular app, put focus into the window, and then try the given key combinations, we get the following browser output:

Chained keyboard events using the Event Manager plugin system in Angular 9.1.0.

As you can see, using the simple template syntax, our Angular Event Manager plug-in was able to capture the sequence of keyboard events and turn them into an event handler invocation.

And, now that we know what we're trying to do, let's take a look at the Event Manager plug-in. Essentially, each plug-in has to implement three methods:

  • addEventListener( element, eventName, handler )
  • addGlobalEventListener( element, eventName, handler )
  • supports( eventName )

... where the supports() method gets called every time Angular encounters an event binding in a template. The registered event plug-ins get checked in reverse order so that the in-built Angular plug-ins get checked last. This allows the custom plug-ins to step in and manage event orchestration for special types of events.

For example, in my chained keyboard event plug-in, my supports() method looks like this:

public supports( eventName: string ) : boolean {

	return(
		eventName.includes( "keydown." ) &&
		eventName.includes( "+" )
	);

}

As you can see, it's loosely looking for the two strings, keydown. and +. If it encounters this, it returns true, telling Angular that this plug-in is going to be responsible for handling the event-type in question.

And now, my plug-in implementation. I won't go into too much detail; but, you can see in the .setupEventBinding() method that I am using a Timer to make sure that the series of keyboard events are completed within a constrained amount of time:

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

// Import the application components and services.
import { KeyboardEventHelper } from "./keyboard-event-helper";

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

// I define how long the user has to complete an chained-event sequence before the
// internal state is reset and the chain has to be started-over.
// --
// TODO: If we were to package this plugin into a module, we'd likely want to provide a
// module-setting that would allow an application to override this timer duration.
var TIMER_DURATION = 3000;


export class KeyboardEventsChainedKeydownPlugin {

	// The manager will get injected by the EventPluginManager at runtime.
	// --
	// NOTE: Using Definite Assignment Assertion to get around initialization.
	public manager!: EventManager;

	// ---
	// 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 eventNames = this.parseHigherOrderEventName( higherOrderEventName );

		return( this.setupEventBinding( element, eventNames, 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 eventNames = this.parseHigherOrderEventName( higherOrderEventName );

		return( this.setupEventBinding( target, eventNames, 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. In
	// this case, we are supporting KEYDOWN events that use a "+" concatenation.
	public supports( eventName: string ) : boolean {

		return(
			eventName.includes( "keydown." ) &&
			eventName.includes( "+" )
		);

	}

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

	// 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 a collection of individual event names.
	private parseHigherOrderEventName( eventName: string ) : string[] {

		// We know that the event name starts with "keydown.". As such, we can strip that
		// portion off and then split the event on the "+" to get the individual sub-
		// event names.
		var eventNames = eventName
			.slice( "keydown.".length )
			.split( "+" )
			.map(
				( subEventName ) => {

					return( KeyboardEventHelper.parseEventName( subEventName ) );

				}
			)
		;

		console.log( "Parsed event names:", eventNames );

		return( eventNames );

	}


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

		var pendingEventNames = eventNames.slice();
		var timer: any = null;
		var zone = this.manager.getZone();

		// In order to bypass the change-detection system, we're going to bind the DOM
		// event handler outside of the Angular Zone. The calling context can always
		// choose to re-enter the Angular zone if it needs to (such as when synthesizing
		// an event).
		zone.runOutsideAngular( addProxyFunction );

		return( removeProxyFunction );

		// -- HOISTED FUNCTIONS. -- //

		// I add the proxy function as the DOM-event-binding.
		function addProxyFunction() {

			target.addEventListener( "keydown", proxyFunction, false );

		}

		// I remove the proxy function as the DOM-event-binding.
		function removeProxyFunction() {

			// Clear any pending timer so we don't attempt to mess with state after the
			// event-binding has been removed.
			( timer ) && window.clearTimeout( timer );

			target.removeEventListener( "keydown", proxyFunction, false );

		}

		// I reset the internal tracking for the chained-event sequence.
		function reset() {

			// Reset chained state.
			window.clearTimeout( timer );
			pendingEventNames = eventNames.slice();
			timer = null;

		}

		// I am the event-handler that is bound to the DOM. I keep track of the state of
		// the event sequence as the user triggers individual keydown events.
		function proxyFunction( event: KeyboardEvent ) {

			var eventName = KeyboardEventHelper.getEventName( event );

			// If there's no timer, then we're looking for the first event in the chained
			// event sequence.
			if ( ! timer ) {

				// If the current event DOES NOT MATCH the first event name in the chain,
				// ignore this event.
				if ( pendingEventNames[ 0 ] !== eventName ) {

					return;

				}

				// If the current event DOES MATCH the first event name in the chain,
				// setup the timer - this creates a constraint in which the chained
				// keydown events needs to be consumed.
				timer = window.setTimeout( reset, TIMER_DURATION );

			}

			// ASSERT: At this point, we've either just setup the timer for the first
			// event in the sequence; or, we're already part way through the event-chain.

			var pendingEventName = pendingEventNames.shift() !;

			// The incoming event matches the next event in the chained sequence.
			if ( pendingEventName === eventName ) {

				// CAUTION: Since this keyboard event is part of key combination, we want
				// to cancel the default behavior in case the user is trying to override
				// a native browser behavior.
				event.preventDefault();

				// If there are no more pending event-names, it means the user just
				// executed the last event in the chained sequence! We can now re-enter
				// the Angular Zone and invoke the callback.
				if ( ! pendingEventNames.length ) {

					zone.runGuarded(
						function runInZoneSoChangeDetectionWillBeTriggered() {

							handler( event );

						}
					);
					reset();
					// NOTE: Return is not really needed. Including it for clarity.
					return;

				}

			// The incoming event does NOT MATCH the next event in the chained sequence;
			// however, the incoming event is composed entirely of MODIFIER KEYS. In that
			// case, it's possible that the use is "building up" to the desired key. As
			// such, we're going to ignore intermediary events that only contain
			// modifiers.
			} else if ( KeyboardEventHelper.isModifierOnlyEvent( event ) ) {

				// Since we'll need to re-process this pending event, stuff it back into
				// the pending queue.
				pendingEventNames.unshift( pendingEventName );

			// The incoming event does NOT MATCH the next event in the chained sequence.
			// As such, we need to reset the internal state for tracking.
			} else {

				reset();

			}

		}

	}

}

As you can see, the event-handler works by keeping an Array of pending events. Then, as the user enters parts of the chained keyboard event sequence, the event-handler shifts-off one event at a time. And, once all events in the given sequence have been complete, the event-handler turns-around and invokes the user's event-handler.

As I was building this, I had to re-create a lot of the logic that is already in Angular's in-build keyboard event plug-in. I really wish that Angular would make that logic public (as an export) so that custom plug-in authors don't have to re-invent the wheel. That said, in order to separate the logic from the noise, I put all that low-level stuff inside a helper file:

// CAUTION: Much of what is in this file has been COPIED FROM THE ANGULAR PROJECT. As
// such, I'm not going to comment too much on it. It is here to help normalize the way
// keyboard events are configured by the developer and interpreted by the browser. To
// see how ANGULAR originally defined this data, read more in the source:
// --
// https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/key_events.ts

var DOM_KEY_LOCATION_NUMPAD = 3;

interface KeyMap {
	[ key: string ]: string;
};

// Map to convert some key or keyIdentifier values to what will be returned by the
// getEventKey() method.
var  _keyMap: KeyMap = {
  // The following values are here for cross-browser compatibility and to match the W3C
  // standard cf http://www.w3.org/TR/DOM-Level-3-Events-key/
  "\b": "Backspace",
  "\t": "Tab",
  "\x7F": "Delete",
  "\x1B": "Escape",
  "Del": "Delete",
  "Esc": "Escape",
  "Left": "ArrowLeft",
  "Right": "ArrowRight",
  "Up": "ArrowUp",
  "Down": "ArrowDown",
  "Menu": "ContextMenu",
  "Scroll": "ScrollLock",
  "Win": "OS"
};

// There is a bug in Chrome for numeric keypad keys:
// https://code.google.com/p/chromium/issues/detail?id=155654
// 1, 2, 3 ... are reported as A, B, C ...
var _chromeNumKeyPadMap = {
  "A": "1",
  "B": "2",
  "C": "3",
  "D": "4",
  "E": "5",
  "F": "6",
  "G": "7",
  "H": "8",
  "I": "9",
  "J": "*",
  "K": "+",
  "M": "-",
  "N": ".",
  "O": "/",
  "\x60": "0",
  "\x90": "NumLock"
};


export class KeyboardEventHelper {

	// I return the key from the given KeyboardEvent with special keys normalized for
	// internal use.
	static getEventKey( event: KeyboardEvent ) : string {

		var key = KeyboardEventHelper.getEventKeyRaw( event ).toLowerCase();

		switch ( key ) {
			case " ":
				return( "space" );
			break;
			case ".":
				return( "dot" );
			break;
			default:
				return( key );
			break;
		}

	}


	// I return the raw key from the given KeyboardEvent. This is normalized for cross-
	// browser compatibility; but, doesn't represent a format that is normalized for
	// internal usage.
	static getEventKeyRaw( event: any ) : string {

		var key = event.key;

		if ( key == null ) {

			key = event.keyIdentifier;
			// keyIdentifier is defined in the old draft of DOM Level 3 Events
			// implemented by Chrome and Safari cf
			// http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/events.html#Events-KeyboardEvents-Interfaces
			if ( key == null ) {

				return( "Unidentified" );

			}

			if ( key.startsWith( "U+" ) ) {

				key = String.fromCharCode( parseInt( key.substring( 2 ), 16 ) );

				if ( ( event.location === DOM_KEY_LOCATION_NUMPAD ) && _chromeNumKeyPadMap.hasOwnProperty( key )  ) {

					// There is a bug in Chrome for numeric keypad keys:
					// https://code.google.com/p/chromium/issues/detail?id=155654
					// 1, 2, 3 ... are reported as A, B, C ...
					key = ( _chromeNumKeyPadMap as any )[ key ];

				}

			}

		}

		return( _keyMap[ key ] || key );

	}


	// I return the normalized key-combination from the given KeyboardEvent. This
	// includes the primary key as well as any modifier keys, composed in a consistent
	// order and format. The result of this method can be compared to the result of the
	// .parseEventName() method.
	static getEventName( event: KeyboardEvent ) : string {

		var parts: string[] = [];

		// Always add modifier keys in alphabetical order.
		( event.altKey ) && parts.push( "alt" );
		( event.ctrlKey ) && parts.push( "control" );
		( event.metaKey ) && parts.push( "meta" );
		( event.shiftKey ) && parts.push( "shift" );

		// Always add the key last.
		parts.push( KeyboardEventHelper.getEventKey( event ) );

		return( parts.join( "." ) );

	}


	// I determine if the given KeyboardEvent represents some combination of modifier
	// keys without any other key.
	static isModifierOnlyEvent( event: KeyboardEvent ) : boolean {

		switch ( KeyboardEventHelper.getEventKey( event ) ) {
			case "alt":
			case "control":
			case "meta":
			case "shift":
				return( true );
			break;
			default:
				return( false );
			break;
		}

	}


	// I parse the given key name for internal use. This allows for some alias to be
	// used in the event-bindings while still using a consistent internal representation.
	static parseEventKeyAlias( keyName: string ) : string {

		switch( keyName ) {
			case "esc":
				return( "escape" );
			break;
			default:
				return( keyName );
			break;
		}

	}


	// I parse the given keyboard event name into a consistent formatting. It is assumed
	// that the event-type (ex, keydown) has already been removed. The result of this
	// method can be compared to the result of the .getEventName() method.
	static parseEventName( eventName: string ) : string {

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

		var altKey = false;
		var controlKey = false;
		var metaKey = false;
		var shiftKey = false;

		// The key is always ASSUMED to be the LAST item in the event name.
		var key = KeyboardEventHelper.parseEventKeyAlias( parts.pop() ! );

		// With the remaining parts, let's look for modifiers.
		for ( var part of parts ) {

			switch ( part ) {
				case "alt":
					altKey = true;
				break;
				case "control":
					controlKey = true;
				break;
				case "meta":
					metaKey = true;
				break;
				case "shift":
					shiftKey = true;
				break;
				default:
					throw( new Error( `Unexpected event name part: ${ part }` ) );
				break;
			}

		}

		var normalizedParts: string[] = [];

		// Always add modifier keys in alphabetical order.
		( altKey ) && normalizedParts.push( "alt" );
		( controlKey ) && normalizedParts.push( "control" );
		( metaKey ) && normalizedParts.push( "meta" );
		( shiftKey ) && normalizedParts.push( "shift" );

		// Always add the key last.
		normalizedParts.push( key );

		return( normalizedParts.join( "." ) );

	}

}

At least now, I'll be able to re-use this file (ie, copy/paste) in different Angular applications.

Finally, to get this all working, we have to tell Angular about this chained keyboard event plug-in within the App module. We have to provide it as part of the EVENT_MANAGER_PLUGINS dependency-injection token:

// 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 { KeyboardEventsChainedKeydownPlugin } from "./keyboard-events-chained-keydown-plugin";

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

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

I know this is a bit complicated; but, tracking chained keyboard events in a generic way is a bit complicated at its core. That said, how awesome is Angular that we can cleanly encapsulate this logic inside an event plug-in such that any developer can come along and, with the greatest of ease, bind to chained key combinations right in the template syntax. Angular is so freakin' elegant!



Reader Comments

Ha, just realized I forgot to put a+b+a+b in my Konomi code :( Oh well, hopefully y'all get the point.

Reply to this Comment

Hi Ben,
That was a nice writeup! Is there any official documentation about event plugins? I am not able to find any.

Reply to this Comment

@Hassam,

Great question! I believe I happened across the EVENT_MANAGER_PLUGIN stuff by chance while reading someone else blog post a few years ago. The Event Manager itself is documented:

https://angular.io/api/platform-browser/EventManager

... however, I don't see any documentation on how to actually use it on the docs site. If are you curious, just above the comments section, I have a list of "related blog posts", in which I've explored the Event Manager a handful of times. It's really a great feature - it does seem odd that it's not documented more thoroughly.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
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.