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:
- standalone functions and objects ($.xxx);
- jQuery selection functions ($.fn.xxx);
- selectors ($.expr.filters.xxx);
- animations ($.fx.step.xxx).
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.
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:
- Don't rely on
$ being the same as jQuery.
- Hide the implementation details.
- Place everything under the jQuery object.
- Only claim a single name and use that for everything.
- Return the jQuery object for chaining whenever possible.
- Use
$.data to store instance details.
- Pass commands for additional functionality, e.g.
$(selector).xxx('destroy').
- Anticipate customisations.
- Use sensible defaults.
- Allow for localisation/localization.
- Test on the major browsers: IE, FireFox, Safari, Opera, Chrome.
- Provide demonstrations and documentation.
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.
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;
},
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).
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', {...});
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.
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');
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');
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.
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.
You can download the MaxLength plugin
as it would be published and the
supporting files, including the unit tests.