Skip to content

Commit ac7ce78

Browse files
authored
fix: RAC Numberfield scrolling with stepper buttons (#8474)
* fix: RAC Numberfield scrolling with stepper buttons * trigger inc/dec on press end instead of start, cancel if there's a touch scroll * fix comment * update movement threshold for better feel, make scroll delay longer
1 parent d24dbdd commit ac7ce78

File tree

1 file changed

+86
-9
lines changed

1 file changed

+86
-9
lines changed

packages/@react-aria/spinbutton/src/useSpinButton.ts

Lines changed: 86 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {AriaButtonProps} from '@react-types/button';
1515
import {DOMAttributes, InputBase, RangeInputBase, Validation, ValueBase} from '@react-types/shared';
1616
// @ts-ignore
1717
import intlMessages from '../intl/*.json';
18-
import {useEffect, useRef} from 'react';
18+
import {useCallback, useEffect, useRef} from 'react';
1919
import {useEffectEvent, useGlobalListeners} from '@react-aria/utils';
2020
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2121

@@ -57,7 +57,12 @@ export function useSpinButton(
5757
} = props;
5858
const stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/spinbutton');
5959

60-
const clearAsync = () => clearTimeout(_async.current);
60+
let prevTouchPosition = useRef<{x: number, y: number} | null>(null);
61+
let isSpinning = useRef(false);
62+
const clearAsync = () => {
63+
clearTimeout(_async.current);
64+
isSpinning.current = false;
65+
};
6166

6267

6368
useEffect(() => {
@@ -135,9 +140,23 @@ export function useSpinButton(
135140
}
136141
}, [ariaTextValue]);
137142

143+
// For touch users, if they move their finger like they're scrolling, we don't want to trigger a spin.
144+
let onTouchMove = useCallback((e) => {
145+
if (!prevTouchPosition.current) {
146+
prevTouchPosition.current = {x: e.touches[0].clientX, y: e.touches[0].clientY};
147+
}
148+
let touchPosition = {x: e.touches[0].clientX, y: e.touches[0].clientY};
149+
// Arbitrary distance that worked in testing, even with slight movements or a slow-ish start to scrolling.
150+
if (Math.abs(touchPosition.x - prevTouchPosition.current.x) > 1 || Math.abs(touchPosition.y - prevTouchPosition.current.y) > 1) {
151+
clearAsync();
152+
}
153+
prevTouchPosition.current = touchPosition;
154+
}, []);
155+
138156
const onIncrementPressStart = useEffectEvent(
139157
(initialStepDelay: number) => {
140158
clearAsync();
159+
isSpinning.current = true;
141160
onIncrement?.();
142161
// Start spinning after initial delay
143162
_async.current = window.setTimeout(
@@ -154,6 +173,7 @@ export function useSpinButton(
154173
const onDecrementPressStart = useEffectEvent(
155174
(initialStepDelay: number) => {
156175
clearAsync();
176+
isSpinning.current = true;
157177
onDecrement?.();
158178
// Start spinning after initial delay
159179
_async.current = window.setTimeout(
@@ -173,6 +193,12 @@ export function useSpinButton(
173193

174194
let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
175195

196+
// Tracks in touch if the press end event was preceded by a press up.
197+
// If it wasn't, then we know the finger left the button while still in contact with the screen.
198+
// This means that the user is trying to scroll or interact in some way that shouldn't trigger
199+
// an increment or decrement.
200+
let isUp = useRef(false);
201+
176202
return {
177203
spinButtonProps: {
178204
role: 'spinbutton',
@@ -188,26 +214,77 @@ export function useSpinButton(
188214
onBlur
189215
},
190216
incrementButtonProps: {
191-
onPressStart: () => {
192-
onIncrementPressStart(400);
217+
onPressStart: (e) => {
218+
if (e.pointerType !== 'touch') {
219+
onIncrementPressStart(400);
220+
} else {
221+
if (_async.current) {
222+
clearAsync();
223+
}
224+
// For touch users, don't trigger an increment on press start, we'll wait for the press end to trigger it if
225+
// the control isn't spinning.
226+
_async.current = window.setTimeout(() => {
227+
onIncrementPressStart(60);
228+
}, 600);
229+
230+
addGlobalListener(window, 'touchmove', onTouchMove, {capture: true});
231+
isUp.current = false;
232+
}
193233
addGlobalListener(window, 'contextmenu', cancelContextMenu);
194234
},
195-
onPressEnd: () => {
235+
onPressUp: (e) => {
236+
if (e.pointerType === 'touch') {
237+
isUp.current = true;
238+
}
239+
prevTouchPosition.current = null;
196240
clearAsync();
197241
removeAllGlobalListeners();
198242
},
243+
onPressEnd: (e) => {
244+
if (e.pointerType === 'touch') {
245+
if (!isSpinning.current && isUp.current) {
246+
onIncrement?.();
247+
}
248+
}
249+
isUp.current = false;
250+
},
199251
onFocus,
200252
onBlur
201253
},
202254
decrementButtonProps: {
203-
onPressStart: () => {
204-
onDecrementPressStart(400);
205-
addGlobalListener(window, 'contextmenu', cancelContextMenu);
255+
onPressStart: (e) => {
256+
if (e.pointerType !== 'touch') {
257+
onDecrementPressStart(400);
258+
} else {
259+
if (_async.current) {
260+
clearAsync();
261+
}
262+
// For touch users, don't trigger a decrement on press start, we'll wait for the press end to trigger it if
263+
// the control isn't spinning.
264+
_async.current = window.setTimeout(() => {
265+
onDecrementPressStart(60);
266+
}, 600);
267+
268+
addGlobalListener(window, 'touchmove', onTouchMove, {capture: true});
269+
isUp.current = false;
270+
}
206271
},
207-
onPressEnd: () => {
272+
onPressUp: (e) => {
273+
if (e.pointerType === 'touch') {
274+
isUp.current = true;
275+
}
276+
prevTouchPosition.current = null;
208277
clearAsync();
209278
removeAllGlobalListeners();
210279
},
280+
onPressEnd: (e) => {
281+
if (e.pointerType === 'touch') {
282+
if (!isSpinning.current && isUp.current) {
283+
onDecrement?.();
284+
}
285+
}
286+
isUp.current = false;
287+
},
211288
onFocus,
212289
onBlur
213290
}

0 commit comments

Comments
 (0)