Prioritizing Global Key Handlers In Angular 2 RC1
A couple of weeks ago, I demonstrated that Angular 2 came with built-in support for key-combination binding (ex, "Meta+Enter"). That support, however, is limited to element-level bindings - the built-in KeyEventsPlugin doesn't implement the global event handler. As such, if we want to implement key-combination binding at the global level, we have to do so with a custom DOM (Document Object Model) event plugin.
Run this demo in my JavaScript Demos project on GitHub.
When I first started to think about this problem, I thought it would be as simple as extending the native KeyEventsPlugin and then implementing the addGlobalEventListener() interface method. But, the more I thought about it, the more I realized that this approach didn't make sense. For one thing, the KeyEventsPlugin isn't exported in any of the barrels, so I can't extend it (though I strongly believe that this is a bug in Angular RC1).
But, the real problem is one of prioritization and propagation. Meaning, if we have multiple components all listening for key events at the global level, how do we know which component to target? And, if we only want to target a single component, how do we prevent other components from also reacting to the given keyboard event?
One way might be to rely on the top-down initialization of components in the component tree. Meaning, we could leverage the DOM structure to provide an inherent prioritization of event handlers. But, this doesn't help us with sibling elements. Or, sibling tree structures.
Really, what we need, is a way to define an explicit priority for each globally-bound keyboard event. And, for each binding, we need a way to denote the given handler as "terminal" such that the event shouldn't be passed to handlers at a lower priority. Since we know that we can dynamically parse event-names in the Angular 2 plugin syntax, I think we can include both the priority and the terminal nature as part of the event syntax itself.
Using this approach, the "host" binding might look something like this:
"(document: keydown.Meta.Enter @ 150 T)"
Here, the "@" denotes the priority of the handler (150 in this case). And, the "T" indicates that this handler is a terminal handler.
Ok, so let's look at how this might function in a real world application. In the following demo, I have a number of layered components that can each respond to several keyboard events:
- Enter - Opens the next embedded layer.
- Esc - Closes the embedded layer (via output events).
- Space - Prints the layer name.
All of the events will be bound at a particular priority. And, some of them will be terminal. For example, the Esc key handler will be terminal so that only the top-most layer is closed with any given Esc key event.
First, let's look at our root component:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { AbstractLayerComponent } from "./abstract-layer.component";
import { LayerOneComponent } from "./layer-one.component";
@Component({
selector: "my-app",
directives: [ LayerOneComponent ],
// Notice that our key-bindings are using the special "@" syntax for priority.
host: {
"(document: keydown.Enter @ 0)": "openSubLayer()",
"(document: keydown.Space @ 0)": "printLayerName()"
},
template:
`
Welcome to layer Zero.
<layer-one
*ngIf="isSubLayerOpen"
(close)="closeSubLayer()">
</layer-one>
<ul>
<li>
<strong>Enter</strong> - Open Layer.
</li>
<li>
<strong>Esc</strong> - Close Layer.
</li>
<li>
<strong>Space</strong> - Print Layer Name.
</li>
</ul>
`
})
export class AppComponent extends AbstractLayerComponent {
// I print the name of the current layer.
public printLayerName() : void {
console.log( "Layer Zero @ priority 0" );
}
}
You might notice that the AppComponent (as well as all of our other components) extend something called the AbstractLayerComponent. This abstract base component just provides the common functionality for each layer:
// Import the core angular services.
import { EventEmitter } from "@angular/core";
// I provide the base functionality for all layers.
export class AbstractLayerComponent {
// I am the event stream for "close" events.
public closeEvent: EventEmitter;
// I determine if the sub-layer (contained within this layer) is open.
public isSubLayerOpen: boolean ;
// I initialize the component.
constructor() {
this.closeEvent = new EventEmitter<any>();
this.isSubLayerOpen = false;
}
// ---
// PUBLIC METHODS.
// ---
// I close the sub-layer (contained within this layer).
public closeSubLayer() : void {
this.isSubLayerOpen = false;
}
// I emit the close event for this layer.
public emitCloseEvent() : void {
this.closeEvent.next();
}
// I open the sub-layer (contained within this layer).
public openSubLayer() : void {
this.isSubLayerOpen = true;
}
// I print the name of the current layer.
public printLayerName() : void {
throw( new Error( "Method not implemented" ) );
}
}
The more important part of this demo is the "host" bindings. Notice that the AppComponent is binding to keyboard events at priority zero.
Now, let's look at the sub-layer components. In each of the following components, I'm using the AbstractLayerComponent in order to hide as much noise as possible. Really, you should just be looking at the "host" bindings.
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { AbstractLayerComponent } from "./abstract-layer.component";
import { LayerTwoComponent } from "./layer-two.component";
@Component({
selector: "layer-one",
outputs: [ "closeEvent: close" ],
directives: [ LayerTwoComponent ],
// Notice that our key-bindings are using the special "@" syntax for priority. Also,
// we are using the "T" token for "terminal" (in some of the cases). This means that
// those particular key bindings won't be passed-off to any lower-priority bindings.
// --
// NOTE: The "Space" binding is NOT using the Terminal token.
host: {
"(document: keydown.Enter @ 100 T)": "openSubLayer()",
"(document: keydown.Escape @ 100 T)": "emitCloseEvent()",
"(document: keydown.Space @ 100)": "printLayerName()"
},
template:
`
Welcome to layer One.
<layer-two
*ngIf="isSubLayerOpen"
(close)="closeSubLayer()">
</layer-two>
`
})
export class LayerOneComponent extends AbstractLayerComponent {
// I print the name of the current layer.
public printLayerName() : void {
console.log( "Layer One @ priority 100" );
}
}
And, layer two:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { AbstractLayerComponent } from "./abstract-layer.component";
import { LayerThreeComponent } from "./layer-three.component";
@Component({
selector: "layer-two",
outputs: [ "closeEvent: close" ],
directives: [ LayerThreeComponent ],
// Notice that our key-bindings are using the special "@" syntax for priority. Also,
// we are using the "T" token for "terminal" (in some of the cases). This means that
// those particular key bindings won't be passed-off to any lower-priority bindings.
// --
// NOTE: The "Space" binding is NOT using the Terminal token.
host: {
"(document: keydown.Enter @ 200 T)": "openSubLayer()",
"(document: keydown.Escape @ 200 T)": "emitCloseEvent()",
"(document: keydown.Space @ 200)": "printLayerName()"
},
template:
`
Welcome to layer Two.
<layer-three
*ngIf="isSubLayerOpen"
(close)="closeSubLayer()">
</layer-three>
`
})
export class LayerTwoComponent extends AbstractLayerComponent {
// I print the name of the current layer.
public printLayerName() : void {
console.log( "Layer Two @ priority 200." );
}
}
And, layer three:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { AbstractLayerComponent } from "./abstract-layer.component";
@Component({
selector: "layer-three",
outputs: [ "closeEvent: close" ],
// Notice that our key-bindings are using the special "@" syntax for priority. Also,
// we are using the "T" token for "terminal" (in some of the cases). This means that
// those particular key bindings won't be passed-off to any lower-priority bindings.
// --
// NOTE: The "Space" binding is NOT using the Terminal token.
host: {
"(document: keydown.Escape @ 300 T)": "emitCloseEvent()",
"(document: keydown.Space @ 300)": "printLayerName()"
},
template:
`
Welcome to layer Three.
`
})
export class LayerThreeComponent extends AbstractLayerComponent {
// I print the name of the current layer.
public printLayerName() : void {
console.log( "Layer Three @ priority 300." );
}
}
As you can see, other than the host bindings, there's very little difference between each layer component. But, it's the host bindings that we care about. Notice that the "Enter" and "Esc" bindings are all terminal while the "Space" bindings are not. This means that each "Enter" and "Esc" event will be isolated to a single layer (at the highest priority) while the "Space" event will propagate down to all bound event handlers.
It's hard to get a sense of this unless you try the demo or watch the video.
In order to get this to work, we have to add our custom DOM-event plugin to the platform bootstrapping:
// Import the core angular services.
import { bootstrap } from "@angular/platform-browser-dynamic";
import { EVENT_MANAGER_PLUGINS } from "@angular/platform-browser";
// Import the application components and services.
import { AppComponent } from "./app.component";
import { GlobalKeyEventsPlugin } from "./global-key-events.plugin";
// When we bootstrap the application, we're providing a custom DOM-event plugin for
// our global key handlers. This will take precedence over (but will not replace) the
// built-in DOM-event plugins that Angular ships with.
bootstrap(
AppComponent,
[
{
provide: EVENT_MANAGER_PLUGINS,
useClass: GlobalKeyEventsPlugin,
multi: true
}
]
);
And, finally, we have to provide the actual plugin implementation. For some reason, neither the KeyEventsPlugin nor the EventManagerPlugin classes are exposed in any of the Angular 2 barrels (which I strongly suspect is a bug in RC1). As such, I couldn't implement the Plugin interface; and, more consequently, I couldn't take advantage of the existing event-key parsing provided by the KeyEventsPlugin class. This means that I had to reproduce the same logic in my plugin, which I have done in a simplified manner. This is meant to be more of a proof-of-concept than a fleshed-out implementation.
// Import the core angular services.
import { EventManager } from "@angular/platform-browser";
interface EventBinding {
type: string;
keys: string;
priority: number;
terminal: boolean;
handler: Function;
};
interface ParsedEventName {
type: string;
keys: string;
priority: number;
terminal: boolean;
};
// I provide a DOM event plugin that allows keydown and keyup events to be prioritized
// at the document level, complete with granular prioritization and terminal events.
export class GlobalKeyEventsPlugin { /* WISH: extends KeyEventsPlugin | EventManagerPlugin */
// I am the Event Plugin manager (injected by the plugin manager).
public manager: EventManager;
// I hold the collection of key bindings.
private bindings: EventBinding[];
// I am the Regular Expression pattern used to test and parse event names.
private eventNamePattern: RegExp;
// I determine if the root event handlers have been configured yet.
private isConfigured: boolean;
// I initialize the component.
constructor() {
this.bindings = [];
this.isConfigured = false;
// Captures several groups:
// --
// 1) {Required} Event type, ex: "keydown".
// 2) {Required} Key combination, ex: "Command.H".
// 3) {Required} Priority, ex "100".
// 4) {Optional} Termianl flag, "T".
this.eventNamePattern = /^(key(?:down|up))((?:\.[^.\s]+)+)\s*@\s*(\d+)\s*(T)?$/i;
}
// ---
// PUBLIC METHODS.
// ---
// I add a local event binding and return the deregistration function for the event.
// --
// CAUTION: Not supported. Ideally, this would be supplied by the base EventManagerPlugin
// class; but, at the time of this writing (RC1) that class is not be exposed by any
// of the Angular barrels.
public addEventListener(
element: HTMLElement,
eventName: string,
handler: Function
) : Function {
throw( new Error( "Local event listener not implemented." ) );
}
// I add a global event binding and return the deregistration function for the event.
// --
// CAUTION: Event handlers are always bound to "document" regardless of the target
// ("document" or "window") that is being supplied.
public addGlobalEventListener(
target: string,
eventName: string,
handler: Function
) : Function {
this.ensureRootHandlers();
var eventConfig = this.parseEventName( eventName );
var eventBinding = {
type: eventConfig.type,
keys: eventConfig.keys,
priority: eventConfig.priority,
terminal: eventConfig.terminal,
handler: handler
};
return( this.addBinding( eventBinding ) );
}
// I determine if the given event is supported by this plugin.
// --
// WARNING: This plugin only handles the GLOBAL version of events - all local
// versions of "supported" events will throw an error.
public supports( eventName: string ) : boolean {
return( this.eventNamePattern.test( eventName ) );
}
// ---
// PRIVATE METHODS.
// ---
// I add the given event binding and return the deregistration function for it.
private addBinding( eventBinding: EventBinding ) : Function {
// Try to insert the binding in the bindings collection in priority order.
for ( var i = 0, length = this.bindings.length ; i < length ; i++ ) {
if ( this.bindings[ i ].priority <= eventBinding.priority ) {
this.bindings.splice( i, 0, eventBinding );
break;
}
}
// If the offset matches the length at this point, we didn't break out of the
// previous loop which means we didn't insert the binding. If so, just push
// it onto the end.
if ( i === length ) {
this.bindings.push( eventBinding );
}
var deregistration = () => {
this.removeBinding( eventBinding );
};
return( deregistration );
}
// I ensure that the root key handlers are configured.
// --
// CAUTION: These will remain bound for the duration of the application.
private ensureRootHandlers() : void {
if ( this.isConfigured ) {
return;
}
this.isConfigured = true;
this.manager.getZone().runOutsideAngular(
() => {
document.addEventListener( "keydown", this.handleKeyEvent, true );
document.addEventListener( "keyup", this.handleKeyEvent, true );
}
);
}
// I find and return the event bindings that match the given event type and key
// configuration. The bindings are returned in DESCENDING PRIORITY order.
private findBindingsForEvent( event: KeyboardEvent ) : EventBinding[] {
var parts = [ this.getEventKey( event ).toLowerCase() ];
if ( event.altKey ) parts.push( "alt" );
if ( event.ctrlKey ) parts.push( "control" );
if ( event.metaKey ) parts.push( "meta" );
if ( event.shiftKey ) parts.push( "shift" );
var keys = parts.sort().join( "." );
var filteredBindings = this.bindings.filter(
function operator( binding: EventBinding ) : boolean {
return(
( binding.type === event.type ) &&
( binding.keys === keys )
);
}
);
return( filteredBindings );
}
// I return the normalized key represented by the given keyboard event. This does
// not include modifiers.
// --
// 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 getEventKey( event: KeyboardEvent ) : string {
var key = ( event.key || event.keyIdentifier || "Unidentified" );
if ( key.startsWith( "U+" ) ) {
key = String.fromCharCode( parseInt( key.slice( 2 ), 16 ) );
}
var normalizationMap = {
"\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"
};
return( normalizationMap[ key ] || key );
}
// I handle the keyboard events at the global level and invoke the matching locally-
// bound handlers (in the Angular Zone instance).
// --
// CAUTION: Using arrow-function hack to pre-bind method context.
private handleKeyEvent = ( event: KeyboardEvent ) : void => {
if ( this.shouldIgnoreEvent( event ) ) {
return;
}
var bindings = this.findBindingsForEvent( event );
for ( var i = 0, length = bindings.length ; i < length ; i++ ) {
var binding = bindings[ i ];
var result = this.manager.getZone().runGuarded(
function runInZone() {
return( binding.handler( event ) )
}
);
if ( binding.terminal || ( result === false ) ) {
// NOTE: Since we're already at the root of the document, there's no
// point in stopping propagation - there's nowhere else for the event
// to go, other than to try lower-priority bindings.
break;
}
}
}
// I normalized the keys string so that it can be consistently compared.
private normalizeKeys( keys: string ) : string {
var parts = keys
.slice( 1 )
.toLowerCase()
.split( /\./g )
.sort()
;
return( parts.join( "." ) );
}
// I parse the eventName into a normalized structure.
private parseEventName( eventName: string ) : ParsedEventName {
var parts = eventName.match( this.eventNamePattern );
return({
type: parts[ 1 ].toLowerCase(),
keys: this.normalizeKeys( parts[ 2 ] ),
priority: +parts[ 3 ],
terminal: !! parts[ 4 ]
});
}
// I remove the given event binding from the bindings collection.
private removeBinding( eventBinding: EventBinding ) : void {
var index = this.bindings.indexOf( eventBinding );
// NOTE: We don't have to check the index because this will never be called with
// anything other than a valid binding (as this is a closed system).
this.bindings.splice( index, 1 );
}
// I determine if the given event should be ignored by the root event handler.
private shouldIgnoreEvent( event: KeyboardEvent ) : boolean {
// We need to ignore key events that are triggered by any kind of input control.
// Otherwise, we run the risk of too many collisions with local key events.
var inputPattern = /^(input|select|textarea)$/i;
return( inputPattern.test( event.target.nodeName ) );
}
}
I really love the fact that we can hook into the event-binding system in Angular 2 in such a seamless way. It lends itself to creative problem solving in which we can hide a lot of the complexity. I'm not saying that my given approach is perfect. But, I do strongly believe that something like this would be very helpful in an Angular 2 application.
Want to use code from this post? Check out the license.
Reader Comments