/** * @class Ext.ux.form.field.BoxSelect * @extends Ext.form.field.ComboBox * * BoxSelect for ExtJS 4, a combo box improved for multiple value querying, selection and management. * * A friendlier combo box for multiple selections that creates easily individually * removable labels for each selection, as seen on facebook and other sites. Querying * and type-ahead support are also improved for multiple selections. * * Options and usage mostly remain consistent with the {@link Ext.form.field.ComboBox} * control. Some default configuration options have changed, but should still * work properly if overridden. * * Inspired by the SuperBoxSelect component for ExtJS 3 (http://technomedia.co.uk/SuperBoxSelect/examples3.html), * which in turn was inspired by the BoxSelect component for ExtJS 2 (http://efattal.fr/en/extjs/extuxboxselect/). * * Various contributions and suggestions made by many members of the ExtJS community which can be seen * in the user extension posting: http://www.sencha.com/forum/showthread.php?134751-Ext.ux.form.field.BoxSelect * * Many thanks go out to all of those who have contributed, this extension would not be * possible without your help. * * @author kvee_iv http://www.sencha.com/forum/member.php?29437-kveeiv * @version 1.3.1 * @requires BoxSelect.css * @xtype boxselect */ Ext.define('Ext.ux.form.field.BoxSelect_Input', { extend:'Ext.form.field.ComboBox', alias: ['widget.comboboxselect', 'widget.boxselect'], requires: ['Ext.selection.Model', 'Ext.data.Store'], /** * @cfg {Boolean} multiSelect * If set to <code>true</code>, allows the combo field to hold more than one value at a time, and allows selecting * multiple items from the dropdown list. (Defaults to <code>true</code>, the default usage for BoxSelect) */ multiSelect: true, /** * @cfg {Boolean} forceSelection * <code>true</code> to restrict the selected value to one of the values in the list, * <code>false</code> to allow the user to set arbitrary text into the field (defaults to <code>true</code>, the default usage for BoxSelect) */ forceSelection: false, /** * @cfg {Boolean} selectOnFocus <code>true</code> to automatically select any existing field text when the field * receives input focus (defaults to <code>true</code> for best multi-select usability during querying) */ selectOnFocus: true, /** * @cfg {Boolean} triggerOnClick <code>true</code> to activate the trigger when clicking in empty space * in the field. Note that the subsequent behavior of this is controlled by the field's {@link #triggerAction}. * This behavior is similar to that of a basic ComboBox with {@link #editable} <code>false</code>. * (defaults to <code>true</code>). */ triggerOnClick: true, /** * @cfg {Boolean} createNewOnEnter * When forceSelection is false, new records can be created by the user. These records are not added to the * combo's store. By default, this creation is triggered by typing the configured 'delimiter'. With * createNewOnEnter set to true, this creation can also be triggered by the 'enter' key. This configuration * option has no effect if forceSelection is true. (defaults to <code>false</code>) * <code>true</code> to allow the user to press 'enter' to create a new record * <code>false</code> to only allow the user to type the configured 'delimiter' to create a new record */ createNewOnEnter: false, /** * @cfg {Boolean} createNewOnBlur * Similar to {@link #createNewOnEnter}, createNewOnBlur will create a new record when the field loses focus. * This configuration option has no effect if forceSelection is true. Please note that this behavior is also * affected by the configuration options {@link #autoSelect} and {@link #selectOnTab}. If those are true * and an existing item would have been selected as a result, the partial text the user has entered will * be discarded. * <code>true</code> to create a new record when the field loses focus * <code>false</code> to not create a new record on blur */ createNewOnBlur: true, /** * @cfg {Boolean} encodeSubmitValue * Controls the formatting of the form submit value of the field. (defaults to <code>false</code>). This * is not applicable of {@link #multiSelect} is false. * <code>true</code> for the field value to submit as a json encoded array in a single POST variable * <code>false</code> for the field to submit as an array of POST variables */ encodeSubmitValue: false, /** * @cfg {Boolean} stacked * When stacked is true, each labelled item will fill to the width of the form field * <code>true</code> to have each labelled item fill the width of the form field * <code>false</code> to have each labelled item size to its displayed contents (defaults to <code>false</code>) */ stacked: false, /** * @cfg {Boolean} pinList * When multiSelect is true, the pick list used for the combo will stay open after each selection is made. This * config option has no effect if multiSelect is false. * <code>true</code> to keep the pick list expanded after each multiSelect selection * <code>false</code> to collapse the pick list after each multiSelect selection (defaults to <code>true</code>) */ pinList: true, /** * @cfg {Boolean} grow <tt>true</tt> if this field should automatically grow and shrink to its content * (defaults to <tt>true</tt>) */ grow: true, /** * @cfg {Number} growMin The minimum height to allow when <tt>{@link Ext.form.field.Text#grow grow}=true</tt> * (defaults to <tt>false</tt>, which allows for natural growth based on selections) */ growMin: false, /** * @cfg {Number} growMax The maximum height to allow when <tt>{@link Ext.form.field.Text#grow grow}=true</tt> * (defaults to <tt>false</tt>, which allows for natural growth based on selections) */ growMax: false, //private componentLayout: 'boxselectfield', /** * Initialize additional settings and enable simultaneous typeAhead and multiSelect support */ initComponent: function() { var me = this, typeAhead = me.typeAhead; if (typeAhead && !me.editable) { Ext.Error.raise('If typeAhead is enabled the combo must be editable: true -- please change one of those settings.'); } Ext.apply(me, { typeAhead: false }); me.callParent(arguments); me.typeAhead = typeAhead; me.selectionModel = new Ext.selection.Model({ store: me.valueStore, mode: 'MULTI', onSelectChange: function(record, isSelected, suppressEvent, commitFn) { commitFn(); } }); if (!Ext.isEmpty(me.delimiter) && me.multiSelect) { me.delimiterEndingRegexp = new RegExp(String(me.delimiter).replace(/[$%()*+.?\[\\\]{|}]/g, "\\$&") + "$"); } }, /** * Register events for management controls of labelled items */ initEvents: function() { var me = this; me.callParent(arguments); if (!me.enableKeyEvents) { me.mon(me.inputEl, 'keydown', me.onKeyDown, me); } me.mon(me.itemList, 'click', me.onItemListClick, me); me.mon(me.selectionModel, 'selectionchange', me.applyMultiselectItemMarkup, me); }, /** * Create a store for the records of our current value based on the main store's model */ bindStore: function(store, initial) { var me = this, oldStore = me.store; if (oldStore) { me.mun(oldStore, 'beforeload', me.onBeforeLoad, me); if (me.valueStore) { me.mun(me.valueStore, 'datachanged', me.applyMultiselectItemMarkup, me); me.valueStore = null; } } me.callParent(arguments); if (me.store) { me.valueStore = new Ext.data.Store({ model: me.store.model, proxy: { type: 'memory' } }); me.mon(me.valueStore, 'datachanged', me.applyMultiselectItemMarkup, me); me.mon(me.store, 'beforeload', me.onBeforeLoad, me); } }, /** * Add refresh tracking to the picker for selection management */ createPicker: function() { var me = this, picker = me.callParent(arguments); me.mon(picker, { 'beforerefresh': me.onBeforeListRefresh, 'show': function(pick) { /** * Temporary fix for reapplying maxHeight after shorter list was previously shown */ var listEl = picker.listEl, ch = listEl.getHeight(); if (ch > picker.maxHeight) { listEl.setHeight(picker.maxHeight); } }, scope: me }); return picker; }, /** * Clean up labelled items management controls */ onDestroy: function() { var me = this; Ext.destroyMembers(me, 'selectionModel', 'valueStore'); me.callParent(arguments); }, /** * Overridden to avoid use of placeholder, as our main input field is often empty */ afterRender: function() { var me = this; if (Ext.supports.Placeholder && me.inputEl && me.emptyText) { delete me.inputEl.dom.placeholder; } if (me.stacked === true) { me.itemList.addCls('x-boxselect-stacked'); } if (me.grow) { if (Ext.isNumber(me.growMin) && (me.growMin > 0)) { me.itemList.applyStyles('min-height:'+me.growMin+'px'); } if (Ext.isNumber(me.growMax) && (me.growMax > 0)) { me.itemList.applyStyles('max-height:'+me.growMax+'px'); } } me.applyMultiselectItemMarkup(); me.callParent(arguments); }, /** * Overridden to search store snapshot instead of data (if available) */ findRecord: function(field, value) { var ds = this.store, rec = false, idx; if (ds.snapshot) { idx = ds.snapshot.findIndexBy(function(rec) { return rec.get(field) === value; }); rec = (idx !== -1) ? ds.snapshot.getAt(idx) : false; } else { idx = ds.findExact(field, value); rec = (idx !== -1) ? ds.getAt(idx) : false; } return rec; }, /** * When the picker is refreshing, we should ignore selection changes. Otherwise * the value of our field will be changing just because our view of the choices is. */ onBeforeLoad: function() { this.ignoreSelection++; }, /** * Overridden to map previously selected records to the "new" versions of the records * based on value field, if they are part of the new store load */ onLoad: function() { var me = this, valueField = me.valueField, valueStore = me.valueStore, changed = false; if (valueStore) { if (!Ext.isEmpty(me.value) && (valueStore.getCount() == 0)) { me.setValue(me.value, false, true); } valueStore.suspendEvents(); valueStore.each(function(rec) { var r = me.findRecord(valueField, rec.get(valueField)), i = r ? valueStore.indexOf(rec) : -1; if (i >= 0) { valueStore.removeAt(i); valueStore.insert(i, r); changed = true; } }); valueStore.resumeEvents(); if (changed) { valueStore.fireEvent('datachanged', valueStore); } } me.callParent(arguments); me.ignoreSelection = Ext.Number.constrain(me.ignoreSelection - 1, 0); me.alignPicker(); }, /** * @private * Used to determine if a record is filtered (for retaining as a multiSelect value) */ isFilteredRecord: function(record) { var me = this, store = me.store, valueField = me.valueField, storeRecord, filtered = false; storeRecord = store.findExact(valueField, record.get(valueField)); filtered = ((storeRecord === -1) && (!store.snapshot || (me.findRecord(valueField, record.get(valueField)) !== false))); filtered = filtered || (!filtered && (storeRecord === -1) && (me.forceSelection !== true) && (me.valueStore.findExact(valueField, record.get(valueField)) >= 0)); return filtered; }, /** * Overridden to allow for continued querying with multiSelect selections already made */ doRawQuery: function() { var me = this, rawValue = me.inputEl.dom.value; if (me.multiSelect) { rawValue = rawValue.split(me.delimiter).pop(); } this.doQuery(rawValue, false, true); }, /** * When the picker is refreshing, we should ignore selection changes. Otherwise * the value of our field will be changing just because our view of the choices is. */ onBeforeListRefresh: function() { this.ignoreSelection++; }, /** * When the picker is refreshing, we should ignore selection changes. Otherwise * the value of our field will be changing just because our view of the choices is. */ onListRefresh: function() { this.callParent(arguments); this.ignoreSelection = Ext.Number.constrain(this.ignoreSelection - 1, 0); }, /** * Overridden to preserve current labelled items when list is filtered/paged/loaded * and does not include our current value. */ onListSelectionChange: function(list, selectedRecords) { var me = this, valueStore = me.valueStore, mergedRecords = [], i; // Only react to selection if it is not called from setValue, and if our list is // expanded (ignores changes to the selection model triggered elsewhere) if ((me.ignoreSelection <= 0) && me.isExpanded) { // Pull forward records that were already selected or are now filtered out of the store valueStore.each(function(rec) { if (Ext.Array.contains(selectedRecords, rec) || me.isFilteredRecord(rec)) { mergedRecords.push(rec); } }); mergedRecords = Ext.Array.merge(mergedRecords, selectedRecords); i = Ext.Array.intersect(mergedRecords, valueStore.getRange()).length; if ((i != mergedRecords.length) || (i != me.valueStore.getCount())) { me.setValue(mergedRecords, false); if (!me.multiSelect || !me.pinList) { Ext.defer(me.collapse, 1, me); } if (valueStore.getCount() > 0) { me.fireEvent('select', me, valueStore.getRange()); } } me.inputEl.focus(); if (!me.pinList) { me.inputEl.dom.value = ''; } if (me.selectOnFocus) { me.inputEl.dom.select(); } } }, /** * Overridden to use valueStore instead of valueModels, for inclusion of filtered records */ syncSelection: function() { var me = this, picker = me.picker, valueField = me.valueField, pickStore, selection, selModel; if (picker) { pickStore = picker.store; // From the value, find the Models that are in the store's current data selection = []; if (me.valueStore) { me.valueStore.each(function(rec) { var i = pickStore.findExact(valueField, rec.get(valueField)); if (i >= 0) { selection.push(pickStore.getAt(i)); } }); } // Update the selection to match me.ignoreSelection++; selModel = picker.getSelectionModel(); selModel.deselectAll(); if (selection.length > 0) { selModel.select(selection); } me.ignoreSelection = Ext.Number.constrain(me.ignoreSelection - 1, 0); } }, /** * Overridden to align to itemList size instead of inputEl */ alignPicker: function() { var me = this, picker, isAbove, aboveSfx = '-above', itemBox = me.itemList.getBox(false, true); if (this.isExpanded) { picker = me.getPicker(); var pickerScrollPos = picker.getTargetEl().dom.scrollTop; if (me.matchFieldWidth) { // Auto the height (it will be constrained by min and max width) unless there are no records to display. picker.setSize(itemBox.width, picker.store && picker.store.getCount() ? null : 0); } if (picker.isFloating()) { picker.alignTo(me.itemList, me.pickerAlign, me.pickerOffset); // add the {openCls}-above class if the picker was aligned above // the field due to hitting the bottom of the viewport isAbove = picker.el.getY() < me.inputEl.getY(); me.bodyEl[isAbove ? 'addCls' : 'removeCls'](me.openCls + aboveSfx); picker.el[isAbove ? 'addCls' : 'removeCls'](picker.baseCls + aboveSfx); } } }, /** * @private * Get the current cursor position in the input field */ getCursorPosition: function() { var cursorPos; if (Ext.isIE) { cursorPos = document.selection.createRange(); cursorPos.collapse(true); cursorPos.moveStart("character", -this.inputEl.dom.value.length); cursorPos = cursorPos.text.length; } else { cursorPos = this.inputEl.dom.selectionStart; } return cursorPos; }, /** * @private * Check to see if the input field has selected text */ hasSelectedText: function() { var sel, range; if (Ext.isIE) { sel = document.selection; range = sel.createRange(); return (range.parentElement() == this.inputEl.dom); } else { return this.inputEl.dom.selectionStart != this.inputEl.dom.selectionEnd; } }, /** * Handles keyDown processing of key-based selection of labelled items */ onKeyDown: function(e, t) { var me = this, key = e.getKey(), rawValue = me.inputEl.dom.value, valueStore = me.valueStore, selModel = me.selectionModel, stopEvent = false, rec, i; if (me.readOnly || me.disabled || !me.editable) { return; } // Handle keyboard based navigation of selected labelled items if ((valueStore.getCount() > 0) && ((rawValue == '') || ((me.getCursorPosition() === 0) && !me.hasSelectedText()))) { if ((key == e.BACKSPACE) || (key == e.DELETE)) { if (selModel.getCount() > 0) { me.valueStore.remove(selModel.getSelection()); } else { me.valueStore.remove(me.valueStore.last()); } me.setValue(me.valueStore.getRange()); selModel.deselectAll(); stopEvent = true; } else if ((key == e.RIGHT) || (key == e.LEFT)) { if ((selModel.getCount() === 0) && (key == e.LEFT)) { selModel.select(valueStore.last()); stopEvent = true; } else if (selModel.getCount() > 0) { rec = selModel.getLastFocused() || selModel.getLastSelected(); if (rec) { i = valueStore.indexOf(rec); if (key == e.RIGHT) { if (i < (valueStore.getCount() - 1)) { selModel.select(i + 1, e.shiftKey); stopEvent = true; } else if (!e.shiftKey) { selModel.deselect(rec); stopEvent = true; } } else if ((key == e.LEFT) && (i > 0)) { selModel.select(i - 1, e.shiftKey); stopEvent = true; } } } } else if (key == e.A && e.ctrlKey) { selModel.selectAll(); stopEvent = e.A; } me.inputEl.focus(); } if (stopEvent) { me.preventKeyUpEvent = stopEvent; e.stopEvent(); return; } // Prevent key up processing for enter if it is being handled by the picker if (me.isExpanded && (key == e.ENTER) && me.picker.highlightedItem) { me.preventKeyUpEvent = true; } if (me.enableKeyEvents) { me.callParent(arguments); } if (!e.isSpecialKey() && !e.hasModifier()) { me.selectionModel.deselectAll(); me.inputEl.focus(); } }, /** * Handles auto-selection of labelled items based on this field's delimiter, as well * as the keyUp processing of key-based selection of labelled items. */ onKeyUp: function(e, t) { var me = this, rawValue = me.inputEl.dom.value, rec; if (me.preventKeyUpEvent) { e.stopEvent(); if ((me.preventKeyUpEvent === true) || (e.getKey() === me.preventKeyUpEvent)) { delete me.preventKeyUpEvent; } return; } if (me.multiSelect && (me.delimiterEndingRegexp && me.delimiterEndingRegexp.test(rawValue)) || ((me.createNewOnEnter === true) && e.getKey() == e.ENTER)) { rawValue = rawValue.replace(me.delimiterEndingRegexp, ''); if (!Ext.isEmpty(rawValue)) { rec = me.valueStore.findExact(me.valueField, rawValue); if (rec >= 0) { rec = me.valueStore.getAt(rec); } else { rec = me.store.findExact(me.valueField, rawValue); if (rec >= 0) { rec = me.store.getAt(rec); } else { rec = false; } } if (!rec && !me.forceSelection) { rec = {}; rec[me.valueField] = rawValue; rec[me.displayField] = rawValue; rec = new me.valueStore.model(rec); } if (rec) { me.collapse(); me.setValue(me.valueStore.getRange().concat(rec)); me.inputEl.dom.value = ''; me.inputEl.focus(); } } } me.callParent([e,t]); Ext.Function.defer(me.alignPicker, 10, me); }, /** * Overridden to get and set the dom value directly for type-ahead suggestion (bypassing get/setRawValue) */ onTypeAhead: function() { var me = this, displayField = me.displayField, inputElDom = me.inputEl.dom, record = me.store.findRecord(displayField, inputElDom.value), boundList = me.getPicker(), newValue, len, selStart; if (record) { newValue = record.get(displayField); len = newValue.length; selStart = inputElDom.value.length; boundList.highlightItem(boundList.getNode(record)); if (selStart !== 0 && selStart !== len) { inputElDom.value = newValue; me.selectText(selStart, newValue.length); } } }, /** * Delegation control for selecting and removing labelled items or triggering list collapse/expansion */ onItemListClick: function(evt, el, o) { var me = this, itemEl = evt.getTarget('.x-boxselect-item'), closeEl = itemEl ? evt.getTarget('.x-boxselect-item-close') : false; if (me.readOnly || me.disabled) { return; } evt.stopPropagation(); if (itemEl) { if (closeEl) { me.removeByListItemNode(itemEl); } else { me.toggleSelectionByListItemNode(itemEl, evt.shiftKey); } me.inputEl.focus(); } else if (me.triggerOnClick) { me.onTriggerClick(); } }, /** * Build the markup for the labelled items. Template must be built on demand due to ComboBox initComponent * lifecycle for the creation of on-demand stores (to account for automatic valueField/displayField setting) */ getMultiSelectItemMarkup: function() { var me = this; if (!me.multiSelectItemTpl) { if (!me.labelTpl) { me.labelTpl = Ext.create('Ext.XTemplate', '{[values.' + me.displayField + ']}' ); } else if (Ext.isString(me.labelTpl)) { me.labelTpl = Ext.create('Ext.XTemplate', me.labelTpl); } me.multiSelectItemTpl = [ '<tpl for=".">', '<li class="x-boxselect-item ', '<tpl if="this.isSelected(values.'+ me.valueField + ')">', ' selected', '</tpl>', '" qtip="{[typeof values === "string" ? values : values.' + me.displayField + ']}">' , '<div class="x-boxselect-item-text">{[typeof values === "string" ? values : this.getItemLabel(values)]}</div>', '<div class="x-tab-close-btn x-boxselect-item-close"></div>' , '</li>' , '</tpl>', { compile: true, disableFormats: true, isSelected: function(value) { var i = me.valueStore.findExact(me.valueField, value); if (i >= 0) { return me.selectionModel.isSelected(me.valueStore.getAt(i)); } }, getItemLabel: function(values) { return me.getTpl('labelTpl').apply(values); } } ]; } return this.getTpl('multiSelectItemTpl').apply(Ext.Array.pluck(this.valueStore.getRange(), 'data')); }, /** * Update the labelled items rendering */ applyMultiselectItemMarkup: function() { var me = this, itemList = me.itemList, item; if (itemList) { while ((item = me.inputElCt.prev()) != null) { item.remove(); } me.inputElCt.insertHtml('beforeBegin', me.getMultiSelectItemMarkup()); } }, /** * Returns the record from valueStore for the labelled item node */ getRecordByListItemNode: function(itemEl) { var me = this, itemIdx = 0, searchEl = me.itemList.dom.firstChild; while (searchEl && searchEl.nextSibling) { if (searchEl == itemEl) { break; } itemIdx++; searchEl = searchEl.nextSibling; } itemIdx = (searchEl == itemEl) ? itemIdx : false; if (itemIdx === false) { return false; } return me.valueStore.getAt(itemIdx); }, /** * Toggle of labelled item selection by node reference */ toggleSelectionByListItemNode: function(itemEl, keepExisting) { var me = this, rec = me.getRecordByListItemNode(itemEl); if (rec) { if (me.selectionModel.isSelected(rec)) { me.selectionModel.deselect(rec); } else { me.selectionModel.select(rec, keepExisting); } } }, /** * Removal of labelled item by node reference */ removeByListItemNode: function(itemEl) { var me = this, rec = me.getRecordByListItemNode(itemEl); if (rec) { me.valueStore.remove(rec); me.setValue(me.valueStore.getRange()); } }, /** * Intercept calls to getRawValue to pretend there is no inputEl for rawValue handling, * so that we can use inputEl for just the user input. * * **Note that in general, raw values are the rendered value for the input field, * and therefore should not be used for comboboxes or most programmatic logic.** */ getRawValue: function() { var me = this, inputEl = me.inputEl, result; me.inputEl = false; result = me.callParent(arguments); me.inputEl = inputEl; return result; }, /** * Intercept calls to setRawValue to pretend there is no inputEl for rawValue handling, * so that we can use inputEl for just the user input. * * **Note that in general, raw values are the rendered value for the input field, * and therefore should not be used for comboboxes or most programmatic logic.** */ setRawValue: function(value) { var me = this, inputEl = me.inputEl, result; me.inputEl = false; result = me.callParent([value]); me.inputEl = inputEl; return result; }, /** * Adds a value or values to the current value of the field * @param {mixed} valueMixed The value or values to add to the current value */ addValue: function(valueMixed) { var me = this; if (valueMixed) { me.setValue(Ext.Array.merge(me.value, Ext.Array.from(valueMixed))); } }, /** * Removes a value or values from the current value of the field * @param {mixed} valueMixed The value or values to remove from the current value */ removeValue: function(valueMixed) { var me = this; if (valueMixed) { me.setValue(Ext.Array.difference(me.value, Ext.Array.from(valueMixed))); } }, /** * Intercept calls to setValue to use records from the valueStore when available. * Unknown values (if forceSelection is true) will trigger a call to store.load * once to try to retrieve those records. The list of unknown values will be * submitted as the name of the valueField with values separated by the configured * delimiter. This process will cause setValue to asynchronously process. */ setValue: function(value, doSelect, skipLoad) { var me = this, valueStore = me.valueStore, valueField = me.valueField, record, len, i, valueRecord, h, unknownValues = []; if (Ext.isEmpty(value)) { value = null; } if (Ext.isString(value) && me.multiSelect) { value = value.split(me.delimiter); } value = Ext.Array.from(value); for (i = 0, len = value.length; i < len; i++) { record = value[i]; if (!record || !record.isModel) { valueRecord = valueStore.findExact(valueField, record); if (valueRecord >= 0) { value[i] = valueStore.getAt(valueRecord); } else { valueRecord = me.findRecord(valueField, record); if (!valueRecord) { if (me.forceSelection) { unknownValues.push(record); } else { valueRecord = {}; valueRecord[me.valueField] = record; valueRecord[me.displayField] = record; valueRecord = new me.valueStore.model(valueRecord); } } if (valueRecord) { value[i] = valueRecord; } } } } if ((skipLoad !== true) && (unknownValues.length > 0) && (me.queryMode === 'remote')) { var params = {}; params[me.valueField] = unknownValues.join(me.delimiter); me.store.load({ params: params, callback: function() { me.itemList.unmask(); me.setValue(value, doSelect, true); me.autoSize(); } }); return false; } /** * For single-select boxes, use the last value */ if (!me.multiSelect && (value.length > 0)) { value = value[value.length - 1]; } me.callParent([value, doSelect]); }, /** * Returns the records for the field's current value * @return {Array} The records for the field's current value */ getValueRecords: function() { return this.valueStore.getRange(); }, /** * Overridden to optionally allow for submitting the field as a json encoded array. */ getSubmitData: function() { var me = this, val = me.callParent(arguments); if (me.multiSelect && me.encodeSubmitValue && val && val[me.name]) { val[me.name] = Ext.encode(val[me.name]); } return val; }, /** * Overridden to handle creation of new value for unforced selections */ beforeBlur: function() { var me = this; me.doQueryTask.cancel(); me.assertValue(); me.collapse(); }, /** * Overridden to clear the input field if we are auto-setting a value as we blur. */ mimicBlur: function() { var me = this; if (me.selectOnTab && me.picker && me.picker.highlightedItem) { me.inputEl.dom.value = ''; } me.callParent(arguments); }, /** * Overridden to handle partial-input selections more directly */ assertValue: function() { var me = this, rawValue = me.inputEl.dom.value, rec = !Ext.isEmpty(rawValue) ? me.findRecordByDisplay(rawValue) : false, value = false; if (!rec && !me.forceSelection && me.createNewOnBlur && !Ext.isEmpty(rawValue)) { value = rawValue; } else if (rec) { value = rec; } if (value) { me.addValue(value); } me.inputEl.dom.value = ''; me.collapse(); }, /** * Update the valueStore from the new value and fire change events for UI to respond to */ checkChange: function() { if (!this.suspendCheckChange && !this.isDestroyed) { var me = this, valueStore = me.valueStore, lastValue = me.lastValue, valueField = me.valueField, newValue = Ext.Array.map(Ext.Array.from(me.value), function(val) { if (val.isModel) { return val.get(valueField); } return val; }, this).join(this.delimiter); if (!me.isEqual(newValue, lastValue)) { valueStore.suspendEvents(); valueStore.removeAll(); if (Ext.isArray(me.valueModels)) { valueStore.add(me.valueModels); } valueStore.resumeEvents(); valueStore.fireEvent('datachanged', valueStore); me.lastValue = newValue; me.fireEvent('change', me, newValue, lastValue); me.onChange(newValue, lastValue) } } }, /** * Overridden to use value (selection) instead of raw value and to avoid the use of placeholder */ applyEmptyText : function() { var me = this, emptyText = me.emptyText, inputEl, isEmpty; if (me.rendered && emptyText) { isEmpty = Ext.isEmpty(me.value) && !me.hasFocus; inputEl = me.inputEl; if (isEmpty) { inputEl.dom.value = emptyText; inputEl.addCls(me.emptyCls); } else { if (inputEl.dom.value === emptyText) { inputEl.dom.value = ''; } inputEl.removeCls(me.emptyCls); } } }, /** * Overridden to use inputEl instead of raw value and to avoid the use of placeholder */ preFocus : function(){ var me = this, inputEl = me.inputEl, emptyText = me.emptyText, isEmpty; if (emptyText && inputEl.dom.value === emptyText) { inputEl.dom.value = ''; isEmpty = true; inputEl.removeCls(me.emptyCls); } if (me.selectOnFocus || isEmpty) { inputEl.dom.select(); } }, /** * Intercept calls to onFocus to add focusCls, because the base field classes assume this should be applied to inputEl */ onFocus: function() { var me = this, focusCls = me.focusCls, itemList = me.itemList; if (focusCls && itemList) { itemList.addCls(focusCls); } me.callParent(arguments); }, /** * Intercept calls to onBlur to remove focusCls, because the base field classes assume this should be applied to inputEl */ onBlur: function() { var me = this, focusCls = me.focusCls, itemList = me.itemList; if (focusCls && itemList) { itemList.removeCls(focusCls); } me.callParent(arguments); }, /** * Intercept calls to renderActiveError to add invalidCls, because the base field classes assume this should be applied to inputEl */ renderActiveError: function() { var me = this, invalidCls = me.invalidCls, itemList = me.itemList, hasError = me.hasActiveError(); if (invalidCls && itemList) { itemList[hasError ? 'addCls' : 'removeCls'](me.invalidCls + '-field'); } me.callParent(arguments); }, /** * Ensure inputEl is sized well for user input using the remaining * horizontal space available in the list element * * Automatically grows the field to accomodate the height of the selections up to the * maximum field height allowed. This only takes effect if <tt>{@link #grow} = true</tt>, * and fires the {@link #autosize} event if the height changes. */ autoSize: function() { var me = this, height; if (me.rendered) { me.doComponentLayout(); if (me.grow) { height = me.getHeight(); if (height !== me.lastInputHeight) { me.alignPicker(); me.fireEvent('autosize', height); me.lastInputHeight = height; } } } return me; } }, function() { /** * ExtJS 4.0.5 introduced more optimized ways of referencing child elements. As this is * currently a subscriber only release, these registrations are performed here for * backwards compatibility with the currently available public version 4.0.2a */ var useNewSelectors = !Ext.getVersion('extjs').isLessThan('4.0.5'), overrides = {}; if (useNewSelectors) { Ext.apply(overrides, { fieldSubTpl: [ '<div class="x-boxselect">', '<ul id="{cmpId}-itemList" class="x-boxselect-list {fieldCls} {typeCls}">', '<li id="{cmpId}-inputElCt" class="x-boxselect-input">', '<input id="{cmpId}-inputEl" type="{type}" ', '<tpl if="name">name="{name}" </tpl>', '<tpl if="size">size="{size}" </tpl>', '<tpl if="tabIdx">tabIndex="{tabIdx}" </tpl>', 'class="x-boxselect-input-field" autocomplete="off" />', '</li>', '</ul>', '<div id="{cmpId}-triggerWrap" class="{triggerWrapCls}" role="presentation">', '{triggerEl}', '<div class="{clearCls}" role="presentation"></div>', '</div>', '<div class="{clearCls}" role="presentation"></div>', '</div>', { compiled: true, disableFormats: true } ], childEls: ['itemList', 'inputEl', 'inputElCt'] }); } else { Ext.apply(overrides, { fieldSubTpl: [ '<div class="x-boxselect">', '<ul class="x-boxselect-list {fieldCls} {typeCls}">', '<li class="x-boxselect-input">', '<input id="{id}" type="{type}" ', '<tpl if="name">name="{name}" </tpl>', '<tpl if="size">size="{size}" </tpl>', '<tpl if="tabIdx">tabIndex="{tabIdx}" </tpl>', 'class="x-boxselect-input-field" autocomplete="off" />', '</li>', '</ul>', '<div class="{triggerWrapCls}" role="presentation">', '{triggerEl}', '<div class="{clearCls}" role="presentation"></div>', '</div>', '</div>', { compiled: true, disableFormats: true } ], renderSelectors: { itemList: 'ul.x-boxselect-list', inputEl: 'input.x-boxselect-input-field', inputElCt: 'li.x-boxselect-input' } }); } Ext.override(this, overrides); }); /** * This is an amalgamation of the TextArea field layout and the Trigger field layout, * with overrides to manage the layout of the field on the itemList wrap instead * of the inputEl and to grow based on inputEl wrap positioning instead of * raw text value. */ Ext.define('Ext.ux.layout.component.field.BoxSelectField', { /* Begin Definitions */ alias: ['layout.boxselectfield'], extend: 'Ext.layout.component.field.Field', /* End Definitions */ type: 'boxselectfield', /** * Overridden to use an encoded value instead of raw value */ beforeLayout: function(width, height) { var me = this, owner = me.owner, lastValue = this.lastValue, value = Ext.encode(owner.value); this.lastValue = value; return me.callParent(arguments) || (owner.grow && value !== lastValue); }, /** * Overridden to use itemList instead of inputEl, and to merge trigger field * sizing with text field growability. */ sizeBodyContents: function(width, height) { var me = this, owner = me.owner, triggerWrap = owner.triggerWrap, triggerWidth = owner.getTriggerWidth(), itemList, inputEl, inputElCt, lastEntry, listBox, listWidth, inputWidth; // If we or our ancestor is hidden, we can get a triggerWidth calculation // of 0. We don't want to resize in this case. if (owner.hideTrigger || owner.readOnly || triggerWidth > 0) { itemList = owner.itemList; // Decrease the field's width by the width of the triggers. Both the field and the triggerWrap // are floated left in CSS so they'll stack up side by side. me.setElementSize(itemList, Ext.isNumber(width) ? width - triggerWidth : width, height); // Explicitly set the triggerWrap's width, to prevent wrapping triggerWrap.setWidth(triggerWidth); // Size the input el to take up the maximum amount of remaining list width, // or the entirety of list width to cause wrapping if too little space remains. inputEl = owner.inputEl; inputElCt = owner.inputElCt; listBox = itemList.getBox(true, true); listWidth = listBox.width; if ((owner.grow && owner.growMax && (itemList.dom.scrollHeight > (owner.growMax - 25))) || (owner.isFixedHeight() && (itemList.dom.scrollHeight > itemList.dom.clientHeight))) { listWidth = listWidth - Ext.getScrollbarSize().width; } inputWidth = listWidth - 10; lastEntry = inputElCt.dom.previousSibling; if (lastEntry) { inputWidth = inputWidth - (lastEntry.offsetLeft + Ext.fly(lastEntry).getWidth() + Ext.fly(lastEntry).getPadding('lr')); } if (inputWidth < 35) { inputWidth = listWidth - 10; } if (inputWidth >= 0) { me.setElementSize(inputEl, inputWidth); if (owner.hasFocus) { inputElCt.scrollIntoView(itemList); } } } } });