diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index 91bc7901343d..93b5eabd7a19 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -854,6 +854,12 @@ type TextInputBaseProps = Readonly<{ */ onKeyPress?: ?(e: TextInputKeyPressEvent) => unknown, + /** + * Called when a touch is held for 500ms. The input is focused then, as on a + * press, and the release that follows does not call `onPress`. + */ + onLongPress?: ?(event: GestureResponderEvent) => unknown, + /** * Called when a single tap gesture is detected. */ diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 8db8192da372..689bfc6a9b20 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -468,36 +468,45 @@ function InternalTextInput(props: TextInputProps): React.Node { const { editable, hitSlop, + onLongPress, onPress, onPressIn, onPressOut, rejectResponderTermination, } = props; - const config = useMemo( - () => ({ + const config = useMemo(() => { + const focusIfEditable = () => { + if (editable !== false && inputRef.current != null) { + inputRef.current.focus(); + } + }; + return { cancelable: Platform.OS === 'ios' ? !rejectResponderTermination : null, hitSlop, + onLongPress: + onLongPress == null + ? null + : (event: GestureResponderEvent) => { + onLongPress(event); + focusIfEditable(); + }, onPress: (event: GestureResponderEvent) => { onPress?.(event); - if (editable !== false) { - if (inputRef.current != null) { - inputRef.current.focus(); - } - } + focusIfEditable(); }, onPressIn, onPressOut, - }), - [ - editable, - hitSlop, - onPress, - onPressIn, - onPressOut, - rejectResponderTermination, - ], - ); + }; + }, [ + editable, + hitSlop, + onLongPress, + onPress, + onPressIn, + onPressOut, + rejectResponderTermination, + ]); // Hide caret during test runs due to a flashing caret // makes screenshot tests flakey diff --git a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js index 1fd3bfe42808..91b452b53131 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js @@ -18,6 +18,30 @@ import * as React from 'react'; import {createRef, useEffect, useLayoutEffect, useRef} from 'react'; import {TextInput} from 'react-native'; +function touchStart(element: HostInstance): void { + Fantom.dispatchNativeEvent( + element, + 'onTouchStart', + { + touches: [{identifier: 0, pageX: 5, pageY: 5, timestamp: 0}], + changedTouches: [{identifier: 0, pageX: 5, pageY: 5, timestamp: 0}], + }, + {category: Fantom.NativeEventCategory.Discrete}, + ); +} + +function touchEnd(element: HostInstance): void { + Fantom.dispatchNativeEvent( + element, + 'onTouchEnd', + { + touches: [], + changedTouches: [{identifier: 0, pageX: 5, pageY: 5, timestamp: 200}], + }, + {category: Fantom.NativeEventCategory.Discrete}, + ); +} + describe('', () => { describe('props', () => { describe('selection', () => { @@ -255,6 +279,141 @@ describe('', () => { }); }); + describe('onLongPress', () => { + let timers: Fantom.TimerMock; + + beforeEach(() => { + timers = Fantom.installTimerMock(); + }); + + afterEach(() => { + timers.uninstall(); + }); + + it('is called once a touch is held past the long press delay', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onLongPress = jest.fn(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + const element = nullthrows(nodeRef.current); + touchStart(element); + timers.advanceTimersByTime(499); + expect(onLongPress).toHaveBeenCalledTimes(0); + + timers.advanceTimersByTime(1); + expect(onLongPress).toHaveBeenCalledTimes(1); + + touchEnd(element); + timers.runAllTimers(); + }); + + it('focuses the input as a press does, and the release that follows calls no onPress', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onPress = jest.fn(); + + Fantom.runTask(() => { + root.render( + {}} + onPress={onPress} + ref={nodeRef} + style={{height: 100, width: 100}} + />, + ); + }); + + root.takeMountingManagerLogs(); + + const element = nullthrows(nodeRef.current); + touchStart(element); + timers.advanceTimersByTime(500); + + expect(root.takeMountingManagerLogs()).toEqual([ + 'Command {type: "AndroidTextInput", nativeID: "text-input", name: "focus"}', + ]); + + touchEnd(element); + timers.runAllTimers(); + + expect(onPress).toHaveBeenCalledTimes(0); + }); + + it('does not change a press released before the delay, which calls onPress and focuses', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onLongPress = jest.fn(); + const onPress = jest.fn(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + root.takeMountingManagerLogs(); + + const element = nullthrows(nodeRef.current); + touchStart(element); + timers.advanceTimersByTime(100); + touchEnd(element); + timers.runAllTimers(); + + expect(onLongPress).toHaveBeenCalledTimes(0); + expect(onPress).toHaveBeenCalledTimes(1); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Command {type: "AndroidTextInput", nativeID: "text-input", name: "focus"}', + ]); + }); + + it('when not set, leaves a held press calling onPress and focusing', () => { + const root = Fantom.createRoot(); + const nodeRef = createRef(); + const onPress = jest.fn(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + root.takeMountingManagerLogs(); + + const element = nullthrows(nodeRef.current); + touchStart(element); + timers.advanceTimersByTime(500); + touchEnd(element); + timers.runAllTimers(); + + expect(onPress).toHaveBeenCalledTimes(1); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Command {type: "AndroidTextInput", nativeID: "text-input", name: "focus"}', + ]); + }); + }); + describe('id and nativeID', () => { it(`has 'id' propagated as 'nativeID' to the mounting layer`, () => { const root = Fantom.createRoot(); diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 74c407136ff1..9ff05941124b 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<1b73a40ce1edc05ec461e82434a82f45>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5048,6 +5048,7 @@ declare type TextInputBaseProps = { readonly onEndEditing?: (e: TextInputEndEditingEvent) => unknown readonly onFocus?: (e: TextInputFocusEvent) => unknown readonly onKeyPress?: (e: TextInputKeyPressEvent) => unknown + readonly onLongPress?: (event: GestureResponderEvent) => unknown readonly onPress?: (event: GestureResponderEvent) => unknown readonly onPressIn?: (event: GestureResponderEvent) => unknown readonly onPressOut?: (event: GestureResponderEvent) => unknown @@ -6022,7 +6023,7 @@ export { TaskProvider, // 266dedf2 Text, // 6f8269c1 TextContentType, // 239b3ecc - TextInput, // cf6467dc + TextInput, // 33a0ccfa TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b4c7a58c TextInputChangeEvent, // 92363e10 @@ -6032,7 +6033,7 @@ export { TextInputIOSProps, // fb3c9327 TextInputInstance, // 53fe7b73 TextInputKeyPressEvent, // cbbc322a - TextInputProps, // bb087c92 + TextInputProps, // 3ea65957 TextInputSelectionChangeEvent, // 4a8f10c2 TextInputSubmitEditingEvent, // e811fdbd TextInstance, // 7c7e2069 diff --git a/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts index b3ed14124246..a7f8a0de2d3d 100644 --- a/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts +++ b/packages/react-native/types_DEPRECATED/Libraries/Components/TextInput/TextInput.d.ts @@ -828,6 +828,13 @@ export interface TextInputProps */ onEndEditing?: ((e: TextInputEndEditingEvent) => void) | undefined; + /** + * Called when a touch is held for 500ms. The input is focused then, as on a + * press, and the release that follows does not call `onPress`. + */ + onLongPress?: + ((e: NativeSyntheticEvent) => void) | undefined; + /** * Called when a single tap gesture is detected. */