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
6 changes: 6 additions & 0 deletions docs/docs/api-reference/enriched-text-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ For details, see [sanitization](/core-functionalities/web-support).

Called whenever the input loses focus.

On mobile, the input participates in React Native's text input focus tracking,
so it also blurs through the standard keyboard-dismiss paths, just like
`TextInput`: taps outside of it inside a `ScrollView` (following its
[`keyboardShouldPersistTaps`](https://reactnative.dev/docs/scrollview#keyboardshouldpersisttaps)
setting) and [`Keyboard.dismiss()`](https://reactnative.dev/docs/keyboard#dismiss).

| Type | Platforms |
| ------------------------ | ----------------- |
| `(e: BlurEvent) => void` | Android, iOS, Web |
Expand Down
8 changes: 0 additions & 8 deletions docs/docs/misc/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,3 @@ We may add support for more tags and related functionality over time - see the
not supported yet, the best way to let us know is to open a
[GitHub issue](https://github.com/software-mansion/react-native-enriched-html/issues)
in our repo.

## No global blur support on mobile

On mobile, you can use `Keyboard.dismiss()` and `keyboardShouldPersistTaps` to blur
the React Native's `TextInput` on taps outside of it. This doesn't work
with `EnrichedTextInput`. This is a limitation of React Native itself, not
something specific to this library: React Native doesn't expose
`TextInputState` publicly, so we can't register our input in the React Native state.
45 changes: 43 additions & 2 deletions src/native/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
type ComponentRef,
Expand All @@ -26,9 +27,12 @@ import { normalizeHtmlStyle } from '../utils/normalizeHtmlStyle';
import { toNativeRegexConfig } from '../utils/regexParser';
import { nullthrows } from '../utils/nullthrows';
import { ENRICHED_TEXT_INPUT_DEFAULT_PROPS } from '../utils/EnrichedTextInputDefaultProps';
import { TextInputState } from '../utils/textInputState';
import type {
BlurEvent,
ContextMenuItem,
EnrichedTextInputProps,
FocusEvent,
OnLinkDetected,
OnMentionDetected,
} from '../types';
Expand Down Expand Up @@ -145,6 +149,43 @@ export const EnrichedTextInput = ({
};
}, []);

/**
* Mirrors the built-in TextInput's TextInputState integration so scroll
* containers' keyboardShouldPersistTaps logic and Keyboard.dismiss treat
* this input like a native TextInput. See utils/textInputState.ts for the
* full rationale. The unmount cleanup blurs through blurTextInput so both
* the registry entry and the native focus are released, matching the
* TextInput unmount path.
*/
useLayoutEffect(() => {
const instance = nativeRef.current;
if (instance == null) return;

TextInputState.registerInput(instance);
Comment on lines +160 to +164
return () => {
if (TextInputState.currentlyFocusedInput() === instance) {
TextInputState.blurTextInput(instance);
}
TextInputState.unregisterInput(instance);
};
}, []);

const handleFocus = useCallback(
(e: FocusEvent) => {
TextInputState.focusInput(nativeRef.current);
onFocus?.(e);
},
[onFocus]
);

const handleBlur = useCallback(
(e: BlurEvent) => {
TextInputState.blurInput(nativeRef.current);
onBlur?.(e);
},
[onBlur]
);

const normalizedHtmlStyle = useMemo(
() => normalizeHtmlStyle(htmlStyle, mentionIndicators),
[htmlStyle, mentionIndicators]
Expand Down Expand Up @@ -346,8 +387,8 @@ export const EnrichedTextInput = ({
spellCheck={spellCheck ?? autoCorrect}
htmlStyle={normalizedHtmlStyle}
linkRegex={linkRegex}
onInputFocus={onFocus}
onInputBlur={onBlur}
onInputFocus={handleFocus}
onInputBlur={handleBlur}
onChangeText={onChangeText}
onChangeHtml={onChangeHtml}
isOnChangeHtmlSet={onChangeHtml !== undefined}
Expand Down
35 changes: 35 additions & 0 deletions src/utils/textInputState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Bridges EnrichedTextInput into React Native's TextInputState focus registry
* so it participates in RN's keyboard-dismiss behavior (issue #184).
*
* Registering a non-TextInput host component is safe: TextInputState's
* blurTextInput dispatches the built-in TextInput's "blur" command at the
* registered instance, and Fabric command dispatch is name-based - it works
* as long as the component ships a command with that name, which ours does.
*
* A deep import is required because the public TextInput.State API lacks the
* registerInput/focusInput/blurInput bookkeeping functions.
*/
import type { HostInstance } from 'react-native';

interface TextInputStateModule {
registerInput(input: HostInstance): void;
unregisterInput(input: HostInstance): void;
focusInput(input: HostInstance | null): void;
blurInput(input: HostInstance | null): void;
blurTextInput(input: HostInstance | null): void;
currentlyFocusedInput(): HostInstance | null;
}

/**
* The internal module's export shape has changed across RN versions: newer
* versions use `export default` (surfacing as `.default` after Metro's CJS
* transform) while older ones assigned `module.exports` directly. A raw
* require bypasses Babel's import interop, so both shapes are handled here.
*/
const TextInputStateModuleImpl =
// eslint-disable-next-line @react-native/no-deep-imports
require('react-native/Libraries/Components/TextInput/TextInputState');

export const TextInputState = (TextInputStateModuleImpl.default ??
TextInputStateModuleImpl) as TextInputStateModule;