From 30ecc6ff2251eeec5f9f48ecb5d8f0fd6eb35bcc Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 21:10:41 +0300 Subject: [PATCH 1/2] 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. Without onLongPress a held press is still a press. --- .../Components/TextInput/TextInput.flow.js | 6 + .../Components/TextInput/TextInput.js | 3 + .../TextInput/__tests__/TextInput-itest.js | 152 ++++++++++++++++++ packages/react-native/ReactNativeApi.d.ts | 7 +- .../Components/TextInput/TextInput.d.ts | 7 + 5 files changed, 172 insertions(+), 3 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..383542f32d9b 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 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..045e9f5e148c 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -468,6 +468,7 @@ function InternalTextInput(props: TextInputProps): React.Node { const { editable, hitSlop, + onLongPress, onPress, onPressIn, onPressOut, @@ -478,6 +479,7 @@ function InternalTextInput(props: TextInputProps): React.Node { () => ({ cancelable: Platform.OS === 'ios' ? !rejectResponderTermination : null, hitSlop, + onLongPress, onPress: (event: GestureResponderEvent) => { onPress?.(event); if (editable !== false) { @@ -492,6 +494,7 @@ function InternalTextInput(props: TextInputProps): React.Node { [ editable, hitSlop, + onLongPress, onPress, onPressIn, onPressOut, 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..62e92b8c8b72 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,134 @@ 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); + }); + + it('takes the place of the press, so the release calls no onPress and sends no focus command', () => { + 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); + touchEnd(element); + timers.runAllTimers(); + + expect(onPress).toHaveBeenCalledTimes(0); + expect(root.takeMountingManagerLogs()).toEqual([]); + }); + + it('leaves a press released before the delay a press that 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('leaves a held press a press that focuses when it is not set', () => { + 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..8159c870f222 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 release that follows does not + * call `onPress`. + */ + onLongPress?: + ((e: NativeSyntheticEvent) => void) | undefined; + /** * Called when a single tap gesture is detected. */ From 39a8fd07d64856dd63ce564ba535231d1bccf414 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 17 Oct 2026 00:47:24 +0300 Subject: [PATCH 2/2] Focus TextInput when a long press takes the place of its press The release of a press is where TextInput focuses itself, and a long press removes that release, so a held field was focused only by the platform: on Android, a field with contextMenuHidden ended focused with no keyboard. TextInput now focuses the input when onLongPress fires, as it does on a press. Without onLongPress nothing changes. The first onLongPress case now lifts its finger: the touch it left down kept the responder, so the next case's touches never reached its field. --- .../Components/TextInput/TextInput.flow.js | 4 +- .../Components/TextInput/TextInput.js | 44 +++++++++++-------- .../TextInput/__tests__/TextInput-itest.js | 15 +++++-- .../Components/TextInput/TextInput.d.ts | 4 +- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js index 383542f32d9b..93b5eabd7a19 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.flow.js @@ -855,8 +855,8 @@ type TextInputBaseProps = Readonly<{ onKeyPress?: ?(e: TextInputKeyPressEvent) => unknown, /** - * Called when a touch is held for 500ms. The release that follows does not - * call `onPress`. + * 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, diff --git a/packages/react-native/Libraries/Components/TextInput/TextInput.js b/packages/react-native/Libraries/Components/TextInput/TextInput.js index 045e9f5e148c..689bfc6a9b20 100644 --- a/packages/react-native/Libraries/Components/TextInput/TextInput.js +++ b/packages/react-native/Libraries/Components/TextInput/TextInput.js @@ -475,32 +475,38 @@ function InternalTextInput(props: TextInputProps): React.Node { 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: + 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, - onLongPress, - 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 62e92b8c8b72..91b452b53131 100644 --- a/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js +++ b/packages/react-native/Libraries/Components/TextInput/__tests__/TextInput-itest.js @@ -312,9 +312,12 @@ describe('', () => { timers.advanceTimersByTime(1); expect(onLongPress).toHaveBeenCalledTimes(1); + + touchEnd(element); + timers.runAllTimers(); }); - it('takes the place of the press, so the release calls no onPress and sends no focus command', () => { + 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(); @@ -336,14 +339,18 @@ describe('', () => { 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); - expect(root.takeMountingManagerLogs()).toEqual([]); }); - it('leaves a press released before the delay a press that focuses', () => { + 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(); @@ -376,7 +383,7 @@ describe('', () => { ]); }); - it('leaves a held press a press that focuses when it is not set', () => { + it('when not set, leaves a held press calling onPress and focusing', () => { const root = Fantom.createRoot(); const nodeRef = createRef(); const onPress = jest.fn(); 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 8159c870f222..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 @@ -829,8 +829,8 @@ export interface TextInputProps onEndEditing?: ((e: TextInputEndEditingEvent) => void) | undefined; /** - * Called when a touch is held for 500ms. The release that follows does not - * call `onPress`. + * 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;