Backbone.js declarative is-key events

You want to hide a dom element when some key triggers “keyup”. This is a fairly simple scenario where your code just might end up looking like this.

var DialoadView = Backbone.View.extend({
	el: '#dialog',

	events: {
		'keyup': 'closeDialog'
	},

	closeDialog: function (e) {
		if (e.which === 27)
			this.$el.addClass('hidden');
	}
});

So what is the problem? The problem is that closeDialog() has two very clear and very different responsibilities.
1. Determine what key triggered the “keyup” event
2. Add class ‘hidden’ to the DOM element

These two actions should really not be mixed. Yet we don’t want to add closeDialogIfKeyIsEscape() and have this method call two other methods determining key and manipulating DOM, respectively. Instead, we would like to make use of common “is-key” logic that could be used by any function/eventhandler of the View.

Let’s explore a few options.

First try

We could simply wrap the callback of Backbones delegate events. This would allow us to do the following.

var DialoadView = Backbone.View.extend({
	el: '#dialog',

	events: {
		'keyup': ifIsKey(27, 'closeDialog')
	},

	closeDialog: function () {
		this.$el.addClass('hidden');
	}
});

Which would require an implementation similar to the following.

function ifKeyIs(keyCode, method) {
	var slice = Array.prototype.slice;

	return function (e) {
		if (!_.isFunction(method))
			method = this[method];

		if (e.which === keyCode)
			method.apply(this, slice.apply(arguments));
	};
}

Problem

After having implemented this, we soon realize it was an incomplete solution. As long as we only need to listen for a single type of key within a View, we don’t run into trouble. But if different keys need to call different methods then we are out of luck. Properties on objects are unique making it impossible to have multiple declarations as shown by the following.

events: {
	'keyup': ifIsKey(27, 'closeDialog'),
	'keyup': ifIsKey(13, 'submitDialog') // <- Overrides first property
}

Second try

By extending the logic of the property key, instead of the value, we can guarantee uniqueness. We would like to implement functionality that would let us do the following.

var DialoadView = Backbone.View.extend({
	el: '#dialog',

	events: {
		'keyup:iskey(27)': 'closeDialog',
 		'keyup:iskey(13)': 'submitDialog'
	},

	closeDialog: function () {
		this.$el.addClass('hidden');
	},

	submitDialog: function () {},
});

This is much better. We are able to define an event handler for each unique “keyup” event. It is completely reusable by any View and closeDialog() now only has the responsibility of manipulating the DOM element.

Let’s look at an implementation. We are going to intercept the delegateEvents method of the Backbone View prototype. We decorate this with extra behavior before calling the original base function. The Backbone architecture allows us to do this without any hassle. The following would be an implementation of this.

;(function () {
	var slice = Array.prototype.slice;

	function ifKeyIs(keyCode, method) {
		return function (e) {
			if (e.which === keyCode)
				method.apply(this, slice.apply(arguments));
		};
	}

	var delegateEvents = Backbone.View.prototype.delegateEvents,
		eventKeyNameSplitter = /^(\S+):iskey\((\d+)\)\s?(.*)$/;

	Backbone.View.prototype.delegateEvents = function (events) {
		if (!(events || (events = _.result(this, 'events'))))
			return this;

		for (var key in events) {
			var method = events[key],
				match = key.match(eventKeyNameSplitter);
			
			// Does key follow :iskey(...) schema?
			if (!match)
				continue;
			
			// Delete original key
			delete events[key];

			// Map method-key to function if not already a function
			if (!_.isFunction(method))
				method = this[method];

			if (!method)
				continue;

			// Add sanitized key
			var keyCode = parseInt(match[2], 10);
			events[match[1] + ' ' + match[3]] = ifKeyIs(keyCode, method);
		}

		// Run everything as normal
		delegateEvents.apply(this, slice.apply(arguments));

		return this;
	};
}());

That’s it. This code must be loaded after backbone.js but before any View is initialized. Any View will be able to declare :iskey(…) when attaching an events handler through the backbone “events” object. A simple declarative implementation of crosscutting functionality.

Speak Your Mind

*