Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Adam DiCarlo
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Adam DiCarlo ( @adamdicarlo )

Attaching Key Handlers With Dynamically-Parsed Host Event Bindings In Angular 2 Beta 14

By on

A couple of weeks ago, I looked at creating custom DOM (Document Object Model) and Host event bindings in Angular 2. In that demo, I implemented a couple of custom "outside" events, like (clickOutside), that tracked mouse activity outside of the current host element. Since then, I hadn't thought much more about this approach until I read a really interesting article by Sean Larkin on eventType parsing. In his post, he was able to bind multiple events by parsing the eventType. This got me thinking; I wondered if there was a way to use the same approach to bind targeted key events based on dynamically parsed eventTypes.

Run this demo in my JavaScript Demos project on GitHub.

Most of the time, simply binding to a key event, such as keypress, is not sufficient for accomplishing a specific task. Typically, the event handler for said keypress then needs to examine the triggered event to see if it should lead to some additional action. But, what if the binding of the event and the inspection of the event could be rolled into a single expression?

I think it would be really powerful to be able to define the "key stroke" as part of the key event binding. Imagine being able to use the following type of syntax:

  • (key.CMD.Enter)="processForm()"
  • (key.ArrowLeft)="moveToPrevious()
  • (key.ArrowRight)="moveToNext()
  • (key.CMD.s)="saveDocument()"
  • (key.Escape)="closeModalWindow()"

If these bindings were part of a Component's outputs, the "." notation would simply indicate namespacing. But, if these bindings were intended for a custom Browser DOM plugin, the periods would just be part of the eventType string that gets passed into the plugin. So, for example, the binding:

(key.CMD.Enter)="processForm()"

... would pass the following string into the plugin as the requested eventType:

"key.CMD.Enter"

We could then parse this string, pick out the modifiers and the key tokens, and then bind the most appropriate key event handler with built-in filtering. This way, the calling context wouldn't have to worry about inspecting key events as it would only be notified of events that were triggered by the desired key combination.

Before we look at the code, let me just say that this exploration was much more about the eventType parsing and less about the proper binding of key events. I only tested this in Chrome and I only tested the key combinations that I have in the demo. I am sure there is a whole host of cross-browser compatibility issues; but, that's all beyond the scope of this post.

That said, let's take a look at the code. What you'll see here is a single root component that is binding to a number of host-oriented key events. In this case, I happen to be using the global host modifier, "document:"; but, I'm only doing that so I don't need an text input field.

<!doctype html>
<html>
<head>
	<meta charset="utf-8" />

	<title>
		Attaching Key Handlers With Dynamically-Parsed Host Event Bindings In Angular 2 Beta 14
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>

	<h1>
		Attaching Key Handlers With Dynamically-Parsed Host Event Bindings In Angular 2 Beta 14
	</h1>

	<my-app>
		Loading...
	</my-app>

	<!-- Load demo scripts. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/es6-shim.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/Rx.umd.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/angular2-polyfills.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/angular2-all.umd.js"></script>
	<!-- AlmondJS - minimal implementation of RequireJS. -->
	<script type="text/javascript" src="../../vendor/angularjs-2-beta/14/almond.js"></script>
	<script type="text/javascript">

		// Defer bootstrapping until all of the components have been declared.
		requirejs(
			[ /* Using require() for better readability. */ ],
			function run() {

				ng.platform.browser.bootstrap(
					require( "App" ),

					// 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 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: require( "DynamicKeyEventPlugin" ),
								multi: true
							}
						)
					]
				);

			}
		);


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


		// I provide the root application component.
		define(
			"App",
			function registerApp() {

				// Configure the App component definition.
				ng.core
					.Component({
						selector: "my-app",

						// Here, we are using the custom DOM event plugin in order to
						// dynamically bind key events based on token sequences.
						host: {
							"(document: key.Escape)": "handleKeyEvent( $event, 'Escape' )",
							"(document: key.Enter)": "handleKeyEvent( $event, 'Enter' )",
							"(document: key.CMD)": "handleKeyEvent( $event, 'Command' )",
							"(document: key.ALT)": "handleKeyEvent( $event, 'Alt' )",
							"(document: key.SHIFT)": "handleKeyEvent( $event, 'Shift' )",
							"(document: key.CTRL)": "handleKeyEvent( $event, 'Control' )",
							"(document: key.CMD.Enter)": "handleKeyEvent( $event, 'Command + Enter' )",
							"(document: key.CMD.SHIFT.Enter)": "handleKeyEvent( $event, 'Command + Shift + Enter' )",
							"(document: key.ALT.Enter)": "handleKeyEvent( $event, 'Alt + Enter' )",
							"(document: key.Space)": "handleKeyEvent( $event, 'Space' )",
							"(document: key.Tab)": "handleKeyEvent( $event, 'Tab' )",
							"(document: key.ALT.Tab)": "handleKeyEvent( $event, 'Alt + Tab' )",
							"(document: key.ArrowLeft)": "handleKeyEvent( $event, 'Arrow Left' )",
							"(document: key.ArrowUp)": "handleKeyEvent( $event, 'Arrow Up' )",
							"(document: key.ArrowRight)": "handleKeyEvent( $event, 'Arrow Right' )",
							"(document: key.ArrowDown)": "handleKeyEvent( $event, 'Arrow Down' )",
							"(document: key.CMD.ALT.ArrowUp)": "handleKeyEvent( $event, 'Command + Alt + Arrow Up' )",
							"(document: key.CMD.ALT.ArrowDown)": "handleKeyEvent( $event, 'Command + Alt + Arrow Down' )",
							"(document: key.QuestionMark)": "handleKeyEvent( $event, 'Question Mark (?)' )",
							"(document: key.CMD.b)": "handleKeyEvent( $event, 'Command + b' )",
							"(document: key.CMD.i)": "handleKeyEvent( $event, 'Command + i' )",
							"(document: key.CMD.f)": "handleKeyEvent( $event, 'Command + f' )",
							"(document: key.CMD.o)": "handleKeyEvent( $event, 'Command + o' )",
							"(document: key.CMD.p)": "handleKeyEvent( $event, 'Command + p' )",
							"(document: key.CMD.s)": "handleKeyEvent( $event, 'Command + s' )",
							"(document: key.h)": "handleKeyEvent( $event, 'LowerCase h' )",
							"(document: key.e)": "handleKeyEvent( $event, 'LowerCase e' )",
							"(document: key.l)": "handleKeyEvent( $event, 'LowerCase l' )",
							"(document: key.o)": "handleKeyEvent( $event, 'LowerCase o' )"
						},
						template:
						`
							<p>
								<strong>Focus the document</strong> and type some stuff!
							</p>

							<ul>
								<li>Escape</li>
								<li>Enter</li>
								<li>CMD</li>
								<li>ALT</li>
								<li>SHIFT</li>
								<li>CTRL</li>
								<li>CMD.Enter</li>
								<li>CMD.SHIFT.Enter</li>
								<li>ALT.Enter</li>
								<li>Space</li>
								<li>Tab</li>
								<li>ALT.Tab</li>
								<li>ArrowLeft</li>
								<li>ArrowUp</li>
								<li>ArrowRight</li>
								<li>ArrowDown</li>
								<li>CMD.ALT.ArrowUp</li>
								<li>CMD.ALT.ArrowDown</li>
								<li>QuestionMark</li>
								<li>CMD.b</li>
								<li>CMD.i</li>
								<li>CMD.f</li>
								<li>CMD.o</li>
								<li>CMD.p</li>
								<li>CMD.s</li>
								<li>h</li>
								<li>e</li>
								<li>l</li>
								<li>o</li>
							</ul>
						`
					})
					.Class({
						constructor: AppController
					})
				;

				return( AppController );


				// I control the App component.
				function AppController() {

					var vm = this;

					// Expose the public events.
					vm.handleKeyEvent = handleKeyEvent;


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


					// I handle the key event, logging the value.
					function handleKeyEvent( event, value ) {

						console.log( "Recorded Key Event:", value );

						// In order to keep focus on the page, we're going to override
						// some of the key events.
						switch ( value ) {
							case "Command + b": // Override Bookmarks.
							case "Command + i": // Override Info.
							case "Command + f": // Override Find.
							case "Command + o": // Override Open.
							case "Command + p": // Override Print.
							case "Command + s": // Override Save.
							case "Tab":

								event.preventDefault();

							break;
						}

					}

				}

			}
		);


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


		// I provide a Browser platform plugin that will bind dynamically defined key
		// events (like key.CMD.Enter) to host elements.
		define(
			"DynamicKeyEventPlugin",
			function registerDynamicKeyEventPlugin() {

				return( DynamicKeyEventPlugin );


				// I bind and unbind custom "key" DOM events.
				function DynamicKeyEventPlugin() {

					var vm = this;

					// These roughly correspond to special "keypress" events.
					var charMap = {
						One: "1",
						Two: "2",
						Three: "3",
						Four: "4",
						Five: "5",
						Six: "6",
						Seven: "7",
						Eight: "8",
						Nine: "9",
						Period: ".",
						QuestionMark: "?",
						Space: " "
					};

					// These roughly correspond to special "keydown" events.
					var charCodeMap = {
						ArrowLeft: 37,
						ArrowRight: 39,
						ArrowUp: 38,
						ArrowDown: 40,
						Enter: 13,
						Escape: 27,
						Tab: 9
					}


					// 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
					// is 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 ) {

						var config = parseEventName( eventName );

						// NOTE: The "manager" is injected by the Angular framework (via
						// the Event Manager that aggregates the event plugins).
						var zone = vm.manager.getZone();

						// Zone.js patches event-target code to make change-detection
						// easier to manage. As such, when we attach the the event
						// handler, we want to do so outside of the core zone so that we
						// don't automatically trigger change-detection for every key
						// event. Only when we know that we need to invoke our callback
						// handler do we want to route back into the core zone so that we
						// can integrate with the change-detection mechanism.
						zone.runOutsideAngular( addKeyEventListener );

						return( removeKeyEventListener );


						// I add the appropriate key event handler.
						function addKeyEventListener() {

							element.addEventListener( config.eventType, handleKeyEvent, true );

						}


						// I remove the key event handler.
						function removeKeyEventListener() {

							element.removeEventListener( config.eventType, handleKeyEvent, true );

						}


						// I handle all key events. But, I only invoke the callback
						// handler for key events that match the current configuration.
						function handleKeyEvent( event ) {

							// For debugging:
							// --
							// console.group( "Event: " + event.type );
							// console.log( "Which:", event.which );
							// console.log( "Char:", String.fromCharCode( event.which ) );
							// console.log( "CTRL:", event.ctrlKey );
							// console.log( "ALT:", event.altKey );
							// console.log( "SHIFT:", event.shiftKey );
							// console.log( "META:", event.metaKey );
							// console.log( config );
							// console.groupEnd();

							// If the modifiers don't match, ignore event.
							if (
								( config.cmd && ! event.metaKey ) ||
								( config.ctrl && ! event.ctrlKey ) ||
								( config.alt && ! event.altKey ) ||
								( config.shift && ! event.shiftKey )
								) {

								return;

							}

							// If the charCode doesn't match, ignore event.
							if ( config.charCode && ( config.charCode !== event.which ) ) {

								return;

							}

							// If the char doesn't match, ignore event.
							if ( config.char && ( config.char !== String.fromCharCode( event.which ) ) ) {

								return;

							}

							event.eventConfig = config;

							// If we made it this far, it means that this is a key event
							// that we need to handle (ie, invoke the callback inside the
							// core Angular change-detection zone).
							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 ) {

						addEventListener(
							( ( target === "document" ) ? document : window ),
							eventName,
							handler
						);

					}


					// I check to see if the given event is supported by the plugin.
					function supports( eventName ) {

						return( eventName.indexOf( "key." === 0 ) );

					}


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


					// I parse the eventName (ex, key.CMD.Enter) into a configuration
					// object that defines the event requirements.
					function parseEventName( eventName ) {

						var config = {
							eventType: "keypress",

							// Modifiers.
							cmd: false,
							ctrl: false,
							alt: false,
							shift: false,

							// Target keys.
							charCode: null,
							char: null
						};

						var tokens = eventName.split( new RegExp( "\\.", "g" ) );
						var token = null;

						// Merge the tokens into the event configuration.
						for ( var i = 1, length = tokens.length ; i < length ; i++ ) {

							switch ( token = tokens[ i ] ) {

								case "CMD":
								case "CTRL":
								case "ALT":
								case "SHIFT":

									config[ token.toLowerCase() ] = true;

								break;

								default:

									if ( charCodeMap.hasOwnProperty( token ) ) {

										config.charCode = charCodeMap[ token ];

									} else if ( charMap.hasOwnProperty( token ) ) {

										config.char = charMap[ token ];

									} else {

										if ( token.length > 1 ) {

											throw( new Error( "Key event token cannot be more than one character." ) );

										}

										config.char = token;

									}

								break;

							}

						}


						// *******
						// WARNING: The logic here is not supposed to be the end-all-be-all
						// of key-code handling. I am sure there is a lot of cross-browser
						// stuff that I'm not accounting for. However, the point of this
						// demo is supposed to be about the dynamic parsing, and not the
						// actual key code implementation.
						// *******


						// If there is no char defined, then this is an event that needs
						// to be caught with the keydown event.
						if ( ! config.char ) {

							config.eventType = "keydown";

						// For some char-based commands, we need to use the keydown event
						// in order to be able to override the native browser behavior.
						} else if ( config.cmd && config.char ) {

							if (
								( config.char === "f" ) ||
								( config.char === "o" ) ||
								( config.char === "p" ) ||
								( config.char === "s" )
								) {

								config.eventType = "keydown";
								config.char = config.char.toUpperCase();

							}

						}

						return( config );

					}

				}

			}
		);

	</script>

</body>
</html>

As you can see, the handleKeyEvent() event handler in my root component doesn't need to do any filtering on the event. This is because all of the event filtering is done implicitly by the plugin itself based on the dynamic parsing of the event binding. The only thing the event handler, in the root component, has to do is translate the event into proper business logic.

When we run the above demo and try a few of the key combinations, we get the following output:

Dynamic event type parsing and binding in Angular 2 Beta 14 DOM plugins.

While some of the cross-browser compatibility needs to be cleaned up, I can definitely see myself using this kind of approach. Heck, the "key.CMD.Enter" alone pays for the approach. But, more than anything, it's just exciting that the host event binding can be performed in such a dynamic manner in Angular 2 Beta 14. It really paves the way for some creative problem solving.

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

Reader Comments

15,674 Comments

@All,

Holy chickens - it appears that this kind of thing - key-combination event binding - is actually supported by Angular 2 *natively* as part of the Browser platform:

www.bennadel.com/blog/3088-native-key-combination-event-binding-support-in-angular-2-beta-17.htm

The syntax is basically the same as well:

keydown.meta.Enter
keydown.alt.Spacebar
keydown.h
keydown.i
keydown.ArrayLeft

... there are some limitations compared to what I had, but also some obvious flexibility.

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