Creating A Keyboard-Shortcuts Module In A Modular JavaScript Application
Last week, I looked at handling keyboard shortcuts in a modular JavaScript application architecture. In that initial exploration, I distributed the handling of keyboard shortcuts across two different modules: a Controller module and the View module that would ultimately be affected by the command. After I was done with the code, however, I was left with what felt like a poor separation of concerns. Why should the View know about keyboard shortcuts? It seemed both inappropriate and unfortunate due to the additional overhead in inter-module communication. To try a different approach, I factored the keyboard shortcut logic out of one view, creating a separate module dedicated completely to the use of keyboard shortcuts.
In my first approach, the ColorSwatch view module required a Sandbox object so that it could listen for "key" events triggered by the Controller. This worked, but felt quite sloppy. It required both the controller and the ColorSwatch view to know about keyboard events; and, it distributed that responsibility in such a way that was going to be difficult to maintain.
As I thought about this problem afterwards, it suddenly occurred to me that the ColorSwatch module probably shouldn't know anything about keyboard shortcuts; after all, keyboard shortcuts are not really a property of the module - they're a property of the application in which the module resides. This quickly becomes apparent the moment you imagine a keyboard shortcut that involves the coordination of more than one single module.
Keyboard shortcuts are a point-of-contact with your application. They trigger a wide-variety of actions in the same way that a hash-change (in the location) triggers actions. As such, it probably makes sense that their monitoring and subsequent routing be handled by a single-responsibility module; much like hash-change events are monitored and routed within a complex, client-side application.
To test this theory out, I created a new View called KeyboardShortcuts(). I created this module as a View, as opposed to a Model or a Controller, since it relies on the HTML document node. This module completely owns the concept of keyboard shortcuts - determining which keys map to which commands and when those keys should be monitored.
keyboard-shortcuts.js - Our Keyboard Shortcuts View Module
// Define the keyboard shortcuts view controller.
define(
[
"jquery",
"signal"
],
function( $, Signal ){
// I return an initialized component.
function KeyboardShortcuts( stage ){
// Create a collection of cached DOM elements that are
// required for this view to function.
this.dom = {};
// Since this view isn't really associated with any
// particular DOM element, it will listen to events on
// the stage (root element).
this.dom.stage = stage;
// For optimization purposes, we'll cache an "active"
// input field so that we only have to listen to keyboard
// events that are necessary.
this.dom.activeInput = null;
// Create an event factory.
var signalFactory = Signal.forContext( this );
// Create an event landscape for subscriber binding.
// In this case, we will define an event for each
// keyboard shortcut that can be triggered. For this
// demo, there is only one.
this.events = {};
this.events.nextColor = signalFactory( "nextColor" );
// Return this object reference.
return( this );
}
// Define the class methods.
KeyboardShortcuts.prototype = {
// I handle the blur of the currently active input.
handleBlur: function(){
// Unbind our blur handler - the input is no longer
// active.
this.dom.activeInput.off(
"blur.keyboardShortcuts",
this.handleBlur
);
// Remove the active reference for our input.
this.dom.activeInput = null;
// Start watching for global keyboard events again.
this.watchKeys();
},
// I handle the global key events.
handleKeyPress: function( event ){
// Get the active element at the time the event was
// triggered.
var target = $( event.target );
// Check to see if we are in an input field. If so,
// we want to allow the user to continue using the
// UI as usual.
if (target.is( ":input" )){
// It is likely that many keyboard events will be
// triggered while the Input is focused. As such,
// let's stop listening to keyboard events until
// the user has left the input (blur).
this.stopWatchingKeys();
// Cache the active input (in case).
this.dom.activeInput = target;
// Now, wait for the blur on the Input to re-bind
// the global key handler.
target.on(
"blur.keyboardShortcuts",
$.proxy( this.handleBlur, this )
);
// Return out - we don't want to process this
// event any further.
return;
// Make sure that there are not modifier keys being
// pressed - we don't want to start overriding the
// native features of the browser.
} else if (
event.ctrlKey ||
event.altKey ||
event.metaKey
){
// The user is typing in an input - we don't want
// to intercept that kind of action.
return;
}
// If we've made it this far then the key being
// pressed is a valid candidate for our keybaord
// shortcuts. Now, get the key character.
var keyChar = String.fromCharCode( event.which )
.toLowerCase()
;
// For DEBUGGING ONLY ------ //
console.log( "Looking At: ", keyChar );
// For DEBUGGING ONLY ------ //
// Check to see if this key character matches a
// keyboard shortcut within our system.
if (keyChar === "c"){
// This is a known keyboard shortcut. Prevent the
// default behavior so we can override the native
// function of the browser.
event.preventDefault();
// Trigger the event for going to the next Color.
this.events.nextColor.trigger();
}
},
// I activate the module.
start: function(){
// Start watching the keyboard shortcuts.
this.watchKeys();
},
// I deactivate the module until explicitly told to start
// watching the keys again.
stop: function(){
// Stop watching the keys.
this.stopWatchingKeys();
// Check to see if we are watching any input for
// further activity.
if (this.dom.activeInput){
// Unbind the blur handler - this is no longer
// a relevant event in our workflow.
this.dom.activeInput.off(
"blur.keyboardShortcuts",
this.handleBlur
);
// Clear the active input.
this.dom.activeInput = null;
}
},
// I stop watching the keyboard inputs at the global
// level.
stopWatchingKeys: function(){
// Unbind our global key handler.
this.dom.stage.off(
"keypress.keyboardShortcuts",
this.handleKeyPress
);
},
// I start watching keyboard inputs at the global level.
watchKeys: function(){
// Start watching keyboard events that bubble up to
// the stage.
this.dom.stage.on(
"keypress.keyboardShortcuts",
$.proxy( this.handleKeyPress, this )
);
}
};
// -------------------------------------------------- //
// -------------------------------------------------- //
// Return the constructor for this view.
return( KeyboardShortcuts );
}
);
Notice that when the KeyboardShortcuts() module is instantiated, it is passed a "stage" parameter. This stage - our document node - allows the module to monitor the entire application canvas without being too tightly coupled to how things are architected. We have maintained encapsulation by only referencing elements within the scope of the module.
In addition to monitoring keyboard events, the KeyboardShortcuts() module also tries to optimize the event listening. Since we know that input-based keyboard events are never the source of key-commands, the KeyboardShortcuts() module pauses key-based event monitoring while an Input element reamins the active focus of the application. Once the input element is blurred, however, the KeyboardShortcuts() module goes back to monitoring key-based events.
Furthermore, the KeyboardShortcuts() module is completely responsible for knowing which keys map to which internal application events. In this case, the c/C keys maps to the "nextColor" event. Notice, however, that the KeyboardShortcuts() module simply triggers an event for the given command - it doesn't actually execute the command. This is because executing the command is not the responsibility of this module - the KeyboardShortcuts() module simply provides a place where key-commands can be defined and monitored. Executing the commands is the responsibility of the Controller.
Part of this keyboard shortcut logic used to reside in the ColorSwatch() module. Now that this has been factored out, however, the ColorSwatch() module is much more cohesive and modular. Where it used to rely on a Sandbox object to communicate with the Controller, it now requires nothing more than the target HTML elements on which it will act.
color-swatch.js - Our Re-Factored Color Swatch Module
// Define the color swtch View controller.
define(
[
"jquery",
"signal"
],
function( $, Signal ){
// I return an initialized component.
function ColorSwatch( target ){
// Create a collection of cached DOM elements that are
// required for this view to function.
this.dom = {};
this.dom.target = target;
this.dom.action = this.dom.target.find( "a.action" );
// Define the list of colors to be used to display the
// color swatch.
this.colors = [
"#FF0066",
"#FF3366",
"#FF6666",
"#FF9966",
"#FFCC66",
"#FFFF66",
"#CCCCCC",
"#999999",
"#666666",
"#333333"
];
// Store the current color index.
this.currentColorIndex = 0;
// Listen for the click event on the action.
this.dom.action.click(
$.proxy( this.handleClick, this )
);
// Render the view based on the current internal state.
this.render();
// Return this object reference.
return( this );
}
// Define the class methods.
ColorSwatch.prototype = {
// I return the currently selected color.
getColor: function(){
// Return the color at the current index.
return( this.colors[ this.currentColorIndex ] );
},
// I handle the click of the action.
handleClick: function( event ){
// Cancel the default behavior since this isn't a
// real link.
event.preventDefault();
// Go to the next color.
this.nextColor();
},
// I move to the next color.
nextColor: function(){
// Increment the color index.
this.currentColorIndex++;
// If we moved to far, then loop back around.
if (this.currentColorIndex >= this.colors.length){
// Go back to the beginning of the color list.
this.currentColorIndex = 0;
}
// Re-render the view with the new color.
this.render();
},
// I update the display of the swatch based on the
// currently selected color;
render: function(){
// Update the swatch color.
this.dom.target.css(
"background-color",
this.getColor()
);
}
};
// -------------------------------------------------- //
// -------------------------------------------------- //
// Return the constructor for this view.
return( ColorSwatch );
}
);
Notice that the ColorSwatch() module knows nothing about keyboard shortcuts. It simply exposes an API that can be invoked by the Controller. And, it is the Controller that now coordinates the KeyboardShortcuts() module and the ColorSwatch() module:
demo.js - Our Controller Module
// Define the Controller for the demo.
define(
[
"jquery",
"signal",
"view/toggle-keys",
"view/color-swatch",
"view/keyboard-shortcuts"
],
function( $, Signal, ToggleKeys, ColorSwatch, KeyboardShortcuts ){
// I return an initialized component.
function Demo(){
// Create our toggle keys module.
this.toggleKeys = new ToggleKeys(
$( "div.toggle" )
);
// Create our color swatch module.
this.colorSwatch = new ColorSwatch(
$( "div.swatch" )
);
// Create our keyboard shortcut module.
this.keyboardShortcuts = new KeyboardShortcuts(
$( document )
);
// Bind to the toggle module to listen for toggle events.
this.toggleKeys.events.toggled.bind(
this.handleToggle,
this
);
// Bind to the keyboard shortcuts event.
this.keyboardShortcuts.events.nextColor.bind(
this.handleNextColor,
this
);
// Check to see if the keyboard short-cuts are currently
// on or off. If they are on, we need to start watching
// the keys.
if (this.toggleKeys.isOn()){
// Start watching keyboard events.
this.keyboardShortcuts.start();
}
// Return this object reference.
return( this );
}
// Define the class methods.
Demo.prototype = {
// I handle the nextColor keyboard shortcut.
handleNextColor: function( event ){
// Pass this request onto the appropriate module.
this.colorSwatch.nextColor();
},
// I handle a change on the toggle.
handleToggle: function( event, status ){
// If the toggle is On, start watching keys.
if (event.context.isOn()){
// Start watching keys.
this.keyboardShortcuts.start();
} else {
// Stop watching keys.
this.keyboardShortcuts.stop();
}
}
};
// -------------------------------------------------- //
// -------------------------------------------------- //
// Return the constructor for this controller.
return( Demo );
}
);
Notice that the keyboard shortcut logic has been factored out of the Demo() controller as well! It still facilitates communication between the various Views; but, as far as key-based events, the Demo() controller is no longer involved. The Demo() controller simply binds to events on the KeyboardShortcuts() modules and executes relevant commands on the ColorSwatch() module.
The Demo() controller is still responsible for inter-module coordination; but the monitoring and mapping of key-based events is now completely encapsulated within the KeyboardShortcuts() module. This feels like a much cleaner approach! The modules feel more decoupled and, the concept of keyboard shortcuts feels easier to understand and maintain.
Want to use code from this post? Check out the license.
Reader Comments
Maybe you covered this elsewhere but why is ToggleKeys it's own module? Does it control the logic for more than just turning keyboard shortcuts on and off?
If it is just concerned with turning keyboard shortcuts on and off why not include it in the keyboard shortcuts module?
@Bill,
Excellent question! When I first started thinking about this domain, I was using GMail's keyboard shortcuts. In GMail, the toggling on/off of keyboard shortcuts is part of an entire "Settings" area of the application. As such, the checkbox for keyboard shortcuts was just one of many other settings checkboxes.
To keep in this mentality, I wanted to keep the "toggle" feature outside of actual monitoring of the keyboard events.
If I had come to this from some other context, I may have very well thought of putting the tracking of keys directly in the toggle View. I'd have to think about that a bit more, though, before I say for sure. There is something that feels nice about having the KeyboardShortcuts() module only concerned with the "document" node as far as rendered HTML elements.
Of course, all to say, I'm still learning this stuff - refactoring daily :)
@Ben,
In that larger context of a "settings" module that makes sense. Keep track of setting values in one module, and how to apply those settings in the relevant modules for those settings.
that context just wasn't apparent in this example since you trivialized it (for good reason) .
Thanks for the rapid reply.
@Bill,
No problem! I'm really excited to be thinking about this stuff. The more I can make things modular, the more I feel like I am moving in the right direction!
It was dark when I woke. This is a ray of sunihsne.
@Josey,
Ha ha, thanks :)
I like the idea of KeyboardShortcuts being it's own module. The business of ignoring events or unbinding when the target is an input is definitely a candidate for abstraction and reuse.
I think the KeyboardShortcuts module triggering a "nextColor" event seems outside of its concern. If ColorSwatch view controller binds a click event to an element within its target then I don't see why it couldn't bind to the key events it was interested in. It could optionally accept a keyboard event stage as a second param to the constructor for this binding. Or maybe better yet let the demo controller listen for the interesting key events and trigger the ColorSwatch view to change state as needed.