diff --git a/docs/docs/api-reference/enriched-text-input.md b/docs/docs/api-reference/enriched-text-input.md index 382e60a39..b1bb18cbc 100644 --- a/docs/docs/api-reference/enriched-text-input.md +++ b/docs/docs/api-reference/enriched-text-input.md @@ -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 | diff --git a/docs/docs/misc/known-limitations.md b/docs/docs/misc/known-limitations.md index a08dd53f3..f387af516 100644 --- a/docs/docs/misc/known-limitations.md +++ b/docs/docs/misc/known-limitations.md @@ -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. diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index cfe12104c..0ea0bab58 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, type ComponentRef, @@ -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'; @@ -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); + 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] @@ -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} diff --git a/src/utils/textInputState.ts b/src/utils/textInputState.ts new file mode 100644 index 000000000..47a8909ef --- /dev/null +++ b/src/utils/textInputState.ts @@ -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;