Skip to content

Commit 96f6479

Browse files
authored
fix(VAutocomplete/VCombobox): consistent open/close transition (#22144)
1 parent e8e7234 commit 96f6479

File tree

4 files changed

+63
-36
lines changed

4 files changed

+63
-36
lines changed

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { useItems } from '@/composables/list-items'
2424
import { useLocale } from '@/composables/locale'
2525
import { useMenuActivator } from '@/composables/menuActivator'
2626
import { useProxiedModel } from '@/composables/proxiedModel'
27-
import { makeTransitionProps } from '@/composables/transition'
2827

2928
// Utilities
3029
import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue'
@@ -73,7 +72,6 @@ export const makeVAutocompleteProps = propsFactory({
7372
modelValue: null,
7473
role: 'combobox',
7574
}), ['validationValue', 'dirty', 'appendInnerIcon']),
76-
...makeTransitionProps({ transition: false }),
7775
}, 'VAutocomplete')
7876

7977
type ItemType<T> = T extends readonly (infer U)[] ? U : never
@@ -126,6 +124,7 @@ export const VAutocomplete = genericComponent<new <
126124
const vMenuRef = ref<VMenu>()
127125
const vVirtualScrollRef = ref<VVirtualScroll>()
128126
const selectionIndex = shallowRef(-1)
127+
const _searchLock = shallowRef<string | null>(null)
129128
const { items, transformIn, transformOut } = useItems(props)
130129
const { textColorClasses, textColorStyles } = useTextColor(() => vTextFieldRef.value?.color)
131130
const search = useProxiedModel(props, 'search', '')
@@ -145,10 +144,13 @@ export const VAutocomplete = genericComponent<new <
145144
: model.value.length
146145
})
147146
const form = useForm(props)
148-
const { filteredItems, getMatches } = useFilter(props, items, () => isPristine.value ? '' : search.value)
147+
const { filteredItems, getMatches } = useFilter(
148+
props,
149+
items,
150+
() => _searchLock.value ?? (isPristine.value ? '' : search.value))
149151

150152
const displayItems = computed(() => {
151-
if (props.hideSelected) {
153+
if (props.hideSelected && _searchLock.value === null) {
152154
return filteredItems.value.filter(filteredItem => !model.value.some(s => s.value === filteredItem.value))
153155
}
154156
return filteredItems.value
@@ -314,6 +316,7 @@ export const VAutocomplete = genericComponent<new <
314316
isPristine.value = true
315317
vTextFieldRef.value?.focus()
316318
}
319+
_searchLock.value = null
317320
}
318321

319322
function onFocusin (e: FocusEvent) {
@@ -353,6 +356,7 @@ export const VAutocomplete = genericComponent<new <
353356
} else {
354357
const add = set !== false
355358
model.value = add ? [item] : []
359+
_searchLock.value = isPristine.value ? '' : (search.value ?? '')
356360
search.value = add && !hasSelectionSlot.value ? item.title : ''
357361

358362
// watch for search watcher to trigger
@@ -375,6 +379,9 @@ export const VAutocomplete = genericComponent<new <
375379
} else {
376380
if (!props.multiple && search.value == null) model.value = []
377381
menu.value = false
382+
if (!isPristine.value && search.value) {
383+
_searchLock.value = search.value
384+
}
378385
search.value = ''
379386
selectionIndex.value = -1
380387
}
@@ -388,15 +395,16 @@ export const VAutocomplete = genericComponent<new <
388395
isPristine.value = !val
389396
})
390397

391-
watch(menu, () => {
392-
if (!props.hideSelected && menu.value && model.value.length) {
398+
watch(menu, val => {
399+
if (!props.hideSelected && val && model.value.length && isPristine.value) {
393400
const index = displayItems.value.findIndex(
394401
item => model.value.some(s => item.value === s.value)
395402
)
396403
IN_BROWSER && window.requestAnimationFrame(() => {
397404
index >= 0 && vVirtualScrollRef.value?.scrollToIndex(index)
398405
})
399406
}
407+
if (val) _searchLock.value = null
400408
})
401409

402410
watch(items, (newVal, oldVal) => {
@@ -463,7 +471,6 @@ export const VAutocomplete = genericComponent<new <
463471
maxHeight={ 310 }
464472
openOnClick={ false }
465473
closeOnContentClick={ false }
466-
transition={ props.transition }
467474
onAfterEnter={ onAfterEnter }
468475
onAfterLeave={ onAfterLeave }
469476
{ ...props.menuProps }

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { VAutocomplete } from '../VAutocomplete'
33
import { VForm } from '@/components/VForm'
44

55
// Utilities
6-
import { generate, render, screen, userEvent, waitAnimationFrame, waitIdle } from '@test'
6+
import { generate, render, screen, userEvent, wait, waitAnimationFrame, waitIdle } from '@test'
77
import { findAllByRole, queryAllByRole, within } from '@testing-library/vue'
8+
import { commands } from '@vitest/browser/context'
89
import { cloneVNode, ref } from 'vue'
910

1011
const variants = ['underlined', 'outlined', 'filled', 'solo', 'plain'] as const
@@ -82,6 +83,7 @@ describe('VAutocomplete', () => {
8283
))
8384

8485
await userEvent.click(container)
86+
await wait(100) // waitStable was very flaky here
8587

8688
const menu = await screen.findByRole('listbox')
8789

@@ -128,6 +130,7 @@ describe('VAutocomplete', () => {
128130
))
129131

130132
await userEvent.click(container)
133+
await commands.waitStable('.v-list')
131134

132135
const menu = await screen.findByRole('listbox')
133136

@@ -179,6 +182,7 @@ describe('VAutocomplete', () => {
179182
))
180183

181184
await userEvent.click(container)
185+
await wait(100)
182186

183187
const menu = await screen.findByRole('listbox')
184188

@@ -354,6 +358,7 @@ describe('VAutocomplete', () => {
354358
))
355359

356360
await userEvent.click(element)
361+
await commands.waitStable('.v-list')
357362

358363
await userEvent.click(screen.getAllByRole('option')[0])
359364
expect(selectedItems.value).toBe(1)
@@ -422,6 +427,7 @@ describe('VAutocomplete', () => {
422427

423428
const menuIcon = screen.getByRole('button', { name: /open/i })
424429
await userEvent.click(menuIcon)
430+
await commands.waitStable('.v-list')
425431

426432
const listItems = screen.getAllByRole('option')
427433
expect(listItems).toHaveLength(2)
@@ -444,8 +450,7 @@ describe('VAutocomplete', () => {
444450
))
445451

446452
await userEvent.type(element, 'f')
447-
const listItems = screen.getAllByRole('option')
448-
expect(listItems).toHaveLength(2)
453+
await expect.poll(() => screen.findAllByRole('option')).toHaveLength(2)
449454
expect(selectedItems.value).toBeUndefined()
450455
})
451456

@@ -535,6 +540,7 @@ describe('VAutocomplete', () => {
535540
const getItems = () => screen.queryAllByRole('option')
536541

537542
await userEvent.click(element)
543+
await commands.waitStable('.v-list')
538544
await expect.poll(getItems).toHaveLength(6)
539545

540546
await userEvent.keyboard('Cal')
@@ -615,7 +621,7 @@ describe('VAutocomplete', () => {
615621
))
616622

617623
await userEvent.click(element)
618-
624+
await commands.waitStable('.v-list')
619625
const items = await screen.findAllByRole('option')
620626
expect(items).toHaveLength(2)
621627

@@ -633,7 +639,7 @@ describe('VAutocomplete', () => {
633639
expect(screen.queryByRole('listbox')).toBeNull()
634640

635641
await rerender({ items: ['Foo', 'Bar'] })
636-
await expect(screen.findByRole('listbox')).resolves.toBeDisplayed()
642+
await expect(screen.findByRole('listbox')).resolves.toBeVisible()
637643
})
638644

639645
// https://github.com/vuetifyjs/vuetify/issues/19346

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

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import { transformItem, useItems } from '@/composables/list-items'
2525
import { useLocale } from '@/composables/locale'
2626
import { useMenuActivator } from '@/composables/menuActivator'
2727
import { useProxiedModel } from '@/composables/proxiedModel'
28-
import { makeTransitionProps } from '@/composables/transition'
2928

3029
// Utilities
3130
import { computed, mergeProps, nextTick, ref, shallowRef, watch } from 'vue'
@@ -79,7 +78,6 @@ export const makeVComboboxProps = propsFactory({
7978
modelValue: null,
8079
role: 'combobox',
8180
}), ['validationValue', 'dirty', 'appendInnerIcon']),
82-
...makeTransitionProps({ transition: false }),
8381
}, 'VCombobox')
8482

8583
type ItemType<T> = T extends readonly (infer U)[] ? U : never
@@ -151,6 +149,7 @@ export const VCombobox = genericComponent<new <
151149
const hasSelectionSlot = computed(() => hasChips.value || !!slots.selection)
152150

153151
const _search = shallowRef(!props.multiple && !hasSelectionSlot.value ? model.value[0]?.title ?? '' : '')
152+
const _searchLock = shallowRef<string | null>(null)
154153

155154
const search = computed<string>({
156155
get: () => {
@@ -193,11 +192,11 @@ export const VCombobox = genericComponent<new <
193192
const { filteredItems, getMatches } = useFilter(
194193
props,
195194
items,
196-
() => props.alwaysFilter || !isPristine.value ? search.value : ''
195+
() => _searchLock.value ?? (props.alwaysFilter || !isPristine.value ? search.value : '')
197196
)
198197

199198
const displayItems = computed(() => {
200-
if (props.hideSelected) {
199+
if (props.hideSelected && _searchLock.value === null) {
201200
return filteredItems.value.filter(filteredItem => !model.value.some(s => s.value === filteredItem.value))
202201
}
203202
return filteredItems.value
@@ -296,24 +295,19 @@ export const VCombobox = genericComponent<new <
296295
menu.value = false
297296
}
298297

299-
if (['Enter', 'Escape', 'Tab'].includes(e.key)) {
300-
if (
301-
highlightFirst.value &&
302-
['Enter', 'Tab'].includes(e.key) &&
303-
!model.value.some(({ value }) => value === displayItems.value[0].value)
304-
) {
305-
select(filteredItems.value[0])
306-
}
307-
308-
isPristine.value = true
298+
if (highlightFirst.value &&
299+
['Enter', 'Tab'].includes(e.key) &&
300+
!model.value.some(({ value }) => value === displayItems.value[0].value)
301+
) {
302+
select(filteredItems.value[0])
309303
}
310304

311305
if (e.key === 'ArrowDown' && highlightFirst.value) {
312306
listRef.value?.focus('next')
313307
}
314308

315309
if (e.key === 'Enter' && search.value) {
316-
select(transformItem(props, search.value))
310+
select(transformItem(props, search.value), true, true)
317311
if (hasSelectionSlot.value) _search.value = ''
318312
}
319313

@@ -378,9 +372,10 @@ export const VCombobox = genericComponent<new <
378372
isPristine.value = true
379373
vTextFieldRef.value?.focus()
380374
}
375+
_searchLock.value = null
381376
}
382377
/** @param set - null means toggle */
383-
function select (item: ListItem | undefined, set: boolean | null = true) {
378+
function select (item: ListItem | undefined, set: boolean | null = true, keepMenu = false) {
384379
if (!item || item.props.disabled) return
385380

386381
if (props.multiple) {
@@ -401,11 +396,14 @@ export const VCombobox = genericComponent<new <
401396
} else {
402397
const add = set !== false
403398
model.value = add ? [item] : []
399+
if ((!isPristine.value || props.alwaysFilter) && _search.value) {
400+
_searchLock.value = _search.value
401+
}
404402
_search.value = add && !hasSelectionSlot.value ? item.title : ''
405403

406404
// watch for search watcher to trigger
407405
nextTick(() => {
408-
menu.value = false
406+
menu.value = keepMenu
409407
isPristine.value = true
410408
})
411409
}
@@ -446,15 +444,17 @@ export const VCombobox = genericComponent<new <
446444
}
447445
})
448446

449-
watch(menu, () => {
450-
if (!props.hideSelected && menu.value && model.value.length) {
447+
watch(menu, val => {
448+
if (!props.hideSelected && val && model.value.length && isPristine.value) {
451449
const index = displayItems.value.findIndex(
452450
item => model.value.some(s => (props.valueComparator || deepEqual)(s.value, item.value))
453451
)
454452
IN_BROWSER && window.requestAnimationFrame(() => {
455453
index >= 0 && vVirtualScrollRef.value?.scrollToIndex(index)
456454
})
457455
}
456+
457+
if (val) _searchLock.value = null
458458
})
459459

460460
watch(items, (newVal, oldVal) => {
@@ -520,7 +520,6 @@ export const VCombobox = genericComponent<new <
520520
maxHeight={ 310 }
521521
openOnClick={ false }
522522
closeOnContentClick={ false }
523-
transition={ props.transition }
524523
onAfterEnter={ onAfterEnter }
525524
onAfterLeave={ onAfterLeave }
526525
{ ...props.menuProps }

0 commit comments

Comments
 (0)