@vaadin/combo-box
Version:
Web Component for displaying a list of items with filtering
298 lines (259 loc) • 9.12 kB
JavaScript
/**
* @license
* Copyright (c) 2015 - 2025 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { DataProviderController } from '@vaadin/component-base/src/data-provider-controller/data-provider-controller.js';
import { ComboBoxPlaceholder } from './vaadin-combo-box-placeholder.js';
/**
* @polymerMixin
*/
export const ComboBoxDataProviderMixin = (superClass) =>
class DataProviderMixin extends superClass {
static get properties() {
return {
/**
* Number of items fetched at a time from the dataprovider.
* @attr {number} page-size
* @type {number}
*/
pageSize: {
type: Number,
value: 50,
observer: '_pageSizeChanged',
sync: true,
},
/**
* Total number of items.
* @type {number | undefined}
*/
size: {
type: Number,
observer: '_sizeChanged',
sync: true,
},
/**
* Function that provides items lazily. Receives arguments `params`, `callback`
*
* `params.page` Requested page index
*
* `params.pageSize` Current page size
*
* `params.filter` Currently applied filter
*
* `callback(items, size)` Callback function with arguments:
* - `items` Current page of items
* - `size` Total number of items.
* @type {ComboBoxDataProvider | undefined}
*/
dataProvider: {
type: Object,
observer: '_dataProviderChanged',
sync: true,
},
/** @private */
__dataProviderInitialized: {
type: Boolean,
value: false,
},
/** @private */
__previousDataProviderFilter: {
type: String,
},
};
}
static get observers() {
return [
'_dataProviderFilterChanged(filter)',
'_warnDataProviderValue(dataProvider, value)',
'_ensureFirstPage(opened)',
];
}
constructor() {
super();
/**
* @type {DataProviderController}
* @private
*/
this.__dataProviderController = new DataProviderController(this, {
placeholder: new ComboBoxPlaceholder(),
isPlaceholder: (item) => item instanceof ComboBoxPlaceholder,
dataProviderParams: () => ({ filter: this.filter }),
});
this.__dataProviderController.addEventListener('page-requested', this.__onDataProviderPageRequested.bind(this));
this.__dataProviderController.addEventListener('page-loaded', this.__onDataProviderPageLoaded.bind(this));
}
/** @protected */
ready() {
super.ready();
this._scroller.addEventListener('index-requested', (e) => {
if (!this._shouldFetchData()) {
return;
}
const index = e.detail.index;
if (index !== undefined) {
this.__dataProviderController.ensureFlatIndexLoaded(index);
}
});
this.__dataProviderInitialized = true;
if (this.dataProvider) {
this.__synchronizeControllerState();
}
}
/** @private */
_dataProviderFilterChanged(filter) {
if (this.__previousDataProviderFilter === undefined && filter === '') {
this.__previousDataProviderFilter = filter;
return;
}
if (this.__previousDataProviderFilter !== filter) {
this.__previousDataProviderFilter = filter;
this.__keepOverlayOpened = true;
this.size = undefined;
this.clearCache();
this.__keepOverlayOpened = false;
}
}
/** @protected */
_shouldFetchData() {
if (!this.dataProvider) {
return false;
}
return this.opened || (this.filter && this.filter.length);
}
/** @private */
_ensureFirstPage(opened) {
if (!this._shouldFetchData() || !opened) {
return;
}
if (this._forceNextRequest || this.size === undefined) {
this._forceNextRequest = false;
this.__dataProviderController.loadFirstPage();
} else if (this.size > 0) {
this.__dataProviderController.ensureFlatIndexLoaded(0);
}
}
/** @private */
__onDataProviderPageRequested() {
this.loading = true;
}
/** @private */
__onDataProviderPageLoaded() {
// The controller adds new items to the cache through mutation,
// so we need to create a new array to trigger filteredItems observers.
const { rootCache } = this.__dataProviderController;
rootCache.items = [...rootCache.items];
this.__synchronizeControllerState();
if (!this.opened && !this._isInputFocused()) {
this._commitValue();
}
}
/**
* Clears the cached pages and reloads data from dataprovider when needed.
*/
clearCache() {
if (!this.dataProvider) {
return;
}
this.__dataProviderController.clearCache();
this.__synchronizeControllerState();
if (this._shouldFetchData()) {
this._forceNextRequest = false;
this.__dataProviderController.loadFirstPage();
} else {
this._forceNextRequest = true;
}
}
/**
* When the size change originates externally, synchronizes the new size with
* the controller and request a content update to re-render the scroller.
*
* @private
*/
_sizeChanged(size) {
const { rootCache } = this.__dataProviderController;
if (rootCache.size !== size) {
rootCache.size = size;
// The controller adds new placeholders to the cache through mutation,
// so we need to create a new array to trigger filteredItems observers.
rootCache.items = [...rootCache.items];
this.__synchronizeControllerState();
}
}
/**
* When the items change originates externally, synchronizes the new items with
* the controller and requests a content update to re-render the scroller.
*
* @private
* @override
*/
_filteredItemsChanged(items) {
super._filteredItemsChanged(items);
if (this.dataProvider && items) {
const { rootCache } = this.__dataProviderController;
if (rootCache.items !== items) {
rootCache.items = items;
this.__synchronizeControllerState();
}
}
}
/**
* Synchronizes the controller's state with the component, which can be
* out of sync after the controller receives new data from the data provider
* or if the state in the controller is directly manipulated.
*
* @private
*/
__synchronizeControllerState() {
// When the data provider isn't initialized, it means the content update was requested
// by an observer before the `ready()` callback. In such cases, some properties
// in the data provider controller might still be uninitialized, so it's not safe
// to use them to update the component's properties yet. Another content update
// will be requested in the `ready()` callback.
if (this.__dataProviderInitialized && this.dataProvider) {
const { rootCache } = this.__dataProviderController;
this.size = rootCache.size;
this.filteredItems = rootCache.items;
this.loading = this.__dataProviderController.isLoading();
}
}
/** @private */
_pageSizeChanged(pageSize, oldPageSize) {
if (Math.floor(pageSize) !== pageSize || pageSize < 1) {
this.pageSize = oldPageSize;
throw new Error('`pageSize` value must be an integer > 0');
}
this.__dataProviderController.setPageSize(pageSize);
this.clearCache();
}
/** @private */
_dataProviderChanged(dataProvider, oldDataProvider) {
this._ensureItemsOrDataProvider(() => {
this.dataProvider = oldDataProvider;
});
this.__dataProviderController.setDataProvider(dataProvider);
this.clearCache();
}
/** @private */
_ensureItemsOrDataProvider(restoreOldValueCallback) {
if (this.items !== undefined && this.dataProvider !== undefined) {
restoreOldValueCallback();
throw new Error('Using `items` and `dataProvider` together is not supported');
}
}
/** @private */
_warnDataProviderValue(dataProvider, value) {
if (dataProvider && value !== '' && (this.selectedItem === undefined || this.selectedItem === null)) {
const valueIndex = this.__getItemIndexByValue(this.filteredItems, value);
if (valueIndex < 0 || !this._getItemLabel(this.filteredItems[valueIndex])) {
console.warn(
'Warning: unable to determine the label for the provided `value`. ' +
'Nothing to display in the text field. This usually happens when ' +
'setting an initial `value` before any items are returned from ' +
'the `dataProvider` callback. Consider setting `selectedItem` ' +
'instead of `value`',
);
}
}
}
};