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. Documentation on this framework is also available.

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 to apply the plugin 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

Common infrastructure code is provided by the jquery.plugin.js module, which must be included before the code for our own plugin. It allows us to extend a basic plugin JavaScript "class" with our own functionality while inheriting the common abilities of any collection plugin. These include:

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 functionality, 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.

var pluginName = 'maxlength';

/** Create the maxlength plugin. */
$.JQPlugin.createPlugin({

	/** The name of the plugin. */
	name: pluginName,

	/** Default settings for the plugin. */
	defaultOptions: {
		...
	},

	... // Localisations
		
	/** Names of getter methods - those that can't be chained. */
	_getters: ['curLength'],

	... // Other fields and functions as shown below

});

Here we are defining our plugin through a call to the $.JQPlugin.createPlugin function, defined in the framework code. This function accepts two parameters: the first is an optional name of another plugin "class" to extend; the second is an object containing overrides to the base functionality. If no name is provided for the class to extend it defaults to the base JQPlugin one.

The name of our plugin is provided through the name field and is used by the framework to create the jQuery integration. Following the createPlugin call there will be a singleton manager object ($.maxlength) and a collection function ($.fn.maxlength) available in jQuery.

The framework uses a Template Method design pattern to provide the basic functionality for a plugin while allowing for the customisation of the processing through overrides.

The framework maps calls to the jQuery collection function through to functions that it or the plugin provides. By default it returns the original jQuery collection from this call, fulfilling the principle of allowing jQuery calls to be chained whenever possible.

The collection function takes at least one parameter: options. When initially attaching the plugin 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 method to execute and may be followed by additional parameters for that method.

The framework checks to see whether a method is specified that returns a value that prevents us chaining additional jQuery function calls - anything listed in the _getters array or the 'option' method when provided with only an option name or no parameters at all. Otherwise the framework invokes either the attachment function or the function for the named method and passes along any additional parameters provided in the call.

Singleton Manager

Having a single object to manage the interactions for the plugin allows us to centralise the processing. We are again using the claimed name for this object and it is automatically created by the framework within the jQuery object itself. The object serves as a repository for global functions and settings that apply to all instances of the plugin in use (such as defaultOptions).

/** Default settings for the plugin. */
defaultOptions: {
	max: 200,
	truncate: true,
	showFeedback: true,
	feedbackTarget: null,
	onFull: null
},

/** Localisations for the plugin.
	Entries are objects indexed by the language code ('' being the default US/English). */
regionalOptions: { // Available regional settings, indexed by language/country code
	'': { // Default regional settings - English/US
		feedbackText: '{r} characters remaining ({m} maximum)',
		overflowText: '{o} characters too many ({m} maximum)'
	}
},

Our settings include 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), a callback that is triggered when the textarea fills or overflows (onFull), the text to display for the normal feedback (feedbackText), and the text to display for the overflow feedback (overflowText). The last two settings are separated out into a regionalOptions array that is indexed by language to allow for localisation.

These are the settings that we can see users wanting to change - anticipating customisation - and all such settings should be included, even if they have null values. 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 framework adds a function (setDefaults) to allow the override of the global defaults, which updates the set of default settings and returns. It is called as follows:

$.maxlength.setDefaults({max: 100});
Attachment

The framework provides the basic attachment handling, allowing the plugin to hook into the process at several key points to provide custom functionality. The standard processing results in the affected element(s) being marked with a class to denote the initialisation - named 'is-<pluginname>' ('is-maxlength' in this case). Initialisation can only be performed once and the framework quietly exits if the marker class is already present. In addition an instance object is created and stored against each element using the data function - named for the plugin ('maxlength' in this case). That instance object has several attributes by default:

The options are accumulated from the defaultOptions for the plugin as a whole, any options set as metadata on individual elements, and any options passed as parameters to the initialisation call. Each set overwrites the ones before allowing simple customisation of the plugin for each element. Metadata options are provided in a data- attribute on the element - named for the plugin ('data-maxlength' in this case) - with its value being a comma-separated list of name/value pairs.

<textarea rows="5" cols="50"
	data-maxlength="max: 100, feedbackText: 'Used {c} of {m}'">

At the appropriate points in the processing, the framework call functions that may be overridden in the plugin. Add _instSettings to return any additional attributes to be added to the instance object for this element. Add _postAttach to specify any extra setup that needs to be done for each instance when it is initialised.

_instSettings: function(elem, options) {
	return {feedbackTarget: $([])};
},

_postAttach: function(elem, inst) {
	elem.on('keypress.' + inst.name, 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);
		}).
		on('keyup.' + inst.name, function() { $.maxlength._checkLength(elem); });
},

For the Maxlength plugin we use _instSettings to add a field to hold the feedback element that is updated as text is entered in the field. In _postAttach we attach the basic event handlers we need to monitor text entry into the field.

When binding the events 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.

At the end of the attachment processing the framework also calls _optionsChanged to notify the plugin that the options on the element instance have changed and that it should update itself accordingly. We'll explore this in the next section.

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. This function is also called 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 by the framework automatically.

In the case of setting option values, the framework handles the adding of the new values to the instance object. Our plugin can hook into this process by overriding the _optionsChanged function. This function is called before the options have changed in the instance object, allowing us to compare the old value (inst.options.xxx) with the new value (options.xxx).

_optionsChanged: function(elem, inst, options) {
	$.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(elem[0], []);
		}
		else if (inst.options.feedbackTarget) {
			inst.feedbackTarget = $(inst.options.feedbackTarget);
		}
		else {
			inst.feedbackTarget = $('<span></span>').insertAfter(elem);
		}
		inst.feedbackTarget.addClass(this._feedbackClass);
	}
	elem.off('mouseover.' + inst.name + ' focus.' + inst.name +
		'mouseout.' + inst.name + ' blur.' + inst.name);
	if (inst.options.showFeedback == 'active') { // Additional event handlers
		elem.bind('mouseover.' + inst.name, function() {
				inst.feedbackTarget.css('visibility', 'visible');
			}).bind('mouseout.' + inst.name, function() {
				if (!inst.focussed) {
					inst.feedbackTarget.css('visibility', 'hidden');
				}
			}).bind('focus.' + inst.name, function() {
				inst.focussed = true;
				inst.feedbackTarget.css('visibility', 'visible');
			}).bind('blur.' + inst.name, function() {
				inst.focussed = false;
				inst.feedbackTarget.css('visibility', 'hidden');
			});
		inst.feedbackTarget.css('visibility', 'hidden');
	}
	this._checkLength(elem);
},

We are not interested in comparing old and new values, so we immediately add the new options to the old ones. We then remove anything established previously based on option values, and then add back in anything that applies because of the new values. In particular, we attach to or add in a feedback element if requested, and add extra event handlers in the case of only showing feedback when the control is active.

The above code is invoked with the command below:

$(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 (via the framework's _getInst function) 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.
	@private
	@param elem {jQuery} The control to check. */
_checkLength: function(elem) {
	var inst = this._getInst(elem);
	var value = elem.val();
	var len = value.replace(/\r\n/g, '~~').replace(/\n/g, '~~').length;
	elem.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 = elem.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++;
		}
		elem.val(value.substring(0, inst.options.max));
		elem[0].scrollTop = elem[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(elem, [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 elem {jQuery} The control to check.
	@return {object} The current counts with attributes used and remaining.
	@example var lengths = $(selector).maxlength('curLength'); */
curLength: function(elem) {
	var inst = this._getInst(elem);
	var value = elem.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 framework redirects to this function automatically. The framework knows that this function should return a value rather than the jQuery collection since it was listed in the _getters array.

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.

_preDestroy: function(elem, inst) {
	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();
		}
	}
	elem.removeClass(this._fullClass + ' ' + this._overflowClass).off('.' + inst.name);
}

The above code is invoked with the command below, which the framework handles automatically. The framework invokes the _preDestroy function in the plugin to let it undo whatever changes it has made, before continuing on to clear out the instance object attached to the element and to remove the marker class.

$(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 older browsers they may be 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 we 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 versions of the code 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) versions. Alternately, see the Google Closure Compiler for similar functionality. Include a basic demonstration page to get users started.

To publish the plugin at the jQuery plugin repository we need to set it up in a Git repository and push updates through to the jQuery site. We also need to create a manifest file for your plugin (named for the plugin - kbw.maxlength.jquery.json) to allow the plugin repository to identify it and provide basic information about it. It's a good idea to include a namespace (kbw here) for the plugins to help differentiate them from others with similar functionality (claiming names in the repository is on a first-come-first-served basis).

The file contains a JSON object detailing the plugin. The name must match the start of the manifest file name and forms the path to the plugin in the repository. Make sure the version is updated for each release. Then all that is required is to add a tag to the Git repository matching the version number.

{
	"name": "kbw.maxlength",
	"title": "jQuery MaxLength",
	"description": "This plugin sets a textarea field up to limit the amount of text that may be entered.",
	"keywords": [
		"input",
		"maxlength",
		"textarea",
		"ui"
	],
	"version": "2.0.0",
	"author": {
		"name": "Keith Wood",
		"url": "http://keith-wood.name/"
	},
	"maintainers": [
		{
			"name": "Keith Wood",
			"email": "kbwood{at}iinet.com.au",
			"url": "http://keith-wood.name/"
		}
	],
	"licenses": [
		{
			"type": "MIT",
			"url": "https://github.com/jquery/jquery/blob/master/MIT-LICENSE.txt"
		}
	],
	"bugs": "https://github.com/kbwood/maxlength/issues",
	"homepage": "http://keith-wood.name/maxlength.html",
	"docs": "http://keith-wood.name/maxlengthRef.html",
	"download": "http://keith-wood.name/maxlength.html",
	"dependencies": {
		"jquery": ">=1.7"
	}
}
Downloads

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

For more information on creating plugins for jQuery you can check out my book, Extending jQuery from Manning.