fix(app): preserve IME composition across text fields (#3517)

* fix(app): preserve IME composition in form inputs

Form model publication was replaying changing default text into the native or DOM editing surface. Keep the editing surface authoritative, defer web publication until composition commits, and reserve resetKey changes for explicit text replacement.

* refactor(app): centralize text editing authority

Route every app text field, including the composer and plugin runtime, through one platform-owned editing primitive. Its type and runtime reject controlled value replay, its command handle owns programmatic replacement, and lint prevents raw React Native TextInput imports outside the boundary.

* fix(app): restore composer placeholder styling
This commit is contained in:
Mohamed Boudra
2026-08-18 11:32:49 +00:00
committed by GitHub
parent cd2b6bca9d
commit e2e97e4854
39 changed files with 575 additions and 390 deletions
@@ -1,10 +1,10 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { Modal, Platform, Pressable, ScrollView, Text, View } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
@@ -15,7 +15,6 @@ import {
import {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetTextInput,
KEYBOARD_STATUS,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
@@ -30,10 +29,11 @@ import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { createControlGeometry } from "@/components/ui/control-geometry";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
// Horizontal indent token shared by the sheet header (title, back arrow,
// leading icon, search input icon) and any row primitive rendered inside the
@@ -211,15 +211,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
gap: theme.spacing[2],
},
adaptiveInputOutline: {
...createControlGeometry(theme).controlFocusRingColor,
},
adaptiveInputText: {
color: theme.colors.foreground,
},
adaptiveInputPlaceholder: {
color: theme.colors.foregroundMuted,
},
}));
const WEB_EXIT_DURATION_MS = 160;
@@ -288,55 +279,6 @@ function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
);
}
export type AdaptiveTextInputProps = TextInputProps & {
initialValue?: string;
resetKey?: string | number;
};
// React Native controlled TextInput can replay stale JS values during fast input
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
// can seed it once with initialValue and remount with resetKey for real resets.
// See https://github.com/facebook/react-native/issues/44157
//
// Text color and placeholder color are owned by this leaf — not the caller.
// `@gorhom/bottom-sheet` mounts header subtrees before the sheet is visible
// under whatever theme is active at mount time, then keeps them mounted across
// theme changes; any caller that paints color via `StyleSheet.create((theme) =>
// ...)` from outside this leaf ends up with stale colors in dark mode (see
// docs/unistyles.md "Hidden Sheet Content"). withUnistyles wraps the actual
// TextInput so theme-driven re-renders land on the wrapper.
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { value: _value, initialValue, resetKey, defaultValue, style, ...inputProps } = props;
// Leaf-owned color goes LAST so callers cannot override it with a stale
// theme read. Outline color is theme-aware on web :focus-visible.
const textInputProps = {
...inputProps,
defaultValue: initialValue ?? defaultValue,
style: [styles.adaptiveInputOutline, style, styles.adaptiveInputText],
};
if (isMobile && isNative) {
return (
<ThemedBottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <ThemedTextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
export function SheetHeaderView({
header,
onClose,
@@ -0,0 +1,64 @@
import React, { forwardRef, useCallback, useEffect, useRef } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { createControlGeometry } from "@/components/ui/control-geometry";
import {
EditingTextInput,
type EditingTextInputHandle,
type EditingTextInputProps,
} from "@/components/ui/text-input";
export type AdaptiveTextInputProps = EditingTextInputProps & {
initialValue?: string;
resetKey?: string | number;
};
const styles = StyleSheet.create((theme) => ({
outline: {
...createControlGeometry(theme).controlFocusRingColor,
},
text: {
color: theme.colors.foreground,
},
}));
const ThemedTextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
export const AdaptiveTextInput = forwardRef<EditingTextInputHandle, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { initialValue, resetKey, style, onChangeText, ...inputProps } = props;
const inputRef = useRef<EditingTextInputHandle | null>(null);
const replacementTextRef = useRef(initialValue ?? "");
const previousResetKeyRef = useRef(resetKey);
replacementTextRef.current = initialValue ?? "";
const setInputRef = useCallback(
(handle: EditingTextInputHandle | null) => {
inputRef.current = handle;
if (typeof ref === "function") ref(handle);
else if (ref) ref.current = handle;
},
[ref],
);
useEffect(() => {
if (resetKey === previousResetKeyRef.current) return;
previousResetKeyRef.current = resetKey;
inputRef.current?.replaceText(replacementTextRef.current);
}, [resetKey]);
return (
<ThemedTextInput
{...inputProps}
ref={setInputRef}
initialValue={initialValue}
onChangeText={onChangeText}
style={[styles.outline, style, styles.text]}
variant={isMobile && isNative ? "bottom-sheet" : "default"}
/>
);
},
);
@@ -0,0 +1,87 @@
import React, { act, useCallback, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { AdaptiveTextInput } from "./adaptive-text-input";
interface MountedInput {
root: Root;
container: HTMLDivElement;
textarea: HTMLTextAreaElement;
}
const mountedInputs: MountedInput[] = [];
function SchedulePromptHarness({ changes }: { changes: string[] }) {
const [prompt, setPrompt] = useState("");
const handleChangeText = useCallback(
(text: string) => {
changes.push(text);
setPrompt(text);
},
[changes],
);
return (
<AdaptiveTextInput
initialValue={prompt}
multiline
onChangeText={handleChangeText}
testID="schedule-prompt-input"
/>
);
}
function mountSchedulePrompt(changes: string[]): MountedInput {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<SchedulePromptHarness changes={changes} />));
const textarea = container.querySelector("textarea");
if (!textarea) {
throw new Error("Schedule prompt did not render a textarea");
}
const mounted = { root, container, textarea };
mountedInputs.push(mounted);
return mounted;
}
function typeFromIme(textarea: HTMLTextAreaElement, text: string): void {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
if (!valueSetter) {
throw new Error("HTML textarea value setter is unavailable");
}
valueSetter.call(textarea, text);
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
}
afterEach(() => {
for (const mounted of mountedInputs.splice(0)) {
act(() => mounted.root.unmount());
mounted.container.remove();
}
});
describe("AdaptiveTextInput web IME composition", () => {
it("keeps the candidate editing-surface owned until composition commits", () => {
const changes: string[] = [];
const { textarea } = mountSchedulePrompt(changes);
act(() => {
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
typeFromIme(textarea, "n");
});
expect(textarea.value).toBe("n");
expect(changes).toEqual([]);
act(() => {
textarea.value = "你";
textarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
});
expect(textarea.value).toBe("你");
expect(changes).toEqual(["你"]);
});
});
@@ -502,7 +502,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.host")}
initialValue={host}
resetKey={`direct-host-${inputResetKey}`}
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
@@ -522,7 +521,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.port")}
initialValue={port}
resetKey={`direct-port-${inputResetKey}`}
value={port}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
@@ -565,7 +563,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.password")}
initialValue={password}
resetKey={`direct-password-${inputResetKey}`}
value={password}
onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
@@ -615,7 +612,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
@@ -25,10 +25,13 @@ import {
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
applyAvailableAddProjectHosts,
@@ -371,10 +374,12 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const cloneGithubProject = useCloneGithubProject(hostId);
const upsertProject = useSessionStore((store) => store.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<TextInput>(null);
const inputRef = useRef<EditingTextInputHandle>(null);
const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false);
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
const pageInputValueRef = useRef(page.kind === "method" ? "" : pageInput(page));
pageInputValueRef.current = page.kind === "method" ? "" : pageInput(page);
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
@@ -389,6 +394,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
}, [query]);
useEffect(() => {
inputRef.current?.replaceText(pageInputValueRef.current);
const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer);
}, [page.kind]);
@@ -858,7 +864,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
{page.kind === "method" && isNative ? (
// Native hardware-keyboard events need a focused responder even without a visible field.
<TextInput
key={page.kind}
ref={inputRef}
onKeyPress={handleNativeKeyPress}
onSubmitEditing={submitActive}
@@ -875,9 +880,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
) : null}
{page.kind !== "method" ? (
<ThemedTextInput
key={page.kind}
ref={inputRef}
value={pageInput(page)}
initialValue={pageInput(page)}
onChangeText={handleInputChange}
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
onSubmitEditing={isWeb ? undefined : submitActive}
@@ -15,7 +15,6 @@ import {
ListRenderItemInfo,
Pressable,
Text,
TextInput,
View,
type NativeSyntheticEvent,
type PressableStateCallbackType,
@@ -23,6 +22,7 @@ import {
type TextInputKeyPressEventData,
type ViewStyle,
} from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
@@ -210,7 +210,7 @@ function EntryNameInputRow({
</View>
<TextInput
autoFocus
value={name}
initialValue={name}
onChangeText={setName}
onSubmitEditing={commit}
onBlur={commit}
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, TextInput, View } from "react-native";
import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
@@ -11,6 +11,7 @@ import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -67,13 +68,13 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
const isMobile = useIsCompactFormFactor();
const offerUrlRef = useRef("");
const inputRef = useRef<TextInput>(null);
const inputRef = useRef<EditingTextInputHandle>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => {
offerUrlRef.current = "";
inputRef.current?.clear();
inputRef.current?.replaceText("");
}, []);
const pairIcon = useMemo(
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
@@ -13,6 +13,7 @@ import {
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
interface ProviderCatalogListProps {
serverId: string;
@@ -150,7 +151,7 @@ export function ProviderCatalogList({
</View>
<ThemedTextInput
testID="provider-catalog-search"
value={search}
initialValue={search}
onChangeText={setSearch}
accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")}
@@ -210,7 +210,6 @@ function AddCustomModelSubSheet({
<AdaptiveTextInput
initialValue={input}
resetKey={`add-custom-${visible}`}
value={input}
onChangeText={setInput}
onSubmitEditing={handleAdd}
placeholder={t("settings.providers.models.modelIdPlaceholder")}
@@ -1,6 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react";
import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native";
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
@@ -304,7 +305,7 @@ function QuestionOtherInput({
accessibilityLabel={accessibilityLabel}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
initialValue={value}
onChangeText={handleChange}
onSubmitEditing={onSubmit}
editable={!isResponding}
+4 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import {
@@ -9,6 +9,7 @@ import {
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
export interface AdaptiveRenameModalProps {
visible: boolean;
@@ -39,7 +40,7 @@ export function AdaptiveRenameModal({
const [draft, setDraft] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const inputRef = useRef<TextInput>(null);
const inputRef = useRef<EditingTextInputHandle>(null);
useEffect(() => {
if (!visible) return;
@@ -58,7 +59,7 @@ export function AdaptiveRenameModal({
if (isWeb && node instanceof HTMLInputElement) {
node.setSelectionRange(0, length);
} else if (!isWeb && length > 0) {
node.setNativeProps({ selection: { start: 0, end: length } });
node.replaceText(node.getText(), { start: 0, end: length });
}
}, 50);
return () => clearTimeout(timeout);
@@ -110,7 +110,6 @@ export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEd
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
@@ -497,7 +497,6 @@ function ScheduleFormFields({
testID="schedule-name-input"
accessibilityLabel="Schedule name"
initialValue={state.name}
value={state.name}
onChangeText={model.setName}
placeholder="Optional"
autoCapitalize="none"
@@ -511,7 +510,6 @@ function ScheduleFormFields({
testID="schedule-prompt-input"
accessibilityLabel="Prompt"
initialValue={state.prompt}
value={state.prompt}
onChangeText={model.setPrompt}
placeholder="What should the agent do each run?"
style={styles.multilineInput}
@@ -543,7 +541,6 @@ function ScheduleFormFields({
testID="schedule-max-runs-input"
accessibilityLabel="Max runs"
initialValue={state.maxRuns}
value={state.maxRuns}
onChangeText={model.setMaxRuns}
placeholder="Unlimited"
keyboardType="number-pad"
@@ -1,8 +1,9 @@
import type { StyleProp, TextStyle } from "react-native";
import { useMemo } from "react";
import { TextInput, View } from "react-native";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
interface SettingsTextAreaProps {
accessibilityLabel: string;
@@ -29,7 +30,7 @@ export function SettingsTextArea({
testID={testID}
accessibilityLabel={accessibilityLabel}
multiline
value={value}
initialValue={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
+15 -29
View File
@@ -13,7 +13,6 @@ import {
Text,
Pressable,
Modal,
TextInput,
ScrollView,
Platform,
StatusBar,
@@ -26,6 +25,7 @@ import {
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -191,11 +191,11 @@ export function SearchInput({
onChangeText,
onSubmitEditing,
autoFocus = false,
useBottomSheetInput = false,
useBottomSheetInput: _useBottomSheetInput = false,
resetKey,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const inputRef = useRef<EditingTextInputHandle>(null);
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
@@ -209,32 +209,18 @@ export function SearchInput({
return (
<View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} />
{useBottomSheetInput ? (
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
resetKey={resetKey}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
) : (
<TextInput
key={resetKey}
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
)}
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
resetKey={resetKey}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
</View>
);
}
+71 -69
View File
@@ -12,12 +12,12 @@ import {
Text,
View,
type PressableStateCallbackType,
type TextInput,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import {
createControlGeometry,
resolveControlInteractionStyles,
@@ -141,7 +141,10 @@ function stripUnistylesMetadata<TStyle extends object>(style: TStyle): TStyle {
return cleanStyle as TStyle;
}
function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInput | null): void {
function assignTextInputRef(
forwardedRef: ForwardedRef<EditingTextInputHandle>,
node: EditingTextInputHandle | null,
): void {
if (typeof forwardedRef === "function") {
forwardedRef(node);
return;
@@ -151,74 +154,73 @@ function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInp
}
}
export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function FormTextInput(
{ size = "md", style, onFocus, onBlur, editable, ...props },
ref,
) {
const [focused, setFocused] = useState(false);
const isDisabled = editable === false;
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback(
(node: TextInput | null) => {
assignTextInputRef(ref, node);
},
[ref],
);
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
(event) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const inputStyle = useMemo(
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
[inputSizeStyle, splitStyle.inputStyle],
) as AdaptiveTextInputProps["style"];
const chromeStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
formInputStyles.chrome,
chromeSizeStyle,
resolveControlInteractionStyles(
{
controlRest: formInputStyles.controlRest,
controlHover: formInputStyles.controlHover,
controlActive: formInputStyles.controlActive,
controlDisabled: formInputStyles.controlDisabled,
},
{
hovered,
focused,
disabled: isDisabled,
},
),
splitStyle.chromeStyle,
],
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
);
export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
const [focused, setFocused] = useState(false);
const isDisabled = editable === false;
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback(
(node: EditingTextInputHandle | null) => {
assignTextInputRef(ref, node);
},
[ref],
);
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus],
);
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
(event) => {
setFocused(false);
onBlur?.(event);
},
[onBlur],
);
const inputStyle = useMemo(
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
[inputSizeStyle, splitStyle.inputStyle],
) as AdaptiveTextInputProps["style"];
const chromeStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
formInputStyles.chrome,
chromeSizeStyle,
resolveControlInteractionStyles(
{
controlRest: formInputStyles.controlRest,
controlHover: formInputStyles.controlHover,
controlActive: formInputStyles.controlActive,
controlDisabled: formInputStyles.controlDisabled,
},
{
hovered,
focused,
disabled: isDisabled,
},
),
splitStyle.chromeStyle,
],
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
);
return (
<Pressable disabled={isDisabled} style={chromeStyle}>
<AdaptiveTextInput
ref={setInputRef}
editable={editable}
{...props}
onBlur={handleBlur}
onFocus={handleFocus}
style={inputStyle}
/>
</Pressable>
);
});
return (
<Pressable disabled={isDisabled} style={chromeStyle}>
<AdaptiveTextInput
ref={setInputRef}
editable={editable}
{...props}
onBlur={handleBlur}
onFocus={handleFocus}
style={inputStyle}
/>
</Pressable>
);
},
);
const styles = StyleSheet.create((theme) => ({
container: {
@@ -1,8 +1,12 @@
import { useCallback, useState, type ReactElement } from "react";
import { Pressable, TextInput, View } from "react-native";
import { useCallback, useRef, useState, type ReactElement } from "react";
import { Pressable, View } from "react-native";
import { Search, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
const ThemedSearch = withUnistyles(Search);
const ThemedX = withUnistyles(X);
@@ -42,16 +46,21 @@ export function SearchField({
clearTestID,
}: SearchFieldProps): ReactElement {
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<EditingTextInputHandle>(null);
const handleFocus = useCallback(() => setIsFocused(true), []);
const handleBlur = useCallback(() => setIsFocused(false), []);
const handleClear = useCallback(() => onChangeText(""), [onChangeText]);
const handleClear = useCallback(() => {
inputRef.current?.replaceText("");
onChangeText("");
}, [onChangeText]);
return (
<View style={[styles.field, isFocused && styles.fieldFocused]}>
<ThemedSearch size={14} uniProps={mutedColorMapping} />
<ThemedTextInput
testID={testID}
value={value}
ref={inputRef}
initialValue={value}
onChangeText={onChangeText}
onFocus={handleFocus}
onBlur={handleBlur}
@@ -0,0 +1,2 @@
export { EditingTextInput } from "./text-input";
export type { EditingTextInputHandle, EditingTextInputProps } from "./types";
@@ -0,0 +1,96 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { TextInput } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import PasteInput, {
type PastedFile,
type PasteTextInputInstance,
} from "@mattermost/react-native-paste-input";
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
type NativeInput = (TextInput | PasteTextInputInstance) & {
blur(): void;
focus(): void;
isFocused(): boolean;
setNativeProps(props: { text: string; selection?: { start: number; end: number } }): void;
setSelection?(start: number, end: number): void;
getNativeRef?(): unknown;
};
export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
function EditingTextInputNative(allProps, ref) {
const {
initialValue = "",
onChangeText,
onPasteImages,
onPasteError,
variant = "default",
value: _,
defaultValue: __,
...props
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
const inputRef = useRef<NativeInput | null>(null);
const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => inputRef.current?.isFocused() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
inputRef.current?.setNativeProps({ text: nextText, ...(selection ? { selection } : {}) });
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
const handleChangeText = useCallback(
(nextText: string) => {
textRef.current = nextText;
onChangeText?.(nextText);
},
[onChangeText],
);
const handlePaste = useCallback(
(error: string | null | undefined, files: PastedFile[]) => {
if (error) {
onPasteError?.(error);
} else if (files.length > 0) {
onPasteImages?.(files);
}
},
[onPasteError, onPasteImages],
);
if (onPasteImages || onPasteError) {
return (
<PasteInput
{...props}
ref={inputRef as React.Ref<PasteTextInputInstance>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
onPaste={handlePaste}
/>
);
}
if (variant === "bottom-sheet") {
return (
<BottomSheetTextInput
{...props}
ref={inputRef as unknown as React.Ref<never>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
/>
);
}
return (
<TextInput
{...props}
ref={inputRef as React.Ref<TextInput>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
/>
);
},
);
@@ -0,0 +1,90 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { TextInput } from "react-native";
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
interface WebTextInputElement extends TextInput {
value?: string;
setSelectionRange?: (start: number, end: number) => void;
addEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void;
removeEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void;
}
export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
function EditingTextInputWeb(allProps, ref) {
const {
initialValue = "",
onChangeText,
onPasteImages: _,
onPasteError: __,
variant: ___,
value: ____,
defaultValue: _____,
...props
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
const inputRef = useRef<TextInput | null>(null);
const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current);
const isComposingRef = useRef(false);
const onChangeTextRef = useRef(onChangeText);
onChangeTextRef.current = onChangeText;
useEffect(() => {
const input = inputRef.current as WebTextInputElement | null;
if (!input) return;
const startComposition = () => {
isComposingRef.current = true;
};
const endComposition = () => {
isComposingRef.current = false;
const nextText = input.value ?? "";
if (nextText === textRef.current) return;
textRef.current = nextText;
onChangeTextRef.current?.(nextText);
};
input.addEventListener("compositionstart", startComposition);
input.addEventListener("compositionend", endComposition);
return () => {
input.removeEventListener("compositionstart", startComposition);
input.removeEventListener("compositionend", endComposition);
};
}, []);
const handleChangeText = useCallback((nextText: string) => {
if (isComposingRef.current || nextText === textRef.current) return;
textRef.current = nextText;
onChangeTextRef.current?.(nextText);
}, []);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => document.activeElement === inputRef.current,
getText: () => {
const input = inputRef.current as WebTextInputElement | null;
const nextText = input?.value ?? textRef.current;
textRef.current = nextText;
return nextText;
},
replaceText: (nextText, selection) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = nextText;
if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end);
}
},
getNativeRef: () => inputRef.current,
}));
return (
<TextInput
{...props}
ref={inputRef}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
/>
);
},
);
@@ -0,0 +1,22 @@
import type { TextInputProps } from "react-native";
import type { NativePastedFile } from "@/composer/native-pasted-image";
export interface EditingTextInputHandle {
focus(): void;
blur(): void;
isFocused(): boolean;
getText(): string;
replaceText(text: string, selection?: { start: number; end: number }): void;
getNativeRef(): unknown;
}
export interface EditingTextInputProps extends Omit<
TextInputProps,
"defaultValue" | "onChangeText" | "value"
> {
initialValue?: string;
onChangeText?: (text: string) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
onPasteError?: (message: string) => void;
variant?: "default" | "bottom-sheet";
}