Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<Text style={styles.status} testID={testID}>
{label}: {log.focused ? 'focused' : 'blurred'} · focus {log.focusCount} ·
blur {log.blurCount}
</Text>
);
}

function KeyboardDismissOnDragStory({
rejectResponderTermination,
}: {
rejectResponderTermination?: boolean;
}) {
const enriched = useFocusLog();
const builtIn = useFocusLog();

return (
<ScrollView
contentContainerStyle={styles.container}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always"
testID="keyboard-dismiss-scroll"
>
<Text style={styles.title}>Keyboard dismiss on drag (#1158)</Text>
<Text style={styles.description}>
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.
</Text>

<FocusStatus
label="Enriched"
log={enriched.log}
testID="enriched-focus-status"
/>
<View style={styles.editorContainer}>
<EnrichedMarkdownTextInput
testID="enriched-input"
placeholder="EnrichedMarkdownTextInput"
placeholderTextColor="#9CA3AF"
style={styles.input}
rejectResponderTermination={rejectResponderTermination}
onFocus={enriched.onFocus}
onBlur={enriched.onBlur}
/>
</View>

<FocusStatus
label="TextInput"
log={builtIn.log}
testID="text-input-focus-status"
/>
<View style={styles.editorContainer}>
<TextInput
testID="text-input"
multiline
placeholder="React Native TextInput"
placeholderTextColor="#9CA3AF"
style={styles.input}
rejectResponderTermination={rejectResponderTermination}
onFocus={builtIn.onFocus}
onBlur={builtIn.onBlur}
/>
</View>

{FILLER_ROWS.map((row) => (
<View key={row} style={styles.filler}>
<Text style={styles.fillerText}>Row {row}</Text>
</View>
))}
</ScrollView>
);
}

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 }) => (
<KeyboardDismissOnDragStory
rejectResponderTermination={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',
},
});
Original file line number Diff line number Diff line change
@@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion docs-md/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ------------------------------------------- | ------------- | -------- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<PropInfo type="(event: GestureResponderEvent) => unknown" typeHref="https://reactnative.dev/docs/pressevent" />

Expand Down
Original file line number Diff line number Diff line change
@@ -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(<EnrichedMarkdownTextInput {...props} />));

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