Last modified January 11, 2010.
This was written largely to help me make sense of using UI to create my own widgets,
but I hope it may help others. "Widget" to me means a user-interface element, like
a button or something more complicated like a popup date picker, but in jQuery UI terms
it means a class, members of which are associated with HTML elements; things like
Draggable and
Sortable.
In fact, not everything that I would have called a widget uses $.widget; the UI datepicker does not.
Modifying Elements: Plugins
That being as it may, let's use $.widget.
Let's take a paragraph of class target:
<p class="target">This is a paragraph</p>
And lets make it green. We know how; $('.target').css({background: 'green'}).
Now, make it more general-purpose: a plugin:
$.fn.green = function() {return this.css({background: 'green'})}
But this allows us to perform some behavior on the selected elements; it does not leave us with any way to
keep our plugin associated with that element, so we can do something with it later, like
$('.target').off() to remove the green background, but only if we used green to
put it there in the beginning. We also have no way of associating
state with the element, to do $('.target').darker(), which would require knowing how green the element is now.
Keeping State in Plugins
We could create an object and associate it with an element using javascript expandos:element.myobject = new Myobject({'target': element}). Sample code would be:
$.fn.green2 = function() {
return this.each(function(){
if (!this.green) this.green = new Green(this); // associate our state-keeping object with the element
this.green.setLevel(15);
});
};
$.fn.off = function() {
return this.each(function(){
if (this.green) this.green.setLevel(16);
delete this.green; // recover the memory
});
};
$.fn.darker = function() {
return this.each(function(){
if (this.green) this.green.setLevel(this.green.getLevel()-1);
});
};
$.fn.lighter = function() {
return this.each(function(){
if (this.green) this.green.setLevel(this.green.getLevel()+1);
});
};
function Green(target){
greenlevels = ['#000','#010','#020','#030','#040','#050','#060','#070','#080','#090','#0a0','#0b0','#0c0','#0d0','#0e0','#0f0','#fff'];
this.target = target; // associate the element with the object
this.level = 0;
this.getLevel = function() { return this.level; }
this.setLevel = function(x) {
this.level = Math.floor(Math.min(greenlevels.length-1, Math.max(0,x)));
this.target.css({background: greenlevels[this.level]});
}
};
But this pollutes the $.fn namespace terribly, with off, darker and lighter.
There are ways to create real namespaces within $.fn, but the usual design pattern is to use a string to
specify which function to call. Thus, element.green2() to instantiate the plugin,
element.green2('darker') or element.green2('lighter')
to manipulate it:
$.fn.green2 = function(which){
return this.each(function(){
if (which === undefined){ // initial call
if (!this.green) this.green = new Green($(this)); // associate our state-keeping object with the element
this.green.setLevel(15);
}else if (which == 'off'){
if (this.green) this.green.setLevel(16);
delete this.green
}else if (which == 'darker'){
if (this.green) this.green.setLevel(this.green.getLevel()-1);
}else if (which == 'lighter'){
if (this.green) this.green.setLevel(this.green.getLevel()+1);
}
});
};
function Green(target){
greenlevels = ['#000','#010','#020','#030','#040','#050','#060','#070','#080','#090','#0a0','#0b0','#0c0','#0d0','#0e0','#0f0', '#fff'];
this.target = target; // associate the element with the object
this.level = 0;
this.getLevel = function() { return this.level; }
this.setLevel = function(x) {
this.level = Math.floor(Math.min(greenlevels.length-1, Math.max(0,x)));
this.target.css({background: greenlevels[this.level]});
}
};
<p class="target">This is a test paragraph</p>
The Problems with Associating an Object with a Plugin
But you get into trouble with circular
references (note that "this.green = new Green($(this))" gives a DOM element a reference to a javascript object
and
"this.target = target" gives a javascript object a reference to a DOM element) and
memory leaks:
browsers (notably Internet Explorer) uses different garbage collectors for DOM elements and javascript objects.
Circular references mean that each garbage collector thinks the other object is in use and won't delete them.
We also need to remember to reclaim the memory (with delete) if we no longer need the plugin.
jQuery solves the circular reference problem with the $.fn.data plugin:
$(element).data('myobject', new Myobject({'target': element})). But now we've got a lot of "paperwork" to
keep track of, and it hides the underlying program logic. As we know,
design patterns reflect language weakness.
If we are
constantly re-implementing a pattern, we need to abstract it and make it automatic.
Solving the Problem: $.widget
That's where$.widget comes
in. It creates a plugin and an associated javascript class and ties an instance of that class with each
element so we can interact with the object and act on the element, without getting into trouble with
memory leaks.
You still need to create the constructor of your class, but instead of a real constructor function, you need
a prototype object with all the relevant methods. There are a few conventions: the function _init is called on construction,
the function destroy is called on removal. Both of these are predefined but you can override them (and most likely
will need to override init). element is the associated jQuery object (what we called target above).
Widget methods that start with "_" are pseudo-private; they cannot be called with the $(element).plugin('string') notation
var Green3 = {
_init: function() { this.setLevel(15); },
greenlevels: ['#000','#010','#020','#030','#040','#050','#060','#070','#080','#090','#0a0','#0b0','#0c0','#0d0','#0e0','#0f0', '#fff'],
level: 0,
getLevel: function() { return this.level; },
setLevel: function(x) {
this.level = Math.floor(Math.min(this.greenlevels.length-1, Math.max(0,x)));
this.element.css({background: this.greenlevels[this.level]});
},
darker: function() { this.setLevel(this.getLevel()-1); },
lighter: function() { this.setLevel(this.getLevel()+1); },
off: function() {
this.element.css({background: 'none'});
this.destroy(); // use the predefined function
}
};
Notice it's all program logic, no DOM or memory-related bookkeeping. Now we need to create a name, which must be preceded by a
namespace, like "ns.green" . Unfortunately the namespacing is fake; the plugin is just called $().green(). The constructor function
is $.ns.green, but you never use that, so you might as well use the "official" namespace of "ui". But defining the widget couldn't be easier:
$.widget("ui.green3", Green3); // create the widget
Manipulating Widgets
What about our manipulating functions? All the functions defined in the prototype that don't start with an underscore are exposed automatically:
$('.target').green3() creates the widgets; $('.target').green3('darker') manipulates them.
If your function is intended to be a "getter"; something that returns a value rather than manipulates the objects
(like $('.target').html() returns the innerHTML) then you need to tell the widget that by assigning a list of names
(space or comma-delimited) or array of names. Note that only the value for the first element in the jQuery object will
be returned; exactly like .html() or .val().
$.ui.green3.getter = "getLevel otherGetter andAnother";
// or
$.ui.green3.getter = "getLevel, otherGetter, andAnother";
// or
$.ui.green3.getter = ["getLevel","otherGetter","andAnother"];
<p class="target">This is a test paragraph.</p>
Pass arguments to the manipulating functions after the name: $('.target').green3('setLevel', 5).
Data for Each Widget
The astute reader will have noticed that level is a class variable; the same variable is used
for every green3 object. This is clearly not what we want; each instance should have its own copy.
$.widget defines two more functions that let us store and retrieve data for each instance individually:
_setData and _getData. Note that these are functions of the widget object, not the jQuery one. They do not use the
$(element).data('widgetName'); that returns the widget object itself. They store their data in an object
this.options.
Thus:
var Green4 = {
getLevel: function () { return this._getData('level'); }, // note: we could use this.options.level directly, but using the
setLevel: function (x) { // functions gives us more flexibility
var greenlevels = this._getData('greenlevels');
var level = Math.floor(Math.min(greenlevels.length-1, Math.max(0,x)));
this._setData('level', level);
this.element.css({background: greenlevels[level]});
},
_init: function() { this.setLevel(this.getLevel()); }, // grab the default value and use it
darker: function() { this.setLevel(this.getLevel()-1); },
lighter: function() { this.setLevel(this.getLevel()+1); },
off: function() {
this.element.css({background: 'none'});
this.destroy(); // use the predefined function
}
};
$.widget("ui.green4", Green4);
$.ui.green4.getter = "getLevel";
The initial values for the data are stored in the widget's defaults
object:
$.ui.green4.defaults = {
level: 15,
greenlevels: ['#000','#010','#020','#030','#040','#050','#060','#070','#080','#090','#0a0','#0b0','#0c0','#0d0','#0e0','#0f0', '#fff']
};
And on creating an instance of a widget, pass an options object (the way most plugins do)
and override the defaults:
$('.target').green4({level: 8}).
Note that I also put the list of colors into the defaults object, so it too can be overridden. This widget probably shouldn't be called "green" anymore!
<p class="target">This is a test paragraph.</p>
<p class="target">
This is a test paragraph called with .green4({
level:3,
greenlevels: ['#000','#00f','#088', '#0f0', '#880', '#f00', '#808', '#fff']
}).
</p>
Callbacks, or, Keeping the Lines of Communication Open
The programmer who is embedding our widget in his page may want to do other things when the widget changes state. There are two ways to alert the calling program that something has happened:
- Tightly Coupled
- The caller can provide a function to call at the critical point. jQuery jargon calls this a "callback;" it's used in
animations and
Ajax. We can create callback functions
that the widget calls at critical points, and pass them to the widget-constructing plugin like any other option:
var Green5 = { setLevel = function(x){ //... this.element.css({background: greenlevels[level]}); var callback = this._getData('change'); if ($.isFunction(callback)) callback(level); }, // ... rest of widget definition }; $.widget("ui.green5", Green5); $('.target').green5({change: function(x) { alert ("The color changed to "+x); } }); - Loosely Coupled
- Also called the Observer Design Pattern, the widget sends a signal to
the programming framework and the calling program informs the framework that it wants to know about the signal. Events like
clicks and keystrokes work like this, and jQuery allows the widget to create custom events and for the calling program to
bind an event handler to that custom event:
var Green5 = { setLevel = function(x){ //... this.element.css({background: greenlevels[level]}); this.element.trigger ('green5change', [level]); }, // ... rest of widget definition }; $.widget("ui.green5", Green5); $('.target').green5(); $('.target').bind("green5change", function(evt,x) { alert ("The color changed to "+x); });
$.widget allows both forms with the _trigger method. In a widget object,
this._trigger(type, event, data) takes a type {String} with the
name of the event you want (use some short verb, like 'change') and optionally a
$.Event object (if you want to pass things like timestamps and mouse locations.
Don't worry about event.type; _trigger changes it to the constructed event name), and any data to be passed
to the handler. _trigger creates a custom event name of widgetName+type, like green6change
(why it doesn't do naming events this way has been the subject of some discussion), sets type+'.'+widgetName the way jQuery expects
is beyond meevent.type = custom event name (creating a new $.Event if it was not provided)
and calls this.element.trigger(event, data) and then looks for a callback with
callback = this._getData(type) and calls it with
callback.call(this.element[0], event, data).
Notice that this means the function signature is slightly different for the event handler and the callback if data
is an array. element.trigger() uses apply to turn each item in the array into a separate argument.
So this._trigger('change', 0, ['one', 'two'])
requires an event handler of the form function(event, a, b) and a callback of the form
function(event, data).
In practice, it's not as complicated as it sounds. For example, using both methods:
var Green5 = {
getLevel: function () { return this._getData('level'); },
setLevel: function (x) {
var greenlevels = this._getData('greenlevels');
var level = Math.floor(Math.min(greenlevels.length-1, Math.max(0,x)));
this._setData('level', level);
this.element.css({background: greenlevels[level]});
this._trigger('change', 0, level);
},
_init: function() { this.setLevel(this.getLevel()); }, // grab the default value and use it
darker: function() { this.setLevel(this.getLevel()-1); },
lighter: function() { this.setLevel(this.getLevel()+1); },
off: function() {
this.element.css({background: 'none'});
this._trigger('done');
this.destroy(); // use the predefined function
}
};
$.widget("ui.green5", Green5);
$.ui.green5.getter = "getLevel";
$.ui.green5.defaults = {
level: 15,
greenlevels: ['#000','#010','#020','#030','#040','#050','#060','#070','#080','#090','#0a0','#0b0','#0c0','#0d0','#0e0','#0f0', '#fff']
};
<p class="target">This is a test paragraph with green level <span class="level">undefined</span>.</p>
// The on button above does the following:
$('.target').green5({
change: function(event, level) { $('.level', this).text(level); } // callback to handle change event
});
$('.target').bind('green5done', function() { $('.level', this).text('undefined');alert('bye!') }); // event handler for done event
Involving the Mouse
Now, a lot of what we want to do with widgets involves mouse tracking, so ui.core.js provides a mixin object that
includes lots of useful methods for the mouse. All we need to do is add the $.ui.mouse object to our
widget prototype:
var Green6 = $.extend({}, $.ui.mouse, {other relevant functions});
And override $.ui.mouse's functions (_mouseStart,
_mouseDrag,
_mouseStop) to do something useful,
and call this._mouseInit
in your this._init and this._mouseDestroy
in your this.destroy. You also have to incorporate the $.ui.mouse.defaults in your defaults (most easily by doing $.ui.widgetName.defaults = $.extend({}, $.ui.mouse.defaults, {all my other defaults...}).
Let's add some mouse control to our greenerizer:
Green6 = $.extend({}, $.ui.green5.prototype, $.ui.mouse,{ // leave the old Green5 alone; create a new object
_init: function(){
$.ui.green5.prototype._init.call(this); // call the original function
this._mouseInit(); // start up the mouse handling
},
destroy: function(){
this._mouseDestroy();
$.ui.green5.prototype.destroy.call(this); // call the original function
},
// need to override the mouse functions
_mouseStart: function(e){
// keep track of where the mouse started
this._setData('xStart', e.pageX);
this._setData('levelStart', this._getData('level'));
},
_mouseDrag: function(e){
this.setLevel(this._getData('levelStart') +(e.pageX-this._getData('xStart'))/this._getData('distance'));
}
});
$.widget("ui.green6", Green6);
$.ui.green6.defaults = $.extend({}, $.ui.mouse.defaults, {
level: 15,
greenlevels: ['#000','#010','#020','#030','#040','#050','#060','#070','#080','#090','#0a0','#0b0','#0c0','#0d0','#0e0','#0f0', '#fff'],
distance: 10
});
<p class="target">This is a test paragraph with green level <span class="level">undefined</span>.</p>
The ever-alert reader will note what we've just done: subclassed green5 to make green6, including calls
to "super" methods. This ought to be abstracted out
into its own method, something like
$.ui.green5.subclass("green6", $.ui.mouse, {mouseStart: function(){}, mouseDrag: function(){}})
but that's a topic for another day.



{ 37 } Comments
Thanks for the post. Very interesting.
Its good you pointed out the observer pattern, as I feel that is more flexible than the callback approach. It makes it a bit easier to unit test, sometimes, but also allows more than one thing to observe the events, if needed.
Just in case anyone has the problem I did: if you get the error “$.Event is not a function” it’s because your jQuery and jQuery UI versions are incompatible.
You need to use either:
jQuery UI 1.5.3 with jQuery 1.2.6 (not 1.3+)
jQuery UI 1.6rc5 with jQuery 1.3.1 (not 1.2.6)
Obviously didn’t RTFM…
Brilliant post, thank you very much!
–rob
@Rob Desbois:
Yes. This tutorial uses jQuery UI 1.6 (right now it’s rc6, but the release version should not change), which requires jQuery 1.3. The old version of this page (http://bililite.com/blog/2008/08/03/jquery-ui-widgets/) used jQuery 1.5, if you’re sticking with that.
The fact that the UI team couldn’t get the new release out in time, so the “official” versions are incompatible was unfortunate. Hopefully, 1.6 will be released this week and we can all breath a sigh of relief.
-Danny
Thanks very much, it was a great read. I am new to jQuery so the preamble to widgets really helps recognize why jQuery is so friendly. I learnt about event namespacing just a few hours back out of need; hope they can switch the convention in widgets if possible.
Looking forward to reading your post on extending widgets next.
PS: Would be helpful if you dated your articles so it’s easier to gauge relevancy given the rapidly changing landscape.
@aleemb:
Good idea on the dates; I added that. I also added a link to the discussion about event namespacing for widgets; the current way of doing it is reasonable.
-Danny
Very thanks, I fix my widget to work with UI 1.6
Thank for this nice post
Hi Danny,
I tried all the examples in this tutorial and I seem to be having a problem with one. An error keeps occurring in this line (TypeError: this.trigger is not a function):
this.trigger (‘green5change’, [level]);
of the Observer Design Pattern application example.
I tried changing this to:
this.element.trigger(‘green5change’,[level]);
and that seems to work.
I’m quite new to jQuery and jQuery UI development so I would like to verify if I made the right change. Thanks!
@Reggie:
You’re right; that code is an example but not part of the actual code so it never got tested. I’ve changed it now.
Thanks!
Danny
I’m trying to get the definitions of Green5 and Green6 into external Javascript.
But the extend does not work as references to the $.ui.green5 do not exist yet.
I have tried replacing with the variable.
e.g.
var Green6 = $.extend({}, Green5.prototype, $.ui.mouse,{
but then I get an error about Green5.prototype not being defined.
Of course I haven’t made it yet…
Whats the approach for putting this in external Javascript file ?
@mark:
The
var Green5 = {}statements define private variables; you can’t get to them; the only way is to use$.ui.green5.prototype.There’s no reason your external Javascript file couldn’t define $.ui.green5; just include the line
$.widget("ui.green5", Green5);in the anonymous function that defines Green5.If Green5 and Green6 are in the same function, use
var Green6 = $.extend({}, Green5, $.ui.mouse);-Danny
Thebasic thing that i dont see implemented in control is HTML markup that is embeded in it.
I would like to develop a set of client side user control (in Jquery called widget) and reuse them, in those control i dont want to create the DOM elements using API but in amore UI friendly way (E.g. Markup).
Where do you advise on doing it ?
What is the best approach here ?
@ran:
The widget pattern does not address generating HTML. The easiest way to create elements is with the $() function,
$('<div><span>Hello, world</span></div>')but creating long strings gets ugly. There are plugins, like flyDOM that can help create complex elements, but what I have done mostly is use AJAX: create the markup in its own HTML file then use $(selector).load() to retrieve it. It makes debugging the HTML easier.Danny
Joel wrote (but his comment was lost in the move to the new domain, sorry):
Actually, this is exactly what
mouse.defaults.distanceis used for. It is the number of pixels that the mouse has to move before the_mouseDragmethod is called; the step size of dragging, if you will. My code above means that the level will change for every step (calculated as the difference between the current x-value and the original x-value in pixels, divided by that step size).I hope that makes sense.
–Danny
Joel emailed me personally to point out that in fact,
$.ui.mouse._mouseDragrequires adistanceoption and that it is not automatically set. I have updated the tutorial to reflect this.Thanks for the great tutorial!
If I overwrite the Green4.destroy() function, what original or “superclass” destroy would I still need to call?
@Chris:
you would need to do
$.ui.green4.prototype.destroy.call(this)to call the original
destroyIt’s ugly and inconvenient, which is why I use a widget subclassing framework.
Awesome, thank you so much! I’ve been trying to find a good design pattern for plug-in development, but everything I found or came up with didn’t satisfy my requirements. Then I discovered widget, and then this page. Yay
I think the pieces are starting to fall into place…
Question: Do you think it’s safe to use $.widget? I don’t see any documentation about it on jQuery’s site, and am worried about future deprecation.
Thanks again. This was a well written article!
Ooops, my mistake – widget is from the ui core , and so the documentation is on the UI site. Rad. Thanks again…
@Matthew:
Yes, $.widget is a good base for plugin design (that’s why the jQuery UI people made it, to refactor the basic stuff out of all the UI plugins) though it’s pretty heavy to include all of jQuery UI just to get that. You may be better off copying just the $.widget part (http://dev.jqueryui.com/browser/trunk/ui/ui.core.js lines 217-375 in the current build) into your own code.
–Danny
I’m testing Green5 and Green6 and having one problem:
when I test the (‘#divGreen’).green6.(‘getLevel’) returns a jQuery object even though I included the line:
$.ui.green5.getter = “getLevel”;
in my widget definition code?
Awesome tutorial BTW — helped tremendously, specially Green6 where we are sub-classing an existing widget!
–Marv
@Marv:
The widgets are completely separate; if you are using
green6then you need to set$.ui.green6.getter = "getLevel";. Note that thegetteris going to be obsolete (a good thing!) with the next iteration of jQuery UI.–Danny
This tutorial is what I need!
I also create some widgets reference other jQuery UI widget like datepicker, tabs, but your tutorial let me understand the jQuery UI more clearly, thanks again for your proffer.
P.S: I can not believe that you are a pediatric hospitalist!
@Liu:
Thanks for the feedback. As for the PS, is it that you can’t believe I’m so good as to be able to code while working 24 hour hospital shifts or that you can’t believe I’m so foolish as to spend my life doing the scut that that normal doctors can’t wait to get out of after residency?
hehe, I really express my admiration for your useful tutorial while working 24 hour hospital shifts. You are a doctor and I’m a software developer, you can write a good tutorial and I can’t.
Your timepickr is a good stuff, I want used it in my project.
Thanks for the awesome tutorial
This tutorial is what I need!
Here is a template to start your own widget:
(function($) {
$.widget(“ui.widgetName”, {
// options: provided by framework
// element: provided by framework
_init: function() {
},
log: function(msg) {
if(!this.options.log) return;
if(window.console && console.log) { // firebug logger
console.log(msg);
}
},
destroy: function() {
$.widget.prototype.apply(this, arguments); // default destroy
}
});
$.extend($.ui.widgetName, {
version: “@VERSION”,
getter: “length “, //for methods that are getters, not chainables
defaults: {
optionA: “bar”,
suffixA: “foo”
}
});
})(jQuery);
/* END */
@thetoolman:
That’s a good skeleton widget. I develop a more extensive one in my post about extending widgets
–Danny
excellent post! i always found hard to understand jqueryUI… until now… thanks!
Awesome tutorial, tks a lot.
Hi Danny. First I wanted to say a huge thank you for this tutorial. It is one of the only ones I have found that goes into detail about how to use the jQuery widget framework and has been extremely helpful to me.
You mention the ui.core “mixin” for the mouse. Do you know if there is also one for keypress events? And, is there any documentation out there that details those extensions and any others that are available via ui.core? I have had a heck of a time finding documentation on the jQuery ui that doesn’t involve the widgets they have prebuilt. Any help is much appreciated!
Thanks again for the tutorial!
@Lindsay:
The only other documentation on building widgets is the official one, which isn’t very helpful. That’s why I had to write this, as a tutorial for myself! There is no keypress mixin code; you would have to write your own with something like
this.element.bind("keypress."+this.widgetName, function(){...}). ThewidgetNameis there toallow you to unbind your handlers without affecting others.
$.ui.keyCodeexposes some useful names for key codes; use the Source to see them.I have some keypress-simulating code in my sendkeys plugin that may be helpful.
Hope this helps!
–Danny
Thanks for the excellent tutorial! I just wrote my first jQuery UI widget using it (nothing useful to anyone else, at the moment…)
At some point I got confused by the text, “$.widget defines two more functions that let us store and retrieve data for each instance individually: _setData and _getData,” and ended up using _setData and _getData to set and get some widget-private data: this._setData(‘foo’, something). Then I realized that I could just use this._foo = something, and my life got easier.
It may not be worthwhile incorporating this into the tutorial — if you incorporate a clarification for everything anyone ever gets confused by, you will end up with something like the unusably-verbose Perl documentation — but I submit it for your consideration.
Amazing, I can’t believe they don’t mention this widget structure in the ‘authoring a plugin’ docs – this is essential for plugins with states – many thanks & a link to this should definitely go here: http://docs.jquery.com/Plugins/Authoring
@Kragen Javier Sitaker:
Yes,
this._fooworks for private data.this._getData('foo')actually usesthis.options.fooso it can be used for both private data and public options (with$(selector).widget('option', 'foo')). I tried to keep the tutorial as limited as possible; for all the details I would look at the source code (jquery-ui.googlecode.com/svn/tags/1.7.2/ui/ui.core.js for the mostrecent release version); it’s not that complicated.
Be aware, though, that many things are changing for version 1.8. I’ll try to update the tutorial when it is officially released (meaning when it’s on the Google Ajax API server).
–Danny
@Pipeman:
Technically, jQuery UI is a project independent from jQuery itself, so you can write plugins without UI, but I agree. Saving state is so often useful that I almost always start my
plugins from a UI widget. The problem is that if you aren’t using the whole UI, you are including a huge amount of unnecessary stuff, so you may want to include just the widget code (which has been refactored out in release candidate 1.8 in jquery.ui.widget.js.
–Danny
great, thanks..that finally somebody understands the f*kn pain of the plugindevelopment!
br
rob
{ 3 } Trackbacks
[...] programming from a very occasional volunteer webmaster Skip to content HomeAboutAcknowledgementsUnderstanding jQuery UI widgets: A tutorialExtending jQuery UI Widgets { 2009 04 23 [...]
[...] Understanding jQuery UI widgets – A tutorial [...]
[...] Нестрогий перевод статьи Understanding jQuery UI widgets: A tutorial. [...]
Post a Comment