@@ -15,7 +15,7 @@ import {AriaButtonProps} from '@react-types/button';
15
15
import { DOMAttributes , InputBase , RangeInputBase , Validation , ValueBase } from '@react-types/shared' ;
16
16
// @ts -ignore
17
17
import intlMessages from '../intl/*.json' ;
18
- import { useEffect , useRef } from 'react' ;
18
+ import { useCallback , useEffect , useRef } from 'react' ;
19
19
import { useEffectEvent , useGlobalListeners } from '@react-aria/utils' ;
20
20
import { useLocalizedStringFormatter } from '@react-aria/i18n' ;
21
21
@@ -57,7 +57,12 @@ export function useSpinButton(
57
57
} = props ;
58
58
const stringFormatter = useLocalizedStringFormatter ( intlMessages , '@react-aria/spinbutton' ) ;
59
59
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
+ } ;
61
66
62
67
63
68
useEffect ( ( ) => {
@@ -135,9 +140,23 @@ export function useSpinButton(
135
140
}
136
141
} , [ ariaTextValue ] ) ;
137
142
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
+
138
156
const onIncrementPressStart = useEffectEvent (
139
157
( initialStepDelay : number ) => {
140
158
clearAsync ( ) ;
159
+ isSpinning . current = true ;
141
160
onIncrement ?.( ) ;
142
161
// Start spinning after initial delay
143
162
_async . current = window . setTimeout (
@@ -154,6 +173,7 @@ export function useSpinButton(
154
173
const onDecrementPressStart = useEffectEvent (
155
174
( initialStepDelay : number ) => {
156
175
clearAsync ( ) ;
176
+ isSpinning . current = true ;
157
177
onDecrement ?.( ) ;
158
178
// Start spinning after initial delay
159
179
_async . current = window . setTimeout (
@@ -173,6 +193,12 @@ export function useSpinButton(
173
193
174
194
let { addGlobalListener, removeAllGlobalListeners} = useGlobalListeners ( ) ;
175
195
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
+
176
202
return {
177
203
spinButtonProps : {
178
204
role : 'spinbutton' ,
@@ -188,26 +214,77 @@ export function useSpinButton(
188
214
onBlur
189
215
} ,
190
216
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
+ }
193
233
addGlobalListener ( window , 'contextmenu' , cancelContextMenu ) ;
194
234
} ,
195
- onPressEnd : ( ) => {
235
+ onPressUp : ( e ) => {
236
+ if ( e . pointerType === 'touch' ) {
237
+ isUp . current = true ;
238
+ }
239
+ prevTouchPosition . current = null ;
196
240
clearAsync ( ) ;
197
241
removeAllGlobalListeners ( ) ;
198
242
} ,
243
+ onPressEnd : ( e ) => {
244
+ if ( e . pointerType === 'touch' ) {
245
+ if ( ! isSpinning . current && isUp . current ) {
246
+ onIncrement ?.( ) ;
247
+ }
248
+ }
249
+ isUp . current = false ;
250
+ } ,
199
251
onFocus,
200
252
onBlur
201
253
} ,
202
254
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
+ }
206
271
} ,
207
- onPressEnd : ( ) => {
272
+ onPressUp : ( e ) => {
273
+ if ( e . pointerType === 'touch' ) {
274
+ isUp . current = true ;
275
+ }
276
+ prevTouchPosition . current = null ;
208
277
clearAsync ( ) ;
209
278
removeAllGlobalListeners ( ) ;
210
279
} ,
280
+ onPressEnd : ( e ) => {
281
+ if ( e . pointerType === 'touch' ) {
282
+ if ( ! isSpinning . current && isUp . current ) {
283
+ onDecrement ?.( ) ;
284
+ }
285
+ }
286
+ isUp . current = false ;
287
+ } ,
211
288
onFocus,
212
289
onBlur
213
290
}
0 commit comments