diff --git a/js/ui.multiselect.js b/js/ui.multiselect.js
index 1234fa7..c87aba3 100755
--- a/js/ui.multiselect.js
+++ b/js/ui.multiselect.js
@@ -8,329 +8,910 @@
* Dual licensed under the MIT (MIT-LICENSE.txt)
* and GPL (GPL-LICENSE.txt) licenses.
*
- * http://www.quasipartikel.at/multiselect/
+ * http://yanickrochon.uuuq.com/multiselect/
*
*
* Depends:
- * ui.core.js
- * ui.sortable.js
+ * ui.core.js
+ * ui.draggable.js
+ * ui.droppable.js
+ * ui.sortable.js
+ * jquery.blockUI (http://github.com/malsup/blockui/)
+ * jquery.tmpl (http://andrew.hedges.name/blog/2008/09/03/introducing-jquery-simple-templates
*
* Optional:
- * localization (http://plugins.jquery.com/project/localisation)
- * scrollTo (http://plugins.jquery.com/project/ScrollTo)
+ * localization (http://plugins.jquery.com/project/localisation)
+ *
+ * Notes:
+ * The strings in this plugin use a templating engine to enable localization
+ * and allow flexibility in the messages. Read the documentation for more details.
*
* Todo:
- * Make batch actions faster
- * Implement dynamic insertion through remote calls
+ * restore selected items on remote searchable multiselect upon page reload (same behavior as local mode)
+ * (is it worth it??) add a public function to apply the nodeComparator to all items (when using nodeComparator setter)
+ * support for option groups, disabled options, etc.
+ * speed improvements
+ * tests and optimizations
+ * - test getters/setters (including options from the defaults)
*/
+/********************************
+ * Default callbacks
+ ********************************/
+
+// expect data to be "val1=text1[\nval2=text2[\n...]]"
+var defaultDataParser = function(data) {
+ if ( typeof data == 'string' ) {
+ var pattern = /^(\s\n\r\t)*\+?$/;
+ var lineSelected, line, lines = data.split(/\n/);
+ data = {};
+ for (var i in lines) {
+ line = lines[i].split("=");
+ // make sure the key is not empty
+ if (!pattern.test(line[0])) {
+ lineSelected = (line[0].lastIndexOf('+') == line[0].length - 1);
+ if (lineSelected) line[0] = line[0].substr(0,line[0].length-1);
+ // if no value is specified, default to the key value
+ data[line[0]] = {
+ selected: lineSelected,
+ value: line[1] || line[0]
+ };
+ }
+ }
+ } else {
+ this._messages($.ui.multiselect.constants.MESSAGE_ERROR, $.ui.multiselect.locale.errorDataFormat);
+ data = false;
+ }
+ return data;
+};
+
+var defaultNodeComparator = function(node1,node2) {
+ var text1 = node1.text(),
+ text2 = node2.text();
+ return text1 == text2 ? 0 : (text1 < text2 ? -1 : 1);
+};
+
(function($) {
$.widget("ui.multiselect", {
options: {
- sortable: true,
+ // sortable and droppable
+ sortable: 'left',
+ droppable: 'both',
+ // searchable
searchable: true,
- doubleClickable: true,
+ searchDelay: 400,
+ remoteUrl: null,
+ remoteParams: {},
+ // animated
animated: 'fast',
show: 'slideDown',
hide: 'slideUp',
+ // ui
dividerLocation: 0.6,
- nodeComparator: function(node1,node2) {
- var text1 = node1.text(),
- text2 = node2.text();
- return text1 == text2 ? 0 : (text1 < text2 ? -1 : 1);
- }
+ // callbacks
+ dataParser: defaultDataParser,
+ nodeComparator: defaultNodeComparator,
+ nodeInserted: null
},
_create: function() {
this.element.hide();
- this.id = this.element.attr("id");
+ this.busy = false; // busy state
this.container = $('
').insertAfter(this.element);
- this.count = 0; // number of currently selected options
- this.selectedContainer = $('').appendTo(this.container);
- this.availableContainer = $('').appendTo(this.container);
- this.selectedActions = $('').appendTo(this.selectedContainer);
- this.availableActions = $('').appendTo(this.availableContainer);
- this.selectedList = $('').bind('selectstart', function(){return false;}).appendTo(this.selectedContainer);
- this.availableList = $('').bind('selectstart', function(){return false;}).appendTo(this.availableContainer);
+ this.selectedContainer = $('').appendTo(this.container);
+ this.availableContainer = $('').appendTo(this.container);
+ this.selectedActions = $('').appendTo(this.selectedContainer);
+ this.availableActions = $('').appendTo(this.availableContainer);
+ this.selectedList = $('').bind('selectstart', function(){return false;}).appendTo(this.selectedContainer);
+ this.availableList = $('').bind('selectstart', function(){return false;}).appendTo(this.availableContainer);
var that = this;
- // set dimensions
- this.container.width(this.element.width()+1);
- this.selectedContainer.width(Math.floor(this.element.width()*this.options.dividerLocation));
- this.availableContainer.width(Math.floor(this.element.width()*(1-this.options.dividerLocation)));
-
- // fix list height to match ').text(data[key].value).appendTo(this.element)[0] );
+ }
+ else {
+ elements.push( $('').text(data[key].value).appendTo(this.element)[0] );
+ }
+ }
+ }
+ }
+
+ if (elements.length>0) {
+ this._populateLists($(elements));
+ }
+
+ this._filter(this.availableList.children('li.ui-element'));
+
+ this._setBusy(false);
+ return elements.length;
+ } else {
return false;
- });
+ }
},
- destroy: function() {
- this.element.show();
- this.container.remove();
- $.Widget.prototype.destroy.apply(this, arguments);
+ /**************************************
+ * Private
+ **************************************/
+
+ _setData: function(key, value) {
+ switch (key) {
+ // special treatement must be done for theses values when changed
+ case 'dividerLocation':
+ this.options.dividerLocation = value;
+ this._refreshDividerLocation();
+ break;
+ case 'searchable':
+ this.options.searchable = value;
+ this._registerSearchEvents(this.availableContainer.find('input.search'), false);
+ break;
+
+ case 'droppable':
+ case 'sortable':
+ // readonly options
+ this._messages(
+ $.ui.multiselect.constants.MESSAGE_WARNING,
+ $.ui.multiselect.locale.errorReadonly,
+ {option: key}
+ );
+ default:
+ // default behavior
+ this.options[key] = value;
+ break;
+ }
},
- _populateLists: function(options) {
- this.selectedList.children('.ui-element').remove();
- this.availableList.children('.ui-element').remove();
- this.count = 0;
+ _ui: function(type) {
+ var uiObject = {sender: this.element};
+ switch (type) {
+ // events: messages
+ case 'message':
+ uiObject.type = arguments[1];
+ uiObject.message = arguments[2];
+ break;
+ // events: selected, deselected
+ case 'selection':
+ uiObject.option = arguments[1];
+ break;
+ }
+ return uiObject;
+ },
+ _messages: function(type, msg, params) {
+ this._trigger('messages', null, this._ui('message', type, $.tmpl(msg, params)));
+ },
+
+ _refreshDividerLocation: function() {
+ this.selectedContainer.width(Math.floor(this.element.width()*this.options.dividerLocation));
+ this.availableContainer.width(Math.floor(this.element.width()*(1-this.options.dividerLocation)));
+ },
+ _prepareLists: function(side, otherSide, opts) {
+ var that = this;
+ var itemSelected = ('selected' == side);
+ var list = this[side+'List'];
+ var otherList = this[otherSide+'List'];
+ var listDragHelper = opts[otherSide].sortable ? _dragHelper : 'clone';
+
+ list
+ .data('multiselect.sortable', opts[side].sortable )
+ .data('multiselect.droppable', opts[side].droppable )
+ .data('multiselect.draggable', !opts[side].sortable && (opts[otherSide].sortable || opts[otherSide].droppable) );
+
+ if (opts[side].sortable) {
+ list.sortable({
+ appendTo: this.container,
+ connectWith: otherList,
+ containment: this.container,
+ helper: listDragHelper,
+ items: 'li.ui-element',
+ revert: !(opts[otherSide].sortable || opts[otherSide].droppable),
+ receive: function(event, ui) {
+ // DEBUG
+ //that._messages(0, "Receive : " + ui.item.data('multiselect.optionLink') + ":" + ui.item.parent()[0].className + " = " + itemSelected);
+
+ // we received an element from a sortable to another sortable...
+ if (opts[otherSide].sortable) {
+ var optionLink = ui.item.data('multiselect.optionLink');
+
+ that._applyItemState(ui.item.hide(), itemSelected);
+
+ // if the cache already contain an element, remove it
+ if (otherList.data('multiselect.cache')[optionLink.val()]) {
+ delete otherList.data('multiselect.cache')[optionLink.val()];
+ }
+
+ ui.item.hide();
+ that._setSelected(ui.item, itemSelected, true);
+ } else {
+ // the other is droppable only, so merely select the element...
+ setTimeout(function() {
+ that._setSelected(ui.item, itemSelected);
+ }, 10);
+ }
+ },
+ stop: function(event, ui) {
+ // DEBUG
+ //that._messages(0, "Stop : " + (ui.item.parent()[0] == otherList[0]));
+ that._moveOptionNode(ui.item);
+ }
+ });
+ }
+
+ // cannot be droppable if both lists are sortable, it breaks the receive function
+ if (!(opts[side].sortable && opts[otherSide].sortable)
+ && (opts[side].droppable || opts[otherSide].sortable || opts[otherSide].droppable)) {
+ //alert( side + " is droppable ");
+ list.droppable({
+ accept: '.ui-multiselect li.ui-element',
+ hoverClass: 'ui-state-highlight',
+ revert: !(opts[otherSide].sortable || opts[otherSide].droppable),
+ greedy: true,
+ drop: function(event, ui) {
+ // DEBUG
+ //that._messages(0, "drop " + side + " = " + ui.draggable.data('multiselect.optionLink') + ":" + ui.draggable.parent()[0].className);
+
+ //alert( "drop " + itemSelected );
+ // if no optionLink is defined, it was dragged in
+ if (!ui.draggable.data('multiselect.optionLink')) {
+ var optionLink = ui.helper.data('multiselect.optionLink');
+ ui.draggable.data('multiselect.optionLink', optionLink);
+
+ // if the cache already contain an element, remove it
+ if (list.data('multiselect.cache')[optionLink.val()]) {
+ delete list.data('multiselect.cache')[optionLink.val()];
+ }
+ list.data('multiselect.cache')[optionLink.val()] = ui.draggable;
+
+ that._applyItemState(ui.draggable, itemSelected);
+
+ // received an item from a sortable to a droppable
+ } else if (!opts[side].sortable) {
+ setTimeout(function() {
+ ui.draggable.hide();
+ that._setSelected(ui.draggable, itemSelected);
+ }, 10);
+ }
+
+ }
+ });
+ }
+ },
+ _populateLists: function(options) {
+ this._setBusy(true);
+
var that = this;
- var items = $(options.map(function(i) {
- var item = that._getOptionNode(this).appendTo(this.selected ? that.selectedList : that.availableList).show();
-
- if (this.selected) that.count += 1;
- that._applyItemState(item, this.selected);
- item.data('idx', i);
- return item[0];
- }));
+ // do this async so the browser actually display the waiting message
+ setTimeout(function() {
+ $(options.each(function(i) {
+ var list = (this.selected ? that.selectedList : that.availableList);
+ var item = that._getOptionNode(this).show();
+ that._applyItemState(item, this.selected);
+ item.data('multiselect.idx', i);
+
+ // cache
+ list.data('multiselect.cache')[item.data('multiselect.optionLink').val()] = item;
+
+ that._insertToList(item, list);
+ }));
- // update count
- this._updateCount();
- that._filter.apply(this.availableContainer.find('input.search'), [that.availableList]);
- },
+ // update count
+ that._setBusy(false);
+ that._updateCount();
+ }, 1);
+ },
+ _insertToList: function(node, list) {
+ var that = this;
+ this._setBusy(true);
+ // the browsers don't like batch node insertion...
+ var _addNodeRetry = 0;
+ var _addNode = function() {
+ var succ = (that.options.nodeComparator ? that._getSuccessorNode(node, list) : null);
+ try {
+ if (succ) {
+ node.insertBefore(succ);
+ } else {
+ list.append(node);
+ }
+ if (list === that.selectedList) that._moveOptionNode(node);
+
+ // callback after node insertion
+ if ('function' == typeof that.options.nodeInserted) that.options.nodeInserted(node);
+ that._setBusy(false);
+ } catch (e) {
+ // if this problem did not occur too many times already
+ if ( _addNodeRetry++ < 10 ) {
+ // try again later (let the browser cool down first)
+ setTimeout(function() { _addNode(); }, 1);
+ } else {
+ that._messages(
+ $.ui.multiselect.constants.MESSAGE_EXCEPTION,
+ $.ui.multiselect.locale.errorInsertNode,
+ {key:node.data('multiselect.optionLink').val(), value:node.text()}
+ );
+ that._setBusy(false);
+ }
+ }
+ };
+ _addNode();
+ },
_updateCount: function() {
- this.selectedContainer.find('span.count').text(this.count+" "+$.ui.multiselect.locale.itemsCount);
+ var that = this;
+ // defer until system is not busy
+ if (this.busy) setTimeout(function() { that._updateCount(); }, 100);
+ // count only visible (less .ui-helper-hidden*)
+ var count = this.selectedList.children('li:not(.ui-helper-hidden-accessible,.ui-sortable-placeholder):visible').size();
+ var total = this.availableList.children('li:not(.ui-helper-hidden-accessible,.ui-sortable-placeholder,.shadowed)').size() + count;
+ this.selectedContainer.find('span.count')
+ .text($.tmpl($.ui.multiselect.locale.itemsCount, {count:count}))
+ .attr('title', $.tmpl($.ui.multiselect.locale.itemsTotal, {count:total}));
},
_getOptionNode: function(option) {
option = $(option);
- var node = $(''+option.text()+'').hide();
- node.data('optionLink', option);
+ var node = $(''+option.text()+'').hide();
+ node.data('multiselect.optionLink', option);
return node;
},
- // clones an item with associated data
- // didn't find a smarter away around this
- _cloneWithData: function(clonee) {
- var clone = clonee.clone(false,false);
- clone.data('optionLink', clonee.data('optionLink'));
- clone.data('idx', clonee.data('idx'));
- return clone;
+ _moveOptionNode: function(item) {
+ // call this async to let the item be placed correctly
+ setTimeout( function() {
+ var optionLink = item.data('multiselect.optionLink');
+ if (optionLink) {
+ var prevItem = item.prev('li:not(.ui-helper-hidden-accessible,.ui-sortable-placeholder):visible');
+ var prevOptionLink = prevItem.size() ? prevItem.data('multiselect.optionLink') : null;
+
+ if (prevOptionLink) {
+ optionLink.insertAfter(prevOptionLink);
+ } else {
+ optionLink.prependTo(optionLink.parent());
+ }
+ }
+ }, 100);
+ },
+ // used by select and deselect, etc.
+ _findItem: function(text, list) {
+ var found = null;
+ list.children('li.ui-element:visible').each(function(i,el) {
+ el = $(el);
+ if (el.text().toLowerCase() === text.toLowerCase()) {
+ found = el;
+ }
+ });
+ if (found && found.size()) {
+ return found;
+ } else {
+ return false;
+ }
},
- _setSelected: function(item, selected) {
- item.data('optionLink').attr('selected', selected);
+ // clones an item with
+ // didn't find a smarter away around this (michael)
+ // now using cache to speed up the process (yr)
+ _cloneWithData: function(clonee, cacheName, insertItem) {
+ var that = this;
+ var id = clonee.data('multiselect.optionLink').val();
+ var selected = ('selected' == cacheName);
+ var list = (selected ? this.selectedList : this.availableList);
+ var clone = list.data('multiselect.cache')[id];
- if (selected) {
- var selectedItem = this._cloneWithData(item);
- item[this.options.hide](this.options.animated, function() { $(this).remove(); });
- selectedItem.appendTo(this.selectedList).hide()[this.options.show](this.options.animated);
-
- this._applyItemState(selectedItem, true);
- return selectedItem;
+ if (!clone) {
+ clone = clonee.clone().hide();
+ this._applyItemState(clone, selected);
+ // update cache
+ list.data('multiselect.cache')[id] = clone;
+ // update