A jQuery Plugin Framework

jQuery is a JavaScript Library that simplifies HTML page development. As well as all the built-in functionality, jQuery is designed to be extensible, allowing us to enhance jQuery's abilities however we want. We can provide additional:

It is the second category that is examined here. Most jQuery plugins fall under this heading, allowing us to perform an operation on a set of elements resulting from a jQuery selection. Although there are many third-party plugins available, sometimes they don't do just what we want. We can then either update an existing plugin or write our own from scratch.

This article describes a framework that I have used in many of my plugins.

Case Study

To make things less esoteric, we'll go through the process of creating an actual plugin to show how the framework is applied.

HTML input fields have a maxlength attribute to restrict their size, but textareas do not. We will create a plugin to add this functionality. Our final code will look something like this:

$(selector).maxlength({max: 300});

We want to be able to set the maximum length allowed and to provide feedback to the user by showing how many characters are left. For this we need to add event handlers to watch keystrokes and to add content to the page following the textarea. We also need to allow settings to be updated on-the-fly, and to be able to remove the functionality altogether. And, finally, we'll add a way to retrieve the current settings.

As we're developing this plugin, we want to keep in mind the following principles:

First Steps

The preferred file name for a plugin is of the form jquery.xxx.js. We should pick a name that reflects the purpose of the plugin and that we can use throughout, in keeping with the principle of only claiming one name within the jQuery namespace. We'll use 'maxlength' for this example. By only using a single name we reduce the clutter within jQuery and also the possibility of conflicting with another plugin or future enhancements to jQuery itself.

Surround the plugin code with the following to ensure that $ is the same as jQuery and to hide the implementation details:

(function($) { // Hide scope, no $ conflict
	// All the other code ...
})(jQuery);

What this construct is doing is defining an inline, anonymous function and then immediately calling it with a parameter of jQuery. Since the parameter name in the function declaration is $ we can safely use $ within the body of the function knowing that it refers to the jQuery object, as specified by the parameter, regardless of whatever else might be on the page.

Furthermore, this function hides whatever it contains from the outside world, except where we want to expose particular functionalty, such as adding our plugin to jQuery as shown below. Thus we can declare constants and local variables within this function that won't interfere with or be affected by anything else on the page.

// The list of commands that return values and don't permit chaining
var getters = ['settings'];

/* Determine whether a method is a getter and doesn't permit chaining.
   @param  method     (string, optional) the method to run
   @param  otherArgs  ([], optional) any other arguments for the method
   @return  true if the method is a getter, false if not */
function isNotChained(method, otherArgs) {
	if (method == 'option' && (otherArgs.length == 0 ||
			(otherArgs.length == 1 && typeof otherArgs[0] == 'string'))) {
		return true;
	}
	return $.inArray(method, getters) > -1;
}

/* Attach the max length functionality to a jQuery selection.
   @param  options  (object) the new settings to use for these instances (optional) or
                    (string) the method to run (optional)
   @return  (jQuery) for chaining further calls or
            (any) getter value */
$.fn.maxlength = function(options) {
	var otherArgs = Array.prototype.slice.call(arguments, 1);
	if (isNotChained(options, otherArgs)) {
		return plugin['_' + options + 'Plugin'].apply(plugin, [this[0]].concat(otherArgs));
	}
	return this.each(function() {
		if (typeof options == 'string') {
			if (!plugin['_' + options + 'Plugin']) {
				throw 'Unknown method: ' + options;
			}
			plugin['_' + options + 'Plugin'].apply(plugin, [this].concat(otherArgs));
		}
		else {
			plugin._attachPlugin(this, options || {});
		}
	});
};

Here we are defining our plugin function, maxlength, as an extension to $.fn, the set of selection functions. This is the single name that we are claiming within jQuery. Generally, we apply the requested functionality to each of the elements in the current selection and return the result of the each call as the result of our new function, thus fulfilling the principle of allowing jQuery calls to be chained whenever possible.

Our function takes at least one parameter: options. When initially attaching the functionality this parameter is an object containing settings or is not present at all. In all other cases it will be a string value identifying a command to execute and may be followed by additional parameters for that command.

Firstly we check to see whether a command is specified that returns a value that prevents us chaining additional jQuery function calls (isNotChained). In most plugins, the 'option' command may return the current set of options used by the plugin. Otherwise, check the list of known getter commands. If this is such a command, we construct the name of the target method based on the command and return the value of calling it, passing a reference to the first of the selected elements along with the remainder of the arguments as parameters.

Otherwise, based on whether we are initialising the plugin or processing a command, we either call _attachPlugin directly or construct the name of the target method based on the command (_xxxPlugin) and call that. In the latter case we also pass along any other parameters that may have been passed in to the original call, using the Array slice function to extract those after the command and the apply call to pass them on. For both variations we also pass along a reference to the current element as the first parameter. The option || {} construct ensures that a null options object is not passed along to the rest of the code.

Singleton Manager

Having a single object to manage the interactions for the plugin allows us to centralise the processing. We are again using our claimed name for this object and are creating it within the jQuery object itself. We could create the manager outside of jQuery, but that would clutter the global namespace and introduce more opportunities for conflicts with other JavaScript functionality. In the invocation code we made use of this object when redirecting incoming calls and provided the context by passing along the target element as a parameter. The object can also serve as a repository for global settings that apply to all instances of the plugin in use (_defaults).

/* Max length manager. */
function MaxLength() {
	this.regional = []; // Available regional settings, indexed by language code
	this.regional[''] = { // Default regional settings
		feedbackText: '{r} characters remaining ({m} maximum)',
			// Display text for feedback message, use {r} for remaining characters,
			// {c} for characters entered, {m} for maximum
		overflowText: '{o} characters too many ({m} maximum)'
			// Display text when past maximum, use substitutions above
			// and {o} for characters past maximum
	};
	this._defaults = {
		max: 200, // Maximum length
		truncate: true, // True to disallow further input, false to highlight only
		showFeedback: true, // True to always show user feedback, 'active' for hover/focus only
		feedbackTarget: null, // jQuery selector or function for element to fill with feedback
		onFull: null // Callback when full or overflowing,
			// receives one parameter: true if overflowing, false if not
	};
	$.extend(this._defaults, this.regional['']);
}

$.extend(MaxLength.prototype, {
	/* Class name added to elements to indicate already configured with max length. */
	markerClassName: 'hasMaxLength',
	/* Name of the data property for instance settings. */
	propertyName: 'maxlength',

	// Other functions and variables go here...
});

/* Initialise the max length functionality. */
var plugin = $.maxlength = new MaxLength(); // Singleton instance

Our settings include the text to display for the normal feedback (feedbackText), the text to display for the overflow feedback (overflowText), the maximum length allowed (max), whether or not to prevent input after reaching the maximum (truncate), whether or not to show the number of characters remaining (showFeedback), the identity of an optional control to contain any feedback (feedbackTarget), and a callback that is triggered when the textarea fills or overflows (onFull). The first two are separated out into a regional array that is indexed by language to allow for localisation.

These are the settings that we can see users wanting to change - anticipating customisation. Reasonable default values are chosen, although it is likely that the maximum number of characters will need to be altered frequently. Providing sensible defaults allows the plugin's functionality to be easily added to a page and have it work out-of-the-box. Having the feedback text as a setting makes it easy for others to change it or translate it into another language without affecting the underlying functionalty, while using substitution points within it allows us to insert the current values in the appropriate spots, regardless of their order or the language used.

The rest of the code is added to the MaxLength.prototype allowing it to be accessed from all MaxLength objects. We add a function to allow the override of the global defaults, which updates the set of default settings and returns.

/* Override the default settings for all max length instances.
   @param  options  (object) the new settings to use as defaults
   @return  (MaxLength) this object */
setDefaults: function(options) {
	$.extend(this._defaults, options || {});
	return this;
},
Attachment

When initially attaching the functionality we need to bind events and save the settings for the current field instance. We add a marker class to the target element to indicate that it has been processed by this plugin. If it has already been through the attachment step we ignore it on subsequent calls. This approach makes it easy to target multiple, possibly new, occurrences on a page without affecting what has gone before.

/* Attach the max length functionality to a textarea.
   @param  target   (element) the control to affect
   @param  options  (object) the custom options for this instance */
_attachPlugin: function(target, options) {
	target = $(target);
	if (target.hasClass(this.markerClassName)) {
		return;
	}
	var inst = {options: $.extend({}, this._defaults), feedbackTarget: $([])};
	target.addClass(this.markerClassName).
		data(this.propertyName, inst).
		bind('keypress.' + this.propertyName, function(event) {
			if (!inst.options.truncate) {
				return true;
			}
			var ch = String.fromCharCode(
				event.charCode == undefined ? event.keyCode : event.charCode);
			return (event.ctrlKey || event.metaKey || ch == '\u0000' ||
				$(this).val().length < inst.options.max);
		}).
		bind('keyup.' + this.propertyName, function() { plugin._checkLength($(this)); });
	this._optionPlugin(target, options);
},

When binding the events we are interested in to the target element, we use namespaced events (xxx.maxlength, once more using the single name claimed from jQuery). This makes it easier to distinguish our events from others that may be attached to the same element, especially so when it comes to removing them again.

The settings for this instance of the plugin are initialised by creating a new empty settings object and extending it with the global defaults. These will in turn be overridden by any local settings in the _optionPlugin function. The settings are linked to the target element via jQuery's $.data mechanism - once more using our claimed name (this.propertyName).

Change Settings

A common requirement is to be able to change the settings on a control after the plugin functionality has been attached to it. Using the 'option' command we allow a group of settings (as an object) or a single named setting and value to be updated. If the latter format is used, it is converted into the former before processing continues. This function is also reused when attaching the functionality in the first place to initialise the control.

The 'option' command is also used to retrieve one or all setting values for a given instance. This situation is identified by the number and type of parameters provided to the call and is dealt with first in the code below.

/* Retrieve or reconfigure the settings for a control.
   @param  target   (element) the control to affect
   @param  options  (object) the new options for this instance or
                    (string) an individual property name
   @param  value    (any) the individual property value (omit if options
                    is an object or to retrieve the value of a setting)
   @return  (any) if retrieving a value */
_optionPlugin: function(target, options, value) {
	target = $(target);
	var inst = target.data(this.propertyName);
	if (!options || (typeof options == 'string' && value == null)) { // Get option
		var name = options;
		options = (inst || {}).options;
		return (options && name ? options[name] : options);
	}

	if (!target.hasClass(this.markerClassName)) {
		return;
	}
	options = options || {};
	if (typeof options == 'string') {
		var name = options;
		options = {};
		options[name] = value;
	}
	$.extend(inst.options, options);
	if (inst.feedbackTarget.length > 0) { // Remove old feedback element
		if (inst.hadFeedbackTarget) {
			inst.feedbackTarget.empty().val('').
				removeClass(this._feedbackClass + ' ' + this._fullClass + ' ' + this._overflowClass);
		}
		else {
			inst.feedbackTarget.remove();
		}
		inst.feedbackTarget = $([]);
	}
	if (inst.options.showFeedback) { // Add new feedback element
		inst.hadFeedbackTarget = !!inst.options.feedbackTarget;
		if ($.isFunction(inst.options.feedbackTarget)) {
			inst.feedbackTarget = inst.options.feedbackTarget.apply(target[0], []);
		}
		else if (inst.options.feedbackTarget) {
			inst.feedbackTarget = $(inst.options.feedbackTarget);
		}
		else {
			inst.feedbackTarget = $('').insertAfter(target);
		}
		inst.feedbackTarget.addClass(this._feedbackClass);
	}
	target.unbind('mouseover.' + this.propertyName + ' focus.' + this.propertyName +
		'mouseout.' + this.propertyName + ' blur.' + this.propertyName);
	if (inst.options.showFeedback == 'active') { // Additional event handlers
		target.bind('mouseover.' + this.propertyName, function() {
				inst.feedbackTarget.css('visibility', 'visible');
			}).bind('mouseout.' + this.propertyName, function() {
				if (!inst.focussed) {
					inst.feedbackTarget.css('visibility', 'hidden');
				}
			}).bind('focus.' + this.propertyName, function() {
				inst.focussed = true;
				inst.feedbackTarget.css('visibility', 'visible');
			}).bind('blur.' + this.propertyName, function() {
				inst.focussed = false;
				inst.feedbackTarget.css('visibility', 'hidden');
			});
		inst.feedbackTarget.css('visibility', 'hidden');
	}
	this._checkLength(target);
},

We retrieve the existing settings for the control (using $.data) and extend them with the new settings. Then, based on the new settings, we update the control and its associated elements before calling the body function to check the length and thus apply the new settings.

The above code is invoked with the command below, which the main function redirects by constructing the name of the method to call.

$(selector).maxlength('option', {...});
Body

Finally we come to the meat of this plugin - the actual checking of the field length and updating any feedback. We retrieve the settings for the field (from $.data) and truncate the contents if they exceed the specified length. Then any feedback field is updated by substituting into the given text the current values for the various measures. Of course we are using jQuery functionality to perform these changes.

/* Check the length of the text and notify accordingly.
   @param  target  (jQuery) the control to check */
_checkLength: function(target) {
	var inst = target.data(this.propertyName);
	var value = target.val();
	var len = value.replace(/\r\n/g, '~~').replace(/\n/g, '~~').length;
	target.toggleClass(this._fullClass, len >= inst.options.max).
		toggleClass(this._overflowClass, len > inst.options.max);
	if (len > inst.options.max && inst.options.truncate) { // Truncation
		var lines = target.val().split(/\r\n|\n/);
		value = '';
		var i = 0;
		while (value.length < inst.options.max && i < lines.length) {
			value += lines[i].substring(0, inst.options.max - value.length) + '\r\n';
			i++;
		}
		target.val(value.substring(0, inst.options.max));
		target[0].scrollTop = target[0].scrollHeight; // Scroll to bottom
		len = inst.options.max;
	}
	inst.feedbackTarget.toggleClass(this._fullClass, len >= inst.options.max).
		toggleClass(this._overflowClass, len > inst.options.max);
	var feedback = (len > inst.options.max ? // Feedback
		inst.options.overflowText : inst.options.feedbackText).
			replace(/\{c\}/, len).replace(/\{m\}/, inst.options.max).
			replace(/\{r\}/, inst.options.max - len).
			replace(/\{o\}/, len - inst.options.max);
	try {
		inst.feedbackTarget.text(feedback);
	}
	catch(e) {
		// Ignore
	}
	try {
		inst.feedbackTarget.val(feedback);
	}
	catch(e) {
		// Ignore
	}
	if (len >= inst.options.max && $.isFunction(inst.options.onFull)) {
		inst.options.onFull.apply(target, [len > inst.options.max]);
	}
},

This code is invoked in response to any keystroke within the field or when the settings for the field have changed.

Current Length

The 'curLength' command differs from the others in that it returns a value and doesn't allow chaining of further jQuery functions. In this case it returns an object with attributes for the number of characters entered (used) and the number of characters remaining (remaining) for the given textarea.

/* Retrieve the counts of characters used and remaining.
   @param  target  (jQuery) the control to check
   @return  (object) the current counts with attributes used and remaining */
_curLengthPlugin: function(target) {
	var inst = target.data(this.propertyName);
	var value = target.val();
	var len = value.replace(/\r\n/g, '~~').replace(/\n/g, '~~').length;
	return {used: len, remaining: inst.options.max - len};
},

The above code is invoked with the command below, which the main function redirects by constructing the name of the method to call.

var lengths = $(selector).maxlength('curLength');
Destruction

One of the basic commands that all plugins should implement is the ability to remove all the functionality that has been added (events, markup, and settings) and return the DOM to the state it was in before the plugin was invoked. Removing any events that were added is extremely simple due to the use of a namespace when adding them - just unbind everything within that namespace. This leaves any other events attached to the affected controls intact.

/* Remove the plugin functionality from a control.
   @param  target  (element) the control to affect */
_destroyPlugin: function(target) {
	target = $(target);
	if (!target.hasClass(this.markerClassName)) {
		return;
	}
	var inst = target.data(this.propertyName);
	if (inst.feedbackTarget.length > 0) {
		if (inst.hadFeedbackTarget) {
			inst.feedbackTarget.empty().val('').css('visibility', 'visible').
				removeClass(this._feedbackClass + ' ' + this._fullClass + ' ' + this._overflowClass);
		}
		else {
			inst.feedbackTarget.remove();
		}
	}
	target.removeClass(this.markerClassName + ' ' +
			this._fullClass + ' ' + this._overflowClass).
		removeData(this.propertyName).
		unbind('.' + this.propertyName);
}

The above code is invoked with the command below, which the main function redirects by constructing the name of the method to call.

$(selector).maxlength('destroy');
Testing

Obviously testing is very important to ensure that the plugin works the way we expect. I've found a combination of informal visual and user interaction testing alongside automated functional tests works well.

For debugging plugins I use Firefox and Firebug along with jquery.debug.js. With the latter we can include statements like the following to record useful information without disrupting the flow of the processing. When used with Firebug these messages appear in the console, while for other browsers they are added to a list appearing at the end of the page.

$.log(message);

Once the plugin is working well on Firefox, we still need to check it on the other major browsers as there are some differences between their implementations.

For unit testing I use QUnit and create a HTML page that contains the JavaScript for the tests. Add standard sections to the page to (nicely) show the results of the tests and to contain any UI components that we are testing against. The qunit-fixture division is positioned off the page to only show the test results, while still having the controls accessible and "visible" to our tests.

<div id="qunit"></div>
<div id="qunit-fixture">
    UI components here...
</div>

Testing can be grouped into modules, which can then be broken down into sections, each of which consists of a call to test(name, function) with the actual tests appearing in the callback function given here. We should notify how many tests are going to be run within each section with expect(number). Then run the code and make assertions with any of the following:

ok(test, description); // Single test 
equal(v1, v2, description); // Compare two values
notEqual(v1, v2, description); // Ensure two different values
deepEqual(obj1, obj2, description); // Compare two objects
notDeepEqual(obj1, obj2, description); // Ensure two different objects

We need to make sure that we cover as many conditions and functions as we can in these tests. More is generally better as they are very easy to run. We should try to cover edge and error conditions as well as the standard functionality.

We can simulate user events by adding jquery.simulate.js to the page. Use a jQuery selector to locate the target control, then invoke the event by name and pass along any additional parameters for that call.

$(selector).simulate('mouseover', {})
$(selector).simulate('keypress', {charCode: 'a'.charCodeAt(0)})
$(selector).simulate('keydown', {ctrlKey: true, keyCode: $.simulate.VK_HOME})

If everything passes we'll see a green bar across the top of the page. If not, it's a red bar and back to the drawing board. Don't forget to test the plugin in all of the major browsers. If we don't, we can be sure that someone else will.

Publishing

To enable users to be the most out of our plugin, we need to document it and its features. Include a list of all of the possible settings for invoking the plugin (plus their expected types and default values), along with all of the commands that can be executed upon it and their parameters. Make sure you highlight any command(s) that prevent chaining of further jQuery functions. See an example for the MaxLength plugin.

We should also prepare a demonstration page to show the plugin to its best advantage. Provide examples of the various abilities of the plugin and include the code that produces them to allow others to quickly achieve the same effect and to learn from our examples. See a sample for the MaxLength plugin.

The other benefit of providing a demonstration page is that we get to see the plugin from the user's point of view. Is it easy to use and configure? Are its commands and functions consistent? It also provides an additional test-bed for the plugin, as not everything can be checked in an automated unit test. Once again, check the demonstration across all the major browsers - often there are visual aspects that differ between them.

Package all of our JavaScript, CSS, and images into a ZIP file for ease of distribution. Include minimised and/or packed versions so that others may reduce their download costs without having to go through this process themselves. See Dean Edward's Packer tool, which can produce compressed (min) or encoded (pack) versions. Alternately, see the Google Closure Compiler for similar functionality. Include a basic demonstration page to get users started.

And, finally, publish the plugin at the jQuery plugin repository so that others can benefit from our efforts.

Downloads

You can download the MaxLength plugin as it would be published and the supporting files, including the unit tests.