Skip to content

Commit 157c943

Browse files
authored
refactor: extract combo-box items logic into separate mixin (#9805)
1 parent 460cf83 commit 157c943

File tree

6 files changed

+336
-276
lines changed

6 files changed

+336
-276
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2015 - 2025 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import type { Constructor } from '@open-wc/dedupe-mixin';
7+
import type { ComboBoxBaseMixinClass } from './vaadin-combo-box-base-mixin.js';
8+
9+
export declare function ComboBoxItemsMixin<TItem, T extends Constructor<HTMLElement>>(
10+
base: T,
11+
): Constructor<ComboBoxBaseMixinClass> & Constructor<ComboBoxItemsMixinClass<TItem>> & T;
12+
13+
export declare class ComboBoxItemsMixinClass<TItem> {
14+
/**
15+
* A full set of items to filter the visible options from.
16+
* The items can be of either `String` or `Object` type.
17+
*/
18+
items: TItem[] | undefined;
19+
20+
/**
21+
* A subset of items, filtered based on the user input. Filtered items
22+
* can be assigned directly to omit the internal filtering functionality.
23+
* The items can be of either `String` or `Object` type.
24+
*/
25+
filteredItems: TItem[] | undefined;
26+
27+
/**
28+
* Filtering string the user has typed into the input field.
29+
*/
30+
filter: string;
31+
32+
/**
33+
* Path for label of the item. If `items` is an array of objects, the
34+
* `itemLabelPath` is used to fetch the displayed string label for each
35+
* item.
36+
*
37+
* The item label is also used for matching items when processing user
38+
* input, i.e., for filtering and selecting items.
39+
* @attr {string} item-label-path
40+
*/
41+
itemLabelPath: string;
42+
43+
/**
44+
* Path for the value of the item. If `items` is an array of objects, the
45+
* `itemValuePath:` is used to fetch the string value for the selected
46+
* item.
47+
*
48+
* The item value is used in the `value` property of the combo box,
49+
* to provide the form value.
50+
* @attr {string} item-value-path
51+
*/
52+
itemValuePath: string;
53+
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2015 - 2025 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import { get } from '@vaadin/component-base/src/path-utils.js';
7+
import { ComboBoxBaseMixin } from './vaadin-combo-box-base-mixin.js';
8+
import { ComboBoxPlaceholder } from './vaadin-combo-box-placeholder.js';
9+
10+
/**
11+
* Checks if the value is supported as an item value in this control.
12+
*
13+
* @param {unknown} value
14+
* @return {boolean}
15+
*/
16+
function isValidValue(value) {
17+
return value !== undefined && value !== null;
18+
}
19+
20+
/**
21+
* Returns the index of the first item that satisfies the provided testing function
22+
* ignoring placeholder items.
23+
*
24+
* @param {Array<ComboBoxItem | string>} items
25+
* @param {Function} callback
26+
* @return {number}
27+
*/
28+
function findItemIndex(items, callback) {
29+
return items.findIndex((item) => {
30+
if (item instanceof ComboBoxPlaceholder) {
31+
return false;
32+
}
33+
34+
return callback(item);
35+
});
36+
}
37+
38+
/**
39+
* @polymerMixin
40+
* @mixes ComboBoxBaseMixin
41+
*/
42+
export const ComboBoxItemsMixin = (superClass) =>
43+
class ComboBoxItemsMixinClass extends ComboBoxBaseMixin(superClass) {
44+
static get properties() {
45+
return {
46+
/**
47+
* A full set of items to filter the visible options from.
48+
* The items can be of either `String` or `Object` type.
49+
* @type {!Array<!ComboBoxItem | string> | undefined}
50+
*/
51+
items: {
52+
type: Array,
53+
sync: true,
54+
observer: '_itemsChanged',
55+
},
56+
57+
/**
58+
* A subset of items, filtered based on the user input. Filtered items
59+
* can be assigned directly to omit the internal filtering functionality.
60+
* The items can be of either `String` or `Object` type.
61+
* @type {!Array<!ComboBoxItem | string> | undefined}
62+
*/
63+
filteredItems: {
64+
type: Array,
65+
observer: '_filteredItemsChanged',
66+
sync: true,
67+
},
68+
69+
/**
70+
* Filtering string the user has typed into the input field.
71+
* @type {string}
72+
*/
73+
filter: {
74+
type: String,
75+
value: '',
76+
notify: true,
77+
sync: true,
78+
},
79+
80+
/**
81+
* Path for label of the item. If `items` is an array of objects, the
82+
* `itemLabelPath` is used to fetch the displayed string label for each
83+
* item.
84+
*
85+
* The item label is also used for matching items when processing user
86+
* input, i.e., for filtering and selecting items.
87+
* @attr {string} item-label-path
88+
* @type {string}
89+
*/
90+
itemLabelPath: {
91+
type: String,
92+
value: 'label',
93+
observer: '_itemLabelPathChanged',
94+
sync: true,
95+
},
96+
97+
/**
98+
* Path for the value of the item. If `items` is an array of objects, the
99+
* `itemValuePath:` is used to fetch the string value for the selected
100+
* item.
101+
*
102+
* The item value is used in the `value` property of the combo box,
103+
* to provide the form value.
104+
* @attr {string} item-value-path
105+
* @type {string}
106+
*/
107+
itemValuePath: {
108+
type: String,
109+
value: 'value',
110+
sync: true,
111+
},
112+
};
113+
}
114+
115+
/**
116+
* @param {Object} props
117+
* @protected
118+
*/
119+
updated(props) {
120+
super.updated(props);
121+
122+
if (props.has('filter')) {
123+
this._filterChanged(this.filter);
124+
}
125+
}
126+
127+
/**
128+
* Override an event listener from `ComboBoxBaseMixin` to handle
129+
* batched setting of both `opened` and `filter` properties.
130+
* @param {!Event} event
131+
* @protected
132+
* @override
133+
*/
134+
_onInput(event) {
135+
const filter = this._inputElementValue;
136+
137+
// When opening dropdown on user input, both `opened` and `filter` properties are set.
138+
// Perform a batched property update instead of relying on sync property observers.
139+
// This is necessary to avoid an extra data-provider request for loading first page.
140+
const props = {};
141+
142+
if (this.filter === filter) {
143+
// Filter and input value might get out of sync, while keyboard navigating for example.
144+
// Afterwards, input value might be changed to the same value as used in filtering.
145+
// In situation like these, we need to make sure all the filter changes handlers are run.
146+
this._filterChanged(this.filter);
147+
} else {
148+
props.filter = filter;
149+
}
150+
151+
if (!this.opened && !this._isClearButton(event) && !this.autoOpenDisabled) {
152+
props.opened = true;
153+
}
154+
155+
this.setProperties(props);
156+
}
157+
158+
/**
159+
* Override method from `ComboBoxBaseMixin` to handle item label path.
160+
* @protected
161+
* @override
162+
*/
163+
_getItemLabel(item) {
164+
let label = item && this.itemLabelPath ? get(this.itemLabelPath, item) : undefined;
165+
if (label === undefined || label === null) {
166+
label = item ? item.toString() : '';
167+
}
168+
return label;
169+
}
170+
171+
/** @protected */
172+
_getItemValue(item) {
173+
let value = item && this.itemValuePath ? get(this.itemValuePath, item) : undefined;
174+
if (value === undefined) {
175+
value = item ? item.toString() : '';
176+
}
177+
return value;
178+
}
179+
180+
/** @private */
181+
_itemLabelPathChanged(itemLabelPath) {
182+
if (typeof itemLabelPath !== 'string') {
183+
console.error('You should set itemLabelPath to a valid string');
184+
}
185+
}
186+
187+
/** @private */
188+
_filterChanged(filter) {
189+
// Scroll to the top of the list whenever the filter changes.
190+
this._scrollIntoView(0);
191+
192+
this._focusedIndex = -1;
193+
194+
if (this.items) {
195+
this.filteredItems = this._filterItems(this.items, filter);
196+
} else {
197+
// With certain use cases (e. g., external filtering), `items` are
198+
// undefined. Filtering is unnecessary per se, but the filteredItems
199+
// observer should still be invoked to update focused item.
200+
this._filteredItemsChanged(this.filteredItems);
201+
}
202+
}
203+
204+
/** @private */
205+
_itemsChanged(items, oldItems) {
206+
this._ensureItemsOrDataProvider(() => {
207+
this.items = oldItems;
208+
});
209+
210+
if (items) {
211+
this.filteredItems = items.slice(0);
212+
} else if (oldItems) {
213+
// Only clear filteredItems if the component had items previously but got cleared
214+
this.filteredItems = null;
215+
}
216+
}
217+
218+
/** @private */
219+
_filteredItemsChanged(filteredItems) {
220+
this._setDropdownItems(filteredItems);
221+
}
222+
223+
/**
224+
* Provide items to be rendered in the dropdown.
225+
* Override to provide actual implementation.
226+
* @protected
227+
*/
228+
_setDropdownItems() {
229+
// To be implemented
230+
}
231+
232+
/** @private */
233+
_filterItems(arr, filter) {
234+
if (!arr) {
235+
return arr;
236+
}
237+
238+
const filteredItems = arr.filter((item) => {
239+
filter = filter ? filter.toString().toLowerCase() : '';
240+
// Check if item contains input value.
241+
return this._getItemLabel(item).toString().toLowerCase().indexOf(filter) > -1;
242+
});
243+
244+
return filteredItems;
245+
}
246+
247+
/**
248+
* Returns the first item that matches the provided value.
249+
* @protected
250+
*/
251+
__getItemIndexByValue(items, value) {
252+
if (!items || !isValidValue(value)) {
253+
return -1;
254+
}
255+
256+
return findItemIndex(items, (item) => {
257+
return this._getItemValue(item) === value;
258+
});
259+
}
260+
261+
/**
262+
* Returns the first item that matches the provided label.
263+
* Labels are matched against each other case insensitively.
264+
* @protected
265+
*/
266+
__getItemIndexByLabel(items, label) {
267+
if (!items || !label) {
268+
return -1;
269+
}
270+
271+
return findItemIndex(items, (item) => {
272+
return this._getItemLabel(item).toString().toLowerCase() === label.toString().toLowerCase();
273+
});
274+
}
275+
};

packages/combo-box/src/vaadin-combo-box-mixin.d.ts

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,6 @@ export declare class ComboBoxMixinClass<TItem> {
4444
*/
4545
renderer: ComboBoxRenderer<TItem> | null | undefined;
4646

47-
/**
48-
* A full set of items to filter the visible options from.
49-
* The items can be of either `String` or `Object` type.
50-
*/
51-
items: TItem[] | undefined;
52-
5347
/**
5448
* A function used to generate CSS class names for dropdown
5549
* items based on the item. The return value should be the
@@ -67,13 +61,6 @@ export declare class ComboBoxMixinClass<TItem> {
6761
*/
6862
allowCustomValue: boolean;
6963

70-
/**
71-
* A subset of items, filtered based on the user input. Filtered items
72-
* can be assigned directly to omit the internal filtering functionality.
73-
* The items can be of either `String` or `Object` type.
74-
*/
75-
filteredItems: TItem[] | undefined;
76-
7764
/**
7865
* The `String` value for the selected item of the combo box.
7966
*
@@ -89,38 +76,11 @@ export declare class ComboBoxMixinClass<TItem> {
8976
*/
9077
loading: boolean;
9178

92-
/**
93-
* Filtering string the user has typed into the input field.
94-
*/
95-
filter: string;
96-
9779
/**
9880
* The selected item from the `items` array.
9981
*/
10082
selectedItem: TItem | null | undefined;
10183

102-
/**
103-
* Path for label of the item. If `items` is an array of objects, the
104-
* `itemLabelPath` is used to fetch the displayed string label for each
105-
* item.
106-
*
107-
* The item label is also used for matching items when processing user
108-
* input, i.e., for filtering and selecting items.
109-
* @attr {string} item-label-path
110-
*/
111-
itemLabelPath: string;
112-
113-
/**
114-
* Path for the value of the item. If `items` is an array of objects, the
115-
* `itemValuePath:` is used to fetch the string value for the selected
116-
* item.
117-
*
118-
* The item value is used in the `value` property of the combo box,
119-
* to provide the form value.
120-
* @attr {string} item-value-path
121-
*/
122-
itemValuePath: string;
123-
12484
/**
12585
* Path for the id of the item. If `items` is an array of objects,
12686
* the `itemIdPath` is used to compare and identify the same item

0 commit comments

Comments
 (0)