Skip to main content
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Anthony Israel-Davis
Ben Nadel at cf.Objective() 2010 (Minneapolis, MN) with: Anthony Israel-Davis

Handling Keyboard Shortcuts Within Modular JavaScript Application Architecture

By
Published in Comments (1)

Yesterday, when using Gmail, I started to think about keyboard shortcuts. Specifically, I started to think about how keyboard shortcuts get routed within a modular JavaScript application architecture. If I have a module that can respond to the keyboard - but, that module is not supposed to "know" about the document at large - how does it listen for key-based events? I can think of two ways: 1) Either the application listens for keys and then directly invokes methods on encapsulated modules; or 2) the application listens for keys and then announces key events using an intermediary sandbox. While my gut tells me the former is more effective, I've never really used a sandbox bridge; as such, I thought I'd experiment with the latter.

When creating modular JavaScript components, the scope of the component is supposed to be rather limited. This allows the logic of the component to remain decoupled from the rest of the application. Not only does this make building your JavaScript modules easier, it makes augmenting, maintaining, testing, and swapping them out less complex (or so I'm told - I'm still learning this stuff).

Event binding within a module is fairly straightforward for most events: you bind to HTML / UI elements that are within the purview of your module, routing events to handlers that reside within your module controller. Keyboard-based events, however, somewhat complicate the matter; in order to listen for keyboard events, you'll probably have to bind an event handler to an HTML element that is completely outside the domain of your module (ex. the HTML Document node). Doing so breaks encapsulation and tightly couples your module to the rest of the code.

To get around this coupling, we can use a Sandbox object. A sandbox object is passed into a module during instantiation and provides a communications bridge between what the module can see and what the module cannot see. With this approach, we can provide the module with information that it wouldn't ordinarily have while keeping the source of that information completely decoupled from the module mechanics.

To explore the use of a sandbox as a facilitator of keyboard-based shortcuts, I'm going to create a demo in which we have a color swatch that can iterate through a number of predefined colors. The colors can be changed by clicking directly on the swatch; or, if the keyboard shortcuts are enabled, the colors can be toggled by pressing the key, "C" (as in "C"olor). The keyboard shortcuts can be turned on and off by a completely different module, ToggleKeys.

Here is the HTML for the main page of the demo. It has two DIVs that define the two modules and a Form to act as a decoy for our key-press events.

test.htm - Our Modular JavaScript Demo

<!DOCTYPE html>
<html>
<head>
	<title>Toggling Quick-Keys For Modular JavaScript Components</title>

	<!-- Link styles. --->
	<link rel="stylesheet" type="text/css" href="./assets/styles.css"></link>

	<!-- Load the script loader and boot-strapping code. -->
	<script
		type="text/javascript"
		src="./js/lib/require/require.js"
		data-main="./js/main">
	</script>
</head>
<body>

	<h1>
		Toggling Quick-Keys For Modular JavaScript Components
	</h1>


	<!-- BEGIN: Toggle Module. -->
	<div class="toggle">
		<a href="#" class="action">Toggle Quick-Keys:</a>
		<span class="status">Off</span>
	</div>
	<!-- END: Toggle Module. -->


	<!-- BEGIN: Color Swatch Module. -->
	<div class="swatch">
		<a href="#" class="action"><br /></a>
	</div>
	<!-- END: Color Swatch Module. -->


	<!--
		This element is just a decoy to make sure we don't listen
		to keyboard keys unnecessarily.
	-->
	<form>

		<p>
			This is a decoy!<br />
			<input type="text" size="30" />
		</p>

	</form>

</body>
</html>

As you can see by the scripts, this modular JavaScript application is powered by RequireJS. I'm really loving RequireJS because it allows me to think in a silo, concentrating on the inputs and outputs of a single JavaScript module at a time.

In addition to loading some utility classes, this application loads one Controller and two Views:

  • Demo (Controller)
  • ToggleKeys (View)
  • ColorSwatch (View)

The controller has domain over the two views; as such, I want to look at that last. Let's first get an understanding of how the views work. And, to start with, let's take a look at the ToggleKeys view. Since this view does not rely on any outside information, it is entirely encapsulated:

toggle-keys.js - Our ToggleKeys View Helper

// Define the quick-key Toggle View controller.
define(
	[
		"jquery",
		"signal"
	],
	function( $, Signal ){


		// I return an initialized component.
		function ToggleKeys( target ){

			// Create a collection of cached DOM elements that are
			// required for this view to function.
			this.dom = {};
			this.dom.target = target;
			this.dom.toggle = this.dom.target.find( "a.action" );
			this.dom.status = this.dom.target.find( "span.status" );

			// Store the current status.
			this.status = ToggleKeys.STATUS_OFF;

			// Create an event factory.
			var signalFactory = Signal.forContext( this );

			// Create an event landscape for subscriber binding.
			this.events = {};
			this.events.toggled = signalFactory( "toggled" );
			this.events.toggledOn = signalFactory( "toggledOn" );
			this.events.toggledOff = signalFactory( "toggledOff" );

			// Listen for the click event on the toggle.
			this.dom.toggle.click(
				$.proxy( this.handleClick, this )
			);

			// Render the view based on the current internal state.
			this.render();

			// Return this object reference.
			return( this );

		}


		// Create some constants.
		ToggleKeys.STATUS_ON = 1;
		ToggleKeys.STATUS_OFF =2;


		// Define the class methods.
		ToggleKeys.prototype = {

			// Handle the click of the action.
			handleClick: function( event ){

				// Cancel the default behavior since this isn't a
				// real link.
				event.preventDefault();

				// Toggle the status.
				this.toggle();

			},


			// I determine if the toggle is off.
			isOff: function(){

				// Return true if off.
				return( this.status === ToggleKeys.STATUS_OFF );

			},


			// I determine if the toggle is on.
			isOn: function(){

				// Return true if on.
				return( this.status === ToggleKeys.STATUS_ON );

			},


			// I update the display of the toggle based on the
			// current status.
			render: function(){

				// Update the text of the status to reflect the
				// current state.
				this.dom.status.text(
					this.isOn()
						? "On"
						: "Off"
				);

			},


			// I toggle the current status.
			toggle: function(){

				// Check the current status to see how we need
				// to change it.
				this.status = (
					this.isOn()
						? ToggleKeys.STATUS_OFF
						: ToggleKeys.STATUS_ON
				);

				// Render the view for the new status.
				this.render();

				// Check to see which sub-toggle-event we should
				// trigger based on the status.
				if (this.isOn()){

					// Toggled on.
					this.events.toggledOn.trigger();

				} else {

					// Toggled off.
					this.events.toggledOff.trigger();

				}

				// Trigger the toggled event.
				this.events.toggled.trigger( this.status );

			}

		};


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


		// Return the constructor for this view.
		return( ToggleKeys );


	}
);

This View has an action button and a status. The status can be toggled on and off. For each toggling, the view announces an event using the Signal utility class. The signals provided by the view allow the module to announce events without having to worry about anything outside of its own domain.

The ToggleKeys view is cohesive and decoupled from the greater application.

Our other View, ColorSwatch, is a bit more complicated. Like the ToggleKeys module, it also has an internal action button (which iterates through our color choices); however, unlike the ToggleKeys module, the ColorSwatch module relies on events that are initiated outside of the module's container. Specifically, the ColorSwatch needs to know about keyboard events. And, to make matters more complex, it only cares about keyboard events if the ToggleKeys module has enabled keyboard shortcuts.

In order to solve this problem, we could do the following:

  • Let the ColorSwatch module listen for document-wide keyboard events.
  • Let the ColorSwatch module check the status of the ToggleKeys module.

This would work; however, using this approach would couple the ColorSwatch module to both the document and to the ToggleKeys module. Furthermore, in the other direction, it would force the application, at large, to be highly cognizant of the ColorSwatch logic. This level of coupling would quickly create maintenance problems the moment we want to change anything involving keyboard shortcuts.

To create keyboard awareness within the ColorSwatch module while maintaining encapsulation and cohesion, we're going to use a Sandbox. The Sandbox will be passed into the ColorSwatch, providing an event surface that abstracts the concept of keyboard-based events. This will create a coupling between the ColorSwatch module and the Sandbox object, however, the decoupling of the module from the larger application far outweighs this new, localized coupling.

color-swatch.js

// Define the color swtch View controller.
define(
	[
		"jquery",
		"signal"
	],
	function( $, Signal ){


		// I return an initialized component.
		function ColorSwatch( sandbox, target ){

			// Store the sandbox - this is the object through which
			// we will be able to communicate with the world outside
			// of our module.
			this.sandbox = sandbox;

			// 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",
			];

			// Store the current color index.
			this.currentColorIndex = 0;

			// Listen for the click event on the action.
			this.dom.action.click(
				$.proxy( this.handleClick, this )
			);

			// Listen for the key event on the sandbox - the user can
			// use "c" to toggle through the colors without clicking
			// on the action item.
			this.sandbox.events.keyPressed.bind(
				this.handleKeyPress,
				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 handle the pressing of a key.
			handleKeyPress: function( event, keyCode, keyChar ){

				// Check to see if the key is "c" (account of upper-
				// case "C" as well.
				if (
					(keyChar === "c") ||
					(keyChar === "C")
					){

					// 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 );


	}
);

As you can see, in addition to watching internal click events, the ColorSwatch module also binds to the "keyPressed" event on the sandbox. This keyPressed event is a representation of the keyboard-based event; however, the origins of the event remain completely encapsulated away from the ColorSwatch module.

So, where does the keyPressed event come from? The Controller. Our Demo controller is responsible for coordinating the communication between our independent, decoupled modules. Since the keyboard shortcut, "C", only works when the ToggleKeys view has enabled it, our Demo controller knows to only trigger the keyPressed event on the sandbox when the keyboard events are actually relevant. In other words, the ColorSwatch module has absolutely no understanding of enabled vs. disabled keyboard shortcuts - it only knows that the sandbox has announced a keyPressed event.

demo.js - The Demo Controller For Our Two Views

// Define the Controller for the demo.
define(
	[
		"jquery",
		"signal",
		"view/toggle-keys",
		"view/color-swatch"
	],
	function( $, Signal, ToggleKeys, ColorSwatch ){


		// I return an initialized component.
		function Demo(){

			// Before we create our color swatch module, we have
			// to define the sandbox that it will use to listen
			// for key presses.
			this.sandbox = {};
			this.sandbox.events = {};
			this.sandbox.events.keyPressed = new Signal( this, "keyPressed" );

			// Create our toggle keys module.
			this.toggleKeys = new ToggleKeys( $( "div.toggle" ) );

			// Create our color swatch - this one requires the
			// sandbox for keyboard short-cuts.
			this.colorSwatch = new ColorSwatch(
				this.sandbox,
				$( "div.swatch" )
			);

			// Bind to the toggle module to listen for toggle events.
			this.toggleKeys.events.toggled.bind(
				this.handleToggle,
				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.watchKeys();

			}

			// Return this object reference.
			return( this );

		}


		// Define the class methods.
		Demo.prototype = {

			// I handle the key press to see how the key should be
			// routed - not all key presses can be considered
			// keyboard short-cuts.
			handleKeyPress: function( event ){

				// Before we can route the key, check to make sure
				// that the key is not in an input and that there
				// are no "functional" modification keys being
				// pressed -- those might indicate the use of a
				// feature that is not meta to the browser view.
				if (
					$( event.target ).is( ":input" ) ||
					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 made it this far, this is a key strong that
				// we can intercept. Cancel to default event since we
				// want to route this.
				event.preventDefault();

				// Get the key code.
				var keyCode = event.which;

				// Get the key character.
				var keyChar = String.fromCharCode( keyCode );

				// Trigger the key on the sandbox.
				this.sandbox.events.keyPressed.trigger(
					keyCode,
					keyChar
				);

			},


			// 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.watchKeys();

				} else {

					// Stop watching keys.
					this.unwatchKeys();

				}

			},


			// I stop watching the keyboard.
			unwatchKeys: function(){

				// Unbind the key listener.
				$( document ).off( "keypress.demo" );

			},


			// I start watching the keys pressed by the user.
			watchKeys: function(){

				// Start listening for global key strokes.
				$( document ).on(
					"keypress.demo",
					$.proxy( this.handleKeyPress, this )
				);

			}

		};


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


		// Return the constructor for this controller.
		return( Demo );


	}
);

The Demo controller is directly coupled to the two Views - that's its job. It controls and facilitates communication between the views and the application. In order to minimize this coupling, however, we are trying to make good use of a publish and subscribe (Pub/Sub) model of interaction. I'm sure there is a lot of room for optimization here; but, like I said before, I am still learning all of this stuff.

When the Demo controller goes to create the ColorSwatch instance, it has to first create the required Sandbox. As you can see from the code, the sandbox is nothing more than an event surface with a single event signal, keyPressed. The Demo controller will trigger this event and the ColorSwatch view will bind to it.

If you look at the code, you will also see that the Demo controller only triggers this event if the ToggleKeys view has enabled keyboard shortcuts. In fact, the Demo controller doesn't even listen for keyboard events until the ToggleKeys view has enabled the keyboard shortcuts. Using this approach, not only have we encapsulated the source of the keyboard events (from the ColorSwatch module), we also encapsulated the logic behind the activation and deactivation of keyboard shortcuts.

This was my first attempt at creating this type of architecture. I think there's a lot more than can be done to prepare the Demo controller for future change; however, I like the fact that it has kept the View modules very cohesive. A better approach might be to create a KeyboardShortcuts module that manages shortcuts for the entire application; then, have it monitor the keyboard and precipitate actions that directly invoke module behaviors. That's probably what I'll try next.

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

Reader Comments

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