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
);
- and more.
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.
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:
- 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.
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:
- Initialisation for a set of elements
- Handling inline metadata during initialisation
- Option value setting and retrieval
- Allowing methods to be called
- Destroying the plugin changes when it's no longer needed
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.
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});
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:
elem
- the affected element as a jQuery object,
name
- the name of this plugin ('maxlength'),
options
- the current options set for this instance.
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.
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', {...});
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.
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');
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');
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.
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": "http://keith-wood.name/licence.html"
}
],
"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"
}
}
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.