Skip to content

Commit 43989e8

Browse files
authored
fix(SelectTrigger): prevent suppression of mousedown event (#2688)
* fix(SelectTrigger): prevent supression of mousedown event * refactor(SelectTrigger): extract condition as function * test(Select): add state for pointerdown openers and tests
1 parent 52aa59a commit 43989e8

2 files changed

Lines changed: 112 additions & 33 deletions

File tree

packages/core/src/Select/Select.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,69 @@ describe('given default Select', () => {
3838
expect(selectTrigger.attributes('data-placeholder')).toBe('')
3939
})
4040

41+
describe('trigger mouse interop', () => {
42+
async function openSelectWithMouseClick() {
43+
const button = wrapper.find('button')
44+
// Open on pointerdown, then emit the compatibility mouse events that follow in browsers.
45+
await button.trigger('pointerdown', { button: 0, ctrlKey: false })
46+
fireEvent.mouseDown(button.element, { button: 0, ctrlKey: false })
47+
fireEvent.mouseUp(button.element, { button: 0, ctrlKey: false })
48+
fireEvent.click(button.element, { button: 0, ctrlKey: false })
49+
await nextTick()
50+
await nextTick()
51+
}
52+
53+
it('should not suppress window mousedown listeners when opening (#1773)', async () => {
54+
const button = wrapper.find('button').element
55+
const onWindowMousedown = vi.fn()
56+
window.addEventListener('mousedown', onWindowMousedown, true)
57+
58+
fireEvent.pointerDown(button, { button: 0, ctrlKey: false, pointerType: 'mouse' })
59+
const mousedownEvent = new MouseEvent('mousedown', {
60+
bubbles: true,
61+
cancelable: true,
62+
button: 0,
63+
ctrlKey: false,
64+
})
65+
button.dispatchEvent(mousedownEvent)
66+
67+
expect(onWindowMousedown).toHaveBeenCalled()
68+
expect(mousedownEvent.defaultPrevented).toBe(true)
69+
70+
window.removeEventListener('mousedown', onWindowMousedown, true)
71+
})
72+
73+
it('should focus the trigger on click without a preceding pointerdown', async () => {
74+
const trigger = wrapper.find('[role="combobox"]').element as HTMLElement
75+
const focusSpy = vi.spyOn(trigger, 'focus')
76+
77+
fireEvent.click(trigger, { button: 0, ctrlKey: false })
78+
79+
expect(focusSpy).toHaveBeenCalled()
80+
focusSpy.mockRestore()
81+
})
82+
83+
it('should not re-focus the trigger on click after opening via pointerdown', async () => {
84+
const trigger = wrapper.find('[role="combobox"]').element as HTMLElement
85+
const focusSpy = vi.spyOn(trigger, 'focus')
86+
87+
await wrapper.find('button').trigger('pointerdown', { button: 0, ctrlKey: false })
88+
fireEvent.click(trigger, { button: 0, ctrlKey: false })
89+
90+
expect(focusSpy).not.toHaveBeenCalled()
91+
focusSpy.mockRestore()
92+
})
93+
94+
it('should not leave focus on the trigger after opening via mouse click', async () => {
95+
const trigger = wrapper.find('[role="combobox"]').element
96+
97+
await openSelectWithMouseClick()
98+
99+
expect(wrapper.html()).toContain('Apple')
100+
expect(document.activeElement).not.toBe(trigger)
101+
})
102+
})
103+
41104
describe('opening the modal', () => {
42105
beforeEach(async () => {
43106
await wrapper.find('button').trigger('pointerdown', {

packages/core/src/Select/SelectTrigger.vue

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,52 @@ function handlePointerOpen(event: PointerEvent) {
4747
y: Math.round(event.pageY),
4848
}
4949
}
50+
51+
function isPlainLeftClick(event: MouseEvent) {
52+
return event.button === 0 && event.ctrlKey === false
53+
}
54+
55+
// Tracks direct mouse presses handled in `pointerdown` so the Safari label
56+
// `click` workaround below does not re-focus the trigger after opening.
57+
let openedFromPointerDown = false
58+
59+
function onTriggerPointerDown(event: PointerEvent) {
60+
// Prevent opening on touch down.
61+
// https://github.com/unovue/reka-ui/issues/804
62+
if (event.pointerType === 'touch')
63+
return event.preventDefault()
64+
65+
// prevent implicit pointer capture
66+
// https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture
67+
const target = event.target as HTMLElement
68+
if (target.hasPointerCapture(event.pointerId))
69+
target.releasePointerCapture(event.pointerId)
70+
71+
// only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
72+
// but not when the control key is pressed (avoiding MacOS right click)
73+
if (isPlainLeftClick(event)) {
74+
handlePointerOpen(event)
75+
openedFromPointerDown = true
76+
}
77+
}
78+
79+
function onTriggerMouseDown(event: MouseEvent) {
80+
// Prevent trigger from stealing focus from the active item after opening.
81+
// We avoid calling `preventDefault` in `pointerdown` because that suppresses
82+
// compatibility mouse events (`mousedown`, `mouseup`, `click`).
83+
if (isPlainLeftClick(event))
84+
event.preventDefault()
85+
}
86+
87+
function onTriggerClick(event: MouseEvent) {
88+
// Safari: label-associated clicks may not run `pointerdown` on the trigger.
89+
// Direct mouse clicks open in `pointerdown` and must not re-focus the trigger
90+
// here — `mousedown` `preventDefault` does not suppress `click`.
91+
if (!openedFromPointerDown)
92+
(event.currentTarget as HTMLElement)?.focus()
93+
94+
openedFromPointerDown = false
95+
}
5096
</script>
5197

5298
<template>
@@ -69,39 +115,9 @@ function handlePointerOpen(event: PointerEvent) {
69115
:data-placeholder="shouldShowPlaceholder(rootContext.modelValue?.value) ? '' : undefined"
70116
:as-child="asChild"
71117
:as="as"
72-
@click="
73-
(event: MouseEvent) => {
74-
// Whilst browsers generally have no issue focusing the trigger when clicking
75-
// on a label, Safari seems to struggle with the fact that there's no `onClick`.
76-
// We force `focus` in this case. Note: this doesn't create any other side-effect
77-
// because we are preventing default in `onPointerDown` so effectively
78-
// this only runs for a label 'click'
79-
(event?.currentTarget as HTMLElement)?.focus();
80-
}
81-
"
82-
@pointerdown="
83-
(event: PointerEvent) => {
84-
// Prevent opening on touch down.
85-
// https://github.com/unovue/reka-ui/issues/804
86-
if (event.pointerType === 'touch')
87-
return event.preventDefault();
88-
89-
// prevent implicit pointer capture
90-
// https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture
91-
const target = event.target as HTMLElement;
92-
if (target.hasPointerCapture(event.pointerId)) {
93-
target.releasePointerCapture(event.pointerId);
94-
}
95-
96-
// only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
97-
// but not when the control key is pressed (avoiding MacOS right click)
98-
if (event.button === 0 && event.ctrlKey === false) {
99-
handlePointerOpen(event)
100-
// prevent trigger from stealing focus from the active item after opening.
101-
event.preventDefault();
102-
}
103-
}
104-
"
118+
@click="onTriggerClick"
119+
@pointerdown="onTriggerPointerDown"
120+
@mousedown="onTriggerMouseDown"
105121
@pointerup.prevent="
106122
(event: PointerEvent) => {
107123
// Only open on pointer up when using touch devices

0 commit comments

Comments
 (0)