/**
* @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', {
extend:'Ext.form.field.ComboBox',
alias: ['widget.comboboxselect', 'widget.boxselect'],
requires: ['Ext.selection.Model', 'Ext.data.Store'],
/**
* @cfg {Boolean} multiSelect
* If set to true
, allows the combo field to hold more than one value at a time, and allows selecting
* multiple items from the dropdown list. (Defaults to true
, the default usage for BoxSelect)
*/
multiSelect: true,
/**
* @cfg {Boolean} forceSelection
* true
to restrict the selected value to one of the values in the list,
* false
to allow the user to set arbitrary text into the field (defaults to true
, the default usage for BoxSelect)
*/
forceSelection: true,
/**
* @cfg {Boolean} selectOnFocus true
to automatically select any existing field text when the field
* receives input focus (defaults to true
for best multi-select usability during querying)
*/
selectOnFocus: true,
/**
* @cfg {Boolean} triggerOnClick true
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} false
.
* (defaults to true
).
*/
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 false
)
* true
to allow the user to press 'enter' to create a new record
* false
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.
* true
to create a new record when the field loses focus
* false
to not create a new record on blur
*/
createNewOnBlur: false,
/**
* @cfg {Boolean} encodeSubmitValue
* Controls the formatting of the form submit value of the field. (defaults to false
). This
* is not applicable of {@link #multiSelect} is false.
* true
for the field value to submit as a json encoded array in a single POST variable
* false
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
* true
to have each labelled item fill the width of the form field
* false
to have each labelled item size to its displayed contents (defaults to false
)
*/
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.
* true
to keep the pick list expanded after each multiSelect selection
* false
to collapse the pick list after each multiSelect selection (defaults to true
)
*/
pinList: true,
/**
* @cfg {Boolean} grow true if this field should automatically grow and shrink to its content
* (defaults to true)
*/
grow: true,
/**
* @cfg {Number} growMin The minimum height to allow when {@link Ext.form.field.Text#grow grow}=true
* (defaults to false, which allows for natural growth based on selections)
*/
growMin: false,
/**
* @cfg {Number} growMax The maximum height to allow when {@link Ext.form.field.Text#grow grow}=true
* (defaults to false, 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 = [
'