Skip to content

Commit cdd14a9

Browse files
authored
feat(VSelect/VAutocomplete/VCombobox): new events for multi-selection (#23038)
resolves #4054 resolves #5682
1 parent eada595 commit cdd14a9

8 files changed

Lines changed: 239 additions & 16 deletions

File tree

packages/api-generator/src/locale/en/VCombobox.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
"slots": {
1111
"item": "Define a custom item appearance. The root element of this slot must be a **v-list-item** with `v-bind=\"props\"` applied. `props` includes everything required for the default select list behaviour - including title, value, click handlers, virtual scrolling, and anything else that has been added with `item-props`."
1212
},
13+
"events": {
14+
"item:created": "Emitted when a value is committed that does not match any existing item (free-text create). Also emits **item:added**."
15+
},
1316
"exposed": {
1417
"selectionIndex": "The index of the currently selected item."
1518
}

packages/api-generator/src/locale/en/generic.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@
103103
"click:prependInner": "Emitted when prepended inner icon is clicked.",
104104
"input": "The updated bound model.",
105105
"group:selected": "Event that is emitted when an item is selected within a group.",
106+
"item:added": "Emitted when an item is added to the model.",
107+
"item:removed": "Emitted when an item is removed from the model.",
106108
"rejected": "Emitted when some of the files from user input, drop or folder selection did not pass through `strict-accept` filter.",
107109
"update:focused": "Event that is emitted when the component's focus state changes.",
108110
"update:menu": "Event that is emitted when the component's menu state changes.",

packages/docs/src/data/new-in.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
"slots": {
2323
"menu-header": "3.12.0",
2424
"menu-footer": "3.12.0"
25+
},
26+
"events": {
27+
"item:added": "3.13.0",
28+
"item:removed": "3.13.0"
2529
}
2630
},
2731
"VAvatar": {
@@ -83,6 +87,11 @@
8387
"slots": {
8488
"menu-header": "3.12.0",
8589
"menu-footer": "3.12.0"
90+
},
91+
"events": {
92+
"item:added": "3.13.0",
93+
"item:removed": "3.13.0",
94+
"item:created": "3.13.0"
8695
}
8796
},
8897
"VConfirmEdit": {
@@ -289,6 +298,10 @@
289298
"slots": {
290299
"menu-header": "3.12.0",
291300
"menu-footer": "3.12.0"
301+
},
302+
"events": {
303+
"item:added": "3.13.0",
304+
"item:removed": "3.13.0"
292305
}
293306
},
294307
"VSkeletonLoader": {

packages/vuetify/src/components/VAutocomplete/VAutocomplete.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,11 @@ export const VAutocomplete = genericComponent<new <
119119
'update:search': (value: any) => true,
120120
'update:modelValue': (value: any) => true,
121121
'update:menu': (value: boolean) => true,
122+
'item:added': (item: ListItem) => true,
123+
'item:removed': (item: ListItem) => true,
122124
},
123125

124-
setup (props, { slots }) {
126+
setup (props, { emit, slots }) {
125127
const { t } = useLocale()
126128
const vTextFieldRef = ref<VTextField>()
127129
const isFocused = shallowRef(false)
@@ -368,7 +370,10 @@ export const VAutocomplete = genericComponent<new <
368370
}
369371
}
370372
function onUpdateModelValue (v: any) {
371-
if (v == null || (v === '' && !props.multiple && !hasSelectionSlot.value)) model.value = []
373+
if (v == null || (v === '' && !props.multiple && !hasSelectionSlot.value)) {
374+
for (const item of model.value) emit('item:removed', item)
375+
model.value = []
376+
}
372377
}
373378

374379
function onBlur (e: FocusEvent) {
@@ -384,15 +389,19 @@ export const VAutocomplete = genericComponent<new <
384389
function select (item: ListItem | undefined, set: boolean | null = true) {
385390
if (!item || item.props.disabled) return
386391

392+
const comparator = props.valueComparator || deepEqual
393+
387394
if (props.multiple) {
388-
const index = model.value.findIndex(selection => (props.valueComparator || deepEqual)(selection.value, item.value))
395+
const index = model.value.findIndex(selection => comparator(selection.value, item.value))
389396
const add = set == null ? !~index : set
390397

391398
if (~index) {
392399
const value = add ? [...model.value, item] : [...model.value]
393-
value.splice(index, 1)
400+
const [removed] = value.splice(index, 1)
401+
if (!add) emit('item:removed', removed) // skip if only reordered
394402
model.value = value
395403
} else if (add) {
404+
emit('item:added', item)
396405
model.value = [...model.value, item]
397406
}
398407

@@ -401,7 +410,21 @@ export const VAutocomplete = genericComponent<new <
401410
}
402411
} else {
403412
const add = set !== false
404-
model.value = add ? [item] : []
413+
const old = model.value[0]
414+
415+
if (add) {
416+
if (old && !comparator(old.value, item.value)) {
417+
emit('item:removed', old)
418+
emit('item:added', item)
419+
} else if (!old) {
420+
emit('item:added', item)
421+
}
422+
model.value = [item]
423+
} else {
424+
if (old) emit('item:removed', old)
425+
model.value = []
426+
}
427+
405428
_searchLock.value = isPristine.value ? '' : (search.value ?? '')
406429
search.value = add && !hasSelectionSlot.value ? item.title : ''
407430

@@ -423,7 +446,10 @@ export const VAutocomplete = genericComponent<new <
423446

424447
nextTick(() => isSelecting.value = false)
425448
} else {
426-
if (!props.multiple && search.value == null) model.value = []
449+
if (!props.multiple && search.value == null) {
450+
for (const item of model.value) emit('item:removed', item)
451+
model.value = []
452+
}
427453
menu.value = false
428454
if (!isPristine.value && search.value) {
429455
_searchLock.value = search.value

packages/vuetify/src/components/VCombobox/VCombobox.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ export const VCombobox = genericComponent<new <
125125
'update:modelValue': (value: any) => true,
126126
'update:search': (value: string) => true,
127127
'update:menu': (value: boolean) => true,
128+
'item:added': (item: ListItem) => true,
129+
'item:removed': (item: ListItem) => true,
130+
'item:created': (item: ListItem) => true,
128131
},
129132

130133
setup (props, { emit, slots }) {
@@ -166,6 +169,9 @@ export const VCombobox = genericComponent<new <
166169
set: async (val: string | null) => {
167170
_search.value = val ?? ''
168171
if (val === null || (val === '' && !props.multiple && !hasSelectionSlot.value)) {
172+
for (const item of model.value) {
173+
emit('item:removed', item)
174+
}
169175
model.value = []
170176
} else if (!props.multiple && !hasSelectionSlot.value) {
171177
model.value = [transformItem(props, val)]
@@ -413,19 +419,30 @@ export const VCombobox = genericComponent<new <
413419
isPristine.value = true
414420
_searchLock.value = null
415421
}
422+
423+
function isExistingItem (item: ListItem) {
424+
const comparator = props.valueComparator || deepEqual
425+
return items.value.some(i => comparator(i.value, item.value))
426+
}
427+
416428
/** @param set - null means toggle */
417429
function select (item: ListItem | undefined, set: boolean | null = true, keepMenu = false) {
418430
if (!item || item.props.disabled) return
419431

432+
const comparator = props.valueComparator || deepEqual
433+
420434
if (props.multiple) {
421-
const index = model.value.findIndex(selection => (props.valueComparator || deepEqual)(selection.value, item.value))
435+
const index = model.value.findIndex(selection => comparator(selection.value, item.value))
422436
const add = set == null ? !~index : set
423437

424438
if (~index) {
425439
const value = add ? [...model.value, item] : [...model.value]
426-
value.splice(index, 1)
440+
const [removed] = value.splice(index, 1)
441+
if (!add) emit('item:removed', removed) // skip if only reordered
427442
model.value = value
428443
} else if (add) {
444+
emit('item:added', item)
445+
if (!isExistingItem(item)) emit('item:created', item)
429446
model.value = [...model.value, item]
430447
}
431448

@@ -434,7 +451,23 @@ export const VCombobox = genericComponent<new <
434451
}
435452
} else {
436453
const add = set !== false
437-
model.value = add ? [item] : []
454+
const old = model.value[0]
455+
456+
if (add) {
457+
if (old && !comparator(old.value, item.value)) {
458+
emit('item:removed', old)
459+
emit('item:added', item)
460+
if (!isExistingItem(item)) emit('item:created', item)
461+
} else if (!old) {
462+
emit('item:added', item)
463+
if (!isExistingItem(item)) emit('item:created', item)
464+
}
465+
model.value = [item]
466+
} else {
467+
if (old) emit('item:removed', old)
468+
model.value = []
469+
}
470+
438471
if ((!isPristine.value || props.alwaysFilter) && _search.value) {
439472
_searchLock.value = _search.value
440473
}

packages/vuetify/src/components/VCombobox/__tests__/VCombobox.spec.browser.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,5 +921,75 @@ describe('VCombobox', () => {
921921
expect(screen.getByRole('listbox').contains(document.activeElement)).toBe(true)
922922
})
923923

924+
describe('selection events for multiple', () => {
925+
it('should emit item:created for free-text values', async () => {
926+
const added = vi.fn()
927+
const created = vi.fn()
928+
const model = ref<string[]>([])
929+
930+
const { element } = render(() => (
931+
<VCombobox
932+
items={['Item 1', 'Item 2']}
933+
multiple
934+
modelValue={ model.value }
935+
onUpdate:modelValue={ val => model.value = val as string[] }
936+
onItem:added={ added }
937+
onItem:created={ created }
938+
/>
939+
))
940+
941+
await userEvent.click(element)
942+
await userEvent.keyboard('Brand new{Enter}')
943+
944+
expect(added).toHaveBeenCalledTimes(1)
945+
expect(created).toHaveBeenCalledTimes(1)
946+
expect(created.mock.calls[0][0]).toMatchObject({ title: 'Brand new', value: 'Brand new', raw: 'Brand new' })
947+
expect(model.value).toEqual(['Brand new'])
948+
})
949+
950+
it('should not emit item:created when selecting an existing item', async () => {
951+
const added = vi.fn()
952+
const created = vi.fn()
953+
const model = ref<string[]>([])
954+
955+
const { element } = render(() => (
956+
<VCombobox
957+
items={['Item 1', 'Item 2']}
958+
multiple
959+
modelValue={ model.value }
960+
onUpdate:modelValue={ val => model.value = val as string[] }
961+
onItem:added={ added }
962+
onItem:created={ created }
963+
/>
964+
))
965+
966+
await userEvent.click(element)
967+
await commands.waitStable('.v-list')
968+
await userEvent.click(screen.getAllByRole('option')[0])
969+
970+
expect(added).toHaveBeenCalledTimes(1)
971+
expect(created).not.toHaveBeenCalled()
972+
})
973+
974+
it('should emit item:removed when closing a chip', async () => {
975+
const removed = vi.fn()
976+
977+
render(() => (
978+
<VCombobox
979+
items={['Item 1', 'Item 2']}
980+
modelValue={['Item 1', 'Item 2']}
981+
multiple
982+
chips
983+
closableChips
984+
onItem:removed={ removed }
985+
/>
986+
))
987+
988+
await userEvent.click(screen.getAllByTestId('close-chip')[0])
989+
expect(removed).toHaveBeenCalledTimes(1)
990+
expect(removed.mock.calls[0][0]).toMatchObject({ title: 'Item 1', value: 'Item 1' })
991+
})
992+
})
993+
924994
showcase({ stories })
925995
})

packages/vuetify/src/components/VSelect/VSelect.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,11 @@ export const VSelect = genericComponent<new <
148148
'update:modelValue': (value: any) => true,
149149
'update:menu': (ue: boolean) => true,
150150
'update:search': (value: string) => true,
151+
'item:added': (item: ListItem) => true,
152+
'item:removed': (item: ListItem) => true,
151153
},
152154

153-
setup (props, { slots }) {
155+
setup (props, { emit, slots }) {
154156
const { t } = useLocale()
155157
const vTextFieldRef = ref<VTextField>()
156158
const vMenuRef = ref<VMenu>()
@@ -280,6 +282,7 @@ export const VSelect = genericComponent<new <
280282

281283
if (props.clearable && e.key === 'Backspace') {
282284
e.preventDefault()
285+
for (const item of model.value) emit('item:removed', item)
283286
model.value = []
284287
onClear(e)
285288
return
@@ -343,28 +346,45 @@ export const VSelect = genericComponent<new <
343346
keyboardLookupIndex = index
344347
listRef.value?.focus(index)
345348
if (!props.multiple) {
346-
model.value = [item]
349+
select(item, true)
347350
}
348351
}
349352

350353
/** @param set - null means toggle */
351354
function select (item: ListItem, set: boolean | null = true) {
352355
if (item.props.disabled) return
353356

357+
const comparator = props.valueComparator || deepEqual
358+
354359
if (props.multiple) {
355-
const index = model.value.findIndex(selection => (props.valueComparator || deepEqual)(selection.value, item.value))
360+
const index = model.value.findIndex(selection => comparator(selection.value, item.value))
356361
const add = set == null ? !~index : set
357362

358363
if (~index) {
359364
const value = add ? [...model.value, item] : [...model.value]
360-
value.splice(index, 1)
365+
const [removed] = value.splice(index, 1)
366+
if (!add) emit('item:removed', removed) // skip if only reordered
361367
model.value = value
362368
} else if (add) {
369+
emit('item:added', item)
363370
model.value = [...model.value, item]
364371
}
365372
} else {
366373
const add = set !== false
367-
model.value = add ? [item] : []
374+
const old = model.value[0]
375+
376+
if (add) {
377+
if (old && !comparator(old.value, item.value)) {
378+
emit('item:removed', old)
379+
emit('item:added', item)
380+
} else if (!old) {
381+
emit('item:added', item)
382+
}
383+
model.value = [item]
384+
} else {
385+
if (old) emit('item:removed', old)
386+
model.value = []
387+
}
368388

369389
nextTick(() => {
370390
menu.value = false
@@ -427,8 +447,10 @@ export const VSelect = genericComponent<new <
427447
}
428448
}
429449
function onModelUpdate (v: any) {
430-
if (v == null) model.value = []
431-
else if (matchesSelector(vTextFieldRef.value, ':autofill') || matchesSelector(vTextFieldRef.value, ':-webkit-autofill')) {
450+
if (v == null) {
451+
for (const item of model.value) emit('item:removed', item)
452+
model.value = []
453+
} else if (matchesSelector(vTextFieldRef.value, ':autofill') || matchesSelector(vTextFieldRef.value, ':-webkit-autofill')) {
432454
const item = items.value.find(item => item.title === v)
433455
if (item) {
434456
select(item)

0 commit comments

Comments
 (0)