@@ -15,7 +15,7 @@ import {AriaButtonProps} from '@react-types/button';
1515import { DOMAttributes , InputBase , RangeInputBase , Validation , ValueBase } from '@react-types/shared' ;
1616// @ts -ignore
1717import intlMessages from '../intl/*.json' ;
18- import { useEffect , useRef } from 'react' ;
18+ import { useCallback , useEffect , useRef } from 'react' ;
1919import { useEffectEvent , useGlobalListeners } from '@react-aria/utils' ;
2020import { 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