Skip to content

Commit 795581c

Browse files
committed
fix(VAutocomplete/VCombobox): consistent open/close transition
1 parent d110c0a commit 795581c

File tree

4 files changed

+60
-33
lines changed

4 files changed

+60
-33
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: 16 additions & 17 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,16 +295,11 @@ 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) {
@@ -378,6 +372,7 @@ 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 */
383378
function select (item: ListItem | undefined, set: boolean | null = true) {
@@ -401,6 +396,9 @@ 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
@@ -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) {
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 }

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { VCombobox } from '../VCombobox'
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'
7+
import { commands } from '@vitest/browser/context'
78
import { cloneVNode, ref } from 'vue'
89

910
const variants = ['underlined', 'outlined', 'filled', 'solo', 'plain'] as const
@@ -92,9 +93,10 @@ describe('VCombobox', () => {
9293
))
9394

9495
await userEvent.click(element)
96+
await wait(100)
9597
await userEvent.click((await screen.findAllByRole('option'))[0])
9698
expect(model.value).toStrictEqual(items[0])
97-
expect(search.value).toBe(items[0].title)
99+
await expect.poll(() => search.value).toBe(items[0].title)
98100
expect(screen.getByCSS('input')).toHaveValue(items[0].title)
99101
expect(screen.getByCSS('.v-combobox__selection')).toHaveTextContent(items[0].title)
100102

@@ -141,6 +143,7 @@ describe('VCombobox', () => {
141143
const input = screen.getByCSS('input')
142144

143145
await userEvent.click(element)
146+
await wait(100)
144147
await userEvent.click(screen.getAllByRole('option')[0])
145148
expect(model.value).toStrictEqual([items[0]])
146149
expect(search.value).toBeUndefined()
@@ -366,6 +369,7 @@ describe('VCombobox', () => {
366369
))
367370

368371
await userEvent.click(element)
372+
await wait(100)
369373

370374
const options = await screen.findAllByRole('option', { selected: true })
371375
expect(options).toHaveLength(2)
@@ -473,10 +477,11 @@ describe('VCombobox', () => {
473477
))
474478

475479
await userEvent.click(element)
480+
await wait(100)
476481

477482
await userEvent.click(screen.getAllByRole('option')[0])
478483

479-
expect(screen.getByCSS('input')).toHaveValue('0')
484+
await expect.poll(() => screen.getByCSS('input')).toHaveValue('0')
480485
})
481486

482487
it('should conditionally show placeholder', async () => {
@@ -544,6 +549,8 @@ describe('VCombobox', () => {
544549
expect(screen.queryAllByRole('listbox')).toHaveLength(0)
545550

546551
await userEvent.click(element)
552+
await commands.waitStable('.v-list')
553+
547554
expect(screen.queryAllByRole('listbox')).toHaveLength(1)
548555
await userEvent.keyboard('{Escape}')
549556
await expect.poll(() => screen.queryAllByRole('listbox')).toHaveLength(0)
@@ -563,6 +570,8 @@ describe('VCombobox', () => {
563570
))
564571

565572
await userEvent.click(element)
573+
await commands.waitStable('.v-list')
574+
566575
expect(screen.getAllByRole('option')).toHaveLength(6)
567576

568577
await userEvent.keyboard('Cal')
@@ -585,6 +594,8 @@ describe('VCombobox', () => {
585594
))
586595

587596
await userEvent.click(element)
597+
await commands.waitStable('.v-list')
598+
588599
expect(screen.getAllByRole('option')).toHaveLength(6)
589600

590601
await userEvent.keyboard('Cal')
@@ -607,6 +618,8 @@ describe('VCombobox', () => {
607618
))
608619

609620
await userEvent.click(element)
621+
await commands.waitStable('.v-list')
622+
610623
expect(screen.getAllByRole('option')).toHaveLength(6)
611624

612625
await userEvent.keyboard('Cal')
@@ -668,6 +681,7 @@ describe('VCombobox', () => {
668681
})
669682

670683
await userEvent.click(element)
684+
await commands.waitStable('.v-list')
671685
await expect(screen.findByRole('listbox')).resolves.toBeVisible()
672686

673687
await userEvent.click(screen.getAllByRole('option')[0])
@@ -734,8 +748,9 @@ describe('VCombobox', () => {
734748
))
735749

736750
await userEvent.click(element)
751+
await wait(100)
737752
await userEvent.click(screen.getAllByRole('option')[0])
738-
expect(model.value).toStrictEqual({ title: 'Item 1', value: 'item1' })
753+
await expect.poll(() => model.value).toStrictEqual({ title: 'Item 1', value: 'item1' })
739754

740755
await userEvent.click(document.body)
741756
expect(model.value).toStrictEqual({ title: 'Item 1', value: 'item1' })

0 commit comments

Comments
 (0)