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} /> ); };