Skip to content

Commit 72a3a1d

Browse files
authored
fix(NumberField): defer touch stepper activation (#2948)
* fix(NumberField): defer touch stepper activation * test(NumberField): cover touch jitter during hold * fix(NumberField): suppress compatibility mouse events
1 parent f4089fc commit 72a3a1d

2 files changed

Lines changed: 224 additions & 23 deletions

File tree

‎packages/core/src/NumberField/NumberField.test.ts‎

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import type { NumberFieldRootProps } from './NumberFieldRoot.vue'
22
import userEvent from '@testing-library/user-event'
33
import { fireEvent, render } from '@testing-library/vue'
44
import { mount } from '@vue/test-utils'
5-
import { beforeEach, describe, expect, it } from 'vitest'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66
import { axe } from 'vitest-axe'
7+
import { nextTick } from 'vue'
78
import { useKbd } from '@/shared'
89
import { handleSubmit } from '@/test'
910
import NumberField from './story/_NumberField.vue'
@@ -21,6 +22,28 @@ function setup(props?: NumberFieldRootProps) {
2122
}
2223

2324
const kbd = useKbd()
25+
26+
async function dispatchPointerEvent(
27+
target: EventTarget,
28+
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
29+
init: MouseEventInit & { pointerId?: number, pointerType?: string } = {},
30+
) {
31+
await nextTick()
32+
const event = new MouseEvent(type, {
33+
bubbles: true,
34+
cancelable: true,
35+
button: 0,
36+
...init,
37+
})
38+
Object.defineProperties(event, {
39+
pointerId: { value: init.pointerId ?? 1 },
40+
pointerType: { value: init.pointerType ?? 'touch' },
41+
})
42+
target.dispatchEvent(event)
43+
await nextTick()
44+
return event
45+
}
46+
2447
describe('numberField', () => {
2548
beforeEach(() => {
2649
// @ts-expect-error aXe throwing error complaining getComputedStyle
@@ -30,6 +53,10 @@ describe('numberField', () => {
3053
document.body.innerHTML = ''
3154
})
3255

56+
afterEach(() => {
57+
vi.useRealTimers()
58+
})
59+
3360
it('should pass axe accessibility tests', async () => {
3461
const { root } = setup()
3562
expect(await axe(root)).toHaveNoViolations()
@@ -91,6 +118,119 @@ describe('numberField', () => {
91118
expect(input.value).toBe('0')
92119
})
93120

121+
describe('with pointer hold controls', () => {
122+
it('should preserve immediate mouse activation', async () => {
123+
const { input, increment } = setup({ defaultValue: 0 })
124+
125+
const pointerDown = await dispatchPointerEvent(increment, 'pointerdown', { pointerType: 'mouse' })
126+
expect(pointerDown.defaultPrevented).toBe(true)
127+
expect(input.value).toBe('1')
128+
129+
await dispatchPointerEvent(window, 'pointerup', { pointerType: 'mouse' })
130+
expect(input.value).toBe('1')
131+
})
132+
133+
it('should activate a touch tap on release', async () => {
134+
const { input, increment } = setup({ defaultValue: 0 })
135+
136+
const pointerDown = await dispatchPointerEvent(increment, 'pointerdown')
137+
expect(pointerDown.defaultPrevented).toBe(true)
138+
expect(input.value).toBe('0')
139+
140+
await dispatchPointerEvent(window, 'pointerup')
141+
expect(input.value).toBe('1')
142+
})
143+
144+
it('should ignore a touch press while disabled', async () => {
145+
vi.useFakeTimers()
146+
const { input, increment } = setup({ defaultValue: 0, disabled: true })
147+
148+
await dispatchPointerEvent(increment, 'pointerdown')
149+
expect(increment).not.toHaveAttribute('data-pressed')
150+
151+
await vi.advanceTimersByTimeAsync(500)
152+
await dispatchPointerEvent(window, 'pointerup')
153+
expect(input.value).toBe('0')
154+
})
155+
156+
it('should cancel a touch press when movement exceeds the tolerance', async () => {
157+
vi.useFakeTimers()
158+
const { input, increment } = setup({ defaultValue: 0 })
159+
160+
await dispatchPointerEvent(increment, 'pointerdown', { clientX: 10, clientY: 10 })
161+
await dispatchPointerEvent(window, 'pointermove', { clientX: 21, clientY: 10 })
162+
await vi.advanceTimersByTimeAsync(500)
163+
await dispatchPointerEvent(window, 'pointerup', { clientX: 21, clientY: 10 })
164+
165+
expect(input.value).toBe('0')
166+
})
167+
168+
it('should repeat a stationary touch press after the hold delay', async () => {
169+
vi.useFakeTimers()
170+
const { input, increment } = setup({ defaultValue: 0 })
171+
172+
await dispatchPointerEvent(increment, 'pointerdown')
173+
await vi.advanceTimersByTimeAsync(399)
174+
expect(input.value).toBe('0')
175+
176+
await vi.advanceTimersByTimeAsync(1)
177+
await nextTick()
178+
expect(input.value).toBe('1')
179+
180+
await vi.advanceTimersByTimeAsync(60)
181+
await nextTick()
182+
expect(input.value).toBe('2')
183+
184+
await dispatchPointerEvent(window, 'pointerup')
185+
await vi.advanceTimersByTimeAsync(60)
186+
expect(input.value).toBe('2')
187+
})
188+
189+
it('should stop an active touch hold when movement exceeds the tolerance', async () => {
190+
vi.useFakeTimers()
191+
const { input, increment } = setup({ defaultValue: 0 })
192+
193+
await dispatchPointerEvent(increment, 'pointerdown', { clientX: 10, clientY: 10 })
194+
await vi.advanceTimersByTimeAsync(400)
195+
await nextTick()
196+
expect(input.value).toBe('1')
197+
198+
await dispatchPointerEvent(window, 'pointermove', { clientX: 21, clientY: 10 })
199+
await vi.advanceTimersByTimeAsync(120)
200+
expect(input.value).toBe('1')
201+
})
202+
203+
it('should tolerate minor movement after a touch hold starts', async () => {
204+
vi.useFakeTimers()
205+
const { input, increment } = setup({ defaultValue: 0 })
206+
207+
await dispatchPointerEvent(increment, 'pointerdown', { clientX: 10, clientY: 10 })
208+
await vi.advanceTimersByTimeAsync(400)
209+
await nextTick()
210+
expect(input.value).toBe('1')
211+
212+
await dispatchPointerEvent(window, 'pointermove', { clientX: 15, clientY: 10 })
213+
await vi.advanceTimersByTimeAsync(60)
214+
await nextTick()
215+
expect(input.value).toBe('2')
216+
217+
await dispatchPointerEvent(window, 'pointerup', { clientX: 15, clientY: 10 })
218+
await vi.advanceTimersByTimeAsync(60)
219+
expect(input.value).toBe('2')
220+
})
221+
222+
it('should cancel a touch press on pointercancel', async () => {
223+
vi.useFakeTimers()
224+
const { input, increment } = setup({ defaultValue: 0 })
225+
226+
await dispatchPointerEvent(increment, 'pointerdown')
227+
await dispatchPointerEvent(window, 'pointercancel')
228+
await vi.advanceTimersByTimeAsync(500)
229+
230+
expect(input.value).toBe('0')
231+
})
232+
})
233+
94234
it('should increase and decrease based on keyboard navigation on input', async () => {
95235
const { input } = setup({ defaultValue: 0, min: 0, max: 10 })
96236

‎packages/core/src/NumberField/utils.ts‎

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,59 +3,120 @@ import type { Ref } from 'vue'
33
import { NumberFormatter, NumberParser } from '@internationalized/number'
44
import { unrefElement, useEventListener } from '@vueuse/core'
55
import { createEventHook, isClient, reactiveComputed } from '@vueuse/shared'
6-
import { computed, ref } from 'vue'
6+
import { computed, onScopeDispose, ref } from 'vue'
7+
8+
const HOLD_DELAY = 400
9+
const REPEAT_INTERVAL = 60
10+
const TOUCH_MOVE_TOLERANCE = 10
711

812
export function usePressedHold(options: { target?: MaybeComputedElementRef, disabled: Ref<boolean> }) {
913
const { disabled } = options
1014
const timeout = ref<number>()
1115
const triggerHook = createEventHook()
12-
const resetTimeout = () => window.clearTimeout(timeout.value)
13-
14-
const onIncrementPressStart = (delay: number) => {
15-
resetTimeout()
16-
if (disabled.value)
16+
const isPressed = ref(false)
17+
const target = computed(() => unrefElement(options.target))
18+
let activePointerId: number | undefined
19+
let activePointerType = ''
20+
let startX = 0
21+
let startY = 0
22+
let holdTriggered = false
23+
24+
const resetTimeout = () => {
25+
if (timeout.value === undefined)
1726
return
1827

19-
triggerHook.trigger()
28+
window.clearTimeout(timeout.value)
29+
timeout.value = undefined
30+
}
2031

21-
timeout.value = window.setTimeout(() => {
22-
onIncrementPressStart(60)
23-
}, delay)
32+
const resetPress = () => {
33+
resetTimeout()
34+
activePointerId = undefined
35+
activePointerType = ''
36+
holdTriggered = false
37+
isPressed.value = false
2438
}
2539

26-
const handlePressStart = () => {
27-
onIncrementPressStart(400)
40+
const trigger = () => {
41+
if (disabled.value) {
42+
resetPress()
43+
return false
44+
}
45+
46+
triggerHook.trigger()
47+
return true
2848
}
2949

30-
const handlePressEnd = () => {
50+
const scheduleRepeat = (delay: number) => {
3151
resetTimeout()
52+
timeout.value = window.setTimeout(() => {
53+
holdTriggered = true
54+
if (trigger())
55+
scheduleRepeat(REPEAT_INTERVAL)
56+
}, delay)
3257
}
3358

3459
// Handle press event, modified version of useMousePressed
35-
const isPressed = ref(false)
36-
const target = computed(() => unrefElement(options.target))
37-
3860
const onPressStart = (event: PointerEvent) => {
3961
// Only handle left clicks, and ignore events that bubbled through portals.
40-
if (event.button !== 0 || isPressed.value)
62+
if (event.button !== 0 || isPressed.value || disabled.value)
4163
return
4264

4365
event.preventDefault()
66+
activePointerId = event.pointerId
67+
activePointerType = event.pointerType
68+
startX = event.clientX
69+
startY = event.clientY
70+
holdTriggered = false
4471
isPressed.value = true
45-
handlePressStart()
72+
73+
if (event.pointerType === 'mouse') {
74+
holdTriggered = true
75+
if (trigger())
76+
scheduleRepeat(HOLD_DELAY)
77+
return
78+
}
79+
80+
scheduleRepeat(HOLD_DELAY)
4681
}
4782

48-
const onPressRelease = () => {
49-
isPressed.value = false
50-
handlePressEnd()
83+
const onPressMove = (event: PointerEvent) => {
84+
if (event.pointerId !== activePointerId || activePointerType === 'mouse')
85+
return
86+
87+
const distanceX = event.clientX - startX
88+
const distanceY = event.clientY - startY
89+
const movedBeyondTolerance = distanceX * distanceX + distanceY * distanceY > TOUCH_MOVE_TOLERANCE ** 2
90+
91+
if (movedBeyondTolerance)
92+
resetPress()
93+
}
94+
95+
const onPressRelease = (event: PointerEvent) => {
96+
if (event.pointerId !== activePointerId)
97+
return
98+
99+
const shouldTriggerTap = activePointerType !== 'mouse' && !holdTriggered
100+
resetPress()
101+
102+
if (shouldTriggerTap)
103+
trigger()
104+
}
105+
106+
const onPressCancel = (event: PointerEvent) => {
107+
if (event.pointerId === activePointerId)
108+
resetPress()
51109
}
52110

53111
if (isClient) {
54112
useEventListener(target || window, 'pointerdown', onPressStart)
113+
useEventListener(window, 'pointermove', onPressMove)
55114
useEventListener(window, 'pointerup', onPressRelease)
56-
useEventListener(window, 'pointercancel', onPressRelease)
115+
useEventListener(window, 'pointercancel', onPressCancel)
57116
}
58117

118+
onScopeDispose(resetPress)
119+
59120
return {
60121
isPressed,
61122
onTrigger: triggerHook.on,

0 commit comments

Comments
 (0)