From 77ea664136d58679122a5c21480d0521eb3a366a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 23 Oct 2026 23:37:30 +0300 Subject: [PATCH] Add onLongPress to TextInput TextInput already handles its presses through Pressability and exposes onPress, onPressIn and onPressOut, but not onLongPress, so a held press on a TextInput cannot be told apart from a tap. TextInput now hands onLongPress to Pressability. Once a touch is held past the long press delay, onLongPress is called, and the release that follows does not call onPress, as on Pressable. That release is where TextInput focuses itself, so the input is focused when onLongPress fires instead; without that, a held field with contextMenuHidden on Android ends focused by the platform with no keyboard. Without onLongPress a held press is still a press. --- .../Components/TextInput/TextInput.flow.js | 6 + .../Components/TextInput/TextInput.js | 43 +++-- .../TextInput/__tests__/TextInput-itest.js | 159 ++++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 7 +- .../Components/TextInput/TextInput.d.ts | 7 + 5 files changed, 202 insertions(+), 20 deletions(-) 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 c0f8309f0ac5..5d54b062cb5a 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<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -5052,6 +5052,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 @@ -6027,7 +6028,7 @@ export { TaskProvider, // 266dedf2 Text, // 935ec305 TextContentType, // 239b3ecc - TextInput, // cf6467dc + TextInput, // 33a0ccfa TextInputAndroidProps, // 9ebbc103 TextInputBlurEvent, // b4c7a58c TextInputChangeEvent, // 92363e10 @@ -6037,7 +6038,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. */