Skip to content

Commit bd039b5

Browse files
web-padawanArtur-claude
authored
feat: add itemLabelGenerator to combo-box and multi-select-combo-box (#10242)
Co-authored-by: Artur Signell <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent de1558d commit bd039b5

File tree

9 files changed

+247
-24
lines changed

9 files changed

+247
-24
lines changed

dev/playground/combo-box.html

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,24 +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="label-generator" label="Select product" clear-button-visible></vaadin-combo-box>
65+
</section>
66+
6267
<script type="module">
6368
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-
};
69+
if (comboBox.id === 'label-generator') {
70+
// Example with itemLabelGenerator
71+
comboBox.items = [
72+
{ id: 'p1', name: 'Laptop', category: 'Electronics', price: 999.99, inStock: true },
73+
{ id: 'p2', name: 'Mouse', category: 'Electronics', price: 29.99, inStock: true },
74+
{ id: 'p3', name: 'Keyboard', category: 'Electronics', price: 79.99, inStock: false },
75+
{ id: 'p4', name: 'Monitor', category: 'Electronics', price: 399.99, inStock: true },
76+
{ id: 'p5', name: 'Desk Chair', category: 'Furniture', price: 249.99, inStock: true },
77+
{ id: 'p6', name: 'Standing Desk', category: 'Furniture', price: 599.99, inStock: false },
78+
];
79+
comboBox.itemValuePath = 'id';
80+
comboBox.itemLabelGenerator = (item) => {
81+
const stockStatus = item.inStock ? '✅' : '❌';
82+
return `${item.name} - $${item.price} ${stockStatus}`;
83+
};
84+
comboBox.value = 'p1';
85+
} else {
86+
comboBox.dataProvider = async (params, callback) => {
87+
const index = params.page * params.pageSize;
88+
const response = await fetch(
89+
`https://demo.vaadin.com/demo-data/1.0/filtered-countries?index=${index}&count=${params.pageSize}&filter=${params.filter}`,
90+
);
91+
if (response.ok) {
92+
const { result, size } = await response.json();
93+
// Emulate network latency for demo purpose
94+
setTimeout(() => {
95+
callback(result, size);
96+
}, 1000);
97+
}
98+
};
7799

78-
if (!comboBox.placeholder) {
79-
comboBox.selectedItem = 'Andorra';
100+
if (!comboBox.placeholder) {
101+
comboBox.selectedItem = 'Andorra';
102+
}
80103
}
81104
});
82105
</script>

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export declare class ComboBoxItemsMixinClass<TItem> {
2929
*/
3030
filter: string;
3131

32+
/**
33+
* A function that is used to generate the label for dropdown
34+
* items based on the item. Receives one argument:
35+
* - `item` The item to generate the label for.
36+
*/
37+
itemLabelGenerator: ((item: TItem) => string) | undefined;
38+
3239
/**
3340
* Path for label of the item. If `items` is an array of objects, the
3441
* `itemLabelPath` is used to fetch the displayed string label for each

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ export const ComboBoxItemsMixin = (superClass) =>
7777
sync: true,
7878
},
7979

80+
/**
81+
* A function that is used to generate the label for dropdown
82+
* items based on the item. Receives one argument:
83+
* - `item` The item to generate the label for.
84+
*/
85+
itemLabelGenerator: {
86+
type: Object,
87+
},
88+
8089
/**
8190
* Path for label of the item. If `items` is an array of objects, the
8291
* `itemLabelPath` is used to fetch the displayed string label for each
@@ -122,6 +131,10 @@ export const ComboBoxItemsMixin = (superClass) =>
122131
if (props.has('filter')) {
123132
this._filterChanged(this.filter);
124133
}
134+
135+
if (props.has('itemLabelGenerator')) {
136+
this.requestContentUpdate();
137+
}
125138
}
126139

127140
/**
@@ -161,6 +174,10 @@ export const ComboBoxItemsMixin = (superClass) =>
161174
* @override
162175
*/
163176
_getItemLabel(item) {
177+
if (typeof this.itemLabelGenerator === 'function' && item) {
178+
return this.itemLabelGenerator(item) || '';
179+
}
180+
164181
let label = item && this.itemLabelPath ? get(this.itemLabelPath, item) : undefined;
165182
if (label === undefined || label === null) {
166183
label = item ? item.toString() : '';

packages/combo-box/test/items.test.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,6 @@ describe('items', () => {
142142
});
143143

144144
describe('itemClassNameGenerator', () => {
145-
let comboBox;
146-
147145
beforeEach(async () => {
148146
comboBox = fixtureSync('<vaadin-combo-box></vaadin-combo-box>');
149147
await nextRender();
@@ -194,4 +192,76 @@ describe('items', () => {
194192
expect(items[2].className).to.equal('');
195193
});
196194
});
195+
196+
describe('itemLabelGenerator', () => {
197+
beforeEach(async () => {
198+
comboBox = fixtureSync('<vaadin-combo-box></vaadin-combo-box>');
199+
comboBox.items = [
200+
{ id: 1, name: 'John', surname: 'Doe', age: 30 },
201+
{ id: 2, name: 'Jane', surname: 'Smith', age: 25 },
202+
{ id: 3, name: 'Bob', surname: 'Johnson', age: 35 },
203+
];
204+
comboBox.itemLabelGenerator = (item) => `${item.name} ${item.surname}`;
205+
await nextRender();
206+
});
207+
208+
it('should generate items text content using itemLabelGenerator', async () => {
209+
comboBox.open();
210+
await nextRender();
211+
212+
const items = getAllItems(comboBox);
213+
expect(items[0].textContent).to.equal('John Doe');
214+
expect(items[1].textContent).to.equal('Jane Smith');
215+
expect(items[2].textContent).to.equal('Bob Johnson');
216+
});
217+
218+
it('should set generated label as input value when item is selected', async () => {
219+
comboBox.itemValuePath = 'id';
220+
comboBox.value = 2;
221+
await nextRender();
222+
expect(comboBox.inputElement.value).to.equal('Jane Smith');
223+
});
224+
225+
it('should filter items using generated label', () => {
226+
setInputValue(comboBox, 'john');
227+
228+
expect(comboBox.filteredItems.length).to.equal(2);
229+
expect(comboBox.filteredItems[0]).to.deep.equal(comboBox.items[0]);
230+
expect(comboBox.filteredItems[1]).to.deep.equal(comboBox.items[2]);
231+
});
232+
233+
it('should use itemLabelGenerator over itemLabelPath', async () => {
234+
comboBox.itemLabelPath = 'surname';
235+
comboBox.itemLabelGenerator = (item) => item.name;
236+
comboBox.open();
237+
await nextRender();
238+
239+
const items = getAllItems(comboBox);
240+
expect(items[0].textContent).to.equal('John');
241+
expect(items[1].textContent).to.equal('Jane');
242+
});
243+
244+
it('should accept empty string returned from itemLabelGenerator', async () => {
245+
comboBox.itemLabelGenerator = (item) => (item.id === 2 ? '' : `${item.name} ${item.surname}`);
246+
comboBox.open();
247+
await nextRender();
248+
249+
const items = getAllItems(comboBox);
250+
expect(items[0].textContent).to.equal('John Doe');
251+
expect(items[1].textContent).to.equal('');
252+
expect(items[2].textContent).to.equal('Bob Johnson');
253+
});
254+
255+
it('should update dropdown when itemLabelGenerator changes', async () => {
256+
comboBox.open();
257+
await nextRender();
258+
259+
expect(getFirstItem(comboBox).textContent).to.equal('John Doe');
260+
261+
comboBox.itemLabelGenerator = (item) => `${item.name} (${item.age})`;
262+
await nextRender();
263+
264+
expect(getFirstItem(comboBox).textContent).to.equal('John (30)');
265+
});
266+
});
197267
});

packages/combo-box/test/typings/combo-box.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ assertType<string>(narrowedComboBox.filter);
100100
assertType<TestComboBoxItem[] | undefined>(narrowedComboBox.filteredItems);
101101
assertType<TestComboBoxItem[] | undefined>(narrowedComboBox.items);
102102
assertType<(item: TestComboBoxItem) => string>(narrowedComboBox.itemClassNameGenerator);
103+
assertType<((item: TestComboBoxItem) => string) | undefined>(narrowedComboBox.itemLabelGenerator);
103104
assertType<string | null | undefined>(narrowedComboBox.itemIdPath);
104105
assertType<string>(narrowedComboBox.itemLabelPath);
105106
assertType<string>(narrowedComboBox.itemValuePath);

packages/multi-select-combo-box/test/chips.test.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import { expect } from '@vaadin/chai-plugins';
22
import { sendKeys } from '@vaadin/test-runner-commands';
33
import { fixtureSync, nextRender, nextResize, nextUpdate } from '@vaadin/testing-helpers';
44
import '../src/vaadin-multi-select-combo-box.js';
5+
import { getChips } from './helpers.js';
56

67
describe('chips', () => {
78
let comboBox, inputElement;
89

9-
const getChips = (combo) => combo.querySelectorAll('vaadin-multi-select-combo-box-chip');
10-
1110
const getChipContent = (chip) => chip.shadowRoot.querySelector('[part="label"]').textContent;
1211

1312
beforeEach(async () => {

packages/multi-select-combo-box/test/helpers.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { fire } from '@vaadin/testing-helpers';
2+
13
export const getDataProvider = (allItems) => (params, callback) => {
24
const offset = params.page * params.pageSize;
35
const filteredItems = allItems.filter((item) => item.indexOf(params.filter) > -1);
@@ -31,3 +33,19 @@ export const getAllItems = (comboBox) => {
3133
export const getFirstItem = (comboBox) => {
3234
return comboBox._scroller.querySelector('vaadin-multi-select-combo-box-item');
3335
};
36+
37+
/**
38+
* Emulates the user filling in something in the combo-box input.
39+
*
40+
* @param {Element} comboBox
41+
* @param {string} value
42+
*/
43+
export function setInputValue(comboBox, value) {
44+
comboBox.inputElement.value = value;
45+
fire(comboBox.inputElement, 'input');
46+
}
47+
48+
/**
49+
* Returns all the chips of the combo-box.
50+
*/
51+
export const getChips = (comboBox) => comboBox.querySelectorAll('vaadin-multi-select-combo-box-chip');

packages/multi-select-combo-box/test/items.test.js

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect } from '@vaadin/chai-plugins';
22
import { fixtureSync, nextRender } from '@vaadin/testing-helpers';
33
import sinon from 'sinon';
44
import '../src/vaadin-multi-select-combo-box.js';
5-
import { getAllItems, getFirstItem } from './helpers.js';
5+
import { getAllItems, getChips, getFirstItem, setInputValue } from './helpers.js';
66

77
describe('items', () => {
88
let comboBox;
@@ -81,10 +81,6 @@ describe('items', () => {
8181
});
8282

8383
describe('itemClassNameGenerator', () => {
84-
let comboBox;
85-
86-
const getChips = (combo) => combo.querySelectorAll('vaadin-multi-select-combo-box-chip');
87-
8884
beforeEach(async () => {
8985
comboBox = fixtureSync('<vaadin-multi-select-combo-box></vaadin-multi-select-combo-box>');
9086
await nextRender();
@@ -184,4 +180,95 @@ describe('items', () => {
184180
expect(chips[2].className).to.equal('');
185181
});
186182
});
183+
184+
describe('itemLabelGenerator', () => {
185+
beforeEach(async () => {
186+
comboBox = fixtureSync(`
187+
<vaadin-multi-select-combo-box
188+
auto-expand-horizontally
189+
item-id-path="id"
190+
></vaadin-multi-select-combo-box>
191+
`);
192+
comboBox.items = [
193+
{ id: 1, name: 'John', surname: 'Doe', age: 30 },
194+
{ id: 2, name: 'Jane', surname: 'Smith', age: 25 },
195+
{ id: 3, name: 'Bob', surname: 'Johnson', age: 35 },
196+
];
197+
comboBox.itemLabelGenerator = (item) => `${item.name} ${item.surname}`;
198+
await nextRender();
199+
});
200+
201+
it('should generate items text content using itemLabelGenerator', async () => {
202+
comboBox.open();
203+
await nextRender();
204+
205+
const items = getAllItems(comboBox);
206+
expect(items[0].textContent).to.equal('John Doe');
207+
expect(items[1].textContent).to.equal('Jane Smith');
208+
expect(items[2].textContent).to.equal('Bob Johnson');
209+
});
210+
211+
it('should generate chips text content using itemLabelGenerator', async () => {
212+
comboBox.selectedItems = [comboBox.items[0], comboBox.items[1]];
213+
await nextRender();
214+
215+
const chips = getChips(comboBox);
216+
expect(chips[1].label).to.equal('John Doe');
217+
expect(chips[2].label).to.equal('Jane Smith');
218+
});
219+
220+
it('should filter items using generated label', () => {
221+
setInputValue(comboBox, 'john');
222+
223+
expect(comboBox.filteredItems.length).to.equal(2);
224+
expect(comboBox.filteredItems[0]).to.deep.equal(comboBox.items[0]);
225+
expect(comboBox.filteredItems[1]).to.deep.equal(comboBox.items[2]);
226+
});
227+
228+
it('should use itemLabelGenerator over itemLabelPath', async () => {
229+
comboBox.itemLabelPath = 'surname';
230+
comboBox.itemLabelGenerator = (item) => item.name;
231+
comboBox.open();
232+
await nextRender();
233+
234+
const items = getAllItems(comboBox);
235+
expect(items[0].textContent).to.equal('John');
236+
expect(items[1].textContent).to.equal('Jane');
237+
});
238+
239+
it('should accept empty string returned from itemLabelGenerator', async () => {
240+
comboBox.itemLabelGenerator = (item) => (item.id === 2 ? '' : `${item.name} ${item.surname}`);
241+
comboBox.open();
242+
await nextRender();
243+
244+
const items = getAllItems(comboBox);
245+
expect(items[0].textContent).to.equal('John Doe');
246+
expect(items[1].textContent).to.equal('');
247+
expect(items[2].textContent).to.equal('Bob Johnson');
248+
});
249+
250+
it('should update dropdown items when itemLabelGenerator changes', async () => {
251+
comboBox.open();
252+
await nextRender();
253+
254+
expect(getFirstItem(comboBox).textContent).to.equal('John Doe');
255+
256+
comboBox.itemLabelGenerator = (item) => `${item.name} (${item.age})`;
257+
await nextRender();
258+
259+
expect(getFirstItem(comboBox).textContent).to.equal('John (30)');
260+
});
261+
262+
it('should update chips when itemLabelGenerator changes', async () => {
263+
comboBox.selectedItems = [comboBox.items[0]];
264+
await nextRender();
265+
266+
expect(getChips(comboBox)[1].label).to.equal('John Doe');
267+
268+
comboBox.itemLabelGenerator = (item) => `${item.name} (${item.age})`;
269+
await nextRender();
270+
271+
expect(getChips(comboBox)[1].label).to.equal('John (30)');
272+
});
273+
});
187274
});

packages/multi-select-combo-box/test/typings/multi-select-combo-box.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ assertType<string>(narrowedComboBox.filter);
9393
assertType<TestComboBoxItem[] | undefined>(narrowedComboBox.filteredItems);
9494
assertType<TestComboBoxItem[] | undefined>(narrowedComboBox.items);
9595
assertType<(item: TestComboBoxItem) => string>(narrowedComboBox.itemClassNameGenerator);
96+
assertType<((item: TestComboBoxItem) => string) | undefined>(narrowedComboBox.itemLabelGenerator);
9697
assertType<string | null | undefined>(narrowedComboBox.itemIdPath);
9798
assertType<string>(narrowedComboBox.itemLabelPath);
9899
assertType<string>(narrowedComboBox.itemValuePath);

0 commit comments

Comments
 (0)