/** * A combobox control with support for autocomplete, remote loading, and many other features. * * A ComboBox is like a combination of a traditional HTML text `<input>` field and a `<select>` * field; the user is able to type freely into the field, and/or pick values from a dropdown * selection list. The user can input any value by default, even if it does not appear * in the selection list; to prevent free-form values and restrict them to items in the list, * set {@link #forceSelection} to `true`. * * The selection list's options are populated from any {@link Ext.data.Store}, including remote * stores. The data items in the store are mapped to each option's displayed text and backing value * via the {@link #valueField} and {@link #displayField} configurations, respectively. * * If your store is not remote, i.e. it depends only on local data and is loaded up front, * you should be sure to set the {@link #queryMode} to `'local'`, as this will improve * responsiveness for the user. * * # Example usage: * * @example * // The data store containing the list of states * var states = Ext.create('Ext.data.Store', { * fields: ['abbr', 'name'], * data : [ * {"abbr":"AL", "name":"Alabama"}, * {"abbr":"AK", "name":"Alaska"}, * {"abbr":"AZ", "name":"Arizona"} * ] * }); * * // Create the combo box, attached to the states data store * Ext.create('Ext.form.ComboBox', { * fieldLabel: 'Choose State', * store: states, * queryMode: 'local', * displayField: 'name', * valueField: 'abbr', * renderTo: Ext.getBody() * }); * * # Events * * To do something when something in ComboBox is selected, configure the select event: * * var cb = Ext.create('Ext.form.ComboBox', { * // all of your config options * listeners:{ * scope: yourScope, * 'select': yourFunction * } * }); * * // Alternatively, you can assign events after the object is created: * var cb = new Ext.form.field.ComboBox(yourOptions); * cb.on('select', yourFunction, yourScope); * * # Multiple Selection * The {@link #multiSelect} config is deprecated. For multiple selection use * {@link Ext.form.field.Tag} or {@link Ext.view.MultiSelector}. * * # Filtered Stores * * If you have a local store that is already filtered, you can use the {@link #lastQuery} * config option to prevent the store from having the filter being cleared on first expand. * * ## Customized combobox * * Both the text shown in dropdown menu and text field can be easily customized: * * @example * var states = Ext.create('Ext.data.Store', { * fields: ['abbr', 'name'], * data : [ * {"abbr":"AL", "name":"Alabama"}, * {"abbr":"AK", "name":"Alaska"}, * {"abbr":"AZ", "name":"Arizona"} * ] * }); * * Ext.create('Ext.form.ComboBox', { * fieldLabel: 'Choose State', * store: states, * queryMode: 'local', * valueField: 'abbr', * renderTo: Ext.getBody(), * // Template for the dropdown menu. * // Note the use of the "x-list-plain" and "x-boundlist-item" class, * // this is required to make the items selectable. * tpl: Ext.create('Ext.XTemplate', * '<ul class="x-list-plain"><tpl for=".">', * '<li role="option" class="x-boundlist-item">{abbr} - {name}</li>', * '</tpl></ul>' * ), * // template for the content inside text field * displayTpl: Ext.create('Ext.XTemplate', * '<tpl for=".">', * '{abbr} - {name}', * '</tpl>' * ) * }); * * See also the {@link #listConfig} option for additional configuration of the dropdown. * */Ext.define('Ext.form.field.ComboBox', { extend: 'Ext.form.field.Picker', alternateClassName: 'Ext.form.ComboBox', alias: ['widget.combobox', 'widget.combo'], requires: [ 'Ext.util.DelayedTask', 'Ext.view.BoundList', 'Ext.data.StoreManager' ], mixins: [ 'Ext.util.StoreHolder' ], config: { filters: null, /** * @cfg {Ext.data.Model} selection * The selected model. Typically used with {@link #bind binding}. */ selection: null, /** * @cfg {String} valueNotFoundText * When using a name/value combo, if the value passed to setValue is not found in the store, * valueNotFoundText will be displayed as the field text if defined. If this default text * is used, it means there is no value set and no validation will occur on this field. */ valueNotFoundText: null, /** * @cfg {String/String[]/Ext.XTemplate} displayTpl * The template to be used to display selected records inside the text field. An array * of the selected records' data will be passed to the template. Defaults to: * * '<tpl for=".">' + * '{[typeof values === "string" ? values : values["' + me.displayField + '"]]}' + * '<tpl if="xindex < xcount">' + me.delimiter + '</tpl>' + * '</tpl>' * * By default only the immediate data of the record is passed (no associated data). * The {@link #getRecordDisplayData} can be overridden to extend this. */ displayTpl: null, /** * @cfg {String} delimiter * The character(s) used to separate the {@link #displayField display values} of multiple * selected items when `{@link #multiSelect} = true`. * @deprecated 5.1.0 For multiple selection use {@link Ext.form.field.Tag} or * {@link Ext.view.MultiSelector} * @locale */ delimiter: ', ', /** * @cfg {String} displayField * The underlying {@link Ext.data.Field#name data field name} to bind to this ComboBox. * * See also `{@link #valueField}`. */ displayField: 'text' }, /** * @cfg publishes * @inheritdoc */ publishes: ['selection'], /** * @cfg twoWayBindable * @inheritdoc */ twoWayBindable: ['selection'], /** * @cfg {String} triggerCls * An additional CSS class used to style the trigger button. The trigger will always get * the {@link Ext.form.trigger.Trigger#baseCls} by default and `triggerCls` will be * **appended** if specified. */ triggerCls: Ext.baseCSSPrefix + 'form-arrow-trigger', /** * @cfg {String} [hiddenName=""] * The name of an underlying hidden field which will be synchronized with the underlying value * of the combo. This option is useful if the combo is part of a form element doing a regular * form post. The hidden field will not be created unless a hiddenName is specified. */ hiddenName: '', /** * @cfg {Boolean} [pinList=true] * Has no effect if {@link #multiSelect} is `false` * * Configure as `false` to automatically collapse the pick list after a selection is made. * @deprecated 5.1.0 For multiple selection use {@link Ext.form.field.Tag} or * {@link Ext.view.MultiSelector} */ /** * @cfg {Boolean} collapseOnSelect * Has no effect if {@link #multiSelect} is `false` * * Configure as true to automatically collapse the pick list after a selection is made. * @deprecated 5.1.0 For multiple selection use {@link Ext.form.field.Tag} or * {@link Ext.view.MultiSelector} */ collapseOnSelect: false, /** * @property {Ext.dom.Element} hiddenDataEl * @private */ /** * @private * @cfg {String} hiddenDataCls * CSS class used to find the {@link #hiddenDataEl} */ hiddenDataCls: Ext.baseCSSPrefix + 'hidden-display ' + Ext.baseCSSPrefix + 'form-data-hidden', /** * @property ariaRole * @inheritdoc */ ariaRole: 'combobox', /** * @property autoDestroyBoundStore * @inheritdoc */ autoDestroyBoundStore: true, /** * @cfg childEls * @inheritdoc */ childEls: { 'hiddenDataEl': true }, /** * @property {Boolean} filtered * True if there are extra `filters` appllied to this component. * @private * @readonly * @since 5.0.0 */ filtered: false, afterRender: function() { var me = this; me.callParent(arguments); me.setHiddenValue(me.value); }, /** * @cfg {Ext.data.Store/String/Array/Object} store (required) * The data source to which the combo / tagfield is bound. Acceptable values for this * property are: * * - **any {@link Ext.data.Store Store} class / subclass** * - **an {@link Ext.data.Store#storeId ID of a store}** * - **an Array** : Arrays will be converted to a {@link Ext.data.Store} internally, * automatically generating {@link Ext.data.Field#name field names} to work with all * data components. * * - **1-dimensional array** : (e.g., `['Foo','Bar']`) * * A 1-dimensional array will automatically be expanded (each array item will be * used for both the combo {@link #valueField} and {@link #displayField}) * * - **2-dimensional array** : (e.g., `[['f','Foo'],['b','Bar']]`) * * For a multi-dimensional array, the value in index 0 of each item will be assumed * to be the combo {@link #valueField}, while the value at index 1 is assumed to be * the combo {@link #displayField}. * - **a {@link Ext.data.Store Store} config object**. When passing a config you can * specify the store type by alias. Passing a config object with a store type will * dynamically create a new store of that type when the combo / tagfield is * instantiated. * * Ext.define('MyApp.store.States', { * extend: 'Ext.data.Store', * alias: 'store.states', * fields: ['name'] * }); * * Ext.create({ * xtype: 'combobox', * renderTo: document.body, * store: { * type: 'states', * data: [{ * name: 'California' * }] * }, * queryMode: 'local', * displayField: 'name', * valueField: 'name' * }); * * See also {@link #queryMode}. */ /** * @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 separated by the {@link #delimiter}. * @deprecated 5.1.0 Use {@link Ext.form.field.Tag} or {@link Ext.view.MultiSelector} */ multiSelect: false, /** * @cfg {String} valueField (required) * The underlying {@link Ext.data.Field#name data value name} to bind to this ComboBox. * * **Note**: use of a `valueField` requires the user to make a selection in order for a value * to be mapped. See also `{@link #displayField}`. * * Defaults to match the value of the {@link #displayField} config. */ /** * @cfg {String} triggerAction * The action to execute when the trigger is clicked. * * - **`'all'`**: {@link #doQuery run the query} specified by the `{@link #allQuery}` * config option * * - **`'last'`**: {@link #doQuery run the query} using the * `{@link #lastQuery last query value}`. * * - **`'query'`**: {@link #doQuery run the query} using the * {@link Ext.form.field.Base#getRawValue raw value}. * * See also `{@link #queryParam}`. */ triggerAction: 'all', /** * @cfg {String} allQuery * The text query to send to the server to return all records for the list with no filtering */ allQuery: '', /** * @cfg {String} queryParam * Name of the parameter used by the Store to pass the typed string when the ComboBox * is configured with `{@link #queryMode}: 'remote'`. If explicitly set to a falsy value * it will not be sent. */ queryParam: 'query', /** * @cfg {String} queryMode * The mode in which the ComboBox uses the configured Store. Acceptable values are: * * - **`'remote'`** : * * In `queryMode: 'remote'`, the ComboBox loads its Store dynamically based upon user * interaction. * * This is typically used for "autocomplete" type inputs, and after the user finishes * typing, the Store is {@link Ext.data.Store#method-load load}ed. * * A parameter containing the typed string is sent in the load request. The default * parameter name for the input string is `query`, but this can be configured using * the {@link #queryParam} config. * * In `queryMode: 'remote'`, the Store may be configured with * `{@link Ext.data.Store#remoteFilter remoteFilter}: true`, and further filters may be * _programatically_ added to the Store which are then passed with every load * request which allows the server to further refine the returned dataset. * * Typically, in an autocomplete situation, {@link #hideTrigger} is configured `true` * because it has no meaning for autocomplete. * * - **`'local'`** : * * ComboBox loads local data * * var combo = new Ext.form.field.ComboBox({ * renderTo: document.body, * queryMode: 'local', * store: new Ext.data.ArrayStore({ * id: 0, * fields: [ * 'myId', // numeric value is the key * 'displayText' * ], * data: [[1, 'item1'], [2, 'item2']] // data is local * }), * valueField: 'myId', * displayField: 'displayText', * triggerAction: 'all' * }); */ queryMode: 'remote', /** * @cfg {Boolean} queryCaching * When true, this prevents the combo from re-querying (either locally or remotely) when * the current query is the same as the previous query. */ queryCaching: true, /** * @cfg {Boolean} autoLoadOnValue * This option controls whether to *initially* load the store when a value is set so that * the display value can be determined from the appropriate record. * The store will only be loaded in a limited set of circumstances: * - The store is not currently loading. * - The store does not have a pending {@link Ext.data.Store#autoLoad}. * - The store has not been loaded before. */ autoLoadOnValue: false, /** * @cfg {Number} pageSize * If greater than `0`, a {@link Ext.toolbar.Paging} is displayed in the footer of the dropdown * list and the {@link #doQuery filter queries} will execute with page start * and {@link Ext.view.BoundList#pageSize limit} parameters. * Only applies when `{@link #queryMode} = 'remote'`. */ pageSize: 0, /** * @cfg {Number} queryDelay * The length of time in milliseconds to delay between the start of typing and sending the query * to filter the dropdown list. * * Defaults to `500` if `{@link #queryMode} = 'remote'` or `10` if * `{@link #queryMode} = 'local'` */ /** * @cfg {Number} minChars * The minimum number of characters the user must type before autocomplete and * {@link #typeAhead} activate. * * Defaults to `4` if `{@link #queryMode} = 'remote'` or `0` if `{@link #queryMode} = 'local'`, * does not apply if `{@link Ext.form.field.Trigger#editable editable} = false`. */ /** * @cfg {Boolean} anyMatch * Configure as `true` to allow matching of the typed characters at any position in the * {@link #valueField}'s value. */ anyMatch: false, /** * @cfg {Boolean} caseSensitive * Configure as `true` to make the filtering match with exact case matching */ caseSensitive: false, /** * @cfg {Boolean} autoSelectMatches *`true` to automatically highlight the record from the store that closest matches the query. * A false value will fall back to autoSelectLast. */ autoSelectMatches: true, /** * @cfg {Boolean} autoSelect * `true` to automatically highlight the first result gathered by the data store in the * dropdown list when it is opened. A false value would cause nothing in the list to be * highlighted automatically, so the user would have to manually highlight an item before * pressing the enter or {@link #selectOnTab tab} key to select it (unless the value of * ({@link #typeAhead}) were true), or use the mouse to select a value. */ autoSelect: true, /** * @cfg {Boolean} autoSelectLast * When `true`, the last selected record in the dropdown list will be re-selected * upon {@link #autoSelect}. Set to `false` to always select the first record in the * drop-down list. For accessible applications it is recommended to set this option * to `false`. */ autoSelectLast: true, /** * @cfg {Boolean} typeAhead * `true` to populate and autoselect the remainder of the text being typed after a configurable * delay ({@link #typeAheadDelay}) if it matches a known value. */ typeAhead: false, /** * @cfg {Number} typeAheadDelay * The length of time in milliseconds to wait until the typeahead text is displayed if * `{@link #typeAhead} = true` */ typeAheadDelay: 250, /** * @cfg {Boolean} selectOnTab * Whether the Tab key should select the currently highlighted item. */ selectOnTab: 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. */ forceSelection: false, /** * @cfg {Boolean} growToLongestValue * `false` to not allow the component to resize itself when its data changes * (and its {@link #grow} property is `true`) */ growToLongestValue: true, /** * @cfg {Boolean} clearFilterOnBlur * *When {@link #queryMode} is `'local'` only* * * As text is entered, the underlying store is filtered to match the value. When this option * is `true`, any filtering applied by this field will be cleared when focus is removed * & reinstated on focus. * If `false`, the filters will be left in place. */ clearFilterOnBlur: true, /** * @cfg {Boolean} enableRegEx * *When {@link #queryMode} is `'local'` only* * * Set to `true` to have the ComboBox use the typed value as a RegExp source to filter the store * to get possible matches. Invalid regex values will be ignored. */ /** * @property {String} lastQuery * The value of the match string used to filter the store. Delete this property to force * a requery. Example use: * * var combo = new Ext.form.field.ComboBox({ * ... * queryMode: 'remote', * listeners: { * // delete the previous query in the beforequery event or set * // combo.lastQuery = null (this will reload the store the next time it expands) * beforequery: function(qe){ * delete qe.combo.lastQuery; * } * } * }); * * To make sure the filter in the store is not cleared the first time the ComboBox trigger * is used configure the combo with `lastQuery=''`. Example use: * * var combo = new Ext.form.field.ComboBox({ * ... * queryMode: 'local', * triggerAction: 'all', * lastQuery: '' * }); */ /** * @cfg {Object} defaultListConfig * Set of options that will be used as defaults for the user-configured {@link #listConfig} * object. */ defaultListConfig: { loadingHeight: 70, minWidth: 70, maxHeight: 300, shadow: 'sides' }, /** * @cfg {String/HTMLElement/Ext.dom.Element} transform * The id, DOM node or {@link Ext.dom.Element} of an existing HTML `<select>` element to convert * into a ComboBox. The target select's options will be used to build the options * in the ComboBox dropdown; a configured {@link #store} will take precedence over this. */ /** * @cfg {Boolean} transformInPlace * `true` to automatically render this combo box in place of the select element that is being * {@link #transform transformed}. If `false`, this combo will be rendered using the normal * rendering, either as part of a layout, or using {@link #renderTo} or {@link #method-render}. */ transformInPlace: true, /** * @cfg {Object} listConfig * An optional set of configuration properties that will be passed to the * {@link Ext.view.BoundList}'s constructor. Any configuration that is valid for BoundList * can be included. Some of the more useful ones are: * * - {@link Ext.view.BoundList#cls cls} - defaults to empty * - {@link Ext.view.BoundList#emptyText emptyText} - defaults to empty string * - {@link Ext.view.BoundList#itemSelector itemSelector} - defaults to the value defined * in BoundList * - {@link Ext.view.BoundList#loadingText loadingText} - defaults to `'Loading...'` * - {@link Ext.view.BoundList#minWidth minWidth} - defaults to `70` * - {@link Ext.view.BoundList#maxWidth maxWidth} - defaults to `undefined` * - {@link Ext.view.BoundList#maxHeight maxHeight} - defaults to `300` * - {@link Ext.view.BoundList#resizable resizable} - defaults to `false` * - {@link Ext.view.BoundList#shadow shadow} - defaults to `'sides'` * - {@link Ext.view.BoundList#width width} - defaults to `undefined` (automatically set * to the width of the ComboBox field if {@link #matchFieldWidth} is true) * - {@link Ext.view.BoundList#getInnerTpl getInnerTpl} A function which returns a template * string which renders the ComboBox's {@link #displayField} value in the dropdown. * This defaults to just outputting the raw value, but may use any * {@link Ext.XTemplate XTemplate} methods to produce output. * * The running template is configured with some extra properties that provide some context: * - field {@link Ext.form.field.ComboBox ComboBox} This combobox * - store {@link Ext.data.Store Store} This combobox's data store */ /** * @private */ clearValueOnEmpty: true, newlineRe: /\r?\n/g, getGrowWidth: function() { var me = this, value = me.inputEl.dom.value, field, store, dataLn, currentLongestLength, i, item, itemLn; if (me.growToLongestValue) { field = me.displayField; store = me.store; dataLn = store.data.length; currentLongestLength = 0; for (i = 0; i < dataLn; i++) { item = store.getAt(i).data[field]; itemLn = item.length; // Compare the current item's length with the current longest length // and store the value. if (itemLn > currentLongestLength) { currentLongestLength = itemLn; value = item; } } } return value; }, /** * @event beforequery * Fires before all queries are processed. Return false to cancel the query or set * the queryPlan's cancel property to true. * * @param {Object} queryPlan An object containing details about the query to be executed. * @param {Ext.form.field.ComboBox} queryPlan.combo A reference to this ComboBox. * @param {String} queryPlan.query The query value to be used to match against the ComboBox's * {@link #valueField}. * @param {Boolean} queryPlan.forceAll If `true`, causes the query to be executed even * if the minChars threshold is not met. * @param {Boolean} queryPlan.cancel A boolean value which, if set to `true` upon return, * causes the query not to be executed. * @param {Boolean} queryPlan.rawQuery If `true` indicates that the raw input field value * is being used, and upon store load, */ /** * @event select * Fires when at least one list item is selected. * @param {Ext.form.field.ComboBox} combo This combo box * @param {Ext.data.Model/Ext.data.Model[]} record With {@link #multiSelect} * `false`, the value will be a single record. With {@link #multiSelect} `true`, the * value will be an array of records. */ /** * @event beforeselect * Fires before the selected item is added to the collection * @param {Ext.form.field.ComboBox} combo This combo box * @param {Ext.data.Record} record The selected record * @param {Number} index The index of the selected record */ /** * @event beforedeselect * Fires before the deselected item is removed from the collection * @param {Ext.form.field.ComboBox} combo This combo box * @param {Ext.data.Record} record The deselected record * @param {Number} index The index of the deselected record */ initComponent: function() { var me = this, isDefined = Ext.isDefined, store = me.store, transform = me.transform, transformSelect, isLocalMode; //<debug> if (me.typeAhead && me.multiSelect) { Ext.raise('typeAhead and multiSelect are mutually exclusive options -- ' + 'please remove one of them.'); } if (me.typeAhead && !me.editable) { Ext.raise('If typeAhead is enabled the combo must be editable: true -- ' + 'please change one of those settings.'); } if (me.selectOnFocus && !me.editable) { Ext.raise('If selectOnFocus is enabled the combo must be editable: true -- ' + 'please change one of those settings.'); } //</debug> // Check for presence of deprecated pinList config, and convert it to collapseOnSelect if ('pinList' in me) { me.collapseOnSelect = !me.pinList; } // Build store from 'transform' HTML select element's options if (transform) { transformSelect = Ext.getDom(transform); if (transformSelect) { if (!me.store) { store = Ext.Array.map(Ext.Array.from(transformSelect.options), function(option) { return [option.value, option.text]; }); } if (!me.name) { me.name = transformSelect.name; } if (!('value' in me)) { me.value = transformSelect.value; } } } // Nothing configured, so generate one. This allows the user to // specify displayField in initComponent for extended classes. // It's necessary to check me.displayTpl.auto here because due to the // order of the config system, the displayTpl might be auto generated // before we get to use the user's config, so we use me.displayTpl.auto // to detect that it is auto generated. if (!me.displayTpl || me.displayTpl.auto) { me.setDisplayTpl(false); } me.bindStore(store || 'ext-empty-store', true, true); isLocalMode = me.queryMode === 'local'; if (!isDefined(me.queryDelay)) { me.queryDelay = isLocalMode ? 10 : 500; } if (!isDefined(me.minChars)) { me.minChars = isLocalMode ? 0 : 4; } me.callParent(); me.doQueryTask = new Ext.util.DelayedTask(me.doRawQuery, me); // render in place of 'transform' select if (transformSelect) { if (me.transformInPlace) { me.render(transformSelect.parentNode, transformSelect); delete me.renderTo; } Ext.removeNode(transformSelect); } }, initEvents: function() { var me = this; me.callParent(); // These key bindings need to have higher priority than BoundList keynav // so that Alt-Up/Down arrows would expand and collapse the picker without // highlighting items and/or changing selection me.altArrowKeyNav = new Ext.util.KeyNav({ target: me.inputEl, forceKeyDown: true, priority: 1002, // BoundList keynav has 1001 scope: me, down: { alt: true, handler: me.onAltDownArrow }, up: { alt: true, handler: me.onAltUpArrow } }); }, getSubTplData: function(fieldData) { var me = this, id = me.id, data, ariaAttr; data = me.callParent([fieldData]); if (!me.ariaStaticRoles[me.ariaRole]) { ariaAttr = data.ariaElAttributes; if (ariaAttr) { ariaAttr['aria-owns'] = id + '-inputEl ' + id + '-picker-listEl'; // TODO Change that to reflect the real behavior ariaAttr['aria-autocomplete'] = 'list'; } } return data; }, getSubTplMarkup: function(fieldData) { var me = this, hiddenDataElMarkup = '', markup = me.callParent(arguments); if (me.hiddenName) { hiddenDataElMarkup = '<div id="' + fieldData.id + '-hiddenDataEl" data-ref="hiddenDataEl" class="' + me.hiddenDataCls + '" role="presentation"></div>'; } return hiddenDataElMarkup + markup; }, applyDisplayTpl: function(displayTpl) { var me = this; if (!displayTpl) { /* eslint-disable indent, max-len */ displayTpl = new Ext.XTemplate( '<tpl for=".">' + '{[typeof values === "string" ? values : values["' + me.getDisplayField() + '"]]}' + '<tpl if="xindex < xcount">' + me.getDelimiter() + '</tpl>' + '</tpl>' ); /* eslint-enable indent, max-len */ displayTpl.auto = true; } else if (!displayTpl.isTemplate) { displayTpl = new Ext.XTemplate(displayTpl); } return displayTpl; }, applyFilters: function(filters, collection) { var me = this; if (filters === null || filters.isFilterCollection) { return filters; } if (filters) { if (!collection) { collection = this.getFilters(); } collection.beginUpdate(); collection.splice(0, collection.length, filters); collection.each(function(filter) { filter.ownerId = me.id; }); collection.endUpdate(); } return collection; }, applyValueNotFoundText: function(v) { var me = this, displayField = me.getDisplayField(), valueField = me.valueField, valueNotFoundRecord = me.valueNotFoundRecord; if (!valueNotFoundRecord) { valueNotFoundRecord = me.valueNotFoundRecord = new Ext.data.Model(); } valueNotFoundRecord.set(displayField, v); if (valueField && displayField !== valueField) { valueNotFoundRecord.set(valueField, v); } return v; }, updateDisplayField: function(displayField) { var me = this; // Force auto generated displayTpl refresh when displayField changes if (displayField && me.displayTpl && me.displayTpl.auto) { me.setDisplayTpl(false); me.setRawValue(me.getDisplayValue()); } }, /** * Returns the `Ext.util.FilterCollection`. Unless `autoCreate` is explicitly passed * as `false` this collection will be automatically created if it does not yet exist. * @param [autoCreate=true] Pass `false` to disable auto-creation of the collection. * @return {Ext.util.FilterCollection} The collection of filters. */ getFilters: function(autoCreate) { var ret = this.filters; if (!ret && autoCreate !== false) { ret = new Ext.util.FilterCollection(); this.setFilters(ret); } return ret; }, updateFilters: function(newFilters, oldFilters) { var me = this; if (oldFilters) { oldFilters.un('endupdate', 'onEndUpdateFilters', me); } if (newFilters) { newFilters.on('endupdate', 'onEndUpdateFilters', me); } me.onEndUpdateFilters(newFilters); }, onEndUpdateFilters: function(filters) { var me = this, was = me.filtered, is = !!filters && (filters.length > 0), // booleanize filters old, storeFilters; if (was || is) { me.filtered = is; old = []; storeFilters = me.store.getFilters(); storeFilters.each(function(filter) { if (filter.ownerId === me.id && !filters.contains(filter)) { old.push(filter); } }); storeFilters.splice(0, old, filters.items); } }, clearLocalFilter: function() { var me = this, filter = me.queryFilter; if (filter) { me.queryFilter = null; // Must set changingFilters flag for this.checkValueOnChange. // the suppressEvents flag does not affect the filterchange event me.changingFilters = true; me.store.removeFilter(filter, true); me.changingFilters = false; } }, completeEdit: function(e) { var me = this; this.callParent([e]); me.doQueryTask.cancel(); me.assertValue(); if (me.queryFilter && me.queryMode === 'local' && me.clearFilterOnBlur) { me.clearLocalFilter(); } }, onFocus: function(e) { var me = this; // For touch devices move focus to the picker if (Ext.isTouchMode()) { me.getPicker().getEl().focus(); } me.callParent([e]); if (me.triggerAction !== 'all' && me.queryFilter && me.queryMode === 'local' && me.clearFilterOnBlur) { delete me.lastQuery; me.doRawQuery(); } }, onAltDownArrow: function(e) { e.stopEvent(); if (!this.isExpanded) { this.onDownArrow(e); } // Stop further keyNav processing return false; }, onAltUpArrow: function(e) { e.stopEvent(); if (this.isExpanded) { this.onEsc(e); } // Stop further keyNav processing return false; }, /** * @private */ assertValue: function() { var me = this, rawValue = me.getRawValue(), displayValue = me.getDisplayValue(), lastRecords = me.lastSelectedRecords, preventChange = false, needToSelect = false, value, rec; if (me.forceSelection) { if (me.multiSelect) { // For multiselect, check that the current displayed value matches the current // selection, if it does not then revert to the most recent selection. if (rawValue !== displayValue) { me.setRawValue(displayValue); } } else { // For single-select, match the displayed value to a record and select it, // if it does not match a record then revert to the most recent selection. rec = me.findRecordByDisplay(rawValue); if (!rec) { // if we have a custom displayTpl it's likely that findRecordByDisplay won't // find the value based on RawValue, so we give it another try using the data // stored in displayTplData if there is any. if (me.displayTplData && me.displayTplData.length) { rec = me.findRecordByValue(me.displayTplData[0][me.valueField]); } if (!rec && lastRecords && (!me.allowBlank || me.rawValue)) { rec = lastRecords[0]; // Can't get rec from display value, so resetting previous one. // We should must fire select for this (As we will change display value) needToSelect = true; } } // Prevent an issue where we have duplicate display values with // different underlying values. else if (me.getDisplayValue([me.getRecordDisplayData(rec)]) === displayValue) { rec = null; preventChange = true; } if (rec) { if (needToSelect || !(lastRecords && lastRecords[0] === rec)) { me.select(rec, true); me.fireEvent('select', me, rec); } } else if (!preventChange) { if (lastRecords) { delete me.lastSelectedRecords; } // We need to reset any value that could have been set in the dom before // or during a store load for remote combos. If we don't reset this, // then ComboBox#getValue() will think that the value has changed and will // then set `undefined` as the .value for forceSelection combos. This then // gets changed AGAIN to `null`, which will get set into the model field // for editors. This is BAD. me.setRawValue(''); } } } // we can only call getValue() in this process if forceSelection is false // otherwise it will break the grid edit on tab // eslint-disable-next-line eqeqeq else if ((value = me.getValue()) && value == rawValue) { rec = me.findRecordByDisplay(value); // fire select if the new record is different from the last record if (rec && (rec !== (lastRecords && lastRecords[0]))) { me.select(rec, true); me.fireEvent('select', me, rec); } } me.collapse(); }, onTypeAhead: function() { var me = this, displayField = me.displayField, record = me.store.findRecord(displayField, me.getRawValue()), newValue, len, selStart; if (record) { newValue = record.get(displayField); len = newValue.length; selStart = me.getRawValue().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; me.setRawValue(newValue); me.selectText(selStart, newValue.length); } } }, // invoked when a different store is bound to this combo // than the original resetToDefault: Ext.emptyFn, beforeReset: function() { this.callParent(); this.clearLocalFilter(); }, onUnbindStore: function() { var me = this, picker = me.picker; // If we'd added a local filter, remove it. // Listeners are unbound, so we don't need the changingFilters flag if (me.queryFilter && !me.store.destroyed) { me.clearLocalFilter(); } if (picker) { picker.bindStore(null); } me.pickerSelectionModel.destroy(); }, onBindStore: function(store, initial) { var me = this, picker = me.picker, extraKeySpec, valueCollectionConfig; // We're being bound, not unbound... if (store) { // If store was created from a 2 dimensional array with generated field names // 'field1' and 'field2' if (store.autoCreated) { me.queryMode = 'local'; me.valueField = me.displayField = 'field1'; if (!store.expanded) { me.displayField = 'field2'; } // displayTpl config will need regenerating with the autogenerated displayField // name 'field1' if (me.getDisplayTpl().auto) { me.setDisplayTpl(null); } } if (!Ext.isDefined(me.valueField)) { me.valueField = me.displayField; } // Add a byValue index to the store so that we can efficiently look up records // by the value field when setValue passes string value(s). // The two indices (Ext.util.CollectionKeys) are configured unique: false, // so that if duplicate keys are found, they are all returned by the get call. // This is so that findByText and findByValue are able to return the *FIRST* // matching value. By default, if unique is true, CollectionKey keeps the *last* // matching value. extraKeySpec = { byValue: { rootProperty: 'data', unique: false } }; extraKeySpec.byValue.property = me.valueField; store.setExtraKeys(extraKeySpec); if (me.displayField === me.valueField) { store.byText = store.byValue; } else { extraKeySpec.byText = { rootProperty: 'data', unique: false }; extraKeySpec.byText.property = me.displayField; store.setExtraKeys(extraKeySpec); } // We hold a collection of the values which have been selected, keyed by this field's // valueField. This collection also functions as the selected items collection // for the BoundList's selection model valueCollectionConfig = { rootProperty: 'data', extraKeys: { byInternalId: { property: 'internalId' }, byValue: { property: me.valueField, rootProperty: 'data' } }, // Whenever this collection is changed by anyone, whether by this field // adding to it, or the BoundList operating, we must refresh our value. listeners: { beginupdate: me.onValueCollectionBeginUpdate, endupdate: me.onValueCollectionEndUpdate, scope: me } }; // This becomes our collection of selected records for the Field. me.valueCollection = new Ext.util.Collection(valueCollectionConfig); // This is the selection model we configure into the dropdown BoundList. // We use the selected Collection as our value collection and the basis // for rendering the tag list. me.pickerSelectionModel = new Ext.selection.DataViewModel({ mode: me.multiSelect ? 'SIMPLE' : 'SINGLE', // There are situations when a row is selected on mousedown but then the mouse // is dragged to another row and released. In these situations, the event target // for the click event won't be the row where the mouse was released // but the boundview. The view will then determine that it should fire a container // click, and the DataViewModel will then deselect all prior selections. // Setting `deselectOnContainerClick` here will prevent the model from deselecting. ordered: true, deselectOnContainerClick: false, enableInitialSelection: false, pruneRemoved: false, selected: me.valueCollection, store: store, listeners: { scope: me, lastselectedchanged: me.updateBindSelection } }); if (!initial) { me.resetToDefault(); } if (picker) { me.pickerSelectionModel.on({ scope: me, beforeselect: me.onBeforeSelect, beforedeselect: me.onBeforeDeselect }); picker.setSelectionModel(me.pickerSelectionModel); if (picker.getStore() !== store) { picker.bindStore(store); } } } }, /** * Binds a store to this instance. * @param {Ext.data.AbstractStore/String} [store] The store to bind or ID of the store. * When no store given (or when `null` or `undefined` passed), unbinds the existing store. * @param {Boolean} [preventFilter] `true` to prevent any active filter from being activated * on the newly bound store. This is only valid when used with {@link #queryMode} `'local'`. * @param {Boolean} initial (private) */ bindStore: function(store, preventFilter, initial) { var me = this, filter = me.queryFilter; me.mixins.storeholder.bindStore.call(me, store, initial); store = me.getStore(); if (store && filter && !preventFilter) { store.getFilters().add(filter); } if (!initial && store && !store.isEmptyStore) { me.setValueOnData(); } }, getStoreListeners: function(store) { var me = this, result; // Don't bother with listeners on the dummy store that is provided for an unconfigured // ComboBox prior to a real store arriving from a ViewModel. // Nothing is ever going to be fired. if (!store.isEmptyStore) { result = { datachanged: me.onDataChanged, load: me.onLoad, exception: me.onException, update: me.onStoreUpdate, remove: me.checkValueOnChange }; // If we are doing remote filtering, then mutating the store's filters should not // result in a re-evaluation of whether the current value is still present in the store. if (!store.getRemoteFilter()) { result.filterchange = me.checkValueOnChange; } return result; } }, onDataChanged: function() { if (this.grow && this.growToLongestValue) { this.autoSize(); } }, checkValueOnChange: function() { var me = this; // Will be triggered by removal of filters upon destroy if (!me.destroying && me.getStore().isLoaded()) { // If multiselecting and the base store is modified, we may have to remove records // from the valueCollection if they have gone from the base store, or update // the rawValue if selected records are mutated. // TODO: 5.1.1: Use a ChainedStore for multiSelect so that selected records are not // filtered out of the base store and are able to be removed. // See https://sencha.jira.com/browse/EXTJS-16096 if (me.multiSelect) { // TODO: Implement in 5.1.1 when selected records are available for modification // and not filtered out. valueCollection must be in sync with what's available // in the base store, and rendered rawValue/tags must match any updated data. } else { if (me.forceSelection && !me.changingFilters && !me.findRecordByValue(me.value)) { // skip this if query mode is remote and the user is typing // or is executing a page load if (me.queryMode !== 'local' && (me.hasFocus || me.isPaging)) { return; } me.setValue(null); } } } }, onStoreUpdate: function(store, record) { // Ensure the rawValue is rendered correctly whenever a store record is mutated this.updateValue(); }, onException: function() { this.isPaging = false; this.collapse(); }, onLoad: function(store, records, success) { var me = this, // This flag is saying that we need to call setValue to match the value property // with the just loaded record set and update the valueCollection // (and thereby any bound ViewModel) with that matched record. needsValueUpdating = !me.valueCollection.byValue.get(me.value); // If not returning from a query, and the value was set from a raw data value, // unrelated to a record because the displayField was not honoured when calculating // the raw value, then we update the raw value. if (success && needsValueUpdating && !me.isPaging && !(store.lastOptions && 'rawQuery' in store.lastOptions)) { me.setValueOnData(); } // This synchronizes the value based upon contents of the store me.checkValueOnChange(); me.isPaging = false; }, setValueOnData: function() { var me = this; me.setValue(me.value); // Highlight the selected record if (me.isExpanded && me.getStore().getCount()) { me.doAutoSelect(); } }, /** * @private * Execute the query with the raw contents within the textfield. */ doRawQuery: function() { var me = this, rawValue = me.inputEl.dom.value; // Use final bit after comma as query value if multiselecting if (me.multiSelect) { rawValue = rawValue.split(me.delimiter).pop(); } me.doQuery(rawValue, false, true); }, /** * Executes a query to filter the dropdown list. Fires the {@link #beforequery} event * prior to performing the query allowing the query action to be canceled if needed. * * @param {String} queryString The string to use to filter available items by matching * against the configured {@link #valueField}. * @param {Boolean} [forceAll=false] `true` to force the query to execute even if there are * currently fewer characters in the field than the minimum specified by the * `{@link #minChars}` config option. It also clears any filter previously saved in the current * store. * @param {Boolean} [rawQuery=false] Pass as true if the raw typed value is being used * as the query string. This causes the resulting store load to leave the raw value * undisturbed. * @return {Boolean} true if the query was permitted to run, false if it was cancelled * by a {@link #beforequery} handler. */ doQuery: function(queryString, forceAll, rawQuery) { var me = this, store = me.getStore(), filters = store.getFilters(), // Decide if, and how we are going to query the store queryPlan = me.beforeQuery({ lastQuery: me.lastQuery || '', query: queryString || '', rawQuery: rawQuery, forceAll: forceAll, combo: me, cancel: false }), refreshFilters; // Allow veto. if (queryPlan !== false && !queryPlan.cancel) { // if we have a queryString and we don't have a queryFilter or the queryFilter // has changed since the last query, we should run a query. refreshFilters = !!queryString && (!me.queryFilter || me.queryFilter && (filters.indexOf(me.queryFilter) < 0)); // If they're using the same value as last time (and not being asked to query all), // and the filters don't need to be refreshed, just show the dropdown if (me.queryCaching && !refreshFilters && queryPlan.query === me.lastQuery) { // The filter changing was done with events suppressed, so // refresh the picker DOM while hidden and it will layout on show. me.getPicker().refresh(); me.expand(); me.afterQuery(queryPlan); } // Otherwise filter or load the store else { me.lastQuery = queryPlan.query; if (me.queryMode === 'local') { me.doLocalQuery(queryPlan); } else { me.doRemoteQuery(queryPlan); } } return true; } // If the query was vetoed we still need to check the change // in case custom validators are used else { me.startCheckChangeTask(); } return false; }, /** * @template * A method which may modify aspects of how the store is to be filtered (if {@link #queryMode} * is `"local"`) of loaded (if {@link #queryMode} is `"remote"`). * * This is called by the {@link #doQuery} method, and may be overridden in subclasses to modify * the default behaviour. * * This method is passed an object containing information about the upcoming query operation * which it may modify before returning. * * @param {Object} queryPlan An object containing details about the query to be executed. * @param {String} queryPlan.query The query value to be used to match against the ComboBox's * {@link #valueField}. * @param {String} queryPlan.lastQuery The query value used the last time a store query * was made. * @param {Boolean} queryPlan.forceAll If `true`, causes the query to be executed even if * the minChars threshold is not met. * @param {Boolean} queryPlan.cancel A boolean value which, if set to `true` upon return, * causes the query not to be executed. * @param {Boolean} queryPlan.rawQuery If `true` indicates that the raw input field value * is being used, and upon store load, the input field value should **not** be overwritten. * */ beforeQuery: function(queryPlan) { var me = this; // Allow beforequery event to veto by returning false if (me.fireEvent('beforequery', queryPlan) === false) { queryPlan.cancel = true; } // Allow beforequery event to veto by returning setting the cancel flag else if (!queryPlan.cancel) { // If the minChars threshold has not been met, and we're not forcing an "all" query, // cancel the query if (queryPlan.query.length < me.minChars && !queryPlan.forceAll) { queryPlan.cancel = true; } } return queryPlan; }, doLocalQuery: function(queryPlan) { var me = this, queryString = queryPlan.query, store = me.getStore(), value = queryString, filter; me.clearLocalFilter(); // Querying by a string... if (queryString) { // User can be typing a regex in here, if it's invalid // just swallow the exception and move on if (me.enableRegEx) { try { value = new RegExp(value); } catch (e) { value = null; } } if (value !== null) { // Must set changingFilters flag for this.checkValueOnChange. // the suppressEvents flag does not affect the filterchange event me.changingFilters = true; filter = me.queryFilter = new Ext.util.Filter({ id: me.id + '-filter', anyMatch: me.anyMatch, caseSensitive: me.caseSensitive, root: 'data', property: me.displayField, value: value }); store.addFilter(filter, true); me.changingFilters = false; } } // Expand after adjusting the filter if there are records or if emptyText is configured. if (me.store.getCount() || me.getPicker().emptyText) { // The filter changing was done with events suppressed, so // refresh the picker DOM while hidden and it will layout on show. me.getPicker().refresh(); me.expand(); } else { me.collapse(); } me.afterQuery(queryPlan); }, doRemoteQuery: function(queryPlan) { var me = this, loadCallback = function() { if (!me.destroyed) { me.afterQuery(queryPlan); } }; // expand before loading so LoadMask can position itself correctly me.expand(); // In queryMode: 'remote', we assume Store filters are added by the developer // as remote filters, and these are automatically passed as params with every load call, // so we do *not* call clearFilter. if (me.pageSize) { // if we're paging, we've changed the query so start at page 1. me.loadPage(1, { rawQuery: queryPlan.rawQuery, callback: loadCallback }); } else { me.store.load({ params: me.getParams(queryPlan.query), rawQuery: queryPlan.rawQuery, callback: loadCallback }); } }, /** * @template * A method called when the filtering caused by the {@link #doQuery} call is complete * and the store has been either filtered locally (if {@link #queryMode} is `"local"`), * or has been loaded using the specified filtering. * * @param {Object} queryPlan An object containing details about the query was executed. * @param {String} queryPlan.query The query value to be used to match against the ComboBox's * {@link #valueField}. * @param {Boolean} queryPlan.forceAll If `true`, causes the query to be executed even if * the minChars threshold is not met. * @param {Boolean} queryPlan.cancel A boolean value which, if set to `true` upon return, * causes the query not to be executed. * @param {Boolean} queryPlan.rawQuery If `true` indicates that the raw input field value * is being used, and upon store load, the input field value should **not** be overwritten. */ afterQuery: function(queryPlan) { var me = this; if (me.store.getCount()) { if (me.typeAhead) { me.doTypeAhead(queryPlan); } if (queryPlan.rawQuery) { if (me.autoSelectMatches && me.picker && queryPlan.query) { me.doAutoSelectMatch(queryPlan.query); } else if (me.picker && !me.picker.getSelectionModel().hasSelection()) { me.doAutoSelect(); } } else { me.doAutoSelect(); } } // doQuery is called upon field mutation, so check for change // after the query has done its thing me.startCheckChangeTask(); }, loadPage: function(pageNum, options) { this.isPaging = true; this.store.loadPage(pageNum, Ext.apply({ params: this.getParams(this.lastQuery) }, options)); }, onPageChange: function(toolbar, newPage) { /* * Return false here so we can call load ourselves and inject the query param. * We don't want to do this for every store load since the developer may load * the store through some other means so we won't add the query param. */ this.loadPage(newPage); return false; }, /** * @private */ getParams: function(queryString) { var params = {}, param = this.queryParam; if (param) { params[param] = queryString; } return params; }, /** * @private * If the autoSelect config is true, and the picker is open, highlights the first item. */ doAutoSelect: function() { var me = this, store = me.store, picker = me.picker, itemNode = 0, selectionModel, lastSelected; if (picker && me.autoSelect && store.getCount() > 0) { if (me.autoSelectLast) { selectionModel = picker.getSelectionModel(); lastSelected = selectionModel.lastSelected; // Highlight the last selected item and scroll it into view, // but only if it wasn't filtered out if (lastSelected && selectionModel.selected.length && store.indexOf(lastSelected) > -1) { itemNode = lastSelected; } } picker.getNavigationModel().setPosition(itemNode); } }, doAutoSelectMatch: function(queryString) { var me = this, store = me.store, picker = me.picker, autoSelectMatches = me.autoSelectMatches, displayField = me.getDisplayField(), index = 0, value, valueStore, valueType, filterValue = typeof queryString === 'string' ? queryString.toLowerCase() : queryString, minLength = Number.MAX_SAFE_INTEGER; if (picker && me.autoSelect && autoSelectMatches) { store.each(function(record, indexRecord) { value = record.get(displayField); valueType = typeof value; valueStore = valueType === 'string' ? value.toLowerCase() : value; if (valueStore === filterValue) { index = indexRecord; return false; } else if (valueType === 'string' && value.length < minLength) { minLength = value.length; index = indexRecord; } else if (valueType === 'number' && value < minLength) { minLength = value; index = indexRecord; } }); } picker.getNavigationModel().setPosition(index); }, doTypeAhead: function(queryPlan) { var me = this; if (!me.typeAheadTask) { me.typeAheadTask = new Ext.util.DelayedTask(me.onTypeAhead, me); } // Only typeahead when user extends the query string, or it's a completely different query // If user is erasing, re-extending with typeahead is not wanted. if (queryPlan.query.length > queryPlan.lastQuery.length || !Ext.String.startsWith(queryPlan.lastQuery, queryPlan.query)) { me.typeAheadTask.delay(me.typeAheadDelay); } }, onTriggerClick: function(comboBox, trigger, e) { var me = this, oldAutoSelect; if (!me.readOnly && !me.disabled) { if (me.isExpanded) { me.collapse(); // Hide keyboard for touch devices when the picker list is collapsed if (e && e.pointerType !== 'mouse') { trigger.getEl().focus(); } } else { // Alt-Down arrow opens the picker but does not select items: // http://www.w3.org/TR/wai-aria-practices/#combobox if (e && e.type === 'keydown' && e.altKey) { oldAutoSelect = me.autoSelect; me.autoSelect = false; me.expand(); me.autoSelect = oldAutoSelect; } else { if (me.triggerAction === 'all') { me.doQuery(me.allQuery, true); } else if (me.triggerAction === 'last') { me.doQuery(me.lastQuery, true); } else { me.doQuery(me.getRawValue(), false, true); } } } } }, onFieldMutation: function(e) { var me = this, key = e.getKey(), isDelete = key === e.BACKSPACE || key === e.DELETE, rawValue = me.inputEl.dom.value, len = rawValue.length; // Do not process two events for the same mutation. // For example an input event followed by the keyup that caused it. // We must process delete keyups. // Also, do not process TAB event which fires on arrival. if (!me.readOnly && (rawValue !== me.lastMutatedValue || isDelete) && key !== e.TAB) { me.lastMutatedValue = rawValue; me.refreshEmptyText(); if (len && (e.type !== 'keyup' || (!e.isSpecialKey() || isDelete))) { me.doQueryTask.delay(me.queryDelay); } else { // We have *erased* back to empty if key is a delete, // or it is a non-key event (cut/copy) if (!len && (!key || isDelete)) { // This portion of code may end up calling setValue will check for change. // But since it's come from field mutations, we need to respect // the checkChangeBuffer, so we suspend checks here, it will be handled // by callParent ++me.suspendCheckChange; // Essentially a silent setValue. // Clear our value, and the tplData used to construct a mathing raw value. if (!me.multiSelect) { me.value = null; me.displayTplData = undefined; } // If the value is blank we can't have a value if (me.clearValueOnEmpty) { me.valueCollection.beginUpdate(); me.pickerSelectionModel.deselectAll(); me.valueCollection.removeAll(); me.valueCollection.endUpdate(); } // Just erased back to empty. Hide the dropdown. me.collapse(); // There may have been a local filter if we were querying locally. // Clear the query filter and suppress the consequences // (we do not want a list refresh). if (me.queryFilter) { me.clearLocalFilter(); } // When queryCaching if the user deletes the value and then starts typing // the same filter again, doQuery can erroneously expand the picker without // filtering first. me.lastQuery = null; --me.suspendCheckChange; } me.callParent([e]); } } }, doDestroy: function() { var me = this; me.doQueryTask.cancel(); if (me.typeAheadTask) { me.typeAheadTask.cancel(); me.typeAheadTask = null; } me.bindStore(null); Ext.destroy(me.altArrowKeyNav, me.valueCollection); me.callParent(); }, // The picker (the dropdown) must have its zIndex managed by the same ZIndexManager which is // providing the zIndex of our Container. onAdded: function() { var me = this; me.callParent(arguments); if (me.picker) { me.picker.ownerCt = me.up('[floating]'); me.picker.registerWithOwnerCt(); } }, createPicker: function() { var me = this, picker, pickerCfg = Ext.apply({ xtype: 'boundlist', id: me.id + '-picker', pickerField: me, selectionModel: me.pickerSelectionModel, floating: true, hidden: true, store: me.getPickerStore(), displayField: me.displayField, preserveScrollOnRefresh: true, pageSize: me.pageSize, tpl: me.tpl, ariaSelectable: me.ariaSelectable }, me.listConfig, me.defaultListConfig); picker = me.picker = Ext.widget(pickerCfg); if (me.pageSize) { picker.pagingToolbar.on('beforechange', me.onPageChange, me); } // We limit the height of the picker to fit in the space above // or below this field unless the picker has its own ideas about that. if (!picker.initialConfig.maxHeight) { picker.on({ beforeshow: me.onBeforePickerShow, scope: me }); } picker.getSelectionModel().on({ beforeselect: me.onBeforeSelect, beforedeselect: me.onBeforeDeselect, focuschange: me.onFocusChange, scope: me }); picker.getNavigationModel().navigateOnSpace = false; return picker; }, getPickerStore: function() { return this.store; }, onBeforePickerShow: function(picker) { // Just before we show the picker, set its maxHeight so it fits // either above or below, it will flip to the side where it fits var me = this, heightAbove = me.getPosition()[1] - Ext.getBody().getScroll().top, heightBelow = Ext.Element.getViewportHeight() - heightAbove - me.getHeight(); // Then ensure that vertically, the dropdown will fit into the space // either above or below the inputEl. // Also have some leeway so we aren't flush against the window edge picker.maxHeight = Math.max(heightAbove, heightBelow) - 5; }, onBeforeSelect: function(list, record, recordIndex) { return this.fireEvent('beforeselect', this, record, recordIndex); }, onBeforeDeselect: function(list, record, recordIndex) { return this.fireEvent('beforedeselect', this, record, recordIndex); }, onFocusChange: function(selModel, prevRecord, newRecord) { var picker = this.picker, inputEl = this.inputEl, el; if (newRecord) { el = picker.getNodeByRecord(newRecord); if (el) { // Need to make sure el has an id without leaving Ext.cache entry if (!el.id) { el.id = Ext.id(); } inputEl.dom.setAttribute('aria-activedescendant', el.id); } else { inputEl.dom.removeAttribute('aria-activedescendant'); } } }, /** * Returns the combobox's selection. * @return {Ext.data.Model} The selected record */ getSelection: function() { var selModel = this.getPicker().getSelectionModel(), selection = selModel.getSelection(); return selection.length ? selModel.getLastSelected() : null; }, updateSelection: function(selection) { var me = this, sm; if (!me.ignoreNextSelection) { me.ignoreNextSelection = true; sm = me.getPicker().getSelectionModel(); if (selection) { sm.select(selection); me.hasHadSelection = true; } else { sm.deselectAll(); } me.ignoreNextSelection = false; } }, updateBindSelection: function(selModel, selection) { var me = this, selected = null; if (!me.ignoreNextSelection) { me.ignoreNextSelection = true; if (selection.length) { selected = selModel.getLastSelected(); me.hasHadSelection = true; } if (me.hasHadSelection) { me.setSelection(selected); } me.ignoreNextSelection = false; } }, onValueCollectionBeginUpdate: Ext.emptyFn, onValueCollectionEndUpdate: function() { var me = this, store = me.store, selectedRecords = me.valueCollection.getRange(), selectedRecord = selectedRecords[0], selectionCount = selectedRecords.length; me.updateBindSelection(me.pickerSelectionModel, selectedRecords); if (me.isSelectionUpdating()) { return; } Ext.suspendLayouts(); me.lastSelection = selectedRecords; if (selectionCount) { // Track the last selection with a value (non blank) for use in // assertValue me.lastSelectedRecords = selectedRecords; } me.updateValue(); // If we have selected a value, and it's not possible to select any more values // or, we are configured to hide the picker each time, then collapse the picker. // eslint-disable-next-line max-len if (selectionCount && ((!me.multiSelect && store.contains(selectedRecord)) || me.collapseOnSelect || !store.getCount())) { me.updatingValue = true; me.collapse(); me.updatingValue = false; } Ext.resumeLayouts(true); if (!me.suspendCheckChange) { if (!me.multiSelect) { selectedRecords = selectedRecord; } me.fireEvent('select', me, selectedRecords); } }, isSelectionUpdating: function() { var selModel = this.pickerSelectionModel; return selModel.deselectingDuringSelect || selModel.refreshing; }, /** * @private * Enables the key navs for the BoundList when it is expanded. */ onExpand: function() { var me = this, picker = me.getPicker(), keyNav = picker.getNavigationModel(), node; if (keyNav) { keyNav.enable(); } me.doAutoSelect(); node = picker.highlightedItem; if (node) { // Need to make sure node has an id without leaving Ext.cache entry if (!node.id) { node.id = Ext.id(); } me.inputEl.dom.setAttribute('aria-activedescendant', node.id); } }, /** * @private * Disables the key navs for the BoundList when it is collapsed. */ onCollapse: function() { var me = this, keyNav = me.getPicker().getNavigationModel(); if (keyNav) { keyNav.disable(); } if (me.updatingValue) { me.doQueryTask.cancel(); } me.inputEl.dom.removeAttribute('aria-activedescendant'); }, /** * Selects an item by a {@link Ext.data.Model Model}, or by a key value. * @param {Object} r * @param {Boolean} assert (private) */ select: function(r, assert) { var me = this, picker = me.picker, fireSelect; if (r && r.isModel && assert === true && picker) { fireSelect = !picker.getSelectionModel().isSelected(r); } if (!fireSelect) { me.suspendEvent('select'); } me.setValue(r); me.resumeEvent('select'); }, /** * Finds the record by searching for a specific field/value combination. * @param {String} field The name of the field to test. * @param {Object} value The value to match the field against. * @return {Ext.data.Model} The matched record or false. */ findRecord: function(field, value) { var ds = this.store, idx = ds.findExact(field, value); return idx !== -1 ? ds.getAt(idx) : false; }, getSelectedRecord: function() { return this.findRecordByValue(this.value) || null; }, /** * Finds the record by searching values in the {@link #valueField}. * @param {Object} value The value to match the field against. * @return {Ext.data.Model} The matched record or `false`. */ findRecordByValue: function(value) { var result = this.store.byValue.get(value), ret = false; // If there are duplicate keys, tested behaviour is to return the *first* match. if (result) { ret = result[0] || result; } return ret; }, /** * Finds the record by searching values in the {@link #displayField}. * @param {Object} value The value to match the field against. * @return {Ext.data.Model} The matched record or `false`. */ findRecordByDisplay: function(value) { var result = this.store.byText.get(value), ret = false; // If there are duplicate keys, tested behaviour is to return the *first* match. if (result) { ret = result[0] || result; } return ret; }, /** * 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) { if (value != null) { return this.doSetValue(value, true); } }, /** * Sets the specified value(s) into the field. For each value, if a record is found * in the {@link #store} that matches based on the {@link #valueField}, then that record's * {@link #displayField} will be displayed in the field. If no match is found, and the * {@link #valueNotFoundText} config option is defined, then that will be displayed * as the default field text. Otherwise a blank value will be shown, although the value * will still be set. * @param {String/String[]} value The value(s) to be set. Can be either a single String * or {@link Ext.data.Model}, or an Array of Strings or Models. * @return {Ext.form.field.Field} this */ setValue: function(value) { var me = this, bind, valueBind; if (!me.getDisplayField() && !me.getDisplayTpl()) { //<debug> Ext.error.raise('Combobox must have either displayField or displayTpl'); //</debug> } // Here we check if the setValue is being called by bind getting synced // if this is the case while the field has focus. If this is the case, we // don't want to change the field value. if (me.hasFocus) { bind = me.getBind(); valueBind = bind && bind.value; if (valueBind && valueBind.syncing) { if ((Ext.isEmpty(value) && Ext.isEmpty(me.value)) || value === me.value) { return me; } else if (Ext.isArray(value) && Ext.isArray(me.value) && Ext.Array.equals(value, me.value)) { return me; } } } else { // This is the value used to forceSelection in assertValue if // an invalid value is left in the field at completeEdit. Must be cleared so // that the next usage of the field is not affected, but only if we are setting // a new value. me.lastSelectedRecords = null; } if (value != null) { me.doSetValue(value); } // Clearing is a special, simpler case. else { me.suspendEvent('select'); me.valueCollection.beginUpdate(); me.pickerSelectionModel.deselectAll(); me.valueCollection.endUpdate(); me.resumeEvent('select'); } return me; }, setRawValue: function(rawValue) { this.callParent([rawValue]); this.lastMutatedValue = rawValue; }, /** * @private * Sets or adds a value/values */ doSetValue: function(value /* private for use by addValue */, add) { var me = this, store = me.getStore(), Model = store.getModel(), matchedRecords = [], valueArray = [], autoLoadOnValue = me.autoLoadOnValue, isLoaded = store.getCount() > 0 || store.isLoaded(), pendingLoad = store.hasPendingLoad(), unloaded = autoLoadOnValue && !isLoaded && !pendingLoad, forceSelection = me.forceSelection, selModel = me.pickerSelectionModel, displayField = me.displayField, valueField = me.valueField, displayIsValue = displayField === valueField, isEmptyStore = store.isEmptyStore, lastSelection = me.lastSelection, i, len, record, dataObj, valueChanged, key, val; //<debug> if (add && !me.multiSelect) { Ext.raise('Cannot add values to non multiSelect ComboBox'); } //</debug> // Called while the Store is loading or we don't have the real store bound yet. // Ensure it is processed by the onLoad/bindStore. // Even if displayField === valueField, we still MUST kick off a load because even though // the value may be correct as the raw value, we must still load the store, and // upon load, match the value and select a record sop we can publish the *selection* to // a ViewModel. if (pendingLoad || unloaded || !isLoaded || isEmptyStore) { // If they are setting the value to a record instance, we can // just add it to the valueCollection and continue with the setValue. // We MUST do this before kicking off the load in case the load is synchronous; // this.value must be available to the onLoad handler. if (!value.isModel) { if (add) { me.value = Ext.Array.from(me.value).concat(value); } else { me.value = value; } me.setHiddenValue(me.value); // If we know that the display value is the same as the value, then show it. // A store load is still scheduled so that the matching record can be published. me.setRawValue(displayIsValue ? value : ''); // if display is value, let's remove the empty text since the store // might not be loaded yet if (displayIsValue && !Ext.isEmpty(value) && me.inputEl && me.emptyText) { me.inputEl.removeCls(me.emptyUICls); } } // Kick off a load. Doesn't matter whether proxy is remote - it needs loading // so we can select the correct record for the value. // // Must do this *after* setting the value above in case the store loads synchronously // and fires the load event, and therefore calls onLoad inline. // // If it is still the default empty store, then the real store must be arriving // in a tick through binding. bindStore will call setValueOnData. if (unloaded && !isEmptyStore) { store.load(); } // If they had set a string value, another setValue call is scheduled // in the onLoad handler. If the store is the defauilt empty one, the setValueOnData // call will be made in bindStore when the real store arrives. if (!value.isModel || isEmptyStore) { return me; } } // This method processes multi-values, so ensure value is an array. value = add ? Ext.Array.from(me.value).concat(value) : Ext.Array.from(value); // Loop through values, matching each from the Store, and collecting matched records for (i = 0, len = value.length; i < len; i++) { record = val = value[i]; // Set value was a key, look up in the store by that key if (!record || !record.isModel) { record = me.findRecordByValue(key = record); // The value might be in a new record created from an unknown value // (if !me.forceSelection). // Or it could be a picked record which is filtered out of the main store. // Or it could be a setValue(record) passed to an empty store with autoLoadOnValue // and added above. if (!record) { record = me.valueCollection.find(valueField, key); } } // record was not found, this could happen because // store is not loaded or they set a value not in the store if (!record) { // If we are allowing insertion of values not represented in the Store, // then push the value and create a new record to push as a display value // for use by the displayTpl if (!forceSelection) { // We are allowing added values to create their own records. // Only if the value is not empty. if (!record && val) { dataObj = {}; if (Ext.isObject(val)) { dataObj[displayField] = val[displayField]; dataObj[valueField] = val[valueField]; } else { dataObj[displayField] = val; if (valueField && displayField !== valueField) { dataObj[valueField] = val; } } record = new Model(dataObj); } } // Else, if valueNotFoundText is defined, display it, // otherwise display nothing for this value else if (me.valueNotFoundRecord) { record = me.valueNotFoundRecord; } } // record found, select it. if (record) { matchedRecords.push(record); valueArray.push(record.get(valueField)); } } // If the same set of records are selected, this setValue has been a no-op if (lastSelection) { len = lastSelection.length; if (len === matchedRecords.length) { for (i = 0; !valueChanged && i < len; i++) { if (Ext.Array.indexOf(me.lastSelection, matchedRecords[i]) === -1) { valueChanged = true; } } } else { valueChanged = true; } } else { valueChanged = matchedRecords.length; } if (valueChanged) { // beginUpdate which means we only want to notify this.onValueCollectionEndUpdate // after it's all changed. me.suspendEvent('select'); me.valueCollection.beginUpdate(); if (matchedRecords.length) { selModel.select(matchedRecords, false); } else { selModel.deselectAll(); } me.valueCollection.endUpdate(); me.resumeEvent('select'); } else { me.updateValue(); } return me; }, /** * @private * Internal setting of value when records are added to the valueCollection * setValue itself adds to the valueCollection. */ updateValue: function() { var me = this, selectedRecords = me.valueCollection.getRange(), len = selectedRecords.length, valueArray = [], displayTplData = me.displayTplData || (me.displayTplData = []), inputEl = me.inputEl, i, record, displayValue; // Loop through values, matching each from the Store, and collecting matched records displayTplData.length = 0; for (i = 0; i < len; i++) { record = selectedRecords[i]; displayTplData.push(me.getRecordDisplayData(record)); // There might be the bogus "value not found" record if forceSelect was set. // Do not include this in the value. if (record !== me.valueNotFoundRecord) { valueArray.push(record.get(me.valueField)); } } // Set the value of this field. If we are multiselecting, then that is an array. me.setHiddenValue(valueArray); me.value = me.multiSelect ? valueArray : valueArray[0]; if (!Ext.isDefined(me.value)) { me.value = undefined; } me.displayTplData = displayTplData; // store for getDisplayValue method displayValue = me.getDisplayValue(); // Calculate raw value from the collection of Model data me.setRawValue(displayValue); me.refreshEmptyText(); me.checkChange(); if (!me.lastSelectedRecords && selectedRecords.length) { me.lastSelectedRecords = selectedRecords; } if (inputEl && me.typeAhead && me.hasFocus) { // if typeahead is configured, deselect any partials me.selectText(displayValue.length); } }, /** * @private * Set the value of {@link #hiddenDataEl} * Dynamically adds and removes input[type=hidden] elements */ setHiddenValue: function(values) { var me = this, name = me.hiddenName, i, dom, childNodes, input, valueCount, childrenCount; if (!me.hiddenDataEl || !name) { return; } values = Ext.Array.from(values); dom = me.hiddenDataEl.dom; childNodes = dom.childNodes; input = childNodes[0]; valueCount = values.length; childrenCount = childNodes.length; if (!input && valueCount > 0) { me.hiddenDataEl.setHtml(Ext.DomHelper.markup({ tag: 'input', type: 'hidden', name: name })); childrenCount = 1; input = dom.firstChild; } while (childrenCount > valueCount) { dom.removeChild(childNodes[0]); --childrenCount; } while (childrenCount < valueCount) { dom.appendChild(input.cloneNode(true)); ++childrenCount; } for (i = 0; i < valueCount; i++) { childNodes[i].value = values[i]; } }, /** * @private * Generates the string value to be displayed in the text field for the currently stored value */ getDisplayValue: function(tplData) { var s; tplData = tplData || this.displayTplData; s = this.getDisplayTpl().apply(tplData); s = (s == null) ? '' : String(s); // The display field may have newlines characters, but the raw value in // the field will not because they will be automatically stripped, so do // the same here for the sake of comparison. return s.replace(this.newlineRe, ''); }, /** * Gets data for each record to be used for constructing the display value with * the {@link #displayTpl}. This may be overridden to provide access to associated records. * @param {Ext.data.Model} record The record. * @return {Object} The data to be passed for each record to the {@link #displayTpl}. * * @protected */ getRecordDisplayData: function(record) { return record.data; }, getValue: function() { // If the user has not changed the raw field value since a value was selected from the list, // then return the structured value from the selection. If the raw field value is different // than what would be displayed due to selection, return that raw value. var me = this, store = me.getStore(), picker = me.picker, rawValue = me.getRawValue(), // current value of text field value = me.value; // stored value from last selection or setValue() call // getValue may be called from initValue before a valid store is bound - // may still be the default empty one. // Also, may be called before the store has been loaded. // In these cases, just return the value. // In other cases, check that the rawValue matches the selected records. if (!store.isEmptyStore && me.getDisplayValue() !== rawValue) { me.displayTplData = undefined; if (picker) { // We do not need to hear about this clearing out of the value collection, // so suspend events. me.valueCollection.suspendEvents(); picker.getSelectionModel().deselectAll(); me.valueCollection.resumeEvents(); me.lastSelection = null; } // If the raw input value gets out of sync in a multiple ComboBox, then we have to // give up. Multiple is not designed for typing *and* displaying the comma separated // result of selection. Same in the case of forceSelection. // Unless the store is not yet loaded, which case will be handled in onLoad if (store.isLoaded() && (me.multiSelect || me.forceSelection)) { value = me.value = undefined; } else { value = me.value = rawValue; } } // Return null if value is undefined/null, not falsy. me.value = value == null ? null : value; return me.value; }, getSubmitValue: function() { var value = this.getValue(); // If the value is null/undefined, we still return an empty string. If we // don't, the field will never get posted to the server since nulls are ignored. if (Ext.isEmpty(value)) { value = ''; } return value; }, isEqual: function(v1, v2) { var fromArray = Ext.Array.from, i, len; v1 = fromArray(v1); v2 = fromArray(v2); len = v1.length; if (len !== v2.length) { return false; } for (i = 0; i < len; i++) { if (v2[i] !== v1[i]) { return false; } } return true; }, /** * Clears any value currently set in the ComboBox. */ clearValue: function() { this.setValue(null); }});