Handling Global Keyboard Shortcuts Using Priority And Terminality In Angular 5.0.5

Consuming global keyboard shortcuts in an Angular application (or any application for that matter) is a non-trivial task. Global key-event handlers often run into collision errors, unbind errors, and timing errors that can make debugging a nightmare. And while Angular provides easy semantics for binding to the Document and Window key-events, there's no native construct for effectively managing these events across complex and deeply-nested applications. As such, I wanted to revisit this challenge and look at a potential solution that uses explicit event-handler priorities and terminal configurations.

Run this demo in my JavaScript Demos project on GitHub.

Out of the box, Angular comes with easy semantics for binding to global key events. Within your Component meta-data, all you have to do is add an event binding to one of the global event targets ("document" or "window"):

host: { "(document: Shift.X)": "handleDocumentEvent( $event )", "(window: Shift.Y)": "handleWindowEvent( $event )", // .... }

This is perfect for simple applications that don't have many global key-bindings. As your applications get more complex, however, this approach starts to fall down. In the past, I've trying to increase the feasibility of this approach by creating a custom key-event plugin that adds additional "priority" and "terminal" settings:

host: { "(document: Shift.X @ 100 T)": "handleDocumentEvent( $event )", "(window: Shift.Y @ 150)": "handleWindowEvent( $event )", // .... }

Here, we are using the "@" to define the priority at which a key-handler will run. This way, if two different views are listening to the same global key-event, the "winning" event-handler becomes the one with the higher priority; it no longer depends on the coincidental structure of the component tree.

I think that this was a step in the right direction; but, it also fell short because it couldn't see global keyboard shortcuts in a holistic light: it could only see and react to one key-event binding at a time. As such, I think the best long-term solution is to move global key-event binding configuration out of the "host" meta-data and into an explicit service that understands and facilitates keyboard shortcuts as a first-class concept within an application's architecture.

When I was thinking about this problem, I had several features that I wanted to include:

Priority : different view components need to assert higher priority in order to take precedence.

Terminal by Default : once a higher-priority event-handler responds to an event, I wanted the default behavior to be one that stops propagation. This would prevent two event-handlers from reacting to the same event until explicitly given permission to within the code.

Runtime Propagation Logic : I want the default behavior to be "terminal"; however, I think it would be a value-add if the propagation logic could vary based on the logic within a given event-handler (either to enable or to deny event propagation).

Ignoring Input Events by Default : in almost all cases that I've seen, global event-handlers are often not wanted when a user is interacting with a form. As such, I wanted the default behavior to ignore events when they originate from some type of Input element.

Change Detection : obviously, any approach needs to interact with change-detection; however, we need a solution that doesn't trigger too much change-detection. The keyboard shortcut service should only trigger change-detection when a global key-event is actually handled by a component.

Ease-of-Use: the native key-combination semantics in Angular are amazing. Those are a must-have for any key-binding solution.

What I came up with was a KeyboardShortcuts service that provides a .listen() method. This method takes two arguments: a collection of global key-bindings (on the Window instance) and a configuration object:

unlisten = keyboardShortcuts.listen( { "Command.F": ( event: KeyboardEvent ) : void => { // Optional. Return FALSE to STOP propagation to lower-priority listeners. return( false ); }, "Escape": ( event: KeyboardEvent ) : void => { // Optional. Return TRUE to ALLOW propagation to lower-priority listeners. return( true ); } }, { priority: 100, // Optional configurations. terminal: true, // Default true. terminalWhitelist: [ "Escape" ], // Default empty. inputs: false // Default false. } );

This .listen() method creates a global key-event listener that works as a single unit. This means that all of the key-handlers operate at the same priority; and, that the concept of "terminal" extends beyond just the matched key-combinations. By default, the "terminal" configuration applies to all of the local key-combinations. However, each handler can return True or False to allow or deny propagation regardless of the listener configuration.

This approach is certainly more verbose than using the component host bindings; however, this verbosity carries with it the benefit of explicit behavior and a holistic view of keyboard shortcuts. For the long-term maintenance of an application, I think this increased clarity will more than pay for itself.

To see this service in action, I'm going to create two nested components (App and Child) that both listen for global key-events. In the AppComponent, you can use the Command+F key-combination to open the ChildComponent; and, you can use the Escape key-combination to close the ChildComponent:

// Import the core angular services. import { Component } from "@angular/core"; import { OnDestroy } from "@angular/core"; import { OnInit } from "@angular/core"; // Import the application components and services. import { KeyboardShortcuts } from "./keyboard-shortcuts"; import { Unlisten } from "./keyboard-shortcuts"; // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // @Component({ selector: "my-app", styleUrls: [ "./app.component.less" ], template: ` <p> Use <code>Command+F</code> to open the child view. Use <code>Esc</code> to close the child view. </p> <my-child *ngIf="isShowingChildView"></my-child> <p> <strong>Note</strong>: The key-commands do not work when the child Input is focused. This is because the default setting is to ignore events that originate from a form element. </p> ` }) export class AppComponent implements OnInit, OnDestroy { public isShowingChildView: boolean; private keyboardShortcuts: KeyboardShortcuts; private unlisten: Unlisten; // I initialize the app component. constructor( keyboardShortcuts: KeyboardShortcuts ) { this.keyboardShortcuts = keyboardShortcuts; this.isShowingChildView = false; this.unlisten = null; } // --- // PUBLIC METHODS. // --- // I get called once when the component is being destroyed. public ngOnDestroy() : void { ( this.unlisten ) && this.unlisten(); } // I get called once after the inputs have been bound for the first time. public ngOnInit() : void { this.unlisten = this.keyboardShortcuts.listen( { "Command.F": ( event: KeyboardEvent ) : void => { console.log( "Handler[ 0 ]: Command.F" ); this.isShowingChildView = true; // Since this is a native browser action, we want to cancel the // default behavior and isolate it as a local action. event.preventDefault(); }, "Escape": ( event: KeyboardEvent ) : void => { console.log( "Handler[ 0 ]: Escape" ); this.isShowingChildView = false; } }, { priority: 0 } ); } }

As you can see, this component's keyboard shortcuts are listening at "priority: 0". This means that other components can setup higher-priority listeners in order to take precedence. And, that's exactly what our ChildComponent is going to do:

NOTE: Priority is just a number; as such, negative numbers and fractional numbers are just as valid.

// Import the core angular services. import { Component } from "@angular/core"; import { OnDestroy } from "@angular/core"; import { OnInit } from "@angular/core"; // Import the application components and services. import { KeyboardShortcuts } from "./keyboard-shortcuts"; import { Unlisten } from "./keyboard-shortcuts"; // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // @Component({ selector: "my-child", styleUrls: [ "./child.component.less" ], template: ` Use <code>Command+F</code> to show search. <ng-template [ngIf]="isShowingSearch"> <br /><br /> <strong>Search:</strong> <input type="text" placeholder="Search...." size="30" autofocus /> </ng-template> ` }) export class ChildComponent implements OnInit, OnDestroy { public isShowingSearch: boolean; private keyboardShortcuts: KeyboardShortcuts; private unlisten: Unlisten; // I initialize the child component. constructor( keyboardShortcuts: KeyboardShortcuts ) { this.keyboardShortcuts = keyboardShortcuts; this.isShowingSearch = false; this.unlisten = null; } // --- // PUBLIC METHODS. // --- // I get called once when the component is being destroyed. public ngOnDestroy() : void { ( this.unlisten ) && this.unlisten(); } // I get called once after the inputs have been bound for the first time. public ngOnInit() : void { this.unlisten = this.keyboardShortcuts.listen( { "Command.F": ( event: KeyboardEvent ) : void => { console.log( "Handler[ 100 ]: Command.F" ); this.isShowingSearch = true; // Since this is a native browser action, we want to cancel the // default behavior and isolate it as a local action. event.preventDefault(); } }, { priority: 100, // NOTE: AppComponent was priority "0". // By default, this keyboard listener is going to be Terminal. However, // we know that something higher-up in the component tree will be // listening for the Escape to close this view. As such, we'll let the // Escape key bubble-down through to a lower priority listener. terminalWhitelist: [ "Escape" ] } ); } }

Notice that both our AppComponent and our ChildComponent are listening for the "Command+F" key-combination. Since our ChildComponent is listening at "priority: 100", however, it will intercept the "Command+F" key-combination and prevent the AppComponent from seeing it (but only when the ChildComponent is open).

By default, the ChildComponent's listener represents a "terminal" listener at level 100. If you look in the configuration, however, you'll see that it is whitelisting the "Escape" key. This allows the "Escape" key-event to propagate down to lower-priority listeners even when all other keys will be terminated in the ChildComponent. This allows the AppComponent to listen for the "Escape" key and respond by closing the ChildComponent.

NOTE: In the listener configuration, you can set "terminal: match" or "terminal: false" to change the default propagation.

Because key-based interactions are more subtle, it's easier to see this working in the video. However, if we open up the demo and emit "Command+F" twice, you can see it open the ChildComponent view:

As you can see, the first "Command+F" is caught by the AppComponent and opens the ChildComponent. The second "Command+F" is then caught by the ChildComponent and opens the search-input. When the ChildComponent catches the global key-event, the AppComponent doesn't see it because the key-event is allowed to propagate down to a lower-priority listener.

Here's the code behind the KeyboardShortcuts service:

// Import the core angular services. import { Injectable } from "@angular/core"; import { NgZone } from "@angular/core"; // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // type Terminal = boolean | "match"; interface ListenerOptions { priority: number; terminal?: Terminal; terminalWhitelist?: string[]; inputs?: boolean; } interface Listener { priority: number; terminal: Terminal; terminalWhitelist: TerminalWhitelist; inputs: boolean; bindings: Bindings; } interface Handler { ( event: KeyboardEvent ) : boolean | void; } interface Bindings { [ key: string ]: Handler; } interface NormalizedKeys { [ key: string ]: string; } interface TerminalWhitelist { [ key: string ]: boolean; } export interface Unlisten { () : void; } // Map to normalized keys across different browser implementations. // -- // https://github.com/angular/angular/blob/5.0.5/packages/platform-browser/src/browser/browser_adapter.ts#L25-L42 var KEY_MAP = { "\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", " ": "Space", ".": "Dot" }; // NOTE: These will only be applied after the key has been lower-cased. As such, both the // alias and the final value (in this mapping) should also be lower-case. var KEY_ALIAS = { command: "meta", ctrl: "control", del: "delete", down: "arrowdown", esc: "escape", left: "arrowleft", right: "arrowright", up: "arrowup" }; @Injectable() export class KeyboardShortcuts { private listeners: Listener[]; private normalizedKeys: NormalizedKeys; private zone: NgZone; // I initialize the keyboard shortcuts service. constructor( zone: NgZone ) { this.zone = zone; this.listeners = []; this.normalizedKeys = Object.create( null ); // Since we're going to create a root event-handler for the keydown event, we're // gonna do this outside of the NgZone. This way, we're not constantly triggering // change-detection for every key event - we'll only re-enter the Angular Zone // when we have an event that is actually being consumed by one of our components. this.zone.runOutsideAngular( () : void => { window.addEventListener( "keydown", this.handleKeyboardEvent ); } ); } // --- // PUBLIC METHODS. // --- // I configure key-event listener at the given priority. Returns a Function that can // be used to unbind the listener. public listen( bindings: Bindings, options: ListenerOptions ) : Unlisten { var listener = this.addListener({ priority: options.priority, terminal: this.normalizeTerminal( options.terminal ), terminalWhitelist: this.normalizeTerminalWhitelist( options.terminalWhitelist ), inputs: this.normalizeInputs( options.inputs ), bindings: this.normalizeBindings( bindings ) }); var unlisten = () : void => { this.removeListener( listener ); }; return( unlisten ); } // --- // PRIVATE METHODS. // --- // I add the listener to the internal collection in DESCENDING priority order. private addListener( listener: Listener ) : Listener { this.listeners.push( listener ); this.listeners.sort( ( a: Listener, b: Listener ) : number => { // We want to sort the listeners in DESCENDING priority order so that the // higher-priority items are at the start of the collection - this will // make it easier to loop over later (highest priority first). if ( a.priority < b.priority ) { return( 1 ); } else if ( a.priority > b.priority ) { return( -1 ); } else { return( 0 ); } } ); return( listener ); } // I get the normalized event-key from the given event. // -- // CAUTION: Most of this logic is taken from the core KeyEventsPlugin code but, // with some of the logic removed. This is simplified for the demo. private getKeyFromEvent( event: KeyboardEvent ) : string { var key = ( event.key || event[ "keyIdentifier" ] || "Unidentified" ); if ( key.startsWith( "U+" ) ) { key = String.fromCharCode( parseInt( key.slice( 2 ), 16 ) ); } var parts = [ KEY_MAP[ key ] || key ]; if ( event.altKey ) parts.push( "Alt" ); if ( event.ctrlKey ) parts.push( "Control" ); if ( event.metaKey ) parts.push( "Meta" ); if ( event.shiftKey ) parts.push( "Shift" ); return( this.normalizeKey( parts.join( "." ) ) ); } // I handle the keyboard events for the root handler (and delegate to the listeners). private handleKeyboardEvent = ( event: KeyboardEvent ) : void => { var key = this.getKeyFromEvent( event ); var isInputEvent = this.isEventFromInput( event ); var handler: Handler; // Iterate over the listeners in DESCENDING priority order. for ( var listener of this.listeners ) { if ( handler = listener.bindings[ key ] ) { // Execute handler if this is NOT an input event that we need to ignore. if ( ! isInputEvent || listener.inputs ) { // Right now, we're executing outside of the NgZone. As such, we // have to re-enter the NgZone so that we can hook back into change- // detection. Plus, this will also catch errors and propagate them // through application properly. var result = this.zone.runGuarded( () : boolean | void => { return( handler( event ) ); } ); // If the handler returned an explicit False, we're going to treat // this listener as Terminal, regardless of the original settings. if ( result === false ) { return; // If the handler returned an explicit True, we're going to treat // this listener as NOT Terminal, regardless of the original settings. } else if ( result === true ) { continue; } } // If this listener is terminal for matches, stop propagation. if ( listener.terminal === "match" ) { return; } } // If this listener is terminal for all events, stop propagation (unless the // event is white-listed for propagation). if ( ( listener.terminal === true ) && ! listener.terminalWhitelist[ key ] ) { return; } } // END: For-loop. } // I determine if the given event originated from a form input element. private isEventFromInput( event: KeyboardEvent ) : boolean { if ( event.target instanceof Node ) { switch ( event.target.nodeName ) { case "INPUT": case "SELECT": case "TEXTAREA": return( true ); // @ts-ignore: TS7027: Unreachable code detected. break; default: return( false ); // @ts-ignore: TS7027: Unreachable code detected. break; } } return( false ); } // I return a bindings collection in which the keys of the given bindings have been // normalized into a predictable format. private normalizeBindings( bindings: Bindings ) : Bindings { var normalized = Object.create( null ); for ( var key in bindings ) { normalized[ this.normalizeKey( key ) ] = bindings[ key ]; } return( normalized ); } // I normalize the inputs option. private normalizeInputs( inputs: boolean | undefined ) : boolean { if ( inputs === undefined ) { return( false ); } return( inputs ); } // I return the given key in a normalized, predictable format. private normalizeKey( key: string ) : string { if ( ! this.normalizedKeys[ key ] ) { this.normalizedKeys[ key ] = key .toLowerCase() .split( "." ) .map( ( segment ) : string => { return( KEY_ALIAS[ segment ] || segment ); } ) .sort() .join( "." ) ; } return( this.normalizedKeys[ key ] ); } // I normalize the terminal option. private normalizeTerminal( terminal: Terminal | undefined ) : Terminal { if ( terminal === undefined ) { return( true ); } return( terminal ); } // I normalize the terminalWhitelist option. private normalizeTerminalWhitelist( keys: string[] | undefined ) : TerminalWhitelist { var normalized = Object.create( null ); if ( keys ) { for ( var key of keys ) { normalized[ this.normalizeKey( key ) ] = true; } } return( normalized ); } // I remove the given listener from the internal collection. private removeListener( listenerToRemove: Listener ) : void { this.listeners = this.listeners.filter( ( listener: Listener ) : boolean => { return( listener !== listenerToRemove ); } ); } }

This solution accounts for many of the real-world issues that I've run into over the years, both in Angular.js and modern Angular. It's a little heavier to work with, when compared to host event bindings; but, the overhead of explicit priorities and propagation rules is a must-have when it comes to application maintenance. This still doesn't make it trivial to implement global keyboard shortcuts; but, I think this is a huge step in making it manageable.

Tweet This Groovy post by @BenNadel - Handling Global Keyboard Shortcuts Using Priority And Terminality In Angular 5.0.5 Woot woot — you rock the party that rocks the body!







