Upgrade to ExtJS 3.2.2 - Released 06/02/2010
[extjs.git] / examples / ux / MultiSelect.js
1 /*!
2  * Ext JS Library 3.2.2
3  * Copyright(c) 2006-2010 Ext JS, Inc.
4  * licensing@extjs.com
5  * http://www.extjs.com/license
6  */
7 Ext.ns('Ext.ux.form');
8
9 /**
10  * @class Ext.ux.form.MultiSelect
11  * @extends Ext.form.Field
12  * A control that allows selection and form submission of multiple list items.
13  *
14  *  @history
15  *    2008-06-19 bpm Original code contributed by Toby Stuart (with contributions from Robert Williams)
16  *    2008-06-19 bpm Docs and demo code clean up
17  *
18  * @constructor
19  * Create a new MultiSelect
20  * @param {Object} config Configuration options
21  * @xtype multiselect
22  */
23 Ext.ux.form.MultiSelect = Ext.extend(Ext.form.Field,  {
24     /**
25      * @cfg {String} legend Wraps the object with a fieldset and specified legend.
26      */
27     /**
28      * @cfg {Ext.ListView} view The {@link Ext.ListView} used to render the multiselect list.
29      */
30     /**
31      * @cfg {String/Array} dragGroup The ddgroup name(s) for the MultiSelect DragZone (defaults to undefined).
32      */
33     /**
34      * @cfg {String/Array} dropGroup The ddgroup name(s) for the MultiSelect DropZone (defaults to undefined).
35      */
36     /**
37      * @cfg {Boolean} ddReorder Whether the items in the MultiSelect list are drag/drop reorderable (defaults to false).
38      */
39     ddReorder:false,
40     /**
41      * @cfg {Object/Array} tbar The top toolbar of the control. This can be a {@link Ext.Toolbar} object, a
42      * toolbar config, or an array of buttons/button configs to be added to the toolbar.
43      */
44     /**
45      * @cfg {String} appendOnly True if the list should only allow append drops when drag/drop is enabled
46      * (use for lists which are sorted, defaults to false).
47      */
48     appendOnly:false,
49     /**
50      * @cfg {Number} width Width in pixels of the control (defaults to 100).
51      */
52     width:100,
53     /**
54      * @cfg {Number} height Height in pixels of the control (defaults to 100).
55      */
56     height:100,
57     /**
58      * @cfg {String/Number} displayField Name/Index of the desired display field in the dataset (defaults to 0).
59      */
60     displayField:0,
61     /**
62      * @cfg {String/Number} valueField Name/Index of the desired value field in the dataset (defaults to 1).
63      */
64     valueField:1,
65     /**
66      * @cfg {Boolean} allowBlank False to require at least one item in the list to be selected, true to allow no
67      * selection (defaults to true).
68      */
69     allowBlank:true,
70     /**
71      * @cfg {Number} minSelections Minimum number of selections allowed (defaults to 0).
72      */
73     minSelections:0,
74     /**
75      * @cfg {Number} maxSelections Maximum number of selections allowed (defaults to Number.MAX_VALUE).
76      */
77     maxSelections:Number.MAX_VALUE,
78     /**
79      * @cfg {String} blankText Default text displayed when the control contains no items (defaults to the same value as
80      * {@link Ext.form.TextField#blankText}.
81      */
82     blankText:Ext.form.TextField.prototype.blankText,
83     /**
84      * @cfg {String} minSelectionsText Validation message displayed when {@link #minSelections} is not met (defaults to 'Minimum {0}
85      * item(s) required').  The {0} token will be replaced by the value of {@link #minSelections}.
86      */
87     minSelectionsText:'Minimum {0} item(s) required',
88     /**
89      * @cfg {String} maxSelectionsText Validation message displayed when {@link #maxSelections} is not met (defaults to 'Maximum {0}
90      * item(s) allowed').  The {0} token will be replaced by the value of {@link #maxSelections}.
91      */
92     maxSelectionsText:'Maximum {0} item(s) allowed',
93     /**
94      * @cfg {String} delimiter The string used to delimit between items when set or returned as a string of values
95      * (defaults to ',').
96      */
97     delimiter:',',
98     /**
99      * @cfg {Ext.data.Store/Array} store The data source to which this MultiSelect is bound (defaults to <tt>undefined</tt>).
100      * Acceptable values for this property are:
101      * <div class="mdetail-params"><ul>
102      * <li><b>any {@link Ext.data.Store Store} subclass</b></li>
103      * <li><b>an Array</b> : Arrays will be converted to a {@link Ext.data.ArrayStore} internally.
104      * <div class="mdetail-params"><ul>
105      * <li><b>1-dimensional array</b> : (e.g., <tt>['Foo','Bar']</tt>)<div class="sub-desc">
106      * A 1-dimensional array will automatically be expanded (each array item will be the combo
107      * {@link #valueField value} and {@link #displayField text})</div></li>
108      * <li><b>2-dimensional array</b> : (e.g., <tt>[['f','Foo'],['b','Bar']]</tt>)<div class="sub-desc">
109      * For a multi-dimensional array, the value in index 0 of each item will be assumed to be the combo
110      * {@link #valueField value}, while the value at index 1 is assumed to be the combo {@link #displayField text}.
111      * </div></li></ul></div></li></ul></div>
112      */
113
114     // private
115     defaultAutoCreate : {tag: "div"},
116
117     // private
118     initComponent: function(){
119         Ext.ux.form.MultiSelect.superclass.initComponent.call(this);
120
121         if(Ext.isArray(this.store)){
122             if (Ext.isArray(this.store[0])){
123                 this.store = new Ext.data.ArrayStore({
124                     fields: ['value','text'],
125                     data: this.store
126                 });
127                 this.valueField = 'value';
128             }else{
129                 this.store = new Ext.data.ArrayStore({
130                     fields: ['text'],
131                     data: this.store,
132                     expandData: true
133                 });
134                 this.valueField = 'text';
135             }
136             this.displayField = 'text';
137         } else {
138             this.store = Ext.StoreMgr.lookup(this.store);
139         }
140
141         this.addEvents({
142             'dblclick' : true,
143             'click' : true,
144             'change' : true,
145             'drop' : true
146         });
147     },
148
149     // private
150     onRender: function(ct, position){
151         Ext.ux.form.MultiSelect.superclass.onRender.call(this, ct, position);
152
153         var fs = this.fs = new Ext.form.FieldSet({
154             renderTo: this.el,
155             title: this.legend,
156             height: this.height,
157             width: this.width,
158             style: "padding:0;",
159             tbar: this.tbar
160         });
161         fs.body.addClass('ux-mselect');
162
163         this.view = new Ext.ListView({
164             multiSelect: true,
165             store: this.store,
166             columns: [{ header: 'Value', width: 1, dataIndex: this.displayField }],
167             hideHeaders: true
168         });
169
170         fs.add(this.view);
171
172         this.view.on('click', this.onViewClick, this);
173         this.view.on('beforeclick', this.onViewBeforeClick, this);
174         this.view.on('dblclick', this.onViewDblClick, this);
175
176         this.hiddenName = this.name || Ext.id();
177         var hiddenTag = { tag: "input", type: "hidden", value: "", name: this.hiddenName };
178         this.hiddenField = this.el.createChild(hiddenTag);
179         this.hiddenField.dom.disabled = this.hiddenName != this.name;
180         fs.doLayout();
181     },
182
183     // private
184     afterRender: function(){
185         Ext.ux.form.MultiSelect.superclass.afterRender.call(this);
186
187         if (this.ddReorder && !this.dragGroup && !this.dropGroup){
188             this.dragGroup = this.dropGroup = 'MultiselectDD-' + Ext.id();
189         }
190
191         if (this.draggable || this.dragGroup){
192             this.dragZone = new Ext.ux.form.MultiSelect.DragZone(this, {
193                 ddGroup: this.dragGroup
194             });
195         }
196         if (this.droppable || this.dropGroup){
197             this.dropZone = new Ext.ux.form.MultiSelect.DropZone(this, {
198                 ddGroup: this.dropGroup
199             });
200         }
201     },
202
203     // private
204     onViewClick: function(vw, index, node, e) {
205         this.fireEvent('change', this, this.getValue(), this.hiddenField.dom.value);
206         this.hiddenField.dom.value = this.getValue();
207         this.fireEvent('click', this, e);
208         this.validate();
209     },
210
211     // private
212     onViewBeforeClick: function(vw, index, node, e) {
213         if (this.disabled || this.readOnly) {
214             return false;
215         }
216     },
217
218     // private
219     onViewDblClick : function(vw, index, node, e) {
220         return this.fireEvent('dblclick', vw, index, node, e);
221     },
222
223     /**
224      * Returns an array of data values for the selected items in the list. The values will be separated
225      * by {@link #delimiter}.
226      * @return {Array} value An array of string data values
227      */
228     getValue: function(valueField){
229         var returnArray = [];
230         var selectionsArray = this.view.getSelectedIndexes();
231         if (selectionsArray.length == 0) {return '';}
232         for (var i=0; i<selectionsArray.length; i++) {
233             returnArray.push(this.store.getAt(selectionsArray[i]).get((valueField != null) ? valueField : this.valueField));
234         }
235         return returnArray.join(this.delimiter);
236     },
237
238     /**
239      * Sets a delimited string (using {@link #delimiter}) or array of data values into the list.
240      * @param {String/Array} values The values to set
241      */
242     setValue: function(values) {
243         var index;
244         var selections = [];
245         this.view.clearSelections();
246         this.hiddenField.dom.value = '';
247
248         if (!values || (values == '')) { return; }
249
250         if (!Ext.isArray(values)) { values = values.split(this.delimiter); }
251         for (var i=0; i<values.length; i++) {
252             index = this.view.store.indexOf(this.view.store.query(this.valueField,
253                 new RegExp('^' + values[i] + '$', "i")).itemAt(0));
254             selections.push(index);
255         }
256         this.view.select(selections);
257         this.hiddenField.dom.value = this.getValue();
258         this.validate();
259     },
260
261     // inherit docs
262     reset : function() {
263         this.setValue('');
264     },
265
266     // inherit docs
267     getRawValue: function(valueField) {
268         var tmp = this.getValue(valueField);
269         if (tmp.length) {
270             tmp = tmp.split(this.delimiter);
271         }
272         else {
273             tmp = [];
274         }
275         return tmp;
276     },
277
278     // inherit docs
279     setRawValue: function(values){
280         setValue(values);
281     },
282
283     // inherit docs
284     validateValue : function(value){
285         if (value.length < 1) { // if it has no value
286              if (this.allowBlank) {
287                  this.clearInvalid();
288                  return true;
289              } else {
290                  this.markInvalid(this.blankText);
291                  return false;
292              }
293         }
294         if (value.length < this.minSelections) {
295             this.markInvalid(String.format(this.minSelectionsText, this.minSelections));
296             return false;
297         }
298         if (value.length > this.maxSelections) {
299             this.markInvalid(String.format(this.maxSelectionsText, this.maxSelections));
300             return false;
301         }
302         return true;
303     },
304
305     // inherit docs
306     disable: function(){
307         this.disabled = true;
308         this.hiddenField.dom.disabled = true;
309         this.fs.disable();
310     },
311
312     // inherit docs
313     enable: function(){
314         this.disabled = false;
315         this.hiddenField.dom.disabled = false;
316         this.fs.enable();
317     },
318
319     // inherit docs
320     destroy: function(){
321         Ext.destroy(this.fs, this.dragZone, this.dropZone);
322         Ext.ux.form.MultiSelect.superclass.destroy.call(this);
323     }
324 });
325
326
327 Ext.reg('multiselect', Ext.ux.form.MultiSelect);
328
329 //backwards compat
330 Ext.ux.Multiselect = Ext.ux.form.MultiSelect;
331
332
333 Ext.ux.form.MultiSelect.DragZone = function(ms, config){
334     this.ms = ms;
335     this.view = ms.view;
336     var ddGroup = config.ddGroup || 'MultiselectDD';
337     var dd;
338     if (Ext.isArray(ddGroup)){
339         dd = ddGroup.shift();
340     } else {
341         dd = ddGroup;
342         ddGroup = null;
343     }
344     Ext.ux.form.MultiSelect.DragZone.superclass.constructor.call(this, this.ms.fs.body, { containerScroll: true, ddGroup: dd });
345     this.setDraggable(ddGroup);
346 };
347
348 Ext.extend(Ext.ux.form.MultiSelect.DragZone, Ext.dd.DragZone, {
349     onInitDrag : function(x, y){
350         var el = Ext.get(this.dragData.ddel.cloneNode(true));
351         this.proxy.update(el.dom);
352         el.setWidth(el.child('em').getWidth());
353         this.onStartDrag(x, y);
354         return true;
355     },
356
357     // private
358     collectSelection: function(data) {
359         data.repairXY = Ext.fly(this.view.getSelectedNodes()[0]).getXY();
360         var i = 0;
361         this.view.store.each(function(rec){
362             if (this.view.isSelected(i)) {
363                 var n = this.view.getNode(i);
364                 var dragNode = n.cloneNode(true);
365                 dragNode.id = Ext.id();
366                 data.ddel.appendChild(dragNode);
367                 data.records.push(this.view.store.getAt(i));
368                 data.viewNodes.push(n);
369             }
370             i++;
371         }, this);
372     },
373
374     // override
375     onEndDrag: function(data, e) {
376         var d = Ext.get(this.dragData.ddel);
377         if (d && d.hasClass("multi-proxy")) {
378             d.remove();
379         }
380     },
381
382     // override
383     getDragData: function(e){
384         var target = this.view.findItemFromChild(e.getTarget());
385         if(target) {
386             if (!this.view.isSelected(target) && !e.ctrlKey && !e.shiftKey) {
387                 this.view.select(target);
388                 this.ms.setValue(this.ms.getValue());
389             }
390             if (this.view.getSelectionCount() == 0 || e.ctrlKey || e.shiftKey) return false;
391             var dragData = {
392                 sourceView: this.view,
393                 viewNodes: [],
394                 records: []
395             };
396             if (this.view.getSelectionCount() == 1) {
397                 var i = this.view.getSelectedIndexes()[0];
398                 var n = this.view.getNode(i);
399                 dragData.viewNodes.push(dragData.ddel = n);
400                 dragData.records.push(this.view.store.getAt(i));
401                 dragData.repairXY = Ext.fly(n).getXY();
402             } else {
403                 dragData.ddel = document.createElement('div');
404                 dragData.ddel.className = 'multi-proxy';
405                 this.collectSelection(dragData);
406             }
407             return dragData;
408         }
409         return false;
410     },
411
412     // override the default repairXY.
413     getRepairXY : function(e){
414         return this.dragData.repairXY;
415     },
416
417     // private
418     setDraggable: function(ddGroup){
419         if (!ddGroup) return;
420         if (Ext.isArray(ddGroup)) {
421             Ext.each(ddGroup, this.setDraggable, this);
422             return;
423         }
424         this.addToGroup(ddGroup);
425     }
426 });
427
428 Ext.ux.form.MultiSelect.DropZone = function(ms, config){
429     this.ms = ms;
430     this.view = ms.view;
431     var ddGroup = config.ddGroup || 'MultiselectDD';
432     var dd;
433     if (Ext.isArray(ddGroup)){
434         dd = ddGroup.shift();
435     } else {
436         dd = ddGroup;
437         ddGroup = null;
438     }
439     Ext.ux.form.MultiSelect.DropZone.superclass.constructor.call(this, this.ms.fs.body, { containerScroll: true, ddGroup: dd });
440     this.setDroppable(ddGroup);
441 };
442
443 Ext.extend(Ext.ux.form.MultiSelect.DropZone, Ext.dd.DropZone, {
444     /**
445      * Part of the Ext.dd.DropZone interface. If no target node is found, the
446      * whole Element becomes the target, and this causes the drop gesture to append.
447      */
448     getTargetFromEvent : function(e) {
449         var target = e.getTarget();
450         return target;
451     },
452
453     // private
454     getDropPoint : function(e, n, dd){
455         if (n == this.ms.fs.body.dom) { return "below"; }
456         var t = Ext.lib.Dom.getY(n), b = t + n.offsetHeight;
457         var c = t + (b - t) / 2;
458         var y = Ext.lib.Event.getPageY(e);
459         if(y <= c) {
460             return "above";
461         }else{
462             return "below";
463         }
464     },
465
466     // private
467     isValidDropPoint: function(pt, n, data) {
468         if (!data.viewNodes || (data.viewNodes.length != 1)) {
469             return true;
470         }
471         var d = data.viewNodes[0];
472         if (d == n) {
473             return false;
474         }
475         if ((pt == "below") && (n.nextSibling == d)) {
476             return false;
477         }
478         if ((pt == "above") && (n.previousSibling == d)) {
479             return false;
480         }
481         return true;
482     },
483
484     // override
485     onNodeEnter : function(n, dd, e, data){
486         return false;
487     },
488
489     // override
490     onNodeOver : function(n, dd, e, data){
491         var dragElClass = this.dropNotAllowed;
492         var pt = this.getDropPoint(e, n, dd);
493         if (this.isValidDropPoint(pt, n, data)) {
494             if (this.ms.appendOnly) {
495                 return "x-tree-drop-ok-below";
496             }
497
498             // set the insert point style on the target node
499             if (pt) {
500                 var targetElClass;
501                 if (pt == "above"){
502                     dragElClass = n.previousSibling ? "x-tree-drop-ok-between" : "x-tree-drop-ok-above";
503                     targetElClass = "x-view-drag-insert-above";
504                 } else {
505                     dragElClass = n.nextSibling ? "x-tree-drop-ok-between" : "x-tree-drop-ok-below";
506                     targetElClass = "x-view-drag-insert-below";
507                 }
508                 if (this.lastInsertClass != targetElClass){
509                     Ext.fly(n).replaceClass(this.lastInsertClass, targetElClass);
510                     this.lastInsertClass = targetElClass;
511                 }
512             }
513         }
514         return dragElClass;
515     },
516
517     // private
518     onNodeOut : function(n, dd, e, data){
519         this.removeDropIndicators(n);
520     },
521
522     // private
523     onNodeDrop : function(n, dd, e, data){
524         if (this.ms.fireEvent("drop", this, n, dd, e, data) === false) {
525             return false;
526         }
527         var pt = this.getDropPoint(e, n, dd);
528         if (n != this.ms.fs.body.dom)
529             n = this.view.findItemFromChild(n);
530
531         if(this.ms.appendOnly) {
532             insertAt = this.view.store.getCount();
533         } else {
534             insertAt = n == this.ms.fs.body.dom ? this.view.store.getCount() - 1 : this.view.indexOf(n);
535             if (pt == "below") {
536                 insertAt++;
537             }
538         }
539
540         var dir = false;
541
542         // Validate if dragging within the same MultiSelect
543         if (data.sourceView == this.view) {
544             // If the first element to be inserted below is the target node, remove it
545             if (pt == "below") {
546                 if (data.viewNodes[0] == n) {
547                     data.viewNodes.shift();
548                 }
549             } else {  // If the last element to be inserted above is the target node, remove it
550                 if (data.viewNodes[data.viewNodes.length - 1] == n) {
551                     data.viewNodes.pop();
552                 }
553             }
554
555             // Nothing to drop...
556             if (!data.viewNodes.length) {
557                 return false;
558             }
559
560             // If we are moving DOWN, then because a store.remove() takes place first,
561             // the insertAt must be decremented.
562             if (insertAt > this.view.store.indexOf(data.records[0])) {
563                 dir = 'down';
564                 insertAt--;
565             }
566         }
567
568         for (var i = 0; i < data.records.length; i++) {
569             var r = data.records[i];
570             if (data.sourceView) {
571                 data.sourceView.store.remove(r);
572             }
573             this.view.store.insert(dir == 'down' ? insertAt : insertAt++, r);
574             var si = this.view.store.sortInfo;
575             if(si){
576                 this.view.store.sort(si.field, si.direction);
577             }
578         }
579         return true;
580     },
581
582     // private
583     removeDropIndicators : function(n){
584         if(n){
585             Ext.fly(n).removeClass([
586                 "x-view-drag-insert-above",
587                 "x-view-drag-insert-left",
588                 "x-view-drag-insert-right",
589                 "x-view-drag-insert-below"]);
590             this.lastInsertClass = "_noclass";
591         }
592     },
593
594     // private
595     setDroppable: function(ddGroup){
596         if (!ddGroup) return;
597         if (Ext.isArray(ddGroup)) {
598             Ext.each(ddGroup, this.setDroppable, this);
599             return;
600         }
601         this.addToGroup(ddGroup);
602     }
603 });