UNPKG

@vaadin/combo-box

Version:

Web Component for displaying a list of items with filtering

595 lines (521 loc) 17.9 kB
/** * @license * Copyright (c) 2015 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { ValidateMixin } from '@vaadin/field-base/src/validate-mixin.js'; import { ComboBoxItemsMixin } from './vaadin-combo-box-items-mixin.js'; /** * Checks if the value is supported as an item value in this control. * * @param {unknown} value * @return {boolean} */ function isValidValue(value) { return value !== undefined && value !== null; } /** * @polymerMixin * @mixes ComboBoxItemsMixin * @mixes ValidateMixin * @param {function(new:HTMLElement)} superClass */ export const ComboBoxMixin = (superClass) => class ComboBoxMixinClass extends ValidateMixin(ComboBoxItemsMixin(superClass)) { static get properties() { return { /** * Custom function for rendering the content of every item. * Receives three arguments: * * - `root` The `<vaadin-combo-box-item>` internal container DOM element. * - `comboBox` The reference to the `<vaadin-combo-box>` element. * - `model` The object with the properties related with the rendered * item, contains: * - `model.index` The index of the rendered item. * - `model.item` The item. * @type {ComboBoxRenderer | undefined} */ renderer: { type: Object, sync: true, }, /** * If `true`, the user can input a value that is not present in the items list. * `value` property will be set to the input value in this case. * Also, when `value` is set programmatically, the input value will be set * to reflect that value. * @attr {boolean} allow-custom-value */ allowCustomValue: { type: Boolean, value: false, }, /** * When set to `true`, "loading" attribute is added to host and the overlay element. */ loading: { type: Boolean, value: false, reflectToAttribute: true, sync: true, }, /** * The selected item from the `items` array. * @type {ComboBoxItem | string | undefined} */ selectedItem: { type: Object, notify: true, sync: true, }, /** * A function used to generate CSS class names for dropdown * items based on the item. The return value should be the * generated class name as a string, or multiple class names * separated by whitespace characters. */ itemClassNameGenerator: { type: Object, }, /** * Path for the id of the item. If `items` is an array of objects, * the `itemIdPath` is used to compare and identify the same item * in `selectedItem` and `filteredItems` (items given by the * `dataProvider` callback). * @attr {string} item-id-path */ itemIdPath: { type: String, sync: true, }, /** @private */ __keepOverlayOpened: { type: Boolean, sync: true, }, }; } static get observers() { return [ '_openedOrItemsChanged(opened, _dropdownItems, loading, __keepOverlayOpened)', '_selectedItemChanged(selectedItem, itemValuePath, itemLabelPath)', '_updateScroller(opened, _dropdownItems, _focusedIndex, _theme)', ]; } /** @protected */ ready() { super.ready(); /** * Used to detect user value changes and fire `change` events. * Do not define in `properties` to avoid triggering updates. * @type {string} * @protected */ this._lastCommittedValue = this.value; } /** * Requests an update for the content of items. * While performing the update, it invokes the renderer (passed in the `renderer` property) once an item. * * It is not guaranteed that the update happens immediately (synchronously) after it is requested. */ requestContentUpdate() { if (!this._scroller) { return; } this._scroller.requestContentUpdate(); this._getItemElements().forEach((item) => { item.requestContentUpdate(); }); } /** * @param {Object} props * @protected */ updated(props) { super.updated(props); ['loading', 'itemIdPath', 'itemClassNameGenerator', 'renderer', 'selectedItem'].forEach((prop) => { if (props.has(prop)) { this._scroller[prop] = this[prop]; } }); } /** @private */ _updateScroller(opened, items, focusedIndex, theme) { if (opened) { this._scroller.style.maxHeight = getComputedStyle(this).getPropertyValue(`--${this._tagNamePrefix}-overlay-max-height`) || '65vh'; } this._scroller.setProperties({ items: opened ? items : [], opened, focusedIndex, theme, }); } /** @private */ _openedOrItemsChanged(opened, items, loading, keepOverlayOpened) { // Close the overlay if there are no items to display. // See https://github.com/vaadin/vaadin-combo-box/pull/964 this._overlayOpened = opened && (keepOverlayOpened || loading || !!(items && items.length)); } /** * Override method from `ComboBoxBaseMixin` to deselect * dropdown item by requesting content update on clear. * @param {Event} event * @protected */ _onClearButtonClick(event) { super._onClearButtonClick(event); if (this.opened) { this.requestContentUpdate(); } } /** * Override method inherited from `InputMixin` * to revert the input value to value. * @protected * @override */ _inputElementChanged(input) { super._inputElementChanged(input); if (input) { this._revertInputValueToValue(); } } /** * Override method from `ComboBoxBaseMixin` to handle loading. * @protected * @override */ _closeOrCommit() { if (!this.opened && !this.loading) { this._commitValue(); } else { this.close(); } } /** * Override method from `ComboBoxBaseMixin` to handle valid value. * @protected * @override */ _hasValidInputValue() { const hasInvalidOption = this._focusedIndex < 0 && this._inputElementValue !== '' && this._getItemLabel(this.selectedItem) !== this._inputElementValue; return this.allowCustomValue || !hasInvalidOption; } /** * Override method from `ComboBoxBaseMixin`. * @protected * @override */ _onEscapeCancel() { this.cancel(); } /** * Override method from `ComboBoxBaseMixin` to reset selected item. * @protected * @override */ _onClearAction() { this.selectedItem = null; if (this.allowCustomValue) { this.value = ''; } this._detectAndDispatchChange(); } /** * Clears the current filter. Should be used instead of setting the property * directly in order to allow overriding this in multi-select combo box. * @protected */ _clearFilter() { this.filter = ''; } /** * Reverts back to original value. */ cancel() { this._revertInputValueToValue(); // In the next _detectAndDispatchChange() call, the change detection should not pass this._lastCommittedValue = this.value; this._closeOrCommit(); } /** * Override method from `ComboBoxBaseMixin` to store last committed value. * @protected * @override */ _onOpened() { this.dispatchEvent(new CustomEvent('vaadin-combo-box-dropdown-opened', { bubbles: true, composed: true })); // _detectAndDispatchChange() should not consider value changes done before opening this._lastCommittedValue = this.value; } /** * Override method from `ComboBoxBaseMixin` to dispatch an event. * @protected * @override */ _onOverlayClosed() { this.dispatchEvent(new CustomEvent('vaadin-combo-box-dropdown-closed', { bubbles: true, composed: true })); } /** * Override method from `ComboBoxBaseMixin` to commit value on overlay closing. * @protected * @override */ _onClosed() { if (!this.loading || this.allowCustomValue) { this._commitValue(); } } /** * Override method from `ComboBoxBaseMixin` to implement value commit logic. * @protected * @override */ _commitValue() { if (this._focusedIndex > -1) { const focusedItem = this._dropdownItems[this._focusedIndex]; if (this.selectedItem !== focusedItem) { this.selectedItem = focusedItem; } // Make sure input field is updated in case value doesn't change (i.e. FOO -> foo) this._inputElementValue = this._getItemLabel(this.selectedItem); this._focusedIndex = -1; } else if (this._inputElementValue === '' || this._inputElementValue === undefined) { this.selectedItem = null; if (this.allowCustomValue) { this.value = ''; } } else { // Try to find an item which label matches the input value. const items = [this.selectedItem, ...(this._dropdownItems || [])]; const itemMatchingInputValue = items[this.__getItemIndexByLabel(items, this._inputElementValue)]; if ( this.allowCustomValue && // To prevent a repetitive input value being saved after pressing ESC and Tab. !itemMatchingInputValue ) { const customValue = this._inputElementValue; // Store reference to the last custom value for checking it on focusout. this._lastCustomValue = customValue; // An item matching by label was not found, but custom values are allowed. // Dispatch a custom-value-set event with the input value. const e = new CustomEvent('custom-value-set', { detail: customValue, composed: true, cancelable: true, bubbles: true, }); this.dispatchEvent(e); if (!e.defaultPrevented) { this.value = customValue; } } else if (!this.allowCustomValue && !this.opened && itemMatchingInputValue) { // An item matching by label was found, select it. this.value = this._getItemValue(itemMatchingInputValue); } else { // Revert the input value this._revertInputValueToValue(); } } this._detectAndDispatchChange(); this._clearSelectionRange(); this._clearFilter(); } /** * Override an event listener from `InputMixin`. * @param {!Event} event * @protected * @override */ _onChange(event) { // Suppress the native change event fired on the native input. // We use `_detectAndDispatchChange` to fire a custom event. event.stopPropagation(); } /** * Override method from `ComboBoxBaseMixin` to handle reverting value. * @protected * @override */ _revertInputValue() { if (this.filter !== '') { this._inputElementValue = this.filter; } else { this._revertInputValueToValue(); } this._clearSelectionRange(); } /** @private */ _revertInputValueToValue() { if (this.allowCustomValue && !this.selectedItem) { this._inputElementValue = this.value; } else { this._inputElementValue = this._getItemLabel(this.selectedItem); } } /** @private */ _selectedItemChanged(selectedItem) { if (selectedItem === null || selectedItem === undefined) { if (this.filteredItems) { if (!this.allowCustomValue) { this.value = ''; } this._toggleHasValue(this._hasValue); this._inputElementValue = this.value; } } else { const value = this._getItemValue(selectedItem); if (this.value !== value) { this.value = value; if (this.value !== value) { // The value was changed to something else in value-changed listener, // so prevent from resetting it to the previous value. return; } } this._toggleHasValue(true); this._inputElementValue = this._getItemLabel(selectedItem); } } /** * Override an observer from `InputMixin`. * @protected * @override */ _valueChanged(value, oldVal) { if (value === '' && oldVal === undefined) { // Initializing, no need to do anything // See https://github.com/vaadin/vaadin-combo-box/issues/554 return; } if (isValidValue(value)) { if (this._getItemValue(this.selectedItem) !== value) { this._selectItemForValue(value); } if (!this.selectedItem && this.allowCustomValue) { this._inputElementValue = value; } this._toggleHasValue(this._hasValue); } else { this.selectedItem = null; } this._clearFilter(); // In the next _detectAndDispatchChange() call, the change detection should pass this._lastCommittedValue = undefined; } /** @private */ _detectAndDispatchChange() { // Do not validate when focusout is caused by document // losing focus, which happens on browser tab switch. if (document.hasFocus()) { this._requestValidation(); } if (this.value !== this._lastCommittedValue) { this.dispatchEvent(new CustomEvent('change', { bubbles: true })); this._lastCommittedValue = this.value; } } /** @private */ _selectItemForValue(value) { const valueIndex = this.__getItemIndexByValue(this.filteredItems, value); const previouslySelectedItem = this.selectedItem; if (valueIndex >= 0) { this.selectedItem = this.filteredItems[valueIndex]; } else if (this.dataProvider && this.selectedItem === undefined) { this.selectedItem = undefined; } else { this.selectedItem = null; } if (this.selectedItem === null && previouslySelectedItem === null) { this._selectedItemChanged(this.selectedItem); } } /** * Provide items to be rendered in the dropdown. * Override this method to show custom items. * * @protected * @override */ _setDropdownItems(newItems) { const oldItems = this._dropdownItems; this._dropdownItems = newItems; // Store the currently focused item if any. The focused index preserves // in the case when more filtered items are loading but it is reset // when the user types in a filter query. const focusedItem = oldItems ? oldItems[this._focusedIndex] : null; // Try to sync `selectedItem` based on `value` once a new set of `filteredItems` is available // (as a result of external filtering or when they have been loaded by the data provider). // When `value` is specified but `selectedItem` is not, it means that there was no item // matching `value` at the moment `value` was set, so `selectedItem` has remained unsynced. const valueIndex = this.__getItemIndexByValue(newItems, this.value); if ((this.selectedItem === null || this.selectedItem === undefined) && valueIndex >= 0) { this.selectedItem = newItems[valueIndex]; } // Try to first set focus on the item that had been focused before `newItems` were updated // if it is still present in the `newItems` array. Otherwise, set the focused index // depending on the selected item or the filter query. const focusedItemIndex = this.__getItemIndexByValue(newItems, this._getItemValue(focusedItem)); if (focusedItemIndex > -1) { this._focusedIndex = focusedItemIndex; } else { // When the user filled in something that is different from the current value = filtering is enabled, // set the focused index to the item that matches the filter query. this._focusedIndex = this.__getItemIndexByLabel(newItems, this.filter); } } /** * Override method from `ComboBoxBaseMixin`. * @protected * @override */ _handleFocusOut() { // User's logic in `custom-value-set` event listener might cause input to blur, // which will result in attempting to commit the same custom value once again. if (!this.opened && this.allowCustomValue && this._inputElementValue === this._lastCustomValue) { delete this._lastCustomValue; return; } super._handleFocusOut(); } /** * Fired when the value changes. * * @event value-changed * @param {Object} detail * @param {String} detail.value the combobox value */ /** * Fired when selected item changes. * * @event selected-item-changed * @param {Object} detail * @param {Object|String} detail.value the selected item. Type is the same as the type of `items`. */ /** * Fired when the user sets a custom value. * @event custom-value-set * @param {String} detail the custom value */ /** * Fired when the user commits a value change. * @event change */ /** * Fired after the `vaadin-combo-box-overlay` opens. * * @event vaadin-combo-box-dropdown-opened */ /** * Fired after the `vaadin-combo-box-overlay` closes. * * @event vaadin-combo-box-dropdown-closed */ };