Managing User Input Key-Events Across Views In AngularJS
Managing click events, in an AngularJS application, is somewhat straight-forward because the structure of the DOM (Document Object Model) very closely mirrors the order in which click handlers will be invoked. Key-events, on the other hand, are not so simple. Not only are the events not necessarily tied to a given DOM element, you can't rely on the order of linking across decoupled AngularJS directives. As such, key-events have to be managed with a little bit more elbow-grease.
Run this demo in my JavaScript Demos project on GitHub.
For me, the biggest problem with managing key-events in an AngularJS application is that my key event handlers are often bound in the reverse order in relation to their relative priority. Meaning, the root of the application will often have the first opportunity to bind a key-event handler even though it has the lowest priority. Sub-views, on the other hand, which have the highest priority, are the last (or can be last based on directive architecture) to bind event handlers.
To get around this, I've stopped relying on the order of binding and started relying on explicit priorities. In the same way that we define explicit priorities for our AngularJS directives, I am defining an explicit priority for each set of event handlers bound by a given directive. This way, directives that link later on in the application lifecycle can create higher-priority handlers over the root of the application (which linked much earlier and persists for much longer).
To facilitate this, I moved key-event binding away from the directive DOM elements and into a centralized service - keyEvents. The keyEvents service maintains an internal queue of event handlers that are sorted based on priority. Then, when a key-event is triggered, the keyEvents service iterates over the relevant queue in descending-priority order and invokes the relevant handlers.
Each handler, in the queue, can be "terminal," in which case the keyService will stop processing the event. Or, the event handler itself can stopImmediatePropagation(). Together, this allows each handler to demonstrate both broad and fine-grained control over the way the event is processed.
In addition to the event workflow, the keyEvents service also decorates the event object with a ".is" object that can be used to created short-hand notations for event state. This allows directives to be able to reference something like "event.is.esc" rather than checking key codes and event types.
To explore this idea, I've put together a demo in which I have a list of friends and form for new friends. By default, the form isn't visible - it has to be toggled by the "F" key. However, we don't want the "F" key to close the form while it's in use; as such, the form also has a layer of key-event bindings that make sure the user's experience remains enjoyable.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Managing User Input Key-Events Across Views In AngularJS
</title>
</head>
<body
ng-controller="AppController"
bn-app-manager>
<h1>
Managing User Input Key-Events Across Views In AngularJS
</h1>
<h2>
Friends
</h2>
<ul>
<li ng-repeat="friend in friends track by friend.id">
{{ friend.name }} — <em>{{ friend.bio }}</em>
</li>
</ul>
<p>
Hit "F" to toggle the <strong>F</strong>orm.
</p>
<!--
BEGIN: Friend form.
--
NOTE: The friend form binds key-event handlers that have a higher priority than
the root controller; as such, they will have an opportunity to intercept and stop
events before they "bubble up" through the keyEvents queue.
-->
<form
ng-if="isShowingForm"
ng-controller="FormController"
bn-form-manager
ng-submit="processForm()">
<p>
Name:<br />
<input type="text" ng-model="form.name" size="30" />
</p>
<p>
Bio:<br />
<textarea ng-model="form.bio" rows="5" cols="30"></textarea>
</p>
<p>
<input type="submit" value="Add Friend" />
</p>
</form>
<!-- END: Friend form. -->
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.15.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// When we manage key events using the keyEvents service, each event is augmented
// with a ".is." object that holds shortcuts that more clearly define the event.
// During the config phase, we have the opportunity to add augment methods to the
// service which will add new shortcuts to each event object.
app.config(
function keyEventsConfig( keyEventsProvider ) {
// Each shortcut() operator gets called with the event object and the
// "is" object. The goal here is to add ".is" properties based on the
// state of the event.
keyEventsProvider.addShortcut(
function operator( event, is ) {
// NOTE: The ESC key is more consistently reported in the keydown
// event (as opposed to the keypress event). As such, we'll
// consider it false unless the event type is appropriate.
is.esc = ( ( event.type === "keydown" ) && ( event.which === 27 ) );
// NOTE: The CMD-ENTER combination is more consistently reported
// in the keydown event (as opposed to the keypress event). As
// such, we'll consider it false unless the event type is appropriate.
is.cmdEnter = ( ( event.type === "keydown" ) && ( event.which === 13 ) && event.metaKey );
is.input = (
( event.target.tagName === "BUTTON" ) ||
( event.target.tagName === "INPUT" ) ||
( event.target.tagName === "SELECT" ) ||
( event.target.tagName === "TEXTAREA" )
);
}
);
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope, friendService ) {
// I maintain the collection of friends to render.
$scope.friends = [];
// I determine whether or not the new-friend form is being rendered.
$scope.isShowingForm = false;
// Initialize the data.
loadRemoteData();
// ---
// PUBLIC METHODS.
// ---
// I close the form and reload the friend data.
// --
// CAUTION: Normally, I might use an event-model for this, or routing.
// But, I'm trying to keep the demo as simple as possible.
$scope.closeFormAndReload = function() {
$scope.isShowingForm = false;
loadRemoteData();
};
// I toggle the linking of the form.
$scope.toggleForm = function() {
$scope.isShowingForm = ! $scope.isShowingForm;
};
// ---
// PRIVATE METHODS.
// ---
// I apply the remote data to the local view-model.
function applyRemoteData( friends ) {
$scope.friends = friends;
}
// I load the remote data.
function loadRemoteData() {
friendService.getFriends().then( applyRemoteData );
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the new friend form.
app.controller(
"FormController",
function( $scope, friendService ) {
// I model the form data for ngModel.
$scope.form = {
name: "",
bio: ""
};
// ---
// PUBLIC METHODS.
// ---
// I process the form, potentially adding a new friend to repository.
$scope.processForm = function() {
if ( ! $scope.form.name ) {
return;
}
friendService.addFriend( $scope.form.name, $scope.form.bio );
// NOTE: No need to reset form data as the form is about to be
// destroyed by the parent controller.
// --
// CAUTION: I would normally use something like routing to move the
// user away from the current form. However, in an effort to keep
// this simple, I am consuming a method inherited from the parent
// controller that indicates that the user has finished with the form.
$scope.closeFormAndReload();
};
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manage the root of the application, mostly dealing with key-events.
app.directive(
"bnAppManager",
function( keyEvents ) {
// Return the directive configuration object.
return({
link: link,
restrict: "A"
});
// I bind the JavaScript events to the local scope.
function link( scope, element, attributes ) {
// Create a new key-handler with priority (1) - this is the lowest
// priority in the application. This means that we will get access
// to key events that haven't been terminally blocked by other
// directives in the application.
var keyHandler = keyEvents.handler( 1 )
.keypress(
function handleKeyEvent( event ) {
// Key: F
if ( event.is.f && ! event.metaKey ) {
// Since we are managing this key event, don't let
// the browser execute the default behavior for this
// event (which may be something like automatically
// searching the page when the user starts typing -
// which is a setting I have in Firefox).
event.preventDefault();
// Change the view-model inside a digest so that
// AngularJS knows something has changed.
scope.$applyAsync( scope.toggleForm );
}
}
)
;
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manage the friend form, mostly dealing with key-events.
app.directive(
"bnFormManager",
function( keyEvents ) {
// Return the directive configuration object.
return({
link: link,
restrict: "A"
});
// I bind the JavaScript events to the local scope.
function link( scope, element, attributes ) {
// Focus the input so the user can start typing right-away.
element[ 0 ]
.querySelectorAll( "input[ ng-model ]" )[ 0 ]
.focus()
;
// Create a new key-handler with priority (100) - this means that
// this handler's methods will be invoked before the the root
// controller. This gives the form an opportunity to intercept events
// and stop them from propagating.
var keyHandler = keyEvents.handler( 100 )
// NOTE: Some key-combinations, like the ESC key are more
// consistently reported across browsers in the keydown event.
.keydown(
function handleKeyDown( event ) {
// If the user hits the ESC key, we want to close the form.
if ( event.is.esc ) {
// Change the view-model inside a digest.
// --
// CAUTION: This scope method is inherited from the
// root controller.
scope.$applyAsync( scope.closeFormAndReload );
// Kill the event entirely.
return( false );
}
// As a convenience, we want to listen for the CMD-Enter
// key-combo and use that to submit the Form. This is
// becoming a standard on the web.
if ( event.is.cmdEnter ) {
// Since we are altering the meaning of this key-
// combo, we have to stop the browser from trying to
// execute the core behavior.
event.preventDefault();
// Change the view-model inside a digest.
scope.$applyAsync( scope.processForm );
}
}
)
.keypress(
function handleKeyPress( event ) {
// If the event is triggered by an input-based event,
// we want to stop any propagation of the event. This way,
// no other event-handlers will have a chance to try and
// mess with it. This makes sense since we don't want to
// interrupt the user while they are typing.
if ( event.is.input ) {
event.stopImmediatePropagation();
}
}
)
;
// Since we are listening for key events on a service (ie, not on
// the current Element), we have to be sure to teardown the bindings
// so that we don't get rogue event handlers persisting in the
// application.
scope.$on(
"$destroy",
function() {
keyHandler.teardown();
}
);
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide an event handler queue that manage prioritized key-event bindings.
app.provider(
"keyEvents",
function provideKeyEvents() {
// I hold the list of functions that can be used to decorate the .is.
// object with additional short-hand properties.
var shortcuts = [
function operator( event, is ) {
// Always add the lower-case key.
is[ String.fromCharCode( event.which ).toLowerCase() ] = true;
}
];
// Return the provider API.
return({
// This is the core factory method for the service.
$get: keyEventsFactory,
// This is the provider-method that lets the developer add shortcut
// operators.
addShortcut: addShortcut
});
// ---
// PUBLIC METHODS.
// ---
// I add a new shortcut operator that will be applied to every key event.
function addShortcut( operator ) {
shortcuts.push( operator );
}
// ---
// FACTORY METHOD.
// ---
// I provide the keyEvents service.
function keyEventsFactory( $document, KeyEventHandler ) {
// I hold the event handler queues in ascending priority order. As
// such, they should be iterated over in reverse order.
var eventHandlers = {
keydown: [],
keypress: [],
keyup: []
};
// Setup the public API.
var api = {
handler: handler,
off: off,
on: on
};
// Return the public API.
return( api );
// ---
// PUBLIC METHODS.
// ---
// I create a new handler with the given priority. If the terminal
// flag is set, no handlers at a lower priority will receive the
// event, regardless of what this handler does to the event.
function handler( priority, isTerminal ) {
return( new KeyEventHandler( api, priority, ( isTerminal || false ) ) );
}
// I unbind the given event handler. This will unbind all matching
// handlers at any priority.
function off( eventType, handler ) {
deregisterHandler( eventType, handler );
}
// I bind a handler to the given event type at the given priority.
// If the terminal flag is set, no other handlers at a lower priority
// will receive the event.
function on( eventType, handler, priority, isTerminal ) {
registerHandler( eventType, handler, priority, isTerminal );
}
// ---
// PRIVATE METHODS.
// ---
// I remove the given handler from the given event type.
function deregisterHandler( eventType, handler ) {
var handlers = eventHandlers[ eventType ];
for ( var i = ( handlers.length - 1 ) ; i >= 0 ; i-- ) {
if ( handlers[ i ].handler === handler ) {
handlers.splice( i, 1 );
}
}
// If this particular event has no more event bindings, then
// stop watching for it at the root level.
if ( ! handlers.length ) {
stopWatchingEvent( eventType );
}
}
// I process the root key-event, passing it through queue of event
// handlers implemented by the directives.
function handleEvent( event ) {
var handlers = eventHandlers[ event.type ];
event.is = {};
// Pass the event through the shortcut operators.
for ( var i = 0, length = shortcuts.length ; i < length ; i++ ) {
shortcuts[ i ]( event, event.is );
}
// Since the handlers are sorted in ascending priority, we need
// to process the queue in reverse order.
for ( var i = ( handlers.length - 1 ) ; i >= 0 ; i-- ) {
// If a handler returns an explicit false, kill the event.
if ( handlers[ i ].handler( event ) === false ) {
event.stopImmediatePropagation();
event.preventDefault();
return( false );
}
// If the handler is flagged as terminal, or if the handle
// explicitly stopped propagation, don't pass the event onto
// any other handlers.
if ( handlers[ i ].isTerminal || event.isImmediatePropagationStopped() ) {
break;
}
}
}
// I register the given event-type handler at the given priority.
function registerHandler( eventType, handler, priority, isTerminal ) {
var handlers = eventHandlers[ eventType ];
var newItem = {
handler: handler,
priority: priority,
isTerminal: isTerminal
};
// If it's the first handler, just push it and start watching for
// events on the document.
if ( ! handlers.length ) {
handlers.push( newItem );
return( startWatchingEvent( eventType ) );
}
// If it's the lowest priority handler, push it onto the bottom.
if ( handlers[ 0 ].priority > priority ) {
return( handlers.unshift( newItem ) );
}
// Otherwise, add it in priority sort order.
for ( var i = ( handlers.length - 1 ) ; i >= 0 ; i-- ) {
if ( handlers[ i ].priority <= priority ) {
return( handlers.splice( ( i + 1 ), 0, newItem ) );
}
}
}
// I start watching the event event-type on the document.
function startWatchingEvent( eventType ) {
$document.on( eventType, handleEvent );
}
// I stop watching the event event-type on the document.
function stopWatchingEvent( eventType ) {
$document.off( eventType, handleEvent );
}
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I work hand-in-hand with the keyEvents service to provide priority-specific
// handler. This is just a convenience proxy to the keyEvents service.
app.factory(
"KeyEventHandler",
function() {
// Return the constructor function.
return( KeyEventHandler );
// I provide event-binding methods that are pre-bound to the given
// priority and terminal settings.
function KeyEventHandler( keyEvents, priority, isTerminal ) {
// I hold the collection of event handlers associated.
var eventHandlers = {
keydown: [],
keypress: [],
keyup: []
};
// Setup the public API.
var api = {
keydown: keydown,
keypress: keypress,
keyup: keyup,
teardown: teardown
};
// Return the public API.
return( api );
// ---
// PUBLIC METHODS.
// ---
// I bind the given handler to the keydown event.
function keydown( handler ) {
eventHandlers.keydown.push( handler );
keyEvents.on( "keydown", handler, priority, isTerminal );
return( api );
}
// I bind the given handler to the keypress event.
function keypress( handler ) {
eventHandlers.keypress.push( handler );
keyEvents.on( "keypress", handler, priority, isTerminal );
return( api );
}
// I bind the given handler to the keyup event.
function keyup( handler ) {
eventHandlers.keyup.push( handler );
keyEvents.on( "keyup", handler, priority, isTerminal );
return( api );
}
// I unbind all of the event handlers bound by this proxy.
function teardown() {
teardownEventType( "keydown" );
teardownEventType( "keypress" );
teardownEventType( "keyup" );
}
// ---
// PRIVATE METHODS.
// ---
// I unbind the specific handlers from the core keyEvents service.
function teardownEventType( eventType ) {
var handlers = eventHandlers[ eventType ];
for ( var i = 0, length = handlers.length ; i < length ; i++ ) {
keyEvents.off( eventType, handlers[ i ] );
}
}
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide access to the friend repository.
app.factory(
"friendService",
function( $q ) {
// Create an initial friend collection.
var friends = [
{
id: 1,
name: "Heather",
bio: "Spends her time rocking out in her indie goth metal band."
},
{
id: 2,
name: "Joanna",
bio: "Rocks out in the gym like it's going out of style."
}
];
// Return the public API.
return({
addFriend: addFriend,
getFriends: getFriends
});
// ---
// PUBLIC METHODS.
// ---
// I add a new friend to the collection. Returns a promise.
function addFriend( name, bio ) {
var friend = {
id: ( new Date() ).getTime(),
name: name,
bio: bio
};
friends.push( friend );
return( $q.when( friend.id ) );
}
// I get the friends collection. Returns a promise.
function getFriends() {
return( $q.when( angular.copy( friends ) ) );
}
}
);
</script>
</body>
</html>
If I open the form and start typing in it, you will notice that typing the letter "F" does not toggle the form. That's because the bnFormManager directive prevents that key event from being processed by the root bnAppManager directive:
One rather tricky part of managing key-events relates to the general quirks across different browsers. For example, the ESC key may be available in the keypress event for Firefox but not in Chrome. Unfortunately, I don't think any "library" can abstract those differences away. As such, the developer still has to consider whether he or she should be working with a keydown event as opposed to a keypress event.
This approach may not be perfect; but, it's the best approach that I've come up with so far. There's no doubt that dealing with key-events is a difficult task in a complex AngularJS application with many nested views. The goal of this approach is just to make some of it easier to manage - not necessarily to abstract-away all of the problems.
Want to use code from this post? Check out the license.
Reader Comments
I like what you've done here but does it work properly when you have sibling priority key handlers?
Lets say you have the following structure
view key handler (priority 1)
- menu key handler (priority 2)
- carousel key handler (priority 2)
If the carousel has focus and doesn't consume the key event, you would like it to propagate to the view key handler, but wouldn't it propagate to the menu key handler instead?
@Arnie,
Good question - the "priority" was intended to handle this exactly. As such, I would say that you shouldn't have two different key event handlers with the same priority. I think about it like setting the Z-Index in CSS. Sure, it *can* work when you have two elements with the same Z-Index... but, at some point, that can cause a problem. And when it does, you either have to rearrange your elements; or, you have to change the Z-Index that is assigned.
Great article. Thanks a bunch for this. For those interested, I genericised your key handler directive to work with both ng-if and ng-show since I was using ng-show. Seems to work a charm. Probably have to deal with the priority issue but for now it seems to work just fine with everything having the same priority. I just make sure all my controllers have a cancel and a cmdEnter function.
app.directive(
"wbKeyHandlerForm",
function( keyEvents ) {
// Return the directive configuration object.
return({
link: link,
restrict: "A"
});
function setup(scope, element) {
// Focus the input so the user can start typing right-away.
element[0].querySelectorAll("input[ng-model]")[0].focus();
// Create a new key-handler with priority (100) - this means that
// this handler's methods will be invoked before the the root
// controller. This gives the form an opportunity to intercept events
// and stop them from propagating.
return keyEvents.handler(100)
// NOTE: Some key-combinations, like the ESC key are more
// consistently reported across browsers in the keydown event.
.keydown(
function handleKeyDown(event) {
// If the user hits the ESC key, we want to close the form.
if (event.is.esc) {
// Change the view-model inside a digest.
// --
// CAUTION: This scope method is inherited from the
// root controller.
scope.$applyAsync(scope.cancel);
// Kill the event entirely.
return false;
}
// As a convenience, we want to listen for the CMD-Enter
// key-combo and use that to submit the Form. This is
// becoming a standard on the web.
if ( event.is.cmdEnter ) {
// Since we are altering the meaning of this key-
// combo, we have to stop the browser from trying to
// execute the core behavior.
event.preventDefault();
// Change the view-model inside a digest.
scope.$applyAsync(scope.cmdEnter);
}
}
)
.keypress(
function handleKeyPress(event) {
// If the event is triggered by an input-based event,
// we want to stop any propagation of the event. This way,
// no other event-handlers will have a chance to try and
// mess with it. This makes sense since we don't want to
// interrupt the user while they are typing.
if (event.is.input) {
event.stopImmediatePropagation();
}
}
);
}
// I bind the JavaScript events to the local scope.
function link(scope, element, attrs) {
var keyHandler;
if (attrs.ngIf) {
keyHandler = setup(scope, element);
// teardown the key handler when the element is destroyed
scope.$on(
"$destroy",
function() {
keyHandler.teardown();
}
);
} else {
// Setup a watch so that as the element is shown or hidden
// we either recreate the keyhandler or tear it down.
scope.$watch(attrs.ngShow, function(val) {
//
// If the variable we are watching is undefined then this is
// a false call. Not sure what triggers that.
if (val === undefined) {
return;
}
if (val) {
keyHandler = setup(scope, element);
} else {
// Since we are listening for key events on a service (ie, not on
// the current Element), we have to be sure to teardown the bindings
// so that we don't get rogue event handlers persisting in the
// application.
if (keyHandler) {
keyHandler.teardown();
keyHandler == null;
}
}
});
}
}
}
);
Ugh. sorry about the size and formatting. Thought it would be handled better. :(
Hi Ben,
I got circular dependency exception at bnFormManager, it is like bnFormManager -> keyEvents -> KeyEventHandler -> keyEvents.. what might be i am missing ?
@Cristi,
I got same error. Did you solved it somehow?