Skip to content

Commit 2e4cdcd

Browse files
committed
feat(Combobox): support customizing the tag display for multi-select
1 parent d7e39c5 commit 2e4cdcd

File tree

3 files changed

+360
-4
lines changed

3 files changed

+360
-4
lines changed

src/components/Combobox.vue

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,24 @@ export default defineComponent({
106106
escapeOverflow: {
107107
type: Boolean,
108108
default: false
109+
},
110+
/**
111+
* Maximum number of tags to display in multi-select mode.
112+
* When exceeded, remaining selections are collapsed into a "+N more" indicator.
113+
* Set to `undefined` (default) to display all tags.
114+
*/
115+
maxTags: {
116+
type: Number as PropType<number | undefined>,
117+
default: undefined
118+
},
119+
/**
120+
* Custom function to generate the collapsed tags label.
121+
* Receives the count of hidden tags.
122+
* Default: `(count) => \`+${count} more\``
123+
*/
124+
collapsedTagsLabel: {
125+
type: Function as PropType<(count: number) => string>,
126+
default: (count: number) => `+${count} more`
109127
}
110128
},
111129
emits: ['update:modelValue', 'update:query'],
@@ -215,6 +233,18 @@ export default defineComponent({
215233
return undefined
216234
})
217235
236+
const visibleValues = computed(() => {
237+
if (!isMultiSelect(model.value)) return []
238+
if (props.maxTags === undefined) return model.value
239+
return model.value.slice(0, props.maxTags)
240+
})
241+
242+
const hiddenCount = computed(() => {
243+
if (!isMultiSelect(model.value)) return 0
244+
if (props.maxTags === undefined) return 0
245+
return Math.max(0, model.value.length - props.maxTags)
246+
})
247+
218248
const handleDelete = () => {
219249
if (isMultiSelect(model.value) && !query.value) {
220250
model.value = model.value.slice(0, -1)
@@ -247,7 +277,9 @@ export default defineComponent({
247277
comboboxButton,
248278
optionsPanelWidth,
249279
handleBlur,
250-
container
280+
container,
281+
visibleValues,
282+
hiddenCount
251283
}
252284
}
253285
})
@@ -285,16 +317,28 @@ export default defineComponent({
285317
'mr-auto': variant !== 'subtle'
286318
}
287319
]">
288-
<template v-if="isMultiSelect && !inline">
320+
<!-- @slot Named `#selected-tags` slot. Use to customize how selected values are displayed in multi-select mode. Slot props: `selectedValues` (all selected values), `visibleValues` (values to display respecting maxTags), `hiddenCount` (number of hidden selections), `getOption` (helper to get option details), `removeValue` (helper to remove a value), `disabled` (whether combobox is disabled) -->
321+
<slot
322+
v-if="isMultiSelect && !inline"
323+
name="selected-tags"
324+
:selected-values="model"
325+
:visible-values="visibleValues"
326+
:hidden-count="hiddenCount"
327+
:get-option="getOption"
328+
:remove-value="removeValue"
329+
:disabled="$attrs.disabled">
289330
<ATag
290-
v-for="value in model"
331+
v-for="value in visibleValues"
291332
:key="value"
292333
:removable="!($attrs.disabled || getOption(value)?.disabled)"
293334
:color="getOption(value)?.color"
294335
@remove="removeValue(value, $event)">
295336
{{ getOption(value)?.label || value }}
296337
</ATag>
297-
</template>
338+
<span v-if="hiddenCount > 0" class="flex items-center px-1 text-stone body-xs-400">
339+
{{ collapsedTagsLabel(hiddenCount) }}
340+
</span>
341+
</slot>
298342
<AColorCircle
299343
v-if="singleModelValueColor"
300344
:color="singleModelValueColor"
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { render, screen } from '@testing-library/vue'
2+
3+
import Combobox from '../Combobox.vue'
4+
5+
const defaultOptions = [
6+
{ value: 'a', label: 'Option A' },
7+
{ value: 'b', label: 'Option B' },
8+
{ value: 'c', label: 'Option C' },
9+
{ value: 'd', label: 'Option D' },
10+
{ value: 'e', label: 'Option E' }
11+
]
12+
13+
describe('Combobox.vue - Multi-select tag display', () => {
14+
describe('maxTags prop', () => {
15+
it('displays all tags when maxTags is undefined', () => {
16+
render(Combobox, {
17+
props: {
18+
options: defaultOptions,
19+
modelValue: ['a', 'b', 'c', 'd', 'e']
20+
}
21+
})
22+
23+
expect(screen.getByText('Option A')).toBeInTheDocument()
24+
expect(screen.getByText('Option B')).toBeInTheDocument()
25+
expect(screen.getByText('Option C')).toBeInTheDocument()
26+
expect(screen.getByText('Option D')).toBeInTheDocument()
27+
expect(screen.getByText('Option E')).toBeInTheDocument()
28+
})
29+
30+
it('limits visible tags to maxTags count', () => {
31+
render(Combobox, {
32+
props: {
33+
options: defaultOptions,
34+
modelValue: ['a', 'b', 'c', 'd', 'e'],
35+
maxTags: 2
36+
}
37+
})
38+
39+
expect(screen.getByText('Option A')).toBeInTheDocument()
40+
expect(screen.getByText('Option B')).toBeInTheDocument()
41+
expect(screen.queryByText('Option C')).not.toBeInTheDocument()
42+
expect(screen.queryByText('Option D')).not.toBeInTheDocument()
43+
expect(screen.queryByText('Option E')).not.toBeInTheDocument()
44+
})
45+
46+
it('shows collapsed indicator when tags exceed maxTags', () => {
47+
render(Combobox, {
48+
props: {
49+
options: defaultOptions,
50+
modelValue: ['a', 'b', 'c', 'd', 'e'],
51+
maxTags: 2
52+
}
53+
})
54+
55+
expect(screen.getByText('+3 more')).toBeInTheDocument()
56+
})
57+
58+
it('does not show indicator when maxTags >= selected count', () => {
59+
render(Combobox, {
60+
props: {
61+
options: defaultOptions,
62+
modelValue: ['a', 'b'],
63+
maxTags: 3
64+
}
65+
})
66+
67+
expect(screen.queryByText(/more/)).not.toBeInTheDocument()
68+
})
69+
70+
it('handles maxTags: 0 showing only indicator', () => {
71+
render(Combobox, {
72+
props: {
73+
options: defaultOptions,
74+
modelValue: ['a', 'b', 'c'],
75+
maxTags: 0
76+
}
77+
})
78+
79+
expect(screen.queryByText('Option A')).not.toBeInTheDocument()
80+
expect(screen.queryByText('Option B')).not.toBeInTheDocument()
81+
expect(screen.queryByText('Option C')).not.toBeInTheDocument()
82+
expect(screen.getByText('+3 more')).toBeInTheDocument()
83+
})
84+
})
85+
86+
describe('collapsedTagsLabel prop', () => {
87+
it('uses default label format "+N more"', () => {
88+
render(Combobox, {
89+
props: {
90+
options: defaultOptions,
91+
modelValue: ['a', 'b', 'c', 'd'],
92+
maxTags: 1
93+
}
94+
})
95+
96+
expect(screen.getByText('+3 more')).toBeInTheDocument()
97+
})
98+
99+
it('uses custom label function when provided', () => {
100+
render(Combobox, {
101+
props: {
102+
options: defaultOptions,
103+
modelValue: ['a', 'b', 'c', 'd'],
104+
maxTags: 1,
105+
collapsedTagsLabel: (count: number) => `and ${count} others`
106+
}
107+
})
108+
109+
expect(screen.getByText('and 3 others')).toBeInTheDocument()
110+
})
111+
112+
it('passes correct hidden count to label function', () => {
113+
const labelFn = vi.fn((count: number) => `(${count} hidden)`)
114+
115+
render(Combobox, {
116+
props: {
117+
options: defaultOptions,
118+
modelValue: ['a', 'b', 'c', 'd', 'e'],
119+
maxTags: 2,
120+
collapsedTagsLabel: labelFn
121+
}
122+
})
123+
124+
expect(labelFn).toHaveBeenCalledWith(3)
125+
expect(screen.getByText('(3 hidden)')).toBeInTheDocument()
126+
})
127+
})
128+
129+
describe('#selected-tags slot', () => {
130+
it('renders custom content when slot is used', () => {
131+
render(Combobox, {
132+
props: {
133+
options: defaultOptions,
134+
modelValue: ['a', 'b', 'c']
135+
},
136+
slots: {
137+
'selected-tags': '<span data-testid="custom-tags">Custom tags content</span>'
138+
}
139+
})
140+
141+
expect(screen.getByTestId('custom-tags')).toBeInTheDocument()
142+
expect(screen.getByText('Custom tags content')).toBeInTheDocument()
143+
})
144+
145+
it('provides selectedValues slot prop', () => {
146+
render(Combobox, {
147+
props: {
148+
options: defaultOptions,
149+
modelValue: ['a', 'b', 'c']
150+
},
151+
slots: {
152+
'selected-tags': `
153+
<template #selected-tags="{ selectedValues }">
154+
<span data-testid="count">{{ selectedValues.length }} selected</span>
155+
</template>
156+
`
157+
}
158+
})
159+
160+
expect(screen.getByTestId('count')).toHaveTextContent('3 selected')
161+
})
162+
163+
it('provides visibleValues slot prop respecting maxTags', () => {
164+
render(Combobox, {
165+
props: {
166+
options: defaultOptions,
167+
modelValue: ['a', 'b', 'c', 'd', 'e'],
168+
maxTags: 2
169+
},
170+
slots: {
171+
'selected-tags': `
172+
<template #selected-tags="{ visibleValues }">
173+
<span data-testid="visible">{{ visibleValues.length }} visible</span>
174+
</template>
175+
`
176+
}
177+
})
178+
179+
expect(screen.getByTestId('visible')).toHaveTextContent('2 visible')
180+
})
181+
182+
it('provides hiddenCount slot prop', () => {
183+
render(Combobox, {
184+
props: {
185+
options: defaultOptions,
186+
modelValue: ['a', 'b', 'c', 'd', 'e'],
187+
maxTags: 2
188+
},
189+
slots: {
190+
'selected-tags': `
191+
<template #selected-tags="{ hiddenCount }">
192+
<span data-testid="hidden">{{ hiddenCount }} hidden</span>
193+
</template>
194+
`
195+
}
196+
})
197+
198+
expect(screen.getByTestId('hidden')).toHaveTextContent('3 hidden')
199+
})
200+
})
201+
202+
describe('edge cases', () => {
203+
it('ignores maxTags in single-select mode', () => {
204+
render(Combobox, {
205+
props: {
206+
options: defaultOptions,
207+
modelValue: 'a',
208+
maxTags: 0
209+
}
210+
})
211+
212+
// In single-select mode, the selected value is displayed as text, not tags
213+
// The maxTags prop should have no effect
214+
expect(screen.queryByText('+1 more')).not.toBeInTheDocument()
215+
})
216+
217+
it('does not render tags in inline mode', () => {
218+
render(Combobox, {
219+
props: {
220+
options: defaultOptions,
221+
modelValue: ['a', 'b', 'c'],
222+
inline: true
223+
}
224+
})
225+
226+
// In inline mode, tags are not rendered (existing behavior)
227+
// Just verify no tags are shown
228+
expect(screen.queryByRole('button', { name: /Option A/i })).not.toBeInTheDocument()
229+
})
230+
231+
it('handles empty selection', () => {
232+
render(Combobox, {
233+
props: {
234+
options: defaultOptions,
235+
modelValue: [],
236+
maxTags: 2
237+
}
238+
})
239+
240+
expect(screen.queryByText(/more/)).not.toBeInTheDocument()
241+
})
242+
243+
it('handles selection with values not in options', () => {
244+
render(Combobox, {
245+
props: {
246+
options: defaultOptions,
247+
modelValue: ['unknown1', 'unknown2', 'unknown3'],
248+
maxTags: 1
249+
}
250+
})
251+
252+
// Should show the raw value when option not found
253+
expect(screen.getByText('unknown1')).toBeInTheDocument()
254+
expect(screen.getByText('+2 more')).toBeInTheDocument()
255+
})
256+
})
257+
})

0 commit comments

Comments
 (0)