Skip to content

Commit ed7531b

Browse files
benjamincanaczernoniaclaude
authored
fix(Combobox): keep content open when interacting with associated label (#2686)
* fix(Combobox): keep content open when interacting with associated label A `<label>` tied (via `for`) to a control inside the combobox forwards its click/focus to that control. Without accounting for this, clicking such a label while open dismissed the content on `pointerdown`, then the forwarded click/focus immediately re-opened it. * test(Combobox): wait for async dismiss in associated-label test The 'stays open' assertion ran after a single nextTick, but a real dismiss is emitted after an internal nextTick too — so the test could pass even if the fix failed to prevent dismiss. Match the wait used in the dismiss test to actually guard against that regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: zernonia <zernonia@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b2e781d commit ed7531b

2 files changed

Lines changed: 79 additions & 4 deletions

File tree

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,68 @@ describe('given a Combobox with openOnFocus', () => {
349349
})
350350
})
351351

352+
describe('given combobox with an associated label', () => {
353+
let wrapper: VueWrapper<InstanceType<typeof Combobox>>
354+
355+
window.HTMLElement.prototype.releasePointerCapture = vi.fn()
356+
window.HTMLElement.prototype.hasPointerCapture = vi.fn()
357+
window.HTMLElement.prototype.scrollIntoView = vi.fn()
358+
359+
beforeEach(() => {
360+
document.body.innerHTML = ''
361+
wrapper = mount(Combobox, { attachTo: document.body })
362+
})
363+
364+
it('should not dismiss when interacting with a label tied to a control inside', async () => {
365+
// A `<label for="...">` pointing to the combobox input forwards its click/focus
366+
// to that input. Clicking it should keep the content open instead of dismissing
367+
// on `pointerdown` and immediately re-opening from the forwarded click.
368+
const input = wrapper.find('input')
369+
input.element.id = 'combobox-input'
370+
371+
const label = document.createElement('label')
372+
label.setAttribute('for', 'combobox-input')
373+
label.textContent = 'Fruit'
374+
document.body.appendChild(label)
375+
376+
await wrapper.find('button').trigger('click')
377+
await nextTick()
378+
// The document `pointerdown` listener is registered via `setTimeout(0)`.
379+
await sleep(1)
380+
expect(wrapper.find('[role=group]').exists()).toBe(true)
381+
382+
label.dispatchEvent(new Event('pointerdown', { bubbles: true }))
383+
// Wait as long as a real dismiss would take (emitted after an internal
384+
// `await nextTick()`) so a regression that fails to prevent it is caught.
385+
await sleep(1)
386+
await nextTick()
387+
388+
expect(wrapper.find('[role=group]').exists()).toBe(true)
389+
390+
label.remove()
391+
})
392+
393+
it('should dismiss when interacting with an unrelated label', async () => {
394+
const externalLabel = document.createElement('label')
395+
externalLabel.textContent = 'Unrelated'
396+
document.body.appendChild(externalLabel)
397+
398+
await wrapper.find('button').trigger('click')
399+
await nextTick()
400+
await sleep(1)
401+
expect(wrapper.find('[role=group]').exists()).toBe(true)
402+
403+
externalLabel.dispatchEvent(new Event('pointerdown', { bubbles: true }))
404+
// dismiss is emitted after an internal `await nextTick()`.
405+
await sleep(1)
406+
await nextTick()
407+
408+
expect(wrapper.find('[role=group]').exists()).toBe(false)
409+
410+
externalLabel.remove()
411+
})
412+
})
413+
352414
describe('given combobox in a form', async () => {
353415
let wrapper: VueWrapper<InstanceType<any>>
354416
let valueBox: DOMWrapper<HTMLInputElement>

packages/core/src/Combobox/ComboboxContentImpl.vue

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ onUnmounted(() => {
9797
rootContext.triggerElement.value?.focus()
9898
}
9999
})
100+
101+
function isEventTargetWithinCombobox(target: EventTarget | null) {
102+
if (rootContext.parentElement.value?.contains(target as Node))
103+
return true
104+
105+
// A `<label>` associated (via `for`) with an element inside the combobox forwards its
106+
// click/focus to that control, so interacting with it should not dismiss the content.
107+
// Without this, clicking such a label while open dismisses on `pointerdown` and the
108+
// forwarded click/focus immediately re-opens it.
109+
const label = target instanceof Element ? target.closest('label') : null
110+
const control = label?.control
111+
return !!control && !!rootContext.parentElement.value?.contains(control)
112+
}
100113
</script>
101114

102115
<template>
@@ -111,15 +124,15 @@ onUnmounted(() => {
111124
:disable-outside-pointer-events="disableOutsidePointerEvents"
112125
@dismiss="rootContext.onOpenChange(false)"
113126
@focus-outside="(ev) => {
114-
// if clicking inside the combobox, prevent dismiss
115-
if (rootContext.parentElement.value?.contains(ev.target as Node)) ev.preventDefault()
127+
// if focusing inside the combobox (or a label tied to it), prevent dismiss
128+
if (isEventTargetWithinCombobox(ev.target)) ev.preventDefault()
116129
emits('focusOutside', ev)
117130
}"
118131
@interact-outside="emits('interactOutside', $event)"
119132
@escape-key-down="emits('escapeKeyDown', $event)"
120133
@pointer-down-outside="(ev) => {
121-
// if clicking inside the combobox, prevent dismiss
122-
if (rootContext.parentElement.value?.contains(ev.target as Node)) ev.preventDefault()
134+
// if clicking inside the combobox (or a label tied to it), prevent dismiss
135+
if (isEventTargetWithinCombobox(ev.target)) ev.preventDefault()
123136
emits('pointerDownOutside', ev)
124137
}"
125138
>

0 commit comments

Comments
 (0)