Skip to content

Commit 6313bcc

Browse files
Artur-claude
andcommitted
feat(multi-select-combo-box): add itemLabelGenerator property
Allows custom label generation for items in multi-select-combo-box by providing a function that receives an item and returns a string. This property overrides itemLabelPath when set. - Added itemLabelGenerator property to mixin with observer - Updated chip creation to use generated labels - Added TypeScript type definition - Created comprehensive tests - Added new example in dev page with team member selector Related to #8333 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent dec5247 commit 6313bcc

File tree

4 files changed

+230
-2
lines changed

4 files changed

+230
-2
lines changed

dev/multi-select-combo-box.html

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,18 @@
8484
</vaadin-multi-select-combo-box>
8585
</div>
8686

87+
<div style="margin-top: 2rem;">
88+
<vaadin-multi-select-combo-box
89+
id="team-combo"
90+
label="Select team members (with itemLabelGenerator)"
91+
clear-button-visible
92+
>
93+
<vaadin-tooltip slot="tooltip" text="Using itemLabelGenerator to show custom labels"></vaadin-tooltip>
94+
</vaadin-multi-select-combo-box>
95+
</div>
96+
8797
<script>
88-
const comboBox = document.querySelector('vaadin-multi-select-combo-box');
98+
const comboBox = document.querySelector('vaadin-multi-select-combo-box:not(#team-combo)');
8999

90100
comboBox.items = [
91101
'Hydrogen',
@@ -164,6 +174,23 @@
164174
console.log('change', comboBox.selectedItems);
165175
});
166176

177+
// Example with itemLabelGenerator
178+
const teamCombo = document.querySelector('#team-combo');
179+
teamCombo.items = [
180+
{ id: 1, name: 'John Doe', email: '[email protected]', role: 'Developer' },
181+
{ id: 2, name: 'Jane Smith', email: '[email protected]', role: 'Designer' },
182+
{ id: 3, name: 'Bob Johnson', email: '[email protected]', role: 'Manager' },
183+
{ id: 4, name: 'Alice Williams', email: '[email protected]', role: 'Developer' },
184+
{ id: 5, name: 'Charlie Brown', email: '[email protected]', role: 'QA Engineer' },
185+
{ id: 6, name: 'Diana Davis', email: '[email protected]', role: 'Product Owner' },
186+
{ id: 7, name: 'Eric Evans', email: '[email protected]', role: 'Developer' },
187+
{ id: 8, name: 'Fiona Foster', email: '[email protected]', role: 'Designer' }
188+
];
189+
teamCombo.itemIdPath = 'id';
190+
teamCombo.itemValuePath = 'id';
191+
// Generate labels showing name, role and email
192+
teamCombo.itemLabelGenerator = (item) => `${item.name} - ${item.role} (${item.email})`;
193+
167194
/*
168195
comboBox.items = [
169196
{ id: 1, name: 'Hydrogen' },

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export declare function MultiSelectComboBoxMixin<TItem, T extends Constructor<HT
6060
Constructor<ValidateMixinClass> &
6161
T;
6262

63+
export type MultiSelectComboBoxItemLabelGenerator<TItem> = (item: TItem) => string;
64+
6365
export declare class MultiSelectComboBoxMixinClass<TItem> {
6466
/**
6567
* Set to true to auto expand horizontally, causing input field to
@@ -90,6 +92,13 @@ export declare class MultiSelectComboBoxMixinClass<TItem> {
9092
*/
9193
itemClassNameGenerator: (item: TItem) => string;
9294

95+
/**
96+
* A function to generate the label for each item based on the item.
97+
* The function receives the item as an argument and should return a string.
98+
* When set, it overrides the `itemLabelPath` property.
99+
*/
100+
itemLabelGenerator: MultiSelectComboBoxItemLabelGenerator<TItem> | undefined;
101+
93102
/**
94103
* Path for the id of the item, used to detect whether the item is selected.
95104
* @attr {string} item-id-path
@@ -117,6 +126,8 @@ export declare class MultiSelectComboBoxMixinClass<TItem> {
117126
* total: '{count} items selected',
118127
* }
119128
* ```
129+
* @type {!MultiSelectComboBoxI18n}
130+
* @default {English/US}
120131
*/
121132
i18n: MultiSelectComboBoxI18n;
122133

@@ -175,4 +186,4 @@ export declare class MultiSelectComboBoxMixinClass<TItem> {
175186
* It is not guaranteed that the update happens immediately (synchronously) after it is requested.
176187
*/
177188
requestContentUpdate(): void;
178-
}
189+
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ export const MultiSelectComboBoxMixin = (superClass) =>
6363
sync: true,
6464
},
6565

66+
/**
67+
* A function to generate the label for each item based on the item.
68+
* The function receives the item as an argument and should return a string.
69+
* When set, it overrides the `itemLabelPath` property.
70+
*
71+
* @type {MultiSelectComboBoxItemLabelGenerator | undefined}
72+
*/
73+
itemLabelGenerator: {
74+
type: Function,
75+
observer: '_itemLabelGeneratorChanged',
76+
},
77+
6678
/**
6779
* Path for the id of the item, used to detect whether the item is selected.
6880
* @attr {string} item-id-path
@@ -713,6 +725,14 @@ export const MultiSelectComboBoxMixin = (superClass) =>
713725
return items.map((item) => this._getItemLabel(item)).join(', ');
714726
}
715727

728+
/** @private */
729+
_itemLabelGeneratorChanged() {
730+
// Update existing chips when the generator changes
731+
if (this._hasValue) {
732+
this.__updateChips();
733+
}
734+
}
735+
716736
/** @private */
717737
_findIndex(item, selectedItems, itemIdPath) {
718738
if (itemIdPath && item) {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { expect } from '@vaadin/chai-plugins';
2+
import { fixtureSync, nextFrame, nextRender } from '@vaadin/testing-helpers';
3+
import '../src/vaadin-multi-select-combo-box.js';
4+
5+
describe('multi-select-combo-box itemLabelGenerator', () => {
6+
let comboBox;
7+
8+
beforeEach(async () => {
9+
comboBox = fixtureSync('<vaadin-multi-select-combo-box></vaadin-multi-select-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 internalComboBox = comboBox.$.comboBox;
28+
const items = internalComboBox._scroller.querySelectorAll('vaadin-multi-select-combo-box-item');
29+
expect(items[0].textContent.trim()).to.equal('Option 1');
30+
expect(items[1].textContent.trim()).to.equal('Option 2');
31+
expect(items[2].textContent.trim()).to.equal('Option 3');
32+
});
33+
34+
it('should use itemLabelGenerator when provided', async () => {
35+
comboBox.itemLabelGenerator = (item) => `${item.customProp} - ${item.value}`;
36+
await nextFrame();
37+
38+
comboBox.opened = true;
39+
await nextRender();
40+
41+
const internalComboBox = comboBox.$.comboBox;
42+
const items = internalComboBox._scroller.querySelectorAll('vaadin-multi-select-combo-box-item');
43+
expect(items[0].textContent.trim()).to.equal('Custom 1 - 1');
44+
expect(items[1].textContent.trim()).to.equal('Custom 2 - 2');
45+
expect(items[2].textContent.trim()).to.equal('Custom 3 - 3');
46+
});
47+
48+
it('should override itemLabelPath when itemLabelGenerator is set', async () => {
49+
comboBox.itemLabelPath = 'customProp';
50+
comboBox.itemLabelGenerator = (item) => `Generated: ${item.value}`;
51+
await nextFrame();
52+
53+
comboBox.opened = true;
54+
await nextRender();
55+
56+
const internalComboBox = comboBox.$.comboBox;
57+
const items = internalComboBox._scroller.querySelectorAll('vaadin-multi-select-combo-box-item');
58+
expect(items[0].textContent.trim()).to.equal('Generated: 1');
59+
});
60+
});
61+
62+
describe('selected items display', () => {
63+
beforeEach(async () => {
64+
comboBox.items = [
65+
{ label: 'Option 1', value: '1', displayName: 'First' },
66+
{ label: 'Option 2', value: '2', displayName: 'Second' },
67+
{ label: 'Option 3', value: '3', displayName: 'Third' },
68+
];
69+
comboBox.itemLabelPath = 'label';
70+
await nextFrame();
71+
});
72+
73+
it('should show generated labels in chips', async () => {
74+
comboBox.itemLabelGenerator = (item) => item.displayName;
75+
comboBox.selectedItems = [comboBox.items[0], comboBox.items[1]];
76+
await nextFrame();
77+
78+
const chips = Array.from(comboBox.querySelectorAll('vaadin-multi-select-combo-box-chip')).filter(
79+
(chip) => !chip.hasAttribute('hidden'),
80+
);
81+
expect(chips).to.have.lengthOf(2);
82+
expect(chips[0].shadowRoot.querySelector('[part="label"]').textContent).to.equal('First');
83+
expect(chips[1].shadowRoot.querySelector('[part="label"]').textContent).to.equal('Second');
84+
});
85+
86+
it('should update chip labels when itemLabelGenerator changes', async () => {
87+
comboBox.selectedItems = [comboBox.items[0]];
88+
await nextFrame();
89+
90+
const chips = Array.from(comboBox.querySelectorAll('vaadin-multi-select-combo-box-chip')).filter(
91+
(chip) => !chip.hasAttribute('hidden') && !chip.overflow,
92+
);
93+
expect(chips[0].shadowRoot.querySelector('[part="label"]').textContent).to.equal('Option 1');
94+
95+
comboBox.itemLabelGenerator = (item) => item.displayName;
96+
await nextFrame();
97+
98+
const chipsAfter = Array.from(comboBox.querySelectorAll('vaadin-multi-select-combo-box-chip')).filter(
99+
(chip) => !chip.hasAttribute('hidden') && !chip.overflow,
100+
);
101+
expect(chipsAfter[0].shadowRoot.querySelector('[part="label"]').textContent).to.equal('First');
102+
});
103+
});
104+
105+
describe('with complex items', () => {
106+
it('should work with nested object properties', async () => {
107+
comboBox.items = [
108+
{ data: { name: 'John', age: 30 }, value: '1' },
109+
{ data: { name: 'Jane', age: 25 }, value: '2' },
110+
];
111+
112+
comboBox.itemLabelGenerator = (item) => `${item.data.name} (${item.data.age})`;
113+
await nextFrame();
114+
115+
comboBox.opened = true;
116+
await nextRender();
117+
118+
const internalComboBox = comboBox.$.comboBox;
119+
const items = internalComboBox._scroller.querySelectorAll('vaadin-multi-select-combo-box-item');
120+
expect(items[0].textContent.trim()).to.equal('John (30)');
121+
expect(items[1].textContent.trim()).to.equal('Jane (25)');
122+
});
123+
});
124+
125+
describe('filtering', () => {
126+
beforeEach(() => {
127+
comboBox.items = [
128+
{ label: 'Apple', value: 'apple', code: 'APL' },
129+
{ label: 'Banana', value: 'banana', code: 'BNN' },
130+
{ label: 'Cherry', value: 'cherry', code: 'CHR' },
131+
];
132+
});
133+
134+
it('should filter items based on generated labels', async () => {
135+
comboBox.itemLabelGenerator = (item) => `${item.code} - ${item.label}`;
136+
await nextFrame();
137+
138+
comboBox.opened = true;
139+
comboBox.filter = 'APL';
140+
await nextFrame();
141+
142+
const items = comboBox.filteredItems;
143+
expect(items).to.have.lengthOf(1);
144+
expect(items[0].value).to.equal('apple');
145+
});
146+
});
147+
148+
describe('readonly mode', () => {
149+
it('should show generated labels in chips when readonly', async () => {
150+
comboBox.items = [
151+
{ label: 'Option 1', value: '1', displayName: 'First' },
152+
{ label: 'Option 2', value: '2', displayName: 'Second' },
153+
];
154+
comboBox.itemLabelPath = 'label';
155+
comboBox.itemLabelGenerator = (item) => item.displayName;
156+
comboBox.selectedItems = [comboBox.items[0], comboBox.items[1]];
157+
comboBox.readonly = true;
158+
await nextFrame();
159+
160+
const chips = Array.from(comboBox.querySelectorAll('vaadin-multi-select-combo-box-chip')).filter(
161+
(chip) => !chip.hasAttribute('hidden') && !chip.overflow,
162+
);
163+
expect(chips).to.have.lengthOf(2);
164+
expect(chips[0].shadowRoot.querySelector('[part="label"]').textContent).to.equal('First');
165+
expect(chips[1].shadowRoot.querySelector('[part="label"]').textContent).to.equal('Second');
166+
expect(chips[0].hasAttribute('readonly')).to.be.true;
167+
expect(chips[1].hasAttribute('readonly')).to.be.true;
168+
});
169+
});
170+
});

0 commit comments

Comments
 (0)