diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6f07113572..5ffb407db0 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -110,6 +110,12 @@ export type Props = Omit & { * The number of milliseconds a user must touch the element before executing `onLongPress`. */ delayLongPress?: number; + /** + * Delay in ms, from the start of the touch, before the press feedback (ripple) is shown (Native-only). + * Quick taps shorter than this delay will still fire `onPress` but might not show the ripple effect. + * Recommended values for buttons in scrollable lists are between 50ms and 100ms. + */ + unstable_pressDelay?: number; /** * Style of button's inner content. * Use this prop to apply custom height and width, to set a custom padding or to set the icon on the right with `flexDirection: 'row-reverse'`. @@ -179,6 +185,7 @@ const Button = ({ onPressOut, onLongPress, delayLongPress, + unstable_pressDelay, style, theme: themeOverrides, uppercase: uppercaseProp, @@ -327,6 +334,7 @@ const Button = ({ onPressIn={hasPassedTouchHandler ? handlePressIn : undefined} onPressOut={hasPassedTouchHandler ? handlePressOut : undefined} delayLongPress={delayLongPress} + unstable_pressDelay={unstable_pressDelay} aria-label={ariaLabel} accessibilityHint={accessibilityHint} role={role} diff --git a/src/components/__tests__/Button.test.tsx b/src/components/__tests__/Button.test.tsx index 7ea496a419..ec562cdb1d 100644 --- a/src/components/__tests__/Button.test.tsx +++ b/src/components/__tests__/Button.test.tsx @@ -3,7 +3,7 @@ import { StyleSheet } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/react-native'; -import { render, screen } from '../../test-utils'; +import { act, fireEvent, render, screen } from '../../test-utils'; import { pink500, white } from '../../theme/colors'; import { DarkTheme, LightTheme } from '../../theme/schemes'; import { tokens } from '../../theme/tokens'; @@ -98,6 +98,104 @@ it('renders active button if only onLongPress handler is passed', async () => { expect(screen.getByTestId('active-button')).toBeEnabled(); }); +const createResponderEvent = () => ({ + persist: () => {}, + nativeEvent: { timestamp: Date.now() }, + currentTarget: { + measure: (callback: any) => { + callback(0, 0, 100, 100, 0, 0); + }, + }, +}); + +it('suppresses onPressIn when touch is terminated before unstable_pressDelay', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn(); + + await render( + + ); + + const button = screen.getByTestId('delay-button'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(50); + }); + await fireEvent(button, 'responderTerminate', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).not.toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); +}); + +it('triggers onPressIn after unstable_pressDelay when touch is not terminated', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn(); + + await render( + + ); + + const button = screen.getByTestId('delay-button'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + expect(onPressIn).not.toHaveBeenCalled(); + + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).toHaveBeenCalledTimes(1); + + await fireEvent(button, 'responderRelease', createResponderEvent()); + expect(onPress).toHaveBeenCalledTimes(1); +}); + +it('triggers onPress for fast taps shorter than unstable_pressDelay', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn(); + + await render( + + ); + + const button = screen.getByTestId('delay-button'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(60); + }); + expect(onPressIn).not.toHaveBeenCalled(); + + await fireEvent(button, 'responderRelease', createResponderEvent()); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPressIn).toHaveBeenCalledTimes(1); +}); + it('renders button with color', async () => { const tree = ( await render() diff --git a/src/components/__tests__/TouchableRipple.test.tsx b/src/components/__tests__/TouchableRipple.test.tsx index 663d540bec..bdfa9823f5 100644 --- a/src/components/__tests__/TouchableRipple.test.tsx +++ b/src/components/__tests__/TouchableRipple.test.tsx @@ -4,7 +4,7 @@ import type { GestureResponderEvent } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import { userEvent } from '@testing-library/react-native'; -import { render, screen } from '../../test-utils'; +import { act, fireEvent, render, screen } from '../../test-utils'; import TouchableRipple from '../TouchableRipple/TouchableRipple.native'; describe('TouchableRipple', () => { @@ -44,6 +44,104 @@ describe('TouchableRipple', () => { expect(onPress).not.toHaveBeenCalled(); }); + const createResponderEvent = () => ({ + persist: () => {}, + nativeEvent: { timestamp: Date.now() }, + currentTarget: { + measure: (callback: any) => { + callback(0, 0, 100, 100, 0, 0); + }, + }, + }); + + it('suppresses onPressIn when touch is terminated before unstable_pressDelay', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + + await render( + + Button + + ); + + const button = screen.getByTestId('delay-ripple'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(50); + }); + await fireEvent(button, 'responderTerminate', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).not.toHaveBeenCalled(); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('triggers onPressIn after unstable_pressDelay when touch is not terminated', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + + await render( + + Button + + ); + + const button = screen.getByTestId('delay-ripple'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + expect(onPressIn).not.toHaveBeenCalled(); + + await act(() => { + jest.advanceTimersByTime(100); + }); + + expect(onPressIn).toHaveBeenCalledTimes(1); + + await fireEvent(button, 'responderRelease', createResponderEvent()); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('triggers onPress for fast taps shorter than unstable_pressDelay', async () => { + const onPressIn = jest.fn(); + const onPress = jest.fn<(event: GestureResponderEvent) => void>(); + + await render( + + Button + + ); + + const button = screen.getByTestId('delay-ripple'); + + await fireEvent(button, 'responderGrant', createResponderEvent()); + await act(() => { + jest.advanceTimersByTime(60); + }); + expect(onPressIn).not.toHaveBeenCalled(); + + await fireEvent(button, 'responderRelease', createResponderEvent()); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPressIn).toHaveBeenCalledTimes(1); + }); + describe('on iOS', () => { Platform.OS = 'ios';