/**
* `tagfield` provides a combobox that removes the hassle of dealing with long and unruly select
* options. The selected list is visually maintained in the value display area instead of
* within the picker itself. Users may easily add or remove `tags` from the
* display value area.
*
* @example
* var shows = Ext.create('Ext.data.Store', {
* fields: ['id','show'],
* data: [
* {id: 0, show: 'Battlestar Galactica'},
* {id: 1, show: 'Doctor Who'},
* {id: 2, show: 'Farscape'},
* {id: 3, show: 'Firefly'},
* {id: 4, show: 'Star Trek'},
* {id: 5, show: 'Star Wars: Christmas Special'}
* ]
* });
*
* Ext.create('Ext.form.Panel', {
* renderTo: Ext.getBody(),
* title: 'Sci-Fi Television',
* height: 200,
* width: 500,
* items: [{
* xtype: 'tagfield',
* fieldLabel: 'Select a Show',
* store: shows,
* displayField: 'show',
* valueField: 'id',
* queryMode: 'local',
* filterPickList: true
* }]
* });
*
* ### History
*
* Inspired by the SuperBoxSelect component for ExtJS 3,
* which in turn was inspired by the BoxSelect component for ExtJS 2.
*
* Various contributions and suggestions made by many members of the ExtJS community which
* can be seen in the [user extension forum post](http://www.sencha.com/forum/showthread.php?134751-Ext.ux.form.field.BoxSelect).
*
* By: kvee_iv http://www.sencha.com/forum/member.php?29437-kveeiv
*/
Ext.define('Ext.form.field.Tag', {
extend: 'Ext.form.field.ComboBox',
xtype: 'tagfield',
requires: [
'Ext.selection.Model',
'Ext.data.Store',
'Ext.data.ChainedStore',
'Ext.view.TagKeyNav'
],
/**
* @property noWrap
* @inheritdoc
*/
noWrap: false,
/**
* @cfg allowOnlyWhitespace
* @hide
* Currently unsupported since the value of a tagfield is an array of values and shouldn't
* ever be a string.
*/
/**
* @cfg {String} valueParam
* The name of the parameter used to load unknown records into the store. If left unspecified,
* {@link #valueField} will be used.
*/
/**
* @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. The combo's text field will show all
* selected values using the template defined by {@link #labelTpl}.
*
*/
multiSelect: true,
/**
* @cfg {String} delimiter
* The character(s) used to separate new values to be added when {@link #createNewOnEnter}
* or {@link #createNewOnBlur} are set.
* `{@link #multiSelect} = true`.
*/
delimiter: ',',
/**
* @cfg {String/Ext.XTemplate} labelTpl
* The {@link Ext.XTemplate XTemplate} to use for the inner
* markup of the labeled items. Defaults to the configured {@link #displayField}
*/
/**
* @cfg {String/Ext.XTemplate} tipTpl
* The {@link Ext.XTemplate XTemplate} to use for the tip of the labeled items.
*
* @since 5.1.1
*/
tipTpl: undefined,
/**
* @cfg forceSelection
* @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. Multiple new values
* may be added by separating them with the {@link #delimiter}, and can be further configured
* using the {@link #createNewOnEnter} and {@link #createNewOnBlur} configuration options.
*
* This functionality is primarily useful for things such as an email address.
*/
forceSelection: true,
/**
* @cfg {Boolean} createNewOnEnter
* Has no effect if {@link #forceSelection} is `true`.
*
* With this set to `true`, the creation described in
* {@link #forceSelection} will also be triggered by the 'enter' key.
*/
createNewOnEnter: false,
/**
* @cfg {Boolean} createNewOnBlur
* Has no effect if {@link #forceSelection} is `true`.
*
* With this 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.
*
* Setting this option to `true` is not recommended for accessible applications.
*/
createNewOnBlur: false,
/**
* @cfg {Boolean} encodeSubmitValue
* 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,
/**
* @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`.
*/
triggerOnClick: true,
/**
* @cfg {Boolean} stacked
* - `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} filterPickList
* True to hide the currently selected values from the drop down list.
*
* Setting this option to `true` is not recommended for accessible applications.
*
* - `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,
/**
* @cfg {Boolean} focusLastAddedItem
* True to focus, after the currently selected values from the drop down list.
*
* - `true` to focus, after the currently selected values from the drop down pick list
* - `false` to keep the focus at the first item in the pick list as a selected item
*/
focusLastAddedItem: false,
/**
* @cfg {Boolean} clearOnBackspace
* Set to `false` to disable clearing selected values with Backspace key. This mode
* is recommended for accessible applications.
*/
clearOnBackspace: true,
/**
* @cfg {Boolean} grow
*
* `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} growMin
* 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} growMax
* 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 {Boolean} simulatePlaceholder
* @private
*/
simulatePlaceholder: true,
/**
* @cfg selectOnFocus
* @inheritdoc
*/
selectOnFocus: true,
/**
* @cfg growToLongestValue
* @hide
* Currently unsupported since this is used for horizontal growth and this component
* only supports vertical growth.
*/
/**
* @cfg {String} ariaHelpText
* The text to be announced by screen readers when input element is
* focused. This text is used when this component is configured not to allow creating
* new values; when {@link #createNewOnEnter} is set to `true`, {@link #ariaHelpTextEditable}
* will be used instead.
* @locale
*/
ariaHelpText: 'Use Up and Down arrows to view available values, Enter to select. ' +
'Use Left and Right arrows to view selected values, Delete key to deselect.',
/**
* @cfg {String} ariaHelpTextEditable
* The text to be announced by screen readers when
* input element is focused. This text is used when {@link #createNewOnEnter} is set to `true`;
* see also {@link #ariaHelpText}.
* @locale
*/
ariaHelpTextEditable: 'Use Up and Down arrows to view available values, Enter to select. ' +
'Type and press Enter to create a new value. ' +
'Use Left and Right arrows to view selected values, ' +
'Delete key to deselect.',
/**
* @cfg {String} ariaSelectedText
* Template text for announcing selected values to screen
* reader users. '{0}' will be replaced with the list of selected values.
* @locale
*/
ariaSelectedText: 'Selected {0}.',
/**
* @cfg {String} ariaDeselectedText
* Template text for announcing deselected values to
* screen reader users. '{0}' will be replaced with the list of values removed from
* selected list.
* @locale
*/
ariaDeselectedText: '{0} removed from selection.',
/**
* @cfg {String} ariaNoneSelectedText
* Text to announce to screen reader users when no
* values are currently selected. This text is used when Tag field is focused.
* @locale
*/
ariaNoneSelectedText: 'No value selected.',
/**
* @cfg {String} ariaSelectedListLabel
* Label to be announced to screen reader users
* when they use Left and Right arrow keys to navigate the list of currently selected values.
* @locale
*/
ariaSelectedListLabel: 'Selected values',
/**
* @cfg {String} ariaAvailableListLabel
* Label to be announced to screen reader users
* when they use Up and Down arrow keys to navigate the list of available values.
* @locale
*/
ariaAvailableListLabel: 'Available values',
/**
* @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.form.field.Tag} this This field
* @param {Number} height The new field height
*/
/* eslint-disable indent, max-len */
/**
* @cfg fieldSubTpl
* @private
*/
fieldSubTpl: [
// listWrapper div is tabbable in Firefox, for some unfathomable reason
'<div id="{cmpId}-listWrapper" data-ref="listWrapper"' + (Ext.isGecko ? ' tabindex="-1"' : ''),
'<tpl foreach="ariaElAttributes"> {$}="{.}"</tpl>',
' class="' + Ext.baseCSSPrefix + 'tagfield {fieldCls} {typeCls} {typeCls}-{ui}"<tpl if="wrapperStyle"> style="{wrapperStyle}"</tpl>>',
'<span id="{cmpId}-selectedText" data-ref="selectedText" aria-hidden="true" class="' + Ext.baseCSSPrefix + 'hidden-clip"></span>',
'<ul id="{cmpId}-itemList" data-ref="itemList" role="presentation" class="' + Ext.baseCSSPrefix + 'tagfield-list{itemListCls}">',
'<li id="{cmpId}-inputElCt" data-ref="inputElCt" role="presentation" class="' + Ext.baseCSSPrefix + 'tagfield-input">',
'<input id="{cmpId}-inputEl" data-ref="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 != null">tabindex="{tabIdx}" </tpl>',
'<tpl if="disabled"> disabled="disabled"</tpl>',
'<tpl foreach="inputElAriaAttributes"> {$}="{.}"</tpl>',
'class="' + Ext.baseCSSPrefix + 'tagfield-input-field {inputElCls} {noGrowCls} {emptyCls} {fixCls}" autocomplete="off">',
'</li>',
'</ul>',
'<ul id="{cmpId}-ariaList" data-ref="ariaList" role="listbox"',
'<tpl if="ariaSelectedListLabel"> aria-label="{ariaSelectedListLabel}"</tpl>',
'<tpl if="multiSelect"> aria-multiselectable="true"</tpl>',
' class="' + Ext.baseCSSPrefix + 'tagfield-arialist">',
'</ul>',
'</div>',
{
disableFormats: true
}
],
postSubTpl: [
'<label id="{cmpId}-placeholderLabel" data-ref="placeholderLabel" for="{cmpId}-inputEl" class="{placeholderCoverCls} {placeholderCoverCls}-{ui} {emptyCls}">{placeholder}</label>',
'</div>', // end inputWrap
'<tpl for="triggers">{[values.renderTrigger(parent)]}</tpl>',
'</div>' // end triggerWrap
],
/* eslint-enable indent, max-len */
extraFieldBodyCls: Ext.baseCSSPrefix + 'tagfield-body',
/**
* @private
*/
childEls: [
'listWrapper', 'itemList', 'inputEl', 'inputElCt', 'selectedText', 'ariaList'
],
/**
* @private
*/
clearValueOnEmpty: false,
ariaSelectable: true,
/**
* @property ariaEl
* @inheritdoc
*/
ariaEl: 'listWrapper',
/**
* @private
* @cfg {String} notEditableCls
* CSS class added to the element when not editable
*/
notEditableCls: Ext.baseCSSPrefix + 'not-editable',
tagItemCls: Ext.baseCSSPrefix + 'tagfield-item',
tagItemTextCls: Ext.baseCSSPrefix + 'tagfield-item-text',
tagItemCloseCls: Ext.baseCSSPrefix + 'tagfield-item-close',
tagItemSelector: '.' + Ext.baseCSSPrefix + 'tagfield-item',
tagItemCloseSelector: '.' + Ext.baseCSSPrefix + 'tagfield-item-close',
tagSelectedCls: Ext.baseCSSPrefix + 'tagfield-item-selected',
noGrowCls: Ext.baseCSSPrefix + 'tagfield-input-field-nogrow',
initComponent: function() {
var me = this,
typeAhead = me.typeAhead,
delimiter = me.delimiter;
//<debug>
if (typeAhead && !me.editable) {
Ext.raise('If typeAhead is enabled the combo must be editable: true -- ' +
'please change one of those settings.');
}
//</debug>
// Allow unmatched textual values to be converted into new value records.
if (me.createNewOnEnter || me.createNewOnBlur) {
me.forceSelection = false;
}
me.typeAhead = false;
if (me.value == null) {
me.value = [];
}
// This is the selection model for selecting tags in the tag list.
// NOT the dropdown BoundList. Create the selModel before calling parent,
// we need it to be available when we bind the store.
me.selectionModel = new Ext.selection.Model({
mode: 'MULTI',
onSelectChange: function(record, isSelected, suppressEvent, commitFn) {
commitFn();
},
// Relay these selection events passing the field instead of exposing
// the underlying selection model
listeners: {
scope: me,
selectionchange: me.onSelectionChange,
focuschange: me.onFocusChange
}
});
// Users might want to implement centralized help
if (!me.ariaHelp) {
me.ariaHelp = me.createNewOnEnter ? me.ariaHelpTextEditable : me.ariaHelpText;
}
me.callParent();
me.typeAhead = typeAhead;
if (delimiter && me.multiSelect) {
me.delimiterRegexp = new RegExp(Ext.String.escapeRegex(delimiter));
}
},
initEvents: function() {
var me = this,
inputEl = me.inputEl;
me.callParent(arguments);
if (!me.enableKeyEvents) {
inputEl.on('keydown', me.onKeyDown, me);
inputEl.on('keyup', me.onKeyUp, me);
}
me.listWrapper.on({
scope: me,
click: me.onItemListClick,
mousedown: me.onItemMouseDown
});
},
createPicker: function() {
var me = this,
config;
// Avoid munging config on the prototype
config = Ext.apply({
navigationModel: 'tagfield'
}, me.defaultListConfig);
if (me.ariaAvailableListLabel) {
config.ariaRenderAttributes = {
'aria-label': Ext.String.htmlEncode(me.ariaAvailableListLabel)
};
}
me.defaultListConfig = config;
return me.callParent();
},
isValid: function() {
var me = this,
disabled = me.disabled,
validate = me.forceValidation || !disabled;
return validate ? me.validateValue(me.getValue()) : disabled;
},
onBindStore: function(store) {
var me = this;
me.callParent([store]);
if (store) {
// We collect picked records in a value store so that a selection model
// can track selection
me.valueStore = new Ext.data.Store({
model: store.getModel(),
// Assign a proxy here so we don't get the proxy from the model
proxy: 'memory',
// We may have the empty store here, so just ignore empty models
useModelWarning: false
});
me.selectionModel.bindStore(me.valueStore);
// Picked records disappear from the BoundList
if (me.filterPickList) {
me.listFilter = new Ext.util.Filter({
scope: me,
filterFn: me.filterPicked
});
me.changingFilters = true;
store.filter(me.listFilter);
me.changingFilters = false;
}
}
},
filterPicked: function(rec) {
return !this.valueCollection.contains(rec);
},
onUnbindStore: function(store) {
var me = this,
valueStore = me.valueStore,
picker = me.picker;
if (picker) {
picker.bindStore(null);
}
if (valueStore) {
valueStore.destroy();
me.valueStore = null;
}
if (me.filterPickList && !store.destroyed) {
me.changingFilters = true;
store.removeFilter(me.listFilter);
me.changingFilters = false;
}
me.callParent(arguments);
},
clearInput: function() {
var me = this,
valueRecords = me.getValueRecords(),
inputValue = me.inputEl && me.inputEl.dom.value,
lastDisplayValue;
if (valueRecords.length && inputValue) {
lastDisplayValue = valueRecords[valueRecords.length - 1].get(me.displayField);
if (!Ext.String.startsWith(lastDisplayValue, inputValue, true)) {
return;
}
me.inputEl.dom.value = '';
if (me.queryMode === 'local') {
me.clearLocalFilter();
// we need to refresh the picker after removing
// the local filter to display the updated data
me.getPicker().refresh();
}
}
me.syncInputWidth();
},
onValueCollectionEndUpdate: function() {
var me = this,
pickedRecords = me.valueCollection.items,
valueStore = me.valueStore;
if (me.isSelectionUpdating()) {
return;
}
// Ensure the source store is filtered down
if (me.filterPickList) {
me.changingFilters = true;
me.store.filter(me.listFilter);
me.changingFilters = false;
}
me.callParent();
Ext.suspendLayouts();
if (valueStore) {
valueStore.suspendEvents();
valueStore.loadRecords(pickedRecords);
valueStore.resumeEvents();
}
me.refreshEmptyText();
me.clearInput();
Ext.resumeLayouts(true);
me.alignPicker();
},
checkValueOnDataChange: Ext.emptyFn,
onSelectionChange: function(selModel, selectedRecs) {
var me = this,
inputEl = me.inputEl,
item;
me.applyMultiselectItemMarkup();
me.applyAriaListMarkup();
me.applyAriaSelectedText();
// Focus does not really change but we're pretending it does
if (inputEl) {
if (selectedRecs.length === 0) {
inputEl.dom.removeAttribute('aria-activedescendant');
}
else {
item = me.getAriaListNode(selectedRecs[0]);
if (item) {
inputEl.dom.setAttribute('aria-activedescendant', item.id);
}
}
}
me.fireEvent('valueselectionchange', me, selectedRecs);
},
onFocusChange: function(selectionModel, oldFocused, newFocused) {
var me = this;
me.callParent([selectionModel, oldFocused, newFocused]);
me.fireEvent('valuefocuschange', me, oldFocused, newFocused);
},
getAriaListNode: function(record) {
var ariaList = this.ariaList,
node;
if (ariaList && record) {
node = ariaList.selectNode('[data-recordid="' + record.internalId + '"]');
}
return node;
},
doDestroy: function() {
Ext.destroy(this.selectionModel);
// This will unbind the store, which will destroy the valueStore
this.callParent();
},
getSubTplData: function(fieldData) {
var me = this,
id = me.id,
data = me.callParent(arguments),
emptyText = me.emptyText,
isEmpty = emptyText && data.value.length < 1,
growMin = me.growMin,
growMax = me.growMax,
wrapperStyle = '',
attr;
data.value = '';
data.emptyText = isEmpty ? emptyText : '';
data.itemListCls = '';
data.emptyCls = isEmpty ? me.emptyUICls : '';
if (me.grow) {
if (Ext.isNumber(growMin) && growMin > 0) {
wrapperStyle += 'min-height:' + growMin + 'px;';
}
if (Ext.isNumber(growMax) && growMax > 0) {
wrapperStyle += 'max-height:' + growMax + 'px;';
}
}
else {
wrapperStyle += 'max-height: 1px;';
}
data.noGrowCls = !me.grow ? me.noGrowCls : '';
data.wrapperStyle = wrapperStyle;
if (me.stacked === true) {
data.itemListCls += ' ' + Ext.baseCSSPrefix + 'tagfield-stacked';
}
if (!me.multiSelect) {
data.itemListCls += ' ' + Ext.baseCSSPrefix + 'tagfield-singleselect';
}
if (!me.ariaStaticRoles[me.ariaRole]) {
data.multiSelect = me.multiSelect;
data.ariaSelectedListLabel = Ext.String.htmlEncode(me.ariaSelectedListLabel);
attr = data.ariaElAttributes;
if (attr) {
attr['aria-owns'] = id + '-inputEl ' + id + '-picker ' + id + '-ariaList';
}
attr = data.inputElAriaAttributes;
if (attr) {
attr.role = 'textbox';
attr['aria-describedby'] = id + '-selectedText ' + (attr['aria-describedby'] || '');
}
}
return data;
},
onRender: function(container, index) {
var me = this;
me.callParent([container, index]);
me.emptyClsElements.push(me.listWrapper, me.placeholderLabel);
},
afterRender: function() {
var me = this,
inputEl = me.inputEl,
emptyText = me.emptyText;
if (emptyText) {
// We remove HTML5 placeholder here because we use the placeholderLabel instead.
if (Ext.supports.Placeholder && inputEl) {
inputEl.dom.removeAttribute('placeholder');
}
}
me.applyMultiselectItemMarkup();
me.applyAriaListMarkup();
me.applyAriaSelectedText();
me.callParent();
},
findRecord: function(field, value) {
var matches = this.getStore().queryRecords(field, value);
return matches.length ? matches[0] : false;
},
/**
* Get the current cursor position in the input field, for key-based navigation
* @private
*/
getCursorPosition: function() {
var cursorPos;
if (document.selection) {
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 inputEl = this.inputEl.dom,
sel, range;
if (document.selection) {
sel = document.selection;
range = sel.createRange();
return (range.parentElement() === inputEl);
}
else {
return inputEl.selectionStart !== inputEl.selectionEnd;
}
},
/**
* Handles keyDown processing of key-based selection of labeled 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) {
var me = this,
key = e.getKey(),
inputEl = me.inputEl,
rawValue = inputEl && inputEl.dom.value,
valueCollection = me.valueCollection,
selModel = me.selectionModel,
stopEvent = false,
valueCount, lastSelectionIndex, records, text, i, len;
if (me.destroyed || me.readOnly || me.disabled || !me.editable) {
return;
}
valueCount = valueCollection.getCount();
if (valueCount > 0 && rawValue === '') {
// Keyboard navigation of current values
lastSelectionIndex = (selModel.getCount() > 0)
? valueCollection.indexOf(selModel.getLastSelected())
: -1;
// Backspace can be used to clear the rightmost selected value.
// Delete key should only remove selected value if it is highlighted.
if ((key === e.BACKSPACE && me.clearOnBackspace) ||
(key === e.DELETE && lastSelectionIndex > -1)) {
// Delete token
if (lastSelectionIndex > -1) {
if (selModel.getCount() > 1) {
lastSelectionIndex = -1;
}
records = selModel.getSelection();
text = [];
for (i = 0, len = records.length; i < len; i++) {
text.push(records[i].get(me.displayField));
}
text = text.join(', ');
}
else {
records = valueCollection.last();
text = records.get(me.displayField);
}
valueCollection.remove(records);
// Announce the change
if (text) {
me.ariaErrorEl.dom.innerHTML =
Ext.String.formatEncode(me.ariaDeselectedText, text);
}
selModel.clearSelections();
if (lastSelectionIndex === (valueCount - 1)) {
selModel.select(valueCollection.last());
}
else if (lastSelectionIndex > -1) {
selModel.select(lastSelectionIndex);
}
else if (valueCollection.getCount()) {
selModel.select(valueCollection.last());
}
stopEvent = true;
}
else if (key === e.RIGHT || key === e.LEFT) {
// Navigate and select tokens
if (lastSelectionIndex === -1 && key === e.LEFT) {
selModel.select(valueCollection.last());
stopEvent = true;
}
else if (lastSelectionIndex > -1) {
if (key === e.RIGHT) {
if (lastSelectionIndex < (valueCount - 1)) {
selModel.select(lastSelectionIndex + 1, e.shiftKey);
stopEvent = true;
}
else if (!e.shiftKey) {
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) {
// Select all tokens
selModel.selectAll();
stopEvent = e.A;
}
}
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()) {
selModel.deselectAll();
}
},
/**
* Handles auto-selection and creation of labeled items based on this field's
* delimiter, as well as the keyUp processing of key-based selection of labeled items.
* @protected
*/
onKeyUp: function(e, t) {
var me = this,
inputEl = me.inputEl,
rawValue = inputEl.dom.value,
preventKeyUpEvent = me.preventKeyUpEvent;
if (me.preventKeyUpEvent) {
e.stopEvent();
if (preventKeyUpEvent === true || e.getKey() === preventKeyUpEvent) {
delete me.preventKeyUpEvent;
}
return;
}
if (me.multiSelect && me.delimiterRegexp && me.delimiterRegexp.test(rawValue) ||
(me.createNewOnEnter && e.getKey() === e.ENTER)) {
// Announce new value(s)
if (me.createNewOnEnter && rawValue) {
me.ariaErrorEl.dom.innerHTML =
Ext.String.formatEncode(me.ariaSelectedText, rawValue);
}
rawValue = Ext.Array.clean(rawValue.split(me.delimiterRegexp));
inputEl.dom.value = '';
me.setValue(me.valueStore.getRange().concat(rawValue));
inputEl.focus();
}
if (this.growMax && this.growMax >= this.itemList.getHeight()) {
this.autoSize();
}
me.callParent([e, t]);
},
onEsc: function(e) {
var me = this,
selModel = me.selectionModel,
isExpanded = me.isExpanded;
me.callParent([e]);
if (!isExpanded && selModel.getCount() > 0) {
selModel.deselectAll();
}
e.stopEvent();
},
/**
* 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,
record = me.getStore().findRecord(displayField, inputElDom.value),
newValue, len, selStart;
if (record) {
newValue = record.get(displayField);
len = newValue.length;
selStart = inputElDom.value.length;
if (selStart !== 0 && selStart !== len) {
// Setting the raw value will cause a field mutation event.
// Prime the lastMutatedValue so that this does not cause a requery.
me.lastMutatedValue = newValue;
inputElDom.value = newValue;
me.syncInputWidth();
me.selectText(selStart, newValue.length);
}
}
},
/**
* Delegation control for selecting and removing labeled items or triggering
* list collapse/expansion
* @protected
*/
onItemListClick: function(e) {
var me = this,
selectionModel = me.selectionModel,
itemEl = e.getTarget(me.tagItemSelector),
closeEl = itemEl ? e.getTarget(me.tagItemCloseSelector) : false;
if (me.readOnly || me.disabled) {
return;
}
e.stopPropagation();
if (itemEl) {
if (closeEl) {
me.removeByListItemNode(itemEl);
if (me.valueStore.getCount() > 0) {
me.fireEvent('select', me, me.valueStore.getRange());
}
}
else {
me.toggleSelectionByListItemNode(itemEl, e.shiftKey);
}
// If not using touch interactions, focus the input
if (!Ext.supports.TouchEvents) {
me.inputEl.focus();
}
}
else {
if (selectionModel.getCount() > 0) {
selectionModel.deselectAll();
}
me.inputEl.focus();
if (me.triggerOnClick) {
me.onTriggerClick();
}
}
},
// Prevent item from receiving focus.
// See EXTJS-17686.
onItemMouseDown: function(e) {
if (e.target !== this.inputEl.dom) {
e.preventDefault();
}
},
/**
* Build the markup for the labeled items. Template must be built on demand due to ComboBox
* initComponent life cycle for the creation of on-demand stores (to account for automatic
* valueField/displayField setting)
* @private
*/
getMultiSelectItemMarkup: function() {
var me = this,
childElCls = (me._getChildElCls && me._getChildElCls()) || ''; // hook for rtl cls
if (!me.multiSelectItemTpl) {
if (!me.labelTpl) {
me.labelTpl = '{' + me.displayField + '}';
}
me.labelTpl = me.lookupTpl('labelTpl');
if (me.tipTpl) {
me.tipTpl = me.lookupTpl('tipTpl');
}
/* eslint-disable indent, max-len */
me.multiSelectItemTpl = new Ext.XTemplate([
'<tpl for=".">',
'<li data-selectionIndex="{[xindex - 1]}" data-recordId="{internalId}" role="presentation" class="' + me.tagItemCls + childElCls,
'<tpl if="this.isSelected(values)">',
' ' + me.tagSelectedCls,
'</tpl>',
'{%',
'values = values.data;',
'%}',
me.tipTpl ? '" data-qtip="{[this.getTip(values)]}">' : '">',
'<div role="presentation" class="' + me.tagItemTextCls + '">{[this.getItemLabel(values)]}</div>',
'<div role="presentation" class="' + me.tagItemCloseCls + childElCls + '"></div>',
'</li>',
'</tpl>',
{
isSelected: function(rec) {
return me.selectionModel.isSelected(rec);
},
getItemLabel: function(values) {
return Ext.String.htmlEncode(me.labelTpl.apply(values));
},
getTip: function(values) {
return Ext.String.htmlEncode(me.tipTpl.apply(values));
},
strict: true
}
]);
/* eslint-enable indent, max-len */
}
if (!me.multiSelectItemTpl.isTemplate) {
me.multiSelectItemTpl = this.lookupTpl('multiSelectItemTpl');
}
return me.multiSelectItemTpl.apply(me.valueCollection.getRange());
},
/**
* Update the labeled items rendering
* @private
*/
applyMultiselectItemMarkup: function() {
var me = this,
itemList = me.itemList;
if (itemList) {
itemList.select('.' + Ext.baseCSSPrefix + 'tagfield-item').destroy();
me.inputElCt.insertHtml('beforeBegin', me.getMultiSelectItemMarkup());
if (itemList.getHeight() > this.getHeight() && me.focusLastAddedItem) {
itemList.dom.scrollIntoView(false);
}
me.autoSize();
}
},
/**
* Build the markup for ARIA listbox.
* @private
*/
getAriaListMarkup: function() {
var me = this,
values;
if (!me.ariaListItemTpl) {
/* eslint-disable indent, max-len */
me.ariaListItemTpl = new Ext.XTemplate([
'<tpl for=".">',
'<li id="' + me.id + '-{internalId}" role="option"',
' class="' + Ext.baseCSSPrefix + 'tagfield-arialist-item"',
' aria-selected="{[this.isPicked(values)]}"',
' data-recordId="{internalId}"',
'>',
'{[this.getItemLabel(values.data)]}',
'</li>',
'</tpl>',
{
isPicked: function(rec) {
return me.filterPicked(rec) ? 'false' : 'true';
},
isSelected: function(rec) {
return me.selectionModel.isSelected(rec) ? 'true' : 'false';
},
getItemLabel: function(values) {
return Ext.String.htmlEncode(me.labelTpl.apply(values));
},
strict: true
}
]);
/* eslint-enable indent, max-len */
}
if (!me.ariaListItemTpl.isTemplate) {
me.ariaListtemTpl = me.lookupTpl('ariaListItemTpl');
}
values = me.valueCollection.getRange();
return me.ariaListItemTpl.apply(values);
},
applyAriaListMarkup: function() {
var me = this,
ariaList = me.ariaList;
if (ariaList) {
ariaList.select('*').destroy();
ariaList.insertHtml('afterBegin', me.getAriaListMarkup());
}
},
getAriaSelectedText: function(values) {
var me = this;
if (!me.ariaSelectedItemTpl) {
/* eslint-disable indent */
me.ariaSelectedItemTpl = new Ext.XTemplate([
'<tpl for="." between=", ">',
'{[this.getItemLabel(values.data)]}',
'</tpl>',
{
getItemLabel: function(values) {
return Ext.String.htmlEncode(me.labelTpl.apply(values));
},
strict: true
}
]);
/* eslint-enable indent */
}
if (!me.ariaSelectedItemTpl.isTemplate) {
me.ariaSelectedItemTpl = me.lookupTpl('ariaSelectedItemTpl');
}
return Ext.String.format(me.ariaSelectedText, me.ariaSelectedItemTpl.apply(values));
},
applyAriaSelectedText: function() {
var me = this,
selectedText = me.selectedText,
records, text;
if (selectedText) {
records = me.valueCollection.getRange();
text = records.length ? me.getAriaSelectedText(records) : me.ariaNoneSelectedText;
// selectedText element is not aria-live so OK to update every time
selectedText.dom.innerHTML = Ext.String.htmlEncode(text);
}
},
/**
* Returns the record from valueStore for the labeled item node
*/
getRecordByListItemNode: function(itemEl) {
return this.valueCollection.items[Number(itemEl.getAttribute('data-selectionIndex'))];
},
/**
* Toggle of labeled 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)) {
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.pickerSelectionModel.deselect(rec);
}
},
// Private implementation.
// The display value is always the raw value.
// Picked values are displayed by the tag template.
getDisplayValue: function() {
return this.getRawValue();
},
/**
* @method getRawValue
* @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,
records = me.getValueRecords(),
values = [],
i, len;
for (i = 0, len = records.length; i < len; i++) {
values.push(records[i].data[me.displayField]);
}
return values.join(',');
},
setRawValue: function(value) {
// setRawValue is not supported for tagfield.
return;
},
/**
* 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,
valueCollection = me.valueCollection,
len, i, item,
toRemove = [];
if (value) {
value = Ext.Array.from(value);
// Ensure that the remove values are records
for (i = 0, len = value.length; i < len; ++i) {
item = value[i];
// If a key is supplied, find the matching value record from our value collection
if (!item.isModel) {
item = valueCollection.byValue.get(item);
}
if (item) {
toRemove.push(item);
}
}
me.valueCollection.beginUpdate();
me.pickerSelectionModel.deselect(toRemove);
me.valueCollection.endUpdate();
}
},
getValue: function() {
var value = this.callParent();
if (value) {
value = Ext.Array.from(value);
}
return value;
},
/**
* Sets the specified value(s) into the field. The following value formats are recognized:
*
* - 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 #valueParam} 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
* @param add (private)
* @param skipLoad (private)
* @return {Ext.form.field.Field/Boolean} this, or `false` if asynchronously querying
* for unknown values
*/
setValue: function(value, add, skipLoad) {
var me = this,
valueStore = me.valueStore,
valueField = me.valueField,
unknownValues = [],
store = me.store,
autoLoadOnValue = me.autoLoadOnValue,
isLoaded = store.getCount() > 0 || store.isLoaded(),
pendingLoad = store.hasPendingLoad(),
unloaded = autoLoadOnValue && !isLoaded && !pendingLoad,
record, len, i, valueRecord, cls, params, isNull;
if (Ext.isEmpty(value)) {
value = null;
isNull = true;
}
else if (Ext.isString(value) && me.multiSelect) {
value = value.split(me.delimiter);
}
else {
value = Ext.Array.from(value, true);
}
if (!isNull && me.queryMode === 'remote' && !store.isEmptyStore &&
skipLoad !== true && unloaded) {
for (i = 0, len = value.length; i < len; i++) {
record = value[i];
if (!record || !record.isModel) {
valueRecord = valueStore.findExact(valueField, record);
if (valueRecord > -1) {
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;
cls = me.valueStore.getModel();
valueRecord = new cls(valueRecord);
}
}
if (valueRecord) {
value[i] = valueRecord;
}
}
}
}
if (unknownValues.length) {
params = {};
params[me.valueParam || me.valueField] = unknownValues.join(me.delimiter);
store.load({
params: params,
callback: function() {
me.setValue(value, add, true);
me.autoSize();
me.lastQuery = false;
}
});
return false;
}
}
// For single-select boxes, use the last good (formal record) value if possible
if (!isNull && !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, add]);
},
// Private internal setting of value when records are added to the valueCollection
// setValue itself adds to the valueCollection.
updateValue: function() {
var me = this,
valueArray = me.valueCollection.getRange(),
len = valueArray.length,
i;
for (i = 0; i < len; i++) {
valueArray[i] = valueArray[i].get(me.valueField);
}
// Set the value of this field. If we are multi-selecting, then that is an array.
me.setHiddenValue(valueArray);
me.value = me.multiSelect ? valueArray : valueArray[0];
if (!Ext.isDefined(me.value)) {
me.value = undefined;
}
me.applyMultiselectItemMarkup();
me.applyAriaListMarkup();
me.applyAriaSelectedText();
me.checkChange();
},
/**
* Returns the records for the field's current value
* @return {Array} The records for the field's current value
*/
getValueRecords: function() {
return this.valueCollection.getRange();
},
/**
* @method getSubmitData
* @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 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.syncInputWidth();
me.collapse();
me.refreshEmptyText();
},
/**
* 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;
},
/**
* 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;
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,
height;
if (me.autoSizing) {
height = me.getHeight();
if (height !== me.lastInputHeight) {
if (me.isExpanded) {
me.alignPicker();
}
me.fireEvent('autosize', me, height);
me.lastInputHeight = height;
me.autoSizing = false;
}
}
},
onFieldMutation: function(e) {
this.callParent([e]);
this.syncInputWidth(true);
},
syncInputWidth: function(scrollIntoView) {
var me = this,
inputEl = me.inputEl,
editable = !!(inputEl && inputEl.dom.value) || (me.editable && me.hasFocus),
width;
if (me.grow) {
return;
}
me.toggleCls(me.notEditableCls, !editable);
// Don't need to sync width when not editable because inputEl is
// absolutely positioned when field is not editable
if (editable) {
width = Ext.util.TextMetrics.measure(inputEl, inputEl.dom.value).width;
// Fudge factor to ensure there is enough width to accommodate both
// current text and cursor:
width += 3;
inputEl.setWidth(width);
if (scrollIntoView) {
// Since the inputEl is always last we can just scroll to "bottom"
// using a large number to ensure it is fully visible:
me.listWrapper.dom.scrollTop = 99999;
}
}
}
});