Skip to content

Commit dec5247

Browse files
Artur-claude
andcommitted
feat(combo-box): add itemLabelGenerator property
Adds a new itemLabelGenerator function property to vaadin-combo-box that allows customizing how item labels are generated from item objects. This enables users to display custom text for items without modifying the underlying data structure. When set, itemLabelGenerator takes precedence over itemLabelPath, providing more flexibility in label generation while maintaining backward compatibility. - Add itemLabelGenerator property to ComboBoxMixin - Update _getItemLabel to use generator function when provided - Add observer to handle itemLabelGenerator changes - Update TypeScript definitions with ComboBoxItemLabelGenerator type - Add comprehensive tests for the new functionality - Include example usage in dev/combo-box.html 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 166aca1 commit dec5247

File tree

4 files changed

+269
-18
lines changed

4 files changed

+269
-18
lines changed

dev/combo-box.html

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -59,26 +59,47 @@ <h2 class="heading">States</h2>
5959
</vaadin-combo-box>
6060
</section>
6161

62+
<section class="section">
63+
<h2 class="heading">Item Label Generator</h2>
64+
<vaadin-combo-box id="product-combo" label="Products (with itemLabelGenerator)" clear-button-visible>
65+
<vaadin-tooltip slot="tooltip" text="Using itemLabelGenerator to show custom labels"></vaadin-tooltip>
66+
</vaadin-combo-box>
67+
</section>
68+
6269
<script type="module">
6370
document.querySelectorAll('vaadin-combo-box').forEach((comboBox) => {
64-
comboBox.dataProvider = async (params, callback) => {
65-
const index = params.page * params.pageSize;
66-
const response = await fetch(
67-
`https://demo.vaadin.com/demo-data/1.0/filtered-countries?index=${index}&count=${params.pageSize}&filter=${params.filter}`,
68-
);
69-
if (response.ok) {
70-
const { result, size } = await response.json();
71-
// Emulate network latency for demo purpose
72-
setTimeout(() => {
73-
callback(result, size);
74-
}, 1000);
75-
}
76-
};
71+
if (comboBox.id === 'product-combo') {
72+
// Example with itemLabelGenerator
73+
comboBox.items = [
74+
{ sku: 'PRD-001', name: 'Laptop Computer', price: 999.99, value: 'laptop' },
75+
{ sku: 'PRD-002', name: 'Wireless Mouse', price: 29.99, value: 'mouse' },
76+
{ sku: 'PRD-003', name: 'USB Keyboard', price: 49.99, value: 'keyboard' },
77+
{ sku: 'PRD-004', name: 'Monitor 27"', price: 399.99, value: 'monitor' },
78+
{ sku: 'PRD-005', name: 'Desk Lamp', price: 39.99, value: 'lamp' }
79+
];
80+
comboBox.itemValuePath = 'value';
81+
// Generate labels showing SKU, name and price
82+
comboBox.itemLabelGenerator = (item) => `[${item.sku}] ${item.name} - $${item.price}`;
83+
} else {
84+
comboBox.dataProvider = async (params, callback) => {
85+
const index = params.page * params.pageSize;
86+
const response = await fetch(
87+
`https://demo.vaadin.com/demo-data/1.0/filtered-countries?index=${index}&count=${params.pageSize}&filter=${params.filter}`,
88+
);
89+
if (response.ok) {
90+
const { result, size } = await response.json();
91+
// Emulate network latency for demo purpose
92+
setTimeout(() => {
93+
callback(result, size);
94+
}, 1000);
95+
}
96+
};
7797

78-
if (!comboBox.placeholder) {
79-
comboBox.selectedItem = 'Andorra';
98+
if (!comboBox.placeholder) {
99+
comboBox.selectedItem = 'Andorra';
100+
}
80101
}
81102
});
82103
</script>
83104
</body>
84-
</html>
105+
</html>

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export type { ComboBoxDefaultItem, ComboBoxItemModel };
1717

1818
export type ComboBoxRenderer<TItem> = ComboBoxItemRenderer<TItem, ComboBox<TItem>>;
1919

20+
export type ComboBoxItemLabelGenerator<TItem> = (item: TItem) => string;
21+
2022
export declare function ComboBoxMixin<TItem, T extends Constructor<HTMLElement>>(
2123
base: T,
2224
): Constructor<ComboBoxBaseMixinClass> &
@@ -79,6 +81,13 @@ export declare class ComboBoxMixinClass<TItem> {
7981
*/
8082
selectedItem: TItem | null | undefined;
8183

84+
/**
85+
* A function to generate the label for each item based on the item.
86+
* The function receives the item as an argument and should return a string.
87+
* When set, it overrides the `itemLabelPath` property.
88+
*/
89+
itemLabelGenerator: ComboBoxItemLabelGenerator<TItem> | undefined;
90+
8291
/**
8392
* Path for the id of the item. If `items` is an array of objects,
8493
* the `itemIdPath` is used to compare and identify the same item
@@ -95,4 +104,4 @@ export declare class ComboBoxMixinClass<TItem> {
95104
* It is not guaranteed that the update happens immediately (synchronously) after it is requested.
96105
*/
97106
requestContentUpdate(): void;
98-
}
107+
}

packages/combo-box/src/vaadin-combo-box-mixin.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ export const ComboBoxMixin = (superClass) =>
8787
type: Object,
8888
},
8989

90+
/**
91+
* A function to generate the label for each item based on the item.
92+
* The function receives the item as an argument and should return a string.
93+
* When set, it overrides the `itemLabelPath` property.
94+
*
95+
* @type {ComboBoxItemLabelGenerator | undefined}
96+
*/
97+
itemLabelGenerator: {
98+
type: Function,
99+
observer: '_itemLabelGeneratorChanged',
100+
},
101+
90102
/**
91103
* Path for the id of the item. If `items` is an array of objects,
92104
* the `itemIdPath` is used to compare and identify the same item
@@ -110,7 +122,7 @@ export const ComboBoxMixin = (superClass) =>
110122
static get observers() {
111123
return [
112124
'_openedOrItemsChanged(opened, _dropdownItems, loading, __keepOverlayOpened)',
113-
'_selectedItemChanged(selectedItem, itemValuePath, itemLabelPath)',
125+
'_selectedItemChanged(selectedItem, itemValuePath, itemLabelPath, itemLabelGenerator)',
114126
'_updateScroller(opened, _dropdownItems, _focusedIndex, _theme)',
115127
];
116128
}
@@ -387,6 +399,35 @@ export const ComboBoxMixin = (superClass) =>
387399
event.stopPropagation();
388400
}
389401

402+
/**
403+
* Override method from `ComboBoxBaseMixin` to use itemLabelGenerator.
404+
* @protected
405+
* @override
406+
*/
407+
_getItemLabel(item) {
408+
// Use itemLabelGenerator if provided
409+
if (this.itemLabelGenerator) {
410+
return this.itemLabelGenerator(item) || '';
411+
}
412+
// Fall back to base implementation
413+
return super._getItemLabel(item);
414+
}
415+
416+
/** @private */
417+
_itemLabelGeneratorChanged(itemLabelGenerator) {
418+
if (itemLabelGenerator !== undefined && typeof itemLabelGenerator !== 'function') {
419+
console.error('You should set itemLabelGenerator to a valid function');
420+
}
421+
422+
// Re-render items and update input value if needed
423+
if (this._scroller) {
424+
this._scroller.requestContentUpdate();
425+
}
426+
if (this.selectedItem) {
427+
this._inputElementValue = this._getItemLabel(this.selectedItem);
428+
}
429+
}
430+
390431
/**
391432
* Override method from `ComboBoxBaseMixin` to handle reverting value.
392433
* @protected
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { expect } from '@vaadin/chai-plugins';
2+
import { fixtureSync, nextFrame, nextRender } from '@vaadin/testing-helpers';
3+
import '../src/vaadin-combo-box.js';
4+
5+
describe('combo-box itemLabelGenerator', () => {
6+
let comboBox;
7+
8+
beforeEach(async () => {
9+
comboBox = fixtureSync('<vaadin-combo-box></vaadin-combo-box>');
10+
await nextFrame();
11+
});
12+
13+
describe('basic functionality', () => {
14+
beforeEach(async () => {
15+
comboBox.items = [
16+
{ label: 'Option 1', value: '1', customProp: 'Custom 1' },
17+
{ label: 'Option 2', value: '2', customProp: 'Custom 2' },
18+
{ label: 'Option 3', value: '3', customProp: 'Custom 3' },
19+
];
20+
await nextFrame();
21+
});
22+
23+
it('should use item label by default', async () => {
24+
comboBox.opened = true;
25+
await nextRender();
26+
27+
const items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
28+
expect(items[0].textContent.trim()).to.equal('Option 1');
29+
expect(items[1].textContent.trim()).to.equal('Option 2');
30+
expect(items[2].textContent.trim()).to.equal('Option 3');
31+
});
32+
33+
it('should use itemLabelGenerator when provided', async () => {
34+
comboBox.itemLabelGenerator = (item) => `${item.customProp} - ${item.value}`;
35+
await nextFrame();
36+
37+
comboBox.opened = true;
38+
await nextRender();
39+
40+
const items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
41+
expect(items[0].textContent.trim()).to.equal('Custom 1 - 1');
42+
expect(items[1].textContent.trim()).to.equal('Custom 2 - 2');
43+
expect(items[2].textContent.trim()).to.equal('Custom 3 - 3');
44+
});
45+
46+
it('should update items when itemLabelGenerator changes', async () => {
47+
comboBox.itemLabelGenerator = (item) => item.value;
48+
await nextFrame();
49+
50+
comboBox.opened = true;
51+
await nextRender();
52+
53+
let items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
54+
expect(items[0].textContent.trim()).to.equal('1');
55+
56+
comboBox.itemLabelGenerator = (item) => `Value: ${item.value}`;
57+
await nextFrame();
58+
59+
items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
60+
expect(items[0].textContent.trim()).to.equal('Value: 1');
61+
});
62+
63+
it('should handle undefined return from itemLabelGenerator', async () => {
64+
comboBox.itemLabelGenerator = () => undefined;
65+
await nextFrame();
66+
67+
comboBox.opened = true;
68+
await nextRender();
69+
70+
const items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
71+
expect(items[0].textContent.trim()).to.equal('');
72+
});
73+
74+
it('should handle null return from itemLabelGenerator', async () => {
75+
comboBox.itemLabelGenerator = () => null;
76+
await nextFrame();
77+
78+
comboBox.opened = true;
79+
await nextRender();
80+
81+
const items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
82+
expect(items[0].textContent.trim()).to.equal('');
83+
});
84+
85+
it('should override itemLabelPath when itemLabelGenerator is set', async () => {
86+
comboBox.itemLabelPath = 'customProp';
87+
comboBox.itemLabelGenerator = (item) => `Generated: ${item.value}`;
88+
await nextFrame();
89+
90+
comboBox.opened = true;
91+
await nextRender();
92+
93+
const items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
94+
expect(items[0].textContent.trim()).to.equal('Generated: 1');
95+
});
96+
});
97+
98+
describe('with complex items', () => {
99+
it('should work with nested object properties', async () => {
100+
comboBox.items = [
101+
{ data: { name: 'John', age: 30 }, value: '1' },
102+
{ data: { name: 'Jane', age: 25 }, value: '2' },
103+
];
104+
105+
comboBox.itemLabelGenerator = (item) => `${item.data.name} (${item.data.age})`;
106+
await nextFrame();
107+
108+
comboBox.opened = true;
109+
await nextRender();
110+
111+
const items = comboBox._scroller.querySelectorAll('vaadin-combo-box-item');
112+
expect(items[0].textContent.trim()).to.equal('John (30)');
113+
expect(items[1].textContent.trim()).to.equal('Jane (25)');
114+
});
115+
});
116+
117+
describe('selected value display', () => {
118+
it('should show generated label for selected item in input', async () => {
119+
comboBox.items = [
120+
{ label: 'Option 1', value: '1', displayName: 'First' },
121+
{ label: 'Option 2', value: '2', displayName: 'Second' },
122+
];
123+
124+
comboBox.itemLabelGenerator = (item) => item.displayName;
125+
comboBox.value = '1';
126+
await nextFrame();
127+
128+
expect(comboBox.inputElement.value).to.equal('First');
129+
});
130+
131+
it('should update input value when itemLabelGenerator changes and item is selected', async () => {
132+
comboBox.items = [{ label: 'Option 1', value: '1', customProp: 'Custom 1' }];
133+
comboBox.value = '1';
134+
await nextFrame();
135+
136+
expect(comboBox.inputElement.value).to.equal('Option 1');
137+
138+
comboBox.itemLabelGenerator = (item) => item.customProp;
139+
await nextFrame();
140+
141+
expect(comboBox.inputElement.value).to.equal('Custom 1');
142+
});
143+
});
144+
145+
describe('filtering', () => {
146+
beforeEach(() => {
147+
comboBox.items = [
148+
{ label: 'Apple', value: 'apple', code: 'APL' },
149+
{ label: 'Banana', value: 'banana', code: 'BNN' },
150+
{ label: 'Cherry', value: 'cherry', code: 'CHR' },
151+
];
152+
});
153+
154+
it('should filter items based on generated labels', async () => {
155+
comboBox.itemLabelGenerator = (item) => `${item.code} - ${item.label}`;
156+
await nextFrame();
157+
158+
comboBox.opened = true;
159+
comboBox.filter = 'APL';
160+
await nextFrame();
161+
162+
const items = comboBox.filteredItems;
163+
expect(items).to.have.lengthOf(1);
164+
expect(items[0].value).to.equal('apple');
165+
});
166+
167+
it('should filter with custom generated labels', async () => {
168+
comboBox.itemLabelGenerator = (item) => item.code;
169+
await nextFrame();
170+
171+
comboBox.opened = true;
172+
comboBox.filter = 'BNN';
173+
await nextFrame();
174+
175+
const items = comboBox.filteredItems;
176+
expect(items).to.have.lengthOf(1);
177+
expect(items[0].value).to.equal('banana');
178+
});
179+
});
180+
});

0 commit comments

Comments
 (0)