例子 http://kveeiv.github.com/extjs-boxselect/examples/boxselect.html
/**
* BoxSelect for ExtJS 4.1, 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 standard
* [ComboBox](http://docs.sencha.com/ext-js/4-1/#!/api/Ext.form.field.ComboBox) control.
* Some default configuration options have changed, but most should still work properly
* if overridden unless otherwise noted.
*
* Please note, this component does not support versions of ExtJS earlier than 4.1.
*
* 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 [official user extension forum post](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.
*
* See [AUTHORS.txt](../AUTHORS.TXT) for a list of major contributors
*
* @author kvee_iv http://www.sencha.com/forum/member.php?29437-kveeiv
* @version 2.0.3
* @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'],
//
// Begin configuration options related to selected values
//
/**
* @cfg {Boolean}
* 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. The combo's text field will show all selected values using the template
* defined by {@link #labelTpl}.
*
*/
multiSelect: true,
/**
* @cfg {String/Ext.XTemplate} labelTpl
* The [XTemplate](http://docs.sencha.com/ext-js/4-1/#!/api/Ext.XTemplate) to use for the inner
* markup of the labelled items. Defaults to the configured {@link #displayField}
*/
/**
* @cfg
* @inheritdoc
*
* When {@link #forceSelection} is `false`, new records can be created by the user as they
* are typed. These records are **not** added to the combo's store. This creation
* is triggered by typing the configured 'delimiter', and can be further configured using the
* {@link #createNewOnEnter} and {@link #createNewOnBlur} configuration options.
*
* This functionality is primarily useful with BoxSelect components for things
* such as an email address.
*/
forceSelection: true,
/**
* @cfg {Boolean}
* Has no effect if {@link #forceSelection} is `true`.
*
* With {@link #createNewOnEnter} set to `true`, the creation described in
* {@link #forceSelection} will also be triggered by the 'enter' key.
*/
createNewOnEnter: false,
/**
* @cfg {Boolean}
* Has no effect if {@link #forceSelection} is `true`.
*
* With {@link #createNewOnBlur} set to `true`, the creation described in
* {@link #forceSelection} will also be triggered when the field loses focus.
*
* 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 and the existing item will be added to the selection.
*/
createNewOnBlur: false,
/**
* @cfg {Boolean}
* Has no effect if {@link #multiSelect} is `false`.
*
* Controls the formatting of the form submit value of the field as returned by {@link #getSubmitValue}
*
* - `true` for the field value to submit as a json encoded array in a single GET/POST variable
* - `false` for the field to submit as an array of GET/POST variables
*/
encodeSubmitValue: false,
//
// End of configuration options related to selected values
//
//
// Configuration options related to pick list behavior
//
/**
* @cfg {Boolean}
* `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`.
*/
triggerOnClick: true,
/**
* @cfg {Boolean}
* - `true` to have each selected value fill to the width of the form field
* - `false to have each selected value size to its displayed contents
*/
stacked: false,
/**
* @cfg {Boolean}
* Has no effect if {@link #multiSelect} is `false`
*
* `true` to keep the pick list expanded after each selection from the pick list
* `false` to automatically collapse the pick list after a selection is made
*/
pinList: true,
/**
* @cfg {Boolean}
* True to hide the currently selected values from the drop down list. These items are hidden via
* css to maintain simplicity in store and filter management.
*
* - `true` to hide currently selected values from the drop down pick list
* - `false` to keep the item in the pick list as a selected item
*/
filterPickList: false,
//
// End of configuration options related to pick list behavior
//
//
// Configuration options related to text field behavior
//
/**
* @cfg
* @inheritdoc
*/
selectOnFocus: true,
/**
* @cfg {Boolean}
*
* `true` if this field should automatically grow and shrink vertically to its content.
* Note that this overrides the natural trigger grow functionality, which is used to size
* the field horizontally.
*/
grow: true,
/**
* @cfg {Number/Boolean}
* Has no effect if {@link #grow} is `false`
*
* The minimum height to allow when {@link #grow} is `true`, or `false` to allow for
* natural vertical growth based on the current selected values. See also {@link #growMax}.
*/
growMin: false,
/**
* @cfg {Number/Boolean}
* Has no effect if {@link #grow} is `false`
*
* The maximum height to allow when {@link #grow} is `true`, or `false` to allow for
* natural vertical growth based on the current selected values. See also {@link #growMin}.
*/
growMax: false,
/**
* @cfg growAppend
* @hide
* Currently unsupported by BoxSelect since this is used for horizontal growth and
* BoxSelect only supports vertical growth.
*/
/**
* @cfg growToLongestValue
* @hide
* Currently unsupported by BoxSelect since this is used for horizontal growth and
* BoxSelect only supports vertical growth.
*/
//
// End of configuration options related to text field behavior
//
//
// Event signatures
//
/**
* @event autosize
* Fires when the **{@link #autoSize}** function is triggered and the field is resized according to the
* {@link #grow}/{@link #growMin}/{@link #growMax} configs as a result. This event provides a hook for the
* developer to apply additional logic at runtime to resize the field if needed.
* @param {Ext.ux.form.field.BoxSelect} this This BoxSelect field
* @param {Number} height The new field height
*/
//
// End of event signatures
//
//
// Configuration options that will break things if messed with
//
/**
* @private
*/
fieldSubTpl: [
'<div id="{cmpId}-listWrapper" class="x-boxselect {fieldCls} {typeCls}">',
'<ul id="{cmpId}-itemList" class="x-boxselect-list">',
'<li id="{cmpId}-inputElCt" class="x-boxselect-input">',
'<input id="{cmpId}-inputEl" type="{type}" ',
'<tpl if="name">name="{name}" </tpl>',
'<tpl if="value"> value="{[Ext.util.Format.htmlEncode(values.value)]}"</tpl>',
'<tpl if="size">size="{size}" </tpl>',
'<tpl if="tabIdx">tabIndex="{tabIdx}" </tpl>',
'<tpl if="disabled"> disabled="disabled"</tpl>',
'class="x-boxselect-input-field {inputElCls}" autocomplete="off">',
'</li>',
'</ul>',
'</div>',
{
compiled: true,
disableFormats: true
}
],
/**
* @private
*/
childEls: [ 'listWrapper', 'itemList', 'inputEl', 'inputElCt' ],
/**
* @private
*/
componentLayout: 'boxselectfield',
/**
* @inheritdoc
*
* Initialize additional settings and enable simultaneous typeAhead and multiSelect support
* @protected
*/
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();
me.typeAhead = typeAhead;
me.selectionModel = new Ext.selection.Model({
store: me.valueStore,
mode: 'MULTI',
lastFocused: null,
onSelectChange: function(record, isSelected, suppressEvent, commitFn) {
commitFn();
}
});
if (!Ext.isEmpty(me.delimiter) && me.multiSelect) {
me.delimiterRegexp = new RegExp(String(me.delimiter).replace(/[$%()*+.?\[\\\]{|}]/g, "\\$&"));
}
},
/**
* Register events for management controls of labelled items
* @protected
*/
initEvents: function() {
var me = this;
me.callParent(arguments);
if (!me.enableKeyEvents) {
me.mon(me.inputEl, 'keydown', me.onKeyDown, me);
}
me.mon(me.inputEl, 'paste', me.onPaste, me);
me.mon(me.listWrapper, 'click', me.onItemListClick, me);
// I would prefer to use relayEvents here to forward these events on, but I want
// to pass the field instead of exposing the underlying selection model
me.mon(me.selectionModel, {
'selectionchange': function(selModel, selectedRecs) {
me.applyMultiselectItemMarkup();
me.fireEvent('valueselectionchange', me, selectedRecs);
},
'focuschange': function(selectionModel, oldFocused, newFocused) {
me.fireEvent('valuefocuschange', me, oldFocused, newFocused);
},
scope: me
});
},
/**
* @inheritdoc
*
* Create a store for the records of our current value based on the main store's model
* @protected
*/
onBindStore: function(store, initial) {
var me = this;
if (store) {
me.valueStore = new Ext.data.Store({
model: store.model,
proxy: {
type: 'memory'
}
});
me.mon(me.valueStore, 'datachanged', me.applyMultiselectItemMarkup, me);
if (me.selectionModel) {
me.selectionModel.bindStore(me.valueStore);
}
}
},
/**
* @inheritdoc
*
* Remove the selected value store and associated listeners
* @protected
*/
onUnbindStore: function(store) {
var me = this,
valueStore = me.valueStore;
if (valueStore) {
if (me.selectionModel) {
me.selectionModel.setLastFocused(null);
me.selectionModel.deselectAll();
me.selectionModel.bindStore(null);
}
me.mun(valueStore, 'datachanged', me.applyMultiselectItemMarkup, me);
valueStore.destroy();
me.valueStore = null;
}
me.callParent(arguments);
},
/**
* @inheritdoc
*
* Add refresh tracking to the picker for selection management
* @protected
*/
createPicker: function() {
var me = this,
picker = me.callParent(arguments);
me.mon(picker, {
'beforerefresh': me.onBeforeListRefresh,
scope: me
});
if (me.filterPickList) {
picker.addCls('x-boxselect-hideselections');
}
return picker;
},
/**
* @inheritdoc
*
* Clean up selected values management controls
* @protected
*/
onDestroy: function() {
var me = this;
Ext.destroyMembers(me, 'valueStore', 'selectionModel');
me.callParent(arguments);
},
/**
* Add empty text support to initial render.
* @protected
*/
getSubTplData: function() {
var me = this,
data = me.callParent(),
isEmpty = me.emptyText && data.value.length < 1;
if (isEmpty) {
data.value = me.emptyText;
} else {
data.value = '';
}
data.inputElCls = data.fieldCls.match(me.emptyCls) ? me.emptyCls : '';
return data;
},
/**
* @inheritdoc
*
* Overridden to avoid use of placeholder, as our main input field is often empty
* @protected
*/
afterRender: function() {
var me = this;
if (Ext.supports.Placeholder && me.inputEl && me.emptyText) {
delete me.inputEl.dom.placeholder;
}
me.bodyEl.applyStyles('vertical-align:top');
if (me.grow) {
if (Ext.isNumber(me.growMin) && (me.growMin > 0)) {
me.listWrapper.applyStyles('min-height:'+me.growMin+'px');
}
if (Ext.isNumber(me.growMax) && (me.growMax > 0)) {
me.listWrapper.applyStyles('max-height:'+me.growMax+'px');
}
}
if (me.stacked === true) {
me.itemList.addCls('x-boxselect-stacked');
}
if (!me.multiSelect) {
me.itemList.addCls('x-boxselect-singleselect');
}
me.applyMultiselectItemMarkup();
me.callParent(arguments);
},
/**
* Overridden to search entire unfiltered store since already selected values
* can span across multiple store page loads and other filtering. Overlaps
* some with {@link #isFilteredRecord}, but findRecord is used by the base component
* for various logic so this logic is applied here as well.
* @protected
*/
findRecord: function(field, value) {
var ds = this.store,
matches;
if (!ds) {
return false;
}
matches = ds.queryBy(function(rec, id) {
return rec.isEqual(rec.get(field), value);
});
return (matches.getCount() > 0) ? matches.first() : false;
},
/**
* 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
* @protected
*/
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);
},
/**
* Used to determine if a record is filtered out of the current store's data set,
* for determining if a currently selected value should be retained.
*
* Slightly complicated logic. A record is considered filtered and should be retained if:
*
* - It is not in the combo store and the store has no filter or it is in the filtered data set
* (Happens when our selected value is just part of a different load, page or query)
* - It is not in the combo store and forceSelection is false and it is in the value store
* (Happens when our selected value was created manually)
*
* @private
*/
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;
},
/**
* @inheritdoc
*
* Overridden to allow for continued querying with multiSelect selections already made
* @protected
*/
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.
* @protected
*/
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.
* @protected
*/
onListRefresh: function() {
this.callParent(arguments);
if (this.ignoreSelection > 0) {
--this.ignoreSelection;
}
},
/**
* Overridden to preserve current labelled items when list is filtered/paged/loaded
* and does not include our current value. See {@link #isFilteredRecord}
* @private
*/
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. See {@link #isFilteredRecord}
* @private
*/
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);
}
if (me.ignoreSelection > 0) {
--me.ignoreSelection;
}
}
},
/**
* Overridden to align to itemList size instead of inputEl
*/
doAlign: function(){
var me = this,
picker = me.picker,
aboveSfx = '-above',
isAbove;
me.picker.alignTo(me.listWrapper, 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[isAbove ? 'addCls' : 'removeCls'](picker.baseCls + aboveSfx);
},
/**
* Overridden to preserve scroll position of pick list when list is realigned
*/
alignPicker: function() {
var me = this,
picker = me.picker,
pickerScrollPos = picker.getTargetEl().dom.scrollTop;
me.callParent(arguments);
if (me.isExpanded) {
if (me.matchFieldWidth) {
// Auto the height (it will be constrained by min and max width) unless there are no records to display.
picker.setWidth(me.listWrapper.getWidth());
}
picker.getTargetEl().dom.scrollTop = pickerScrollPos;
}
},
/**
* Get the current cursor position in the input field, for key-based navigation
* @private
*/
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;
},
/**
* Check to see if the input field has selected text, for key-based navigation
* @private
*/
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.
* Supported keyboard controls:
*
* - If pick list is expanded
*
* - `CTRL-A` will select all the items in the pick list
*
* - If the cursor is at the beginning of the input field and there are values present
*
* - `CTRL-A` will highlight all the currently selected values
* - `BACKSPACE` and `DELETE` will remove any currently highlighted selected values
* - `RIGHT` and `LEFT` will move the current highlight in the appropriate direction
* - `SHIFT-RIGHT` and `SHIFT-LEFT` will add to the current highlight in the appropriate direction
*
* @protected
*/
onKeyDown: function(e, t) {
var me = this,
key = e.getKey(),
rawValue = me.inputEl.dom.value,
valueStore = me.valueStore,
selModel = me.selectionModel,
stopEvent = false;
if (me.readOnly || me.disabled || !me.editable) {
return;
}
if (me.isExpanded && (key == e.A && e.ctrlKey)) {
// CTRL-A when picker is expanded - add all items in current picker store page to current value
me.select(me.getStore().getRange());
selModel.setLastFocused(null);
selModel.deselectAll();
me.collapse();
me.inputEl.focus();
stopEvent = true;
} else if ((valueStore.getCount() > 0) &&
((rawValue == '') || ((me.getCursorPosition() === 0) && !me.hasSelectedText()))) {
// Keyboard navigation of current values
var lastSelectionIndex = (selModel.getCount() > 0) ? valueStore.indexOf(selModel.getLastSelected() || selModel.getLastFocused()) : -1;
if ((key == e.BACKSPACE) || (key == e.DELETE)) {
if (lastSelectionIndex > -1) {
if (selModel.getCount() > 1) {
lastSelectionIndex = -1;
}
me.valueStore.remove(selModel.getSelection());
} else {
me.valueStore.remove(me.valueStore.last());
}
selModel.clearSelections();
me.setValue(me.valueStore.getRange());
if (lastSelectionIndex > 0) {
selModel.select(lastSelectionIndex - 1);
}
stopEvent = true;
} else if ((key == e.RIGHT) || (key == e.LEFT)) {
if ((lastSelectionIndex == -1) && (key == e.LEFT)) {
selModel.select(valueStore.last());
stopEvent = true;
} else if (lastSelectionIndex > -1) {
if (key == e.RIGHT) {
if (lastSelectionIndex < (valueStore.getCount() - 1)) {
selModel.select(lastSelectionIndex + 1, e.shiftKey);
stopEvent = true;
} else if (!e.shiftKey) {
selModel.setLastFocused(null);
selModel.deselectAll();
stopEvent = true;
}
} else if ((key == e.LEFT) && (lastSelectionIndex > 0)) {
selModel.select(lastSelectionIndex - 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.setLastFocused(null);
me.selectionModel.deselectAll();
me.inputEl.focus();
}
},
/**
* Handles auto-selection and creation of labelled items based on this field's
* delimiter, as well as the keyUp processing of key-based selection of labelled items.
* @protected
*/
onKeyUp: function(e, t) {
var me = this,
rawValue = me.inputEl.dom.value;
if (me.preventKeyUpEvent) {
e.stopEvent();
if ((me.preventKeyUpEvent === true) || (e.getKey() === me.preventKeyUpEvent)) {
delete me.preventKeyUpEvent;
}
return;
}
if (me.multiSelect && (me.delimiterRegexp && me.delimiterRegexp.test(rawValue)) ||
((me.createNewOnEnter === true) && e.getKey() == e.ENTER)) {
rawValue = Ext.Array.clean(rawValue.split(me.delimiterRegexp));
me.inputEl.dom.value = '';
me.setValue(me.valueStore.getRange().concat(rawValue));
me.inputEl.focus();
}
me.callParent([e,t]);
},
/**
* Handles auto-selection of labelled items based on this field's delimiter when pasting
* a list of values in to the field (e.g., for email addresses)
* @protected
*/
onPaste: function(e, t) {
var me = this,
rawValue = me.inputEl.dom.value,
clipboard = (e && e.browserEvent && e.browserEvent.clipboardData) ? e.browserEvent.clipboardData : false;
if (me.multiSelect && (me.delimiterRegexp && me.delimiterRegexp.test(rawValue))) {
if (clipboard && clipboard.getData) {
if (/text\/plain/.test(clipboard.types)) {
rawValue = clipboard.getData('text/plain');
} else if (/text\/html/.test(clipboard.types)) {
rawValue = clipboard.getData('text/html');
}
}
rawValue = Ext.Array.clean(rawValue.split(me.delimiterRegexp));
me.inputEl.dom.value = '';
me.setValue(me.valueStore.getRange().concat(rawValue));
me.inputEl.focus();
}
},
/**
* Overridden to handle key navigation of pick list when list is filtered. Because we
* want to avoid complexity that could be introduced by modifying the store's contents,
* (e.g., always having to search back through and remove values when they might
* be re-sent by the server, adding the values back in their previous position when
* they are removed from the current selection, etc.), we handle this filtering
* via a simple css rule. However, for the moment since those DOM nodes still exist
* in the list we have to hijack the highlighting methods for the picker's BoundListKeyNav
* to appropriately skip over these hidden nodes. This is a less than ideal solution,
* but it centralizes all of the complexity of this problem in to this one method.
* @protected
*/
onExpand: function() {
var me = this,
keyNav = me.listKeyNav;
me.callParent(arguments);
if (keyNav || !me.filterPickList) {
return;
}
keyNav = me.listKeyNav;
keyNav.highlightAt = function(index) {
var boundList = this.boundList,
item = boundList.all.item(index),
len = boundList.all.getCount(),
direction;
if (item && item.hasCls('x-boundlist-selected')) {
if ((index == 0) || !boundList.highlightedItem || (boundList.indexOf(boundList.highlightedItem) < index)) {
direction = 1;
} else {
direction = -1;
}
do {
index = index + direction;
item = boundList.all.item(index);
} while ((index > 0) && (index < len) && item.hasCls('x-boundlist-selected'));
if (item.hasCls('x-boundlist-selected')) {
return;
}
}
if (item) {
item = item.dom;
boundList.highlightItem(item);
boundList.getTargetEl().scrollChildIntoView(item, false);
}
};
},
/**
* Overridden to get and set the DOM value directly for type-ahead suggestion (bypassing get/setRawValue)
* @protected
*/
onTypeAhead: function() {
var me = this,
displayField = me.displayField,
inputElDom = me.inputEl.dom,
valueStore = me.valueStore,
boundList = me.getPicker(),
record, newValue, len, selStart;
if (me.filterPickList) {
var fn = this.createFilterFn(displayField, inputElDom.value);
record = me.store.findBy(function(rec) {
return ((valueStore.indexOfId(rec.getId()) === -1) && fn(rec));
});
record = (record === -1) ? false : me.store.getAt(record);
} else {
record = me.store.findRecord(displayField, inputElDom.value);
}
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
* @protected
*/
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);
if (me.valueStore.getCount() > 0) {
me.fireEvent('select', me, me.valueStore.getRange());
}
} else {
me.toggleSelectionByListItemNode(itemEl, evt.shiftKey);
}
me.inputEl.focus();
} else {
if (me.selectionModel.getCount() > 0) {
me.selectionModel.setLastFocused(null);
me.selectionModel.deselectAll();
}
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)
* @private
*/
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) || Ext.isArray(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));
}
return false;
},
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
* @private
*/
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());
}
Ext.Function.defer(function() {
if (me.picker && me.isExpanded) {
me.alignPicker();
}
if (me.hasFocus) {
me.inputElCt.scrollIntoView(me.listWrapper);
}
}, 15);
},
/**
* 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),
selModel = me.selectionModel;
if (rec) {
if (selModel.isSelected(rec)) {
if (selModel.isFocused(rec)) {
selModel.setLastFocused(null);
}
selModel.deselect(rec);
} else {
selModel.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());
}
},
/**
* @inheritdoc
* Intercept calls to getRawValue to pretend there is no inputEl for rawValue handling,
* so that we can use inputEl for user input of just the current value.
*/
getRawValue: function() {
var me = this,
inputEl = me.inputEl,
result;
me.inputEl = false;
result = me.callParent(arguments);
me.inputEl = inputEl;
return result;
},
/**
* @inheritdoc
* Intercept calls to setRawValue to pretend there is no inputEl for rawValue handling,
* so that we can use inputEl for user input of just the current value.
*/
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} value The value or values to add to the current value, see {@link #setValue}
*/
addValue: function(value) {
var me = this;
if (value) {
me.setValue(Ext.Array.merge(me.value, Ext.Array.from(value)));
}
},
/**
* Removes a value or values from the current value of the field
* @param {Mixed} value The value or values to remove from the current value, see {@link #setValue}
*/
removeValue: function(value) {
var me = this;
if (value) {
me.setValue(Ext.Array.difference(me.value, Ext.Array.from(value)));
}
},
/**
* Sets the specified value(s) into the field. The following value formats are recognised:
*
* - Single Values
*
* - A string associated to this field's configured {@link #valueField}
* - A record containing at least this field's configured {@link #valueField} and {@link #displayField}
*
* - Multiple Values
*
* - If {@link #multiSelect} is `true`, a string containing multiple strings as
* specified in the Single Values section above, concatenated in to one string
* with each entry separated by this field's configured {@link #delimiter}
* - An array of strings as specified in the Single Values section above
* - An array of records as specified in the Single Values section above
*
* In any of the string formats above, the following occurs if an associated record cannot be found:
*
* 1. If {@link #forceSelection} is `false`, a new record of the {@link #store}'s configured model type
* will be created using the given value as the {@link #displayField} and {@link #valueField}.
* This record will be added to the current value, but it will **not** be added to the store.
* 2. If {@link #forceSelection} is `true` and {@link #queryMode} is `remote`, the list of unknown
* values will be submitted as a call to the {@link #store}'s load as a parameter named by
* the {@link #valueField} with values separated by the configured {@link #delimiter}.
* ** This process will cause setValue to asynchronously process. ** This will only be attempted
* once. Any unknown values that the server does not return records for will be removed.
* 3. Otherwise, unknown values will be removed.
*
* @param {Mixed} value The value(s) to be set, see method documentation for details
* @return {Ext.form.field.Field/Boolean} this, or `false` if asynchronously querying for unknown values
*/
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, true);
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() {
if (me.itemList) {
me.itemList.unmask();
}
me.setValue(value, doSelect, true);
me.autoSize();
}
});
return false;
}
// For single-select boxes, use the last good (formal record) value if possible
if (!me.multiSelect && (value.length > 0)) {
for (i = value.length - 1; i >= 0; i--) {
if (value[i].isModel) {
value = value[i];
break;
}
}
if (Ext.isArray(value)) {
value = value[value.length - 1];
}
}
return 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();
},
/**
* @inheritdoc
* 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 clear the input field if we are auto-setting a value as we blur.
* @protected
*/
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();
},
/**
* Expand record values for evaluating change 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),
isEqual = me.isEqual(newValue, lastValue);
if (!isEqual || ((newValue.length > 0 && valueStore.getCount() < newValue.length))) {
valueStore.suspendEvents();
valueStore.removeAll();
if (Ext.isArray(me.valueModels)) {
valueStore.add(me.valueModels);
}
valueStore.resumeEvents();
valueStore.fireEvent('datachanged', valueStore);
if (!isEqual) {
me.lastValue = newValue;
me.fireEvent('change', me, newValue, lastValue);
me.onChange(newValue, lastValue);
}
}
}
},
/**
* Overridden to be more accepting of varied value types
*/
isEqual: function(v1, v2) {
var fromArray = Ext.Array.from,
valueField = this.valueField,
i, len, t1, t2;
v1 = fromArray(v1);
v2 = fromArray(v2);
len = v1.length;
if (len !== v2.length) {
return false;
}
for(i = 0; i < len; i++) {
t1 = v1[i].isModel ? v1[i].get(valueField) : v1[i];
t2 = v2[i].isModel ? v2[i].get(valueField) : v2[i];
if (t1 !== t2) {
return false;
}
}
return true;
},
/**
* 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);
me.listWrapper.addCls(me.emptyCls);
} else {
if (inputEl.dom.value === emptyText) {
inputEl.dom.value = '';
}
me.listWrapper.removeCls(me.emptyCls);
inputEl.removeCls(me.emptyCls);
}
me.autoSize();
}
},
/**
* 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);
me.listWrapper.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);
},
/**
* Initiate auto-sizing for height based on {@link #grow}, if applicable.
*/
autoSize: function() {
var me = this,
height;
if (me.grow && me.rendered) {
me.autoSizing = true;
me.updateLayout();
}
return me;
},
/**
* Track height change to fire {@link #event-autosize} event, when applicable.
*/
afterComponentLayout: function() {
var me = this,
width;
if (me.autoSizing) {
height = me.getHeight();
if (height !== me.lastInputHeight) {
if (me.isExpanded) {
me.alignPicker();
}
me.fireEvent('autosize', me, height);
me.lastInputHeight = height;
delete me.autoSizing;
}
}
}
});
/**
* Ensures the input element takes up the maximum amount of remaining list width,
* or the entirety of the list width if too little space remains. In this case,
* the list height will be automatically increased to accomodate the new line. This
* growth will not occur if {@link Ext.ux.form.field.BoxSelect#multiSelect} or
* {@link Ext.ux.form.field.BoxSelect#grow} is false.
*/
Ext.define('Ext.ux.layout.component.field.BoxSelectField', {
/* Begin Definitions */
alias: ['layout.boxselectfield'],
extend: 'Ext.layout.component.field.Trigger',
/* End Definitions */
type: 'boxselectfield',
/*For proper calculations we need our field to be sized.*/
waitForOuterWidthInDom:true,
beginLayout: function(ownerContext) {
var me = this,
owner = me.owner;
me.callParent(arguments);
ownerContext.inputElCtContext = ownerContext.getEl('inputElCt');
owner.inputElCt.setStyle('width','');
me.skipInputGrowth = !owner.grow || !owner.multiSelect;
},
beginLayoutFixed: function(ownerContext, width, suffix) {
var me = this,
owner = ownerContext.target;
owner.triggerEl.setStyle('height', '24px');
me.callParent(arguments);
if (ownerContext.heightModel.fixed && ownerContext.lastBox) {
owner.listWrapper.setStyle('height', ownerContext.lastBox.height+'px');
owner.itemList.setStyle('height', '100%');
}
/*No inputElCt calculations here!*/
},
/*Calculate and cache value of input container.*/
publishInnerWidth:function(ownerContext) {
var me = this,
owner = me.owner,
width = owner.itemList.getWidth(true) - 10,
lastEntry = owner.inputElCt.prev(null, true);
if (lastEntry && !owner.stacked) {
lastEntry = Ext.fly(lastEntry);
width = width - lastEntry.getOffsetsTo(lastEntry.up(''))[0] - lastEntry.getWidth();
}
if (!me.skipInputGrowth && (width < 35)) {
width = width - 10;
} else if (width < 1) {
width = 1;
}
ownerContext.inputElCtContext.setWidth(width);
}
});