Skip to content

Commit 7e13d0a

Browse files
fix(Combobox): defer handleBlur close to let FocusScope restore focus (#2663)
When two ComboboxRoot instances are on the same page, clicking the second's trigger while the first is open causes the second to open briefly then immediately close. This happens because handleBlur fires before FocusScope's focus-restoration mechanism can pull focus back inside. Wrap handleBlur's close call in a requestAnimationFrame that re-checks document.activeElement, giving FocusScope's handleFocusOut a chance to restore focus first. If focus ends up back inside, the close is skipped.
1 parent 7b7eb8d commit 7e13d0a

2 files changed

Lines changed: 102 additions & 6 deletions

File tree

packages/core/src/Combobox/Combobox.test.ts

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -306,26 +306,47 @@ describe('given a Combobox with openOnFocus', () => {
306306
})
307307

308308
it('should close content when focus moves to an element outside', async () => {
309-
// Add an external focusable element
310309
const externalButton = document.createElement('button')
311310
externalButton.textContent = 'External'
312311
document.body.appendChild(externalButton)
313312

314313
const input = wrapper.find('input')
315314

316-
// Focus input to open via openOnFocus
317-
await input.trigger('focus')
315+
input.element.focus()
318316
await nextTick()
319317
expect(wrapper.find('[role=group]').exists()).toBe(true)
320318

321-
// Simulate blur with relatedTarget pointing to external element
322-
await input.trigger('blur', { relatedTarget: externalButton })
319+
externalButton.focus()
320+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
323321
await nextTick()
324322

325323
expect(wrapper.find('[role=group]').exists()).toBe(false)
326324

327325
externalButton.remove()
328326
})
327+
328+
it('should not close when focus is restored inside before deferred close fires', async () => {
329+
const externalButton = document.createElement('button')
330+
externalButton.textContent = 'External'
331+
document.body.appendChild(externalButton)
332+
333+
const input = wrapper.find('input')
334+
335+
input.element.focus()
336+
await nextTick()
337+
expect(wrapper.find('[role=group]').exists()).toBe(true)
338+
339+
// Synthetic blur: fires handleBlur with relatedTarget outside,
340+
// but doesn't change document.activeElement — simulates FocusScope
341+
// restoring focus back inside before the deferred close callback runs
342+
await input.trigger('blur', { relatedTarget: externalButton })
343+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
344+
await nextTick()
345+
346+
expect(wrapper.find('[role=group]').exists()).toBe(true)
347+
348+
externalButton.remove()
349+
})
329350
})
330351

331352
describe('given combobox in a form', async () => {
@@ -458,3 +479,66 @@ describe('given Combobox with TagsInput and addOnBlur', () => {
458479
expect(document.activeElement).toBe(input.element)
459480
})
460481
})
482+
483+
describe('given combobox handleBlur with deferred close', () => {
484+
let wrapper: VueWrapper<InstanceType<typeof Combobox>>
485+
486+
window.HTMLElement.prototype.releasePointerCapture = vi.fn()
487+
window.HTMLElement.prototype.hasPointerCapture = vi.fn()
488+
window.HTMLElement.prototype.scrollIntoView = vi.fn()
489+
490+
beforeEach(() => {
491+
document.body.innerHTML = ''
492+
wrapper = mount(Combobox, { attachTo: document.body, props: { resetSearchTermOnBlur: true } })
493+
})
494+
495+
it('should not close when focus is restored inside before deferred close fires', async () => {
496+
const externalButton = document.createElement('button')
497+
externalButton.textContent = 'External'
498+
document.body.appendChild(externalButton)
499+
500+
// Open combobox
501+
await wrapper.find('button').trigger('click')
502+
await nextTick()
503+
expect(wrapper.find('[role=group]').exists()).toBe(true)
504+
505+
const input = wrapper.find('input')
506+
input.element.focus()
507+
508+
// Synthetic blur: fires handleBlur with relatedTarget outside,
509+
// but doesn't change document.activeElement — simulates FocusScope
510+
// restoring focus back inside before the deferred close callback runs
511+
await input.trigger('blur', { relatedTarget: externalButton })
512+
513+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
514+
await nextTick()
515+
516+
expect(wrapper.find('[role=group]').exists()).toBe(true)
517+
518+
externalButton.remove()
519+
})
520+
521+
it('should close when focus stays outside after rAF', async () => {
522+
const externalButton = document.createElement('button')
523+
externalButton.textContent = 'External'
524+
document.body.appendChild(externalButton)
525+
526+
// Open combobox
527+
await wrapper.find('button').trigger('click')
528+
await nextTick()
529+
expect(wrapper.find('[role=group]').exists()).toBe(true)
530+
531+
const input = wrapper.find('input')
532+
input.element.focus()
533+
534+
// Focus moves outside and stays there
535+
externalButton.focus()
536+
537+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
538+
await nextTick()
539+
540+
expect(wrapper.find('[role=group]').exists()).toBe(false)
541+
542+
externalButton.remove()
543+
})
544+
})

packages/core/src/Combobox/ComboboxInput.vue

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,19 @@ function handleBlur(ev: FocusEvent) {
7474
const isInsideContent = document.getElementById(rootContext.contentId)?.contains(nextFocus)
7575
7676
if (!isInsideRoot && !isInsideContent) {
77-
rootContext.onOpenChange(false)
77+
// Delay to let FocusScope's focus-restoration (handleFocusOut) run first.
78+
// Without this, closing fires before FocusScope can pull focus back inside,
79+
// causing a second combobox to immediately close when switching between two.
80+
requestAnimationFrame(() => {
81+
if (!rootContext.open.value)
82+
return
83+
const active = document.activeElement
84+
const isStillOutside = !rootContext.parentElement.value?.contains(active)
85+
&& !document.getElementById(rootContext.contentId)?.contains(active)
86+
if (isStillOutside) {
87+
rootContext.onOpenChange(false)
88+
}
89+
})
7890
}
7991
}
8092

0 commit comments

Comments
 (0)