diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/regression/KeyboardDismissOnDrag.stories.tsx b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/regression/KeyboardDismissOnDrag.stories.tsx
new file mode 100644
index 000000000..7dcdd52da
--- /dev/null
+++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/regression/KeyboardDismissOnDrag.stories.tsx
@@ -0,0 +1,181 @@
+import React, { useState } from 'react';
+import { ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
+import { EnrichedMarkdownTextInput } from 'react-native-enriched-markdown';
+import { storyMeta } from '../shared/storyMeta';
+import type { InputStory } from '../shared/storyTypes';
+
+type FocusLog = {
+ focused: boolean;
+ focusCount: number;
+ blurCount: number;
+};
+
+const INITIAL_LOG: FocusLog = { focused: false, focusCount: 0, blurCount: 0 };
+
+const FILLER_ROWS = Array.from({ length: 12 }, (_, index) => index + 1);
+
+function useFocusLog() {
+ const [log, setLog] = useState(INITIAL_LOG);
+ return {
+ log,
+ onFocus: () =>
+ setLog((prev) => ({
+ ...prev,
+ focused: true,
+ focusCount: prev.focusCount + 1,
+ })),
+ onBlur: () =>
+ setLog((prev) => ({
+ ...prev,
+ focused: false,
+ blurCount: prev.blurCount + 1,
+ })),
+ };
+}
+
+function FocusStatus({
+ label,
+ log,
+ testID,
+}: {
+ label: string;
+ log: FocusLog;
+ testID: string;
+}) {
+ return (
+
+ {label}: {log.focused ? 'focused' : 'blurred'} · focus {log.focusCount} ·
+ blur {log.blurCount}
+
+ );
+}
+
+function KeyboardDismissOnDragStory({
+ rejectResponderTermination,
+}: {
+ rejectResponderTermination?: boolean;
+}) {
+ const enriched = useFocusLog();
+ const builtIn = useFocusLog();
+
+ return (
+
+ Keyboard dismiss on drag (#1158)
+
+ Tap an input, then drag a short way starting on it and release. The drag
+ dismisses the keyboard. For the enriched input it must stay dismissed:
+ the focus count must not go up after the release. React Native's
+ TextInput is here for comparison and does re-focus.
+
+
+
+
+
+
+
+
+
+
+
+
+ {FILLER_ROWS.map((row) => (
+
+ Row {row}
+
+ ))}
+
+ );
+}
+
+export default storyMeta('Regression', 'Keyboard Dismiss On Drag (#1158)');
+
+export const Default: InputStory = {
+ args: { rejectResponderTermination: true },
+ argTypes: {
+ rejectResponderTermination: {
+ control: 'boolean',
+ description:
+ 'Passed to both inputs. Off lets the scroll view take the touch, which is the workaround from the issue.',
+ },
+ },
+ render: ({ rejectResponderTermination }) => (
+
+ ),
+};
+
+const styles = StyleSheet.create({
+ container: {
+ padding: 16,
+ gap: 8,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ description: {
+ fontSize: 14,
+ color: '#555',
+ marginBottom: 4,
+ },
+ status: {
+ fontSize: 13,
+ color: '#6B7280',
+ },
+ editorContainer: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ overflow: 'hidden',
+ backgroundColor: '#fff',
+ },
+ input: {
+ minHeight: 120,
+ fontSize: 15,
+ color: '#111827',
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ filler: {
+ height: 56,
+ justifyContent: 'center',
+ paddingHorizontal: 14,
+ borderRadius: 8,
+ backgroundColor: '#F3F4F6',
+ },
+ fillerText: {
+ fontSize: 14,
+ color: '#6B7280',
+ },
+});
diff --git a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/shared/storyMeta.ts b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/shared/storyMeta.ts
index ff1341f18..65d22c600 100644
--- a/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/shared/storyMeta.ts
+++ b/apps/react-native-example/.rnstorybook/stories/components/EnrichedMarkdownTextInput/shared/storyMeta.ts
@@ -1,7 +1,7 @@
import type { Meta } from '@storybook/react-native';
import { EnrichedMarkdownTextInput } from 'react-native-enriched-markdown';
-export type InputStoryCategory = 'Inline' | 'Props' | 'Methods';
+export type InputStoryCategory = 'Inline' | 'Props' | 'Methods' | 'Regression';
export const inputActionArgTypes = {
onChangeMarkdown: { action: 'onChangeMarkdown' },
diff --git a/docs-md/API_REFERENCE.md b/docs-md/API_REFERENCE.md
index ad226aa75..bfe53385c 100644
--- a/docs-md/API_REFERENCE.md
+++ b/docs-md/API_REFERENCE.md
@@ -1111,7 +1111,7 @@ The input participates in React Native's text-input focus tracking (`TextInput.S
### `onPress`, `onPressIn`, `onPressOut`
-Fire for taps on the input, like React Native `TextInput`. The input claims the JS touch responder, so a tap that focuses it does not also fire the `onPress` of an ancestor `Pressable`. After `onPress` runs, the input focuses itself (unless `editable` is `false`) without changing the current selection. As on `TextInput`, responder props passed through `ViewProps` (`onStartShouldSetResponder`, `onResponderGrant`, and the rest) are overridden by the input's press handling.
+Fire for taps on the input, like React Native `TextInput`. The input claims the JS touch responder, so a tap that focuses it does not also fire the `onPress` of an ancestor `Pressable`. After `onPress` runs, the input focuses itself (unless `editable` is `false`) without changing the current selection. A drag is not a tap: if the finger moves more than 10 points, the input does not focus itself, so a scroll that starts on the input leaves the keyboard dismissed. As on `TextInput`, responder props passed through `ViewProps` (`onStartShouldSetResponder`, `onResponderGrant`, and the rest) are overridden by the input's press handling.
| Type | Default Value | Platform |
| ------------------------------------------- | ------------- | -------- |
diff --git a/docs/docs/react-native/api-reference/enriched-markdown-text-input.md b/docs/docs/react-native/api-reference/enriched-markdown-text-input.md
index 9c6532192..df329ec74 100644
--- a/docs/docs/react-native/api-reference/enriched-markdown-text-input.md
+++ b/docs/docs/react-native/api-reference/enriched-markdown-text-input.md
@@ -556,7 +556,7 @@ The input registers with React Native's text-input focus tracking (`TextInput.St
### `onPress`, `onPressIn`, `onPressOut` {#onpress}
-Fire for taps on the input, like the same props on React Native [`TextInput`](https://reactnative.dev/docs/textinput#onpressin). The input claims the JS touch responder, so a tap that focuses it does **not** also fire the `onPress` of an ancestor [`Pressable`](https://reactnative.dev/docs/pressable). After `onPress` runs the input focuses itself - unless [`editable`](#editable) is `false` - without moving the selection.
+Fire for taps on the input, like the same props on React Native [`TextInput`](https://reactnative.dev/docs/textinput#onpressin). The input claims the JS touch responder, so a tap that focuses it does **not** also fire the `onPress` of an ancestor [`Pressable`](https://reactnative.dev/docs/pressable). After `onPress` runs the input focuses itself - unless [`editable`](#editable) is `false` - without moving the selection. A drag is not a tap: if the finger moves more than 10 points, the input does not focus itself, so a scroll that starts on the input leaves the keyboard dismissed.
diff --git a/packages/react-native-enriched-markdown/__tests__/input-press-focus.test.tsx b/packages/react-native-enriched-markdown/__tests__/input-press-focus.test.tsx
new file mode 100644
index 000000000..5fd48e1e9
--- /dev/null
+++ b/packages/react-native-enriched-markdown/__tests__/input-press-focus.test.tsx
@@ -0,0 +1,120 @@
+import { act } from 'react';
+import { createRoot } from 'test-renderer';
+import type { Root, TestInstance } from 'test-renderer';
+import { EnrichedMarkdownTextInput } from '../src/EnrichedMarkdownTextInput';
+import type { EnrichedMarkdownTextInputProps } from '../src/EnrichedMarkdownTextInput';
+import { TextInputState } from '../src/utils/textInputState';
+
+jest.mock('../src/EnrichedMarkdownTextInputNativeComponent', () => ({
+ __esModule: true,
+ default: 'EnrichedMarkdownTextInputNativeComponent',
+ Commands: {},
+}));
+
+type Point = { pageX: number; pageY: number };
+
+const INPUT_FRAME = { width: 320, height: 120, pageX: 0, pageY: 100 };
+const START: Point = { pageX: 160, pageY: 160 };
+
+function responderEvent(nativeEvent: Point) {
+ return {
+ persist: () => {},
+ currentTarget: {
+ measure: (callback: (...frame: number[]) => void) => {
+ const { width, height, pageX, pageY } = INPUT_FRAME;
+ callback(0, 0, width, height, pageX, pageY);
+ },
+ },
+ nativeEvent,
+ };
+}
+
+let root: Root;
+let focusTextInput: jest.SpyInstance;
+
+function renderInput(props: EnrichedMarkdownTextInputProps = {}): TestInstance {
+ root = createRoot();
+ act(() => root.render());
+
+ const [nativeView] = root.container.queryAll(
+ (instance) => instance.type === 'EnrichedMarkdownTextInputNativeComponent'
+ );
+ if (!nativeView) throw new Error('Native input view was not rendered');
+ return nativeView;
+}
+
+function press(view: TestInstance, ...moves: Point[]) {
+ act(() => view.props.onResponderGrant(responderEvent(START)));
+ for (const point of moves) {
+ act(() => view.props.onResponderMove(responderEvent(point)));
+ }
+ act(() =>
+ view.props.onResponderRelease(responderEvent(moves.at(-1) ?? START))
+ );
+}
+
+beforeEach(() => {
+ focusTextInput = jest
+ .spyOn(TextInputState, 'focusTextInput')
+ .mockImplementation(() => {});
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ focusTextInput.mockRestore();
+});
+
+it.each<[string, Point[], number]>([
+ ['focuses on a tap released where it started', [], 1],
+ [
+ 'focuses when the finger shifts a few points during a tap',
+ [{ pageX: 163, pageY: 166 }],
+ 1,
+ ],
+ [
+ 'does not focus after a short drag that stays inside the input',
+ [
+ { pageX: 160, pageY: 147 },
+ { pageX: 160, pageY: 134 },
+ ],
+ 0,
+ ],
+ [
+ 'does not focus after a drag that returns to where it started',
+ [{ pageX: 160, pageY: 140 }, START],
+ 0,
+ ],
+ [
+ 'does not focus after a drag that leaves the press area and comes back',
+ [
+ { pageX: 160, pageY: 60 },
+ { pageX: 160, pageY: 85 },
+ ],
+ 0,
+ ],
+])('%s', (_name, moves, focusCalls) => {
+ const onPress = jest.fn();
+
+ press(renderInput({ onPress }), ...moves);
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(focusTextInput).toHaveBeenCalledTimes(focusCalls);
+});
+
+it('focuses on a tap that follows a drag', () => {
+ const view = renderInput();
+
+ press(view, { pageX: 160, pageY: 134 });
+ press(view);
+
+ expect(focusTextInput).toHaveBeenCalledTimes(1);
+});
+
+it.each<[string, boolean | undefined, boolean]>([
+ ['keeps the responder during a press by default', undefined, false],
+ ['gives up the responder with rejectResponderTermination off', false, true],
+])('%s', (_name, rejectResponderTermination, yields) => {
+ const view = renderInput({ rejectResponderTermination });
+
+ expect(view.props.onResponderTerminationRequest()).toBe(yields);
+});
diff --git a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx
index ee0153c47..1da9fd234 100644
--- a/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx
+++ b/packages/react-native-enriched-markdown/src/EnrichedMarkdownTextInput.tsx
@@ -224,6 +224,8 @@ type PendingRequest = {
reject: (error: Error) => void;
};
+const PRESS_DRAG_DISTANCE = 10;
+
function getNativeRef(ref: React.RefObject): NativeRef {
if (ref.current == null) {
throw new Error(
@@ -510,6 +512,9 @@ export const EnrichedMarkdownTextInput = ({
onBlur?.();
}, [onBlur]);
+ const pressStart = useRef({ pageX: 0, pageY: 0 });
+ const pressDragged = useRef(false);
+
/**
* React Native TextInput attaches usePressability to the native host so taps
* claim the JS touch responder and ancestor Pressable handlers do not also
@@ -523,7 +528,7 @@ export const EnrichedMarkdownTextInput = ({
hitSlop,
onPress: (event: GestureResponderEvent) => {
onPress?.(event);
- if (editable !== false) {
+ if (editable !== false && !pressDragged.current) {
// Same call TextInput's host focus() makes. A tap on the text view
// focuses it natively, which makes this a no-op; it matters when the
// press lands outside the native view, e.g. in the hitSlop area.
@@ -531,6 +536,13 @@ export const EnrichedMarkdownTextInput = ({
}
},
onPressIn,
+ onPressMove: (event: GestureResponderEvent) => {
+ const { pageX, pageY } = event.nativeEvent;
+ const start = pressStart.current;
+ pressDragged.current ||=
+ Math.hypot(pageX - start.pageX, pageY - start.pageY) >
+ PRESS_DRAG_DISTANCE;
+ },
onPressOut,
}),
[
@@ -552,9 +564,20 @@ export const EnrichedMarkdownTextInput = ({
onBlur: _onBlur,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onFocus: _onFocus,
+ onResponderGrant,
...pressabilityHandlers
} = usePressability(pressabilityConfig) ?? {};
+ const handleResponderGrant = useCallback(
+ (event: GestureResponderEvent) => {
+ const { pageX, pageY } = event.nativeEvent;
+ pressStart.current = { pageX, pageY };
+ pressDragged.current = false;
+ return onResponderGrant?.(event);
+ },
+ [onResponderGrant]
+ );
+
const handleRequestMarkdownResult = useCallback(
(e: NativeSyntheticEvent) => {
const { requestId, markdown } = e.nativeEvent;
@@ -713,6 +736,7 @@ export const EnrichedMarkdownTextInput = ({
hitSlop={hitSlop}
{...rest}
{...pressabilityHandlers}
+ onResponderGrant={handleResponderGrant}
/>
);
};