3 * Copyright(c) 2006-2010 Sencha Inc.
5 * http://www.sencha.com/license
12 Ext.ux.MultiCombo = Ext.extend(Ext.form.ComboBox, {
15 * @cfg {String} overClass [x-grid3-row-over]
17 overClass : 'x-grid3-row-over',
19 * @cfg {Boolean} enableKeyEvents for typeAhead
21 enableKeyEvents: true,
23 * @cfg {String} selectedClass [x-grid3-row-selected]
25 selectedClass: 'x-grid3-row-selected',
27 * @cfg {String} highlightClass The css class applied to rows which are hovered with mouse
28 * selected via key-nav, or highlighted when a text-query matches a single item.
30 highlightClass: 'x-grid3-row-over',
32 * @cfg {Number} autoSelectKey [44] COMMA Sets the key used to auto-select an auto-suggest
33 * highlighted query. When pressed, the highlighted text-item will be selected as if the user
34 * selected the row with a mouse click.
38 * @cfg {String} allSelectedText Text to display when all items are selected
40 allSelectedText : 'All selected',
42 * @cfg {Number} maxDisplayRows The maximum number of rows to show before applying vscroll
51 highlightIndex : null,
52 highlightIndexPrev : null,
58 * @cfg {Array} value CheckboxCombo expresses its value as an array.
63 * @cfg {Integer} minChars [0]
67 initComponent : function() {
68 var cls = 'x-combo-list';
70 // when blurring out of field, ensure that rawValue contains ONLY items contained in Store.
71 this.on('blur', this.validateSelections.createDelegate(this));
73 // create an auto-select key handler, like *nix-based console [tab] key behaviour
74 this.on('keypress', function(field, ev) {
75 if (ev.getKey() == this.autoSelectKey) { // COMMA
82 * @event initview Fires when Combo#initView is called.
83 * gives plugins a chance to interact with DataView
86 * @param {DataView} dv
92 // when list expands, constrain the height with @cfg maxDisplayRows
93 if (this.maxDisplayRows) {
94 this.on('expand', function(){
95 var cnt = this.store.getCount();
96 if (cnt > this.maxDisplayRows) {
97 var children = this.view.getNodes();
99 for (var n = 0; n < this.maxDisplayRows; n++) {
100 h += Ext.fly(children[n]).getHeight();
109 this.on('beforequery', this.onQuery, this);
111 // Enforce that plugins is an Array.
112 if (typeof(this.plugins) == 'undefined'){
115 else if (!Ext.isArray(this.plugins)) {
116 this.plugins = [this.plugins];
119 var tmp = this.value; // for case where transform is set.
120 Ext.ux.MultiCombo.superclass.initComponent.call(this);
121 if (this.transform) {
122 if (typeof(tmp) == 'undefined') {
130 onViewClick : function(dv, index, node, ev){
131 var rec = this.store.getAt(index);
132 this.onSelect(rec, index);
135 if(doFocus !== false){
141 // onTriggerClick, overrides Ext.form.ComboBox#onTriggerClick
142 onTriggerClick: function() {
143 if (this.highlightIndex != -1) {
144 this.clearHighlight();
146 this.highlightIndex = -1;
151 if(this.isExpanded()){
156 if(this.triggerAction == 'all') {
157 this.doQuery(this.getRawValue(), true);
158 var vlen = this.getValue().length, slen = this.view.getSelectedRecords().length;
159 if (vlen != slen || vlen == 0) {
160 this.selectByValue(this.value, true);
164 this.doQuery(this.getRawValue());
167 this.highlightIndex = -1
168 this.highlightIndexPrev = null;
170 this.scrollIntoView();
175 // onQuery, beforequery listener, @return false
176 onQuery : function(qe) {
178 forceAll = qe.forceAll;
179 if(forceAll === true || (q.length >= this.minChars)){
180 if(this.lastQuery !== q){
181 if (typeof(this.lastQuery) != 'undefined') {
182 if (q.match(new RegExp('^'+this.allSelectedText))) {
183 this.query = this.store.data;
185 else if (this.lastQuery.length > q.length) {
186 var items = q.replace(/\s+/g, '').split(',');
187 if (items[items.length-1].length == 0) {
190 this.query = this.store.data.filterBy(this.store.createFilterFn(this.displayField, new RegExp('^'+items.join('$|^')+'$', "i"), false, false));
197 if(this.mode == 'local'){
198 var raw = this.getRawValue();
199 if (raw == this.allSelectedText) {
202 var items = raw.replace(/\s+/g, '').split(',');
203 var last = items.pop();
204 this.matches = this.store.data.filterBy(this.store.createFilterFn(this.displayField, new RegExp('^'+last, "i"), false, false)).filterBy(this.createTypeAheadFilterFn(items));
205 if (this.matches.getCount() == 0) {
206 this.clearHighlight();
209 this.view.clearSelections();
210 this.updateValue([]);
215 this.store.baseParams[this.queryParam] = q;
217 params: this.getParams(q)
222 this.selectedIndex = -1;
230 // onLoad, overrides Ext.form.ComboBox#onLoad
236 if(this.store.getCount() > 0){
237 if (!this.isExpanded()) {
239 this.restrictHeight();
241 if(this.lastQuery == this.allQuery){
243 this.el.dom.select();
246 if (this.query != null) {
247 var values = [], indexes = [];
248 this.query.each(function(r){
249 values.push(r.data[this.valueField]);
250 indexes.push(this.store.indexOf(r));
252 this.view.clearSelections();
253 this.updateValue(values, this.getRawValue());
254 this.view.select(indexes);
256 if (this.matches != null) {
257 if (this.matches.getCount() == 1) {
258 this.highlight(this.store.indexOf(this.matches.first()));
259 this.scrollIntoView();
263 // @HACK: If store was configured with a proxy, set its mode to local now that its populated with data.
264 // Re-execute the query now.
266 this.lastQuery = undefined;
267 this.doQuery(this.getRawValue(), true);
269 if(this.typeAhead && this.lastKey != Ext.EventObject.DOWN && this.lastKey != Ext.EventObject.BACKSPACE && this.lastKey != Ext.EventObject.DELETE){
270 this.taTask.delay(this.typeAheadDelay);
274 this.onEmptyResults();
278 onSelect : function(record, index) {
280 throw new Error('MultiCombo#onSelect did not receive a valid index');
283 // select only when user clicks [apply] button
284 if (this.selectOnApply == true) {
288 if (this.fireEvent('beforeselect', this, record, index) !== false) {
291 var rs = this.view.getSelectedRecords();
292 for (var n = 0, len = rs.length; n < len; n++) {
293 text.push(rs[n].data[this.displayField]);
294 value.push(rs[n].data[this.valueField]);
296 this.updateValue(value, (value.length != this.store.getCount()) ? text.join(', ') : this.allSelectedText);
297 var node = this.view.getNode(index);
298 this.innerList.scrollChildIntoView(node, false);
299 this.fireEvent('select', this, record, index);
304 onViewOver : function(ev, node){
305 var t = ev.getTarget(this.view.itemSelector);
309 this.highlightIndex = this.store.indexOf(this.view.getRecord(t));
310 this.clearHighlight();
311 this.highlight(this.highlightIndex);
312 if(this.inKeyMode){ // prevent key nav and mouse over conflicts
319 onTypeAhead : function(){
320 if(this.store.getCount() > 0){
321 this.inKeyMode = false;
322 var raw = this.getRawValue();
323 var pos = this.getCaretPosition(raw);
326 if (pos !== false && pos < raw.length) {
327 items = raw.substr(0, pos).replace(/\s+/g, '').split(',');
330 items = raw.replace(/\s+/g, '').split(',');
333 var rs = this.store.data.filterBy(this.store.createFilterFn(this.displayField, new RegExp(query, "i"), false, false)).filterBy(this.createTypeAheadFilterFn(items));
335 if (rs.getCount() == 1) {
337 var rindex = this.store.indexOf(r)
338 if (!this.view.isSelected(rindex)) {
339 this.typeAheadSelected = true;
340 var selStart = raw.length;
341 var len = items.join(',').length;
343 var newValue = r.data[this.displayField];
344 if (pos !== false && pos < raw.length) {
345 var insertIdx = items.length;
347 items = raw.replace(/\s+/g, '').split(',');
348 items.splice(insertIdx, 1, newValue);
349 selEnd = items.slice(0, insertIdx+1).join(', ').length;
350 this.highlight(rindex);
351 this.scrollIntoView();
355 items.push(newValue);
357 var len = items.join(',').length;
359 var lastWord = raw.split(',').pop();
360 if (items.length >1 && lastWord.match(/^\s+/) == null) {
363 this.setRawValue(items.join(', '));
364 this.selectText(selStart, (selEnd!=null) ? selEnd : this.getRawValue().length);
372 var selected = this.view.getSelectedRecords();
374 for (var n=0,len=selected.length;n<len;n++) {
375 value.push(selected[n].data[this.valueField]);
377 this.setValue(value);
380 getCaretPosition : function(raw) {
381 raw = raw || this.getRawValue();
382 if(document.selection) { // <-- IE, ugh: http://parentnode.org/javascript/working-with-the-cursor-position/
383 var range = document.selection.createRange();
384 //Save the current value. We will need this value later to find out, where the text has been changed
385 var orig = obj.value.replace(/rn/g, "n");
388 // Now get the new content and save it into a temporary variable
389 var actual = tmp = obj.value.replace(/rn/g, "n");
390 /* Find the first occurance, where the original differs
391 from the actual content. This could be the startposition
392 of our text selection, but it has not to be. Think of the
393 selection "ab" and replacing it with "ac". The first
394 difference would be the "c", while the start position
397 for(var diff = 0; diff < orig.length; diff++) {
398 if(orig.charAt(diff) != actual.charAt(diff)) break;
401 /* To get the real start position, we iterate through
402 the string searching for the whole replacement
403 text - "abc", as long as the first difference is not
404 reached. If you do not understand that logic - no
405 blame to you, just copy & paste it ;)
407 for(var index = 0, start = 0; tmp.match(text) && (tmp = tmp.replace(text, "")) && index <= diff; index = start + text.length) {
408 start = actual.indexOf(text, index);
410 } else if(this.el.dom.selectionStart) { // <-- Go the Gecko way
411 return this.el.dom.selectionStart;
413 // Fallback for any other browser
418 onAutoSelect : function() {
419 if (!this.isExpanded()) {
420 var vlen = this.getValue().length, slen = this.view.getSelectedRecords().length;
421 if (vlen != slen || vlen == 0) {
422 this.selectByValue(this.value, true);
425 var raw = this.getRawValue();
426 this.selectText(raw.length, raw.length);
428 var pos = this.getCaretPosition(raw);
430 if (pos !== false && pos < raw.length) {
431 word = Ext.util.Format.trim(raw.substr(0, pos).split(',').pop());
433 word = Ext.util.Format.trim(raw.split(',').pop());
435 var idx = this.store.find(this.displayField, word);
436 if (idx > -1 && !this.view.isSelected(idx)) {
437 var rec = this.store.getAt(idx);
441 // filters-out already-selected items from type-ahead queries.
442 // e.g.: if store contains: "betty, barney, bart" and betty is already selected,
443 // when user types "b", only "bart" and "barney" should be returned as possible matches,
444 // since betty is *already* selected
445 createTypeAheadFilterFn : function(items) {
446 var key = this.displayField;
447 return function(rec) {
448 var re = new RegExp(rec.data[key], "i");
450 for (var n=0,len=items.length;n<len;n++) {
451 if (re.test(items[n])) {
460 updateValue : function(value, text) {
462 if(this.hiddenField){
463 this.hiddenField.value = value.join(',');
465 if (typeof(text) == 'string') {
466 this.setRawValue(text);
473 * Accepts a comma-separated list of ids or an array. if given a string, will conver to Array.
474 * @param {Array, String} v
476 setValue : function(v) {
480 if (typeof(v) == 'string') { // <-- "1,2,3"
481 value = v.match(/\d+/g); // <-- strip multiple spaces and split on ","
483 for (var n=0,len=value.length;n<len;n++) {
484 value[n] = parseInt(value[n]);
488 else if (Ext.isArray(v)) { // <-- [1,2,3]
491 if (value && value.length) {
492 if (this.mode == 'local') {
493 this.updateValue(value);
494 this.setRawValue(this.getTextValue());
497 this.updateValue(value);
499 callback: function() {
500 this.setRawValue(this.getTextValue());
509 getTextValue : function() {
510 if (this.value.length == this.store.getCount()) {
511 return this.allSelectedText;
515 this.store.data.filterBy(this.store.createFilterFn(this.valueField, new RegExp(this.value.join('|'), "i"), false, false)).each(function(r){
516 text.push(r.data[this.displayField]);
518 return text.join(', ');
523 * Select an item in the dropdown list by its numeric index in the list. This function does NOT cause the select event to fire.
524 * The store must be loaded and the list expanded for this function to work, otherwise use setValue.
525 * @param {Number} index The zero-based index of the list item to select
526 * @param {Boolean} scrollIntoView False to prevent the dropdown list from autoscrolling to display the
527 * selected item if it is not currently in view (defaults to true)
529 select : function(index, scrollIntoView){
530 if (!typeof(index) == 'number') {
531 throw new Error('MultiCombo#select expected @param {Number} index but got: ' + typeof(index));
533 this.view.isSelected(index) ? this.view.deselect(index, true) : this.view.select(index, true);
534 this.onSelect(this.store.getAt(index), index);
537 if(scrollIntoView !== false){
538 var el = this.view.getNode(index);
540 this.innerList.scrollChildIntoView(el, false);
546 getLastValue : function() {
547 return Ext.util.Format.trim(this.getRawValue().split(',').pop());
551 * Select an item in the dropdown list by its data value. This function does NOT cause the select event to fire.
552 * The store must be loaded and the list expanded for this function to work, otherwise use setValue.
553 * @param {String} value The data value of the item to select
554 * @param {Boolean} scrollIntoView False to prevent the dropdown list from autoscrolling to display the
555 * selected item if it is not currently in view (defaults to true)
556 * @return {Boolean} True if the value matched an item in the list, else false
558 selectByValue : function(v, scrollIntoView){
561 var rs = this.store.data.filterBy(this.store.createFilterFn(this.valueField, new RegExp(v.join('|'), "i"))).each(function(r){
562 indexes.push(this.store.indexOf(r));
564 if (indexes.length) {
565 this.view.select(indexes);
570 this.view.clearSelections();
571 this.setRawValue('');
577 initEvents : function(){
578 Ext.form.ComboBox.superclass.initEvents.call(this);
579 this.keyNav = new Ext.KeyNav(this.el, {
581 this.lastKey = Ext.EventObject.UP;
582 this.inKeyMode = true;
584 this.scrollIntoView();
587 "down" : function(e){
588 this.inKeyMode = true;
589 if(!this.isExpanded()){
590 this.lastKey = Ext.EventObject.DOWN;
591 this.onTriggerClick();
594 this.scrollIntoView();
599 "enter" : function(e){
600 var idx = this.highlightIndex;
601 if (this.inKeyMode === true) {
602 if (this.plugins.length && (idx <= -1)) {
603 if (this.plugins[idx + 1]) {
604 this.plugins[idx + 1].onEnter(this);
608 if (this.plugins.length && this.highlightIndex == 0 && this.highlightIndexPrev == -1) {
609 if (this.plugins[idx]) {
610 this.plugins[idx].onEnter(this);
614 var idx = this.getHighlightedIndex() || 0;
615 if (this.highlightIndex != null && idx != null) {
616 this.select(idx, true);
617 //this.delayedCheck = true;
618 //this.unsetDelayCheck.defer(10, this);
624 var v = this.getLastValue();
625 var raw = this.getRawValue();
627 /** this block should be moved to method getCurrentWord
630 var pos = this.getCaretPosition(raw);
632 if (pos !== false && pos < raw.length) {
633 word = Ext.util.Format.trim(raw.substr(0, pos).split(',').pop());
635 word = Ext.util.Format.trim(raw.split(',').pop());
637 /*******************************************************/
639 var idx = this.store.find(this.displayField, word);
641 var rec = this.store.getAt(idx);
642 this.select(idx, true);
644 raw = this.getRawValue();
645 this.selectText(raw.length, raw.length);
655 if (this.matches != null && this.matches.getCount() == 1) {
656 var idx = this.store.indexOf(this.matches.first());
657 if (!this.view.isSelected(idx)) {
658 this.select(this.store.indexOf(this.matches.first()), true);
661 else if (this.value.length == 0 && this.getRawValue().length > 0) {
662 this.setRawValue('');
670 doRelay : function(foo, bar, hname){
671 if(hname == 'down' || this.scope.isExpanded()){
672 return Ext.KeyNav.prototype.doRelay.apply(this, arguments);
679 this.queryDelay = Math.max(this.queryDelay || 10,
680 this.mode == 'local' ? 10 : 250);
681 this.dqTask = new Ext.util.DelayedTask(this.initQuery, this);
683 this.taTask = new Ext.util.DelayedTask(this.onTypeAhead, this);
685 if(this.editable !== false){
686 this.el.on("keyup", this.onKeyUp, this);
688 if(this.forceSelection){
689 this.on('blur', this.doForce, this);
693 // private, blur-handler to ensure that rawValue contains only values from selections, in the same order as selected
694 validateSelections : function(field) {
695 var v = this.getValue();
697 for (var i=0,len=v.length;i<len;i++) {
698 var idx = this.store.find(this.valueField, v[i]);
700 text.push(this.store.getAt(idx).data[this.displayField]);
703 this.setRawValue(text.join(', '));
706 scrollIntoView : function() {
707 var el = this.getHighlightedNode();
709 this.innerList.scrollChildIntoView(el);
714 selectNext : function(){
715 this.clearHighlight();
716 if (this.highlightIndex == null) {
717 this.highlightIndex = -1;
719 if (this.highlightIndex <= -1 && this.highlightIndexPrev != -1) {
720 if (this.plugins.length > 0) {
721 var idx = Math.abs(this.highlightIndex)-1;
722 if (this.plugins.length >= Math.abs(this.highlightIndex)) {
723 this.plugins[idx].selectNext(this);
724 this.highlightIndexPrev = this.highlightIndex;
725 this.highlightIndex++;
730 if (this.highlightIndexPrev == -1 && this.highlightIndex == 0) {
731 this.highlightIndex = -1;
733 var ct = this.store.getCount();
735 if (this.highlightIndex == -1 || this.highlightIndex+1 < ct) {
736 if (this.highlightIndex == -1) {
737 this.highlightIndexPrev = 0;
740 this.highlightIndexPrev = this.highlightIndex -1;
742 this.highlight(++this.highlightIndex);
746 this.highlight(ct-1);
752 selectPrev : function(){
753 this.clearHighlight();
754 if (this.highlightIndex <= 0) {
755 var idx = Math.abs(this.highlightIndex);
756 if (this.plugins.length >= idx+1 && this.highlightIndexPrev >= 0) {
757 this.clearHighlight();
758 this.plugins[idx].selectPrev(this);
759 this.highlightIndexPrev = this.highlightIndex;
760 this.highlightIndex--;
761 if (this.highlightIndex == -1) {
762 this.highlightIndexPrev = -1;
767 this.highlightIndex = -1;
768 this.highlightIndexPrev = -1;
774 this.highlightIndexPrev = this.highlightIndex;
775 var ct = this.store.getCount();
777 if (this.highlighIndex == -1) {
778 this.highlightIndex = 0;
780 else if (this.highlightIndex != 0) {
781 this.highlightIndex--;
783 else if (this.highlightIndex == 0) {
786 this.highlight(this.highlightIndex);
790 collapse : function() {
791 if (this.isExpanded()) {
792 this.highlightIndex = null;
793 this.highlightIndexPrev = null;
795 Ext.ux.MultiCombo.superclass.collapse.call(this);
798 highlight : function(index) {
799 this.view.el.select('.'+this.highlightClass).removeClass(this.highlightClass);
800 var node = Ext.fly(this.view.getNode(index));
802 node.addClass(this.highlightClass);
806 getHighlightedIndex : function() {
807 var node = this.view.el.child('.' + this.highlightClass, true);
808 return (node) ? this.store.indexOf(this.view.getRecord(node)) : this.highlightIndex;
810 getHighlightedNode : function() {
811 return this.view.el.child('.'+this.highlightClass, true);
814 clearHighlight : function() {
815 if (typeof(this.view) != 'object') { return false; }
816 var el = this.view.el.select('.'+this.highlightClass);
818 el.removeClass(this.highlightClass);
823 initList : function(){
825 var cls = 'x-combo-list';
827 this.list = new Ext.Layer({
828 shadow: this.shadow, cls: [cls, this.listClass].join(' '), constrain:false
831 var lw = this.listWidth || Math.max(this.wrap.getWidth(), this.minListWidth);
832 this.list.setWidth(lw);
833 this.list.swallowEvent('mousewheel');
834 this.assetHeight = 0;
835 if(this.syncFont !== false){
836 this.list.setStyle('font-size', this.el.getStyle('font-size'));
839 this.header = this.list.createChild({cls:cls+'-hd', html: this.title});
840 this.assetHeight += this.header.getHeight();
843 this.innerList = this.list.createChild({cls:cls+'-inner'});
844 this.innerList.on('mouseover', this.onViewOver, this);
845 this.innerList.on('mousemove', this.onViewMove, this);
846 this.innerList.setWidth(lw - this.list.getFrameWidth('lr'));
849 this.footer = this.list.createChild({cls:cls+'-ft'});
850 this.pageTb = new Ext.PagingToolbar({
852 pageSize: this.pageSize,
855 this.assetHeight += this.footer.getHeight();
860 * @cfg {String/Ext.XTemplate} tpl The template string, or {@link Ext.XTemplate}
861 * instance to use to display each item in the dropdown list. Use
862 * this to create custom UI layouts for items in the list.
864 * If you wish to preserve the default visual look of list items, add the CSS
865 * class name <pre>x-combo-list-item</pre> to the template's container element.
867 * <b>The template must contain one or more substitution parameters using field
868 * names from the Combo's</b> {@link #store Store}. An example of a custom template
869 * would be adding an <pre>ext:qtip</pre> attribute which might display other fields
872 * The dropdown list is displayed in a DataView. See {@link Ext.DataView} for details.
874 this.tpl = '<tpl for="."><div class="'+cls+'-item">{' + this.displayField + '}</div></tpl>';
876 * @cfg {String} itemSelector
877 * <b>This setting is required if a custom XTemplate has been specified in {@link #tpl}
878 * which assigns a class other than <pre>'x-combo-list-item'</pre> to dropdown list items</b>.
879 * A simple CSS selector (e.g. div.some-class or span:first-child) that will be
880 * used to determine what nodes the DataView which handles the dropdown display will
886 * The {@link Ext.DataView DataView} used to display the ComboBox's options.
889 this.view = new Ext.DataView({
890 applyTo: this.innerList,
894 overClass: this.overClass,
895 selectedClass: this.selectedClass,
896 itemSelector: this.itemSelector || '.' + cls + '-item'
898 this.view.on('click', this.onViewClick, this);
899 this.fireEvent('initview', this, this.view);
900 this.bindStore(this.store, true);
903 this.resizer = new Ext.Resizable(this.list, {
904 pinned:true, handles:'se'
906 this.resizer.on('resize', function(r, w, h){
907 this.maxHeight = h-this.handleHeight-this.list.getFrameWidth('tb')-this.assetHeight;
909 this.innerList.setWidth(w - this.list.getFrameWidth('lr'));
910 this.restrictHeight();
912 this[this.pageSize?'footer':'innerList'].setStyle('margin-bottom', this.handleHeight+'px');
919 Ext.reg('multicombo', Ext.ux.MultiCombo);