From e2e97e48543d230dda2787a8dcea9679f3308d70 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 18 Aug 2026 13:32:49 +0200 Subject: [PATCH] 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 --- .oxlintrc.json | 40 +++++ docs/forms.md | 6 + .../app/src/command-center/command-center.tsx | 19 ++- .../src/components/adaptive-modal-sheet.tsx | 74 +-------- .../src/components/adaptive-text-input.tsx | 64 ++++++++ .../adaptive-text-input.web.browser.test.tsx | 87 +++++++++++ .../app/src/components/add-host-modal.tsx | 4 - .../app/src/components/add-project-flow.tsx | 14 +- .../app/src/components/file-explorer-pane.tsx | 4 +- .../app/src/components/pair-link-modal.tsx | 7 +- .../src/components/provider-catalog-list.tsx | 5 +- .../components/provider-diagnostic-sheet.tsx | 1 - .../app/src/components/question-form-card.tsx | 5 +- packages/app/src/components/rename-modal.tsx | 7 +- .../components/schedules/cadence-editor.tsx | 1 - .../schedules/schedule-form-sheet.tsx | 3 - .../app/src/components/settings-textarea.tsx | 5 +- packages/app/src/components/ui/combobox.tsx | 44 ++---- packages/app/src/components/ui/form-field.tsx | 140 +++++++++--------- .../app/src/components/ui/search-field.tsx | 17 ++- .../app/src/components/ui/text-input/index.ts | 2 + .../components/ui/text-input/text-input.tsx | 96 ++++++++++++ .../ui/text-input}/text-input.web.tsx | 68 ++++----- .../app/src/components/ui/text-input/types.ts | 22 +++ packages/app/src/composer/input/input.tsx | 16 +- .../src/composer/input/text-input-types.ts | 20 --- .../app/src/composer/input/text-input.d.ts | 1 - .../src/composer/input/text-input.native.tsx | 72 --------- .../input/text-input.web.browser.test.tsx | 12 +- .../desktop/browser/pane/index.electron.tsx | 23 +-- .../components/pair-device-section.tsx | 13 +- packages/app/src/hosts/host-chooser.tsx | 10 +- packages/app/src/plugins/evaluate.ts | 6 +- packages/app/src/review/surface.tsx | 19 +-- .../src/screens/project-settings-screen.tsx | 7 +- packages/app/src/screens/settings-screen.tsx | 4 +- .../appearance/appearance-section.tsx | 7 +- .../settings/terminal-profile-edit-modal.tsx | 9 +- .../native-renderer/terminal-input.native.tsx | 11 +- 39 files changed, 575 insertions(+), 390 deletions(-) create mode 100644 packages/app/src/components/adaptive-text-input.tsx create mode 100644 packages/app/src/components/adaptive-text-input.web.browser.test.tsx create mode 100644 packages/app/src/components/ui/text-input/index.ts create mode 100644 packages/app/src/components/ui/text-input/text-input.tsx rename packages/app/src/{composer/input => components/ui/text-input}/text-input.web.tsx (60%) create mode 100644 packages/app/src/components/ui/text-input/types.ts delete mode 100644 packages/app/src/composer/input/text-input-types.ts delete mode 100644 packages/app/src/composer/input/text-input.d.ts delete mode 100644 packages/app/src/composer/input/text-input.native.tsx diff --git a/.oxlintrc.json b/.oxlintrc.json index 5fe29499a..90869a9cc 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -94,6 +94,11 @@ "error", { "paths": [ + { + "name": "react-native", + "importNames": ["TextInput"], + "message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety." + }, { "name": "@tanstack/react-query", "importNames": ["useQuery", "useInfiniteQuery", "useQueries"], @@ -120,6 +125,11 @@ "error", { "paths": [ + { + "name": "react-native", + "importNames": ["TextInput"], + "message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety." + }, { "name": "react-native-unistyles", "importNames": ["useUnistyles"], @@ -167,6 +177,11 @@ "error", { "paths": [ + { + "name": "react-native", + "importNames": ["TextInput"], + "message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety." + }, { "name": "react-native-unistyles", "importNames": ["useUnistyles"], @@ -254,6 +269,11 @@ "error", { "paths": [ + { + "name": "react-native", + "importNames": ["TextInput"], + "message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety." + }, { "name": "@tanstack/react-query", "importNames": ["useQuery", "useInfiniteQuery", "useQueries"], @@ -274,6 +294,26 @@ "packages/app/src/desktop/components/pair-device-section.tsx", "packages/app/src/screens/new-workspace-screen.tsx" ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "react-native", + "importNames": ["TextInput"], + "message": "Use EditingTextInput from @/components/ui/text-input. Raw TextInput bypasses editing-surface authority and IME safety." + } + ] + } + ] + } + }, + { + "files": [ + "packages/app/src/components/ui/text-input/**/*.{ts,tsx}", + "packages/app/src/agent-stream/scroll-keyboard-dismiss/use-scroll-keyboard-dismiss.ts" + ], "rules": { "no-restricted-imports": "off" } diff --git a/docs/forms.md b/docs/forms.md index 9f809ed0c..cbb1c2d95 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -61,6 +61,12 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a states) is owned by `components/ui/control-geometry.ts` — controls never declare their own, and screens never nudge global component styles to align a row. +- Every text field consumes `EditingTextInput`, directly or through a UI + wrapper. The editing surface owns in-progress text; React observes committed + edits through `onChangeText`, and programmatic mutations use the input's + `replaceText` command. Its props omit `value` and `defaultValue`, web IME + candidates stay unpublished until composition commits, and lint rejects raw + React Native `TextInput` imports outside the primitive. - The form declares one size for all fields: `sm` on desktop, `md` compact (`useIsCompactFormFactor`). - Availability hierarchy: a field whose capability doesn't apply is **hidden** diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index c1ac5e000..504729f8f 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -3,7 +3,6 @@ import { Modal, Pressable, Text, - TextInput, View, type LayoutChangeEvent, type ListRenderItemInfo, @@ -18,12 +17,15 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetBackdrop, BottomSheetFlatList, - BottomSheetTextInput, type BottomSheetFlatListMethods, } from "@gorhom/bottom-sheet"; import { AgentStatusDot } from "@/components/agent-status-dot"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { + EditingTextInput as TextInput, + type EditingTextInputHandle, +} from "@/components/ui/text-input"; import { Shortcut } from "@/components/ui/shortcut"; import { IsolatedBottomSheetModal, @@ -69,7 +71,7 @@ import { } from "./results"; import { useWorkspaceFileSearch } from "./workspace-file-search"; -const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ +const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({ placeholderTextColor: theme.colors.foregroundMuted, })); const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ @@ -211,7 +213,7 @@ interface CommandCenterState { results: readonly CommandCenterResult[]; rowIndexByResultId: ReadonlyMap; offsets: readonly number[]; - inputRef: React.RefObject; + inputRef: React.RefObject; fileSearchLoading: boolean; fileSearchError: string | null; close(): void; @@ -227,7 +229,7 @@ function useCommandCenterState(): CommandCenterState { const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope); const snapshot = useCommandCenterContributions(); - const inputRef = useRef(null); + const inputRef = useRef(null); const previousOpenRef = useRef(open); const [query, setQuery] = useState(""); const [activeId, setActiveId] = useState(null); @@ -547,7 +549,7 @@ export function CommandCenter() { const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); const listRef = useRef>(null); const bottomSheetListRef = useRef(null); - const bottomSheetInputRef = useRef>(null); + const bottomSheetInputRef = useRef(null); const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 }); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible: state.open, @@ -711,7 +713,8 @@ export function CommandCenter() { ({ 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( - 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 ( - } - {...textInputProps} - /> - ); - } - return ; - }, -); - export function SheetHeaderView({ header, onClose, diff --git a/packages/app/src/components/adaptive-text-input.tsx b/packages/app/src/components/adaptive-text-input.tsx new file mode 100644 index 000000000..b100c366c --- /dev/null +++ b/packages/app/src/components/adaptive-text-input.tsx @@ -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( + function AdaptiveTextInputInner(props, ref) { + const isMobile = useIsCompactFormFactor(); + const { initialValue, resetKey, style, onChangeText, ...inputProps } = props; + const inputRef = useRef(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 ( + + ); + }, +); diff --git a/packages/app/src/components/adaptive-text-input.web.browser.test.tsx b/packages/app/src/components/adaptive-text-input.web.browser.test.tsx new file mode 100644 index 000000000..5a28ccfc0 --- /dev/null +++ b/packages/app/src/components/adaptive-text-input.web.browser.test.tsx @@ -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 ( + + ); +} + +function mountSchedulePrompt(changes: string[]): MountedInput { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => root.render()); + + 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(["你"]); + }); +}); diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index ce7a16bac..ea91e5762 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -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} diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index a4cdc6a64..80cc9075b 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -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(null); + const inputRef = useRef(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. (null); + const inputRef = useRef(null); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const clearInput = useCallback(() => { offerUrlRef.current = ""; - inputRef.current?.clear(); + inputRef.current?.replaceText(""); }, []); const pairIcon = useMemo( diff --git a/packages/app/src/components/provider-catalog-list.tsx b/packages/app/src/components/provider-catalog-list.tsx index bd1ab4781..4f996fdd4 100644 --- a/packages/app/src/components/provider-catalog-list.tsx +++ b/packages/app/src/components/provider-catalog-list.tsx @@ -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({ (null); const [isPending, setIsPending] = useState(false); - const inputRef = useRef(null); + const inputRef = useRef(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); diff --git a/packages/app/src/components/schedules/cadence-editor.tsx b/packages/app/src/components/schedules/cadence-editor.tsx index 02603ec17..2774edd18 100644 --- a/packages/app/src/components/schedules/cadence-editor.tsx +++ b/packages/app/src/components/schedules/cadence-editor.tsx @@ -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" diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 819d3c1b3..6b9290632 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -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" diff --git a/packages/app/src/components/settings-textarea.tsx b/packages/app/src/components/settings-textarea.tsx index b464aef91..b3613e7c8 100644 --- a/packages/app/src/components/settings-textarea.tsx +++ b/packages/app/src/components/settings-textarea.tsx @@ -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} diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index c145b7c65..7ee9407dc 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -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(null); + const inputRef = useRef(null); useEffect(() => { if (autoFocus && IS_WEB && inputRef.current) { @@ -209,32 +209,18 @@ export function SearchInput({ return ( - {useBottomSheetInput ? ( - - ) : ( - - )} + ); } diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index 824f3c6a7..4df8136d0 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -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(style: TStyle): TStyle { return cleanStyle as TStyle; } -function assignTextInputRef(forwardedRef: ForwardedRef, node: TextInput | null): void { +function assignTextInputRef( + forwardedRef: ForwardedRef, + node: EditingTextInputHandle | null, +): void { if (typeof forwardedRef === "function") { forwardedRef(node); return; @@ -151,74 +154,73 @@ function assignTextInputRef(forwardedRef: ForwardedRef, node: TextInp } } -export const FormTextInput = forwardRef(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>( - (event) => { - setFocused(true); - onFocus?.(event); - }, - [onFocus], - ); - const handleBlur = useCallback>( - (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( + 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>( + (event) => { + setFocused(true); + onFocus?.(event); + }, + [onFocus], + ); + const handleBlur = useCallback>( + (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 ( - - - - ); -}); + return ( + + + + ); + }, +); const styles = StyleSheet.create((theme) => ({ container: { diff --git a/packages/app/src/components/ui/search-field.tsx b/packages/app/src/components/ui/search-field.tsx index 07ccb9885..77daed289 100644 --- a/packages/app/src/components/ui/search-field.tsx +++ b/packages/app/src/components/ui/search-field.tsx @@ -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(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 ( ( + function EditingTextInputNative(allProps, ref) { + const { + initialValue = "", + onChangeText, + onPasteImages, + onPasteError, + variant = "default", + value: _, + defaultValue: __, + ...props + } = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown }; + const inputRef = useRef(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 ( + } + defaultValue={initialTextRef.current} + onChangeText={handleChangeText} + onPaste={handlePaste} + /> + ); + } + if (variant === "bottom-sheet") { + return ( + } + defaultValue={initialTextRef.current} + onChangeText={handleChangeText} + /> + ); + } + return ( + } + defaultValue={initialTextRef.current} + onChangeText={handleChangeText} + /> + ); + }, +); diff --git a/packages/app/src/composer/input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx similarity index 60% rename from packages/app/src/composer/input/text-input.web.tsx rename to packages/app/src/components/ui/text-input/text-input.web.tsx index 7c6bf63a8..82f2f47c0 100644 --- a/packages/app/src/composer/input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -1,29 +1,29 @@ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import { TextInput } from "react-native"; -import { withUnistyles } from "react-native-unistyles"; -import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types"; +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; + addEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void; + removeEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void; } -const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ - placeholderTextColor: theme.colors.surface4, -})); - -export const ComposerTextInput = forwardRef( - function ComposerTextInputWeb( - { text, onChangeText, onPasteImages: _, onPasteError: __, ...props }, - ref, - ) { +export const EditingTextInput = forwardRef( + function EditingTextInputWeb(allProps, ref) { + const { + initialValue = "", + onChangeText, + onPasteImages: _, + onPasteError: __, + variant: ___, + value: ____, + defaultValue: _____, + ...props + } = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown }; const inputRef = useRef(null); - const textRef = useRef(text); + const initialTextRef = useRef(initialValue); + const textRef = useRef(initialTextRef.current); const isComposingRef = useRef(false); const onChangeTextRef = useRef(onChangeText); onChangeTextRef.current = onChangeText; @@ -38,10 +38,9 @@ export const ComposerTextInput = forwardRef { isComposingRef.current = false; const nextText = input.value ?? ""; - if (nextText !== textRef.current) { - textRef.current = nextText; - onChangeTextRef.current(nextText); - } + if (nextText === textRef.current) return; + textRef.current = nextText; + onChangeTextRef.current?.(nextText); }; input.addEventListener("compositionstart", startComposition); @@ -52,33 +51,26 @@ export const ComposerTextInput = forwardRef { - if (isComposingRef.current) return; - if (nextText === textRef.current) return; - textRef.current = nextText; - onChangeText(nextText); - }, - [onChangeText], - ); + 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; - if (nextText !== textRef.current) { - textRef.current = nextText; - } + 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 (input && "value" in input) input.value = nextText; if (selection && typeof input?.setSelectionRange === "function") { input.setSelectionRange(selection.start, selection.end); } @@ -87,10 +79,10 @@ export const ComposerTextInput = forwardRef ); diff --git a/packages/app/src/components/ui/text-input/types.ts b/packages/app/src/components/ui/text-input/types.ts new file mode 100644 index 000000000..49a0f60ff --- /dev/null +++ b/packages/app/src/components/ui/text-input/types.ts @@ -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"; +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 12af092f6..c0c087e38 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -2,7 +2,6 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, Text, - TextInput, useWindowDimensions, NativeSyntheticEvent, TextInputContentSizeChangeEventData, @@ -59,8 +58,15 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useComposerHeightMirror } from "./height-mirror"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; -import { ComposerTextInput } from "./text-input"; -import type { ComposerTextInputHandle } from "./text-input-types"; +import { + EditingTextInput, + type EditingTextInputHandle as ComposerTextInputHandle, + type EditingTextInputProps, +} from "@/components/ui/text-input"; + +const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({ + placeholderTextColor: theme.colors.surface4, +})); import { resolveSendTooltipLabel, resolveSubmitAccessibilityLabel, @@ -622,7 +628,7 @@ interface ComposerTextSurfaceProps { readOnly: boolean; value: string; textInputRef: React.Ref; - textInputStyle: React.ComponentProps["style"]; + textInputStyle: EditingTextInputProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; placeholder: string; accessibilityLabel: string; @@ -662,7 +668,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen void; - blur: () => void; - getText: () => string; - replaceText: (text: string, selection?: { start: number; end: number }) => void; - getNativeRef?: () => unknown; -} - -export interface ComposerTextInputProps extends Omit< - TextInputProps, - "defaultValue" | "onChangeText" | "value" -> { - text: string; - onChangeText: (text: string) => void; - onPasteImages?: (files: readonly NativePastedFile[]) => void; - onPasteError?: (message: string) => void; -} diff --git a/packages/app/src/composer/input/text-input.d.ts b/packages/app/src/composer/input/text-input.d.ts deleted file mode 100644 index 5043ffbeb..000000000 --- a/packages/app/src/composer/input/text-input.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./text-input.native"; diff --git a/packages/app/src/composer/input/text-input.native.tsx b/packages/app/src/composer/input/text-input.native.tsx deleted file mode 100644 index 875c983d3..000000000 --- a/packages/app/src/composer/input/text-input.native.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; -import PasteInput, { - type PastedFile, - type PasteTextInputInstance, -} from "@mattermost/react-native-paste-input"; -import { withUnistyles } from "react-native-unistyles"; -import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types"; - -type NativeInputInstance = PasteTextInputInstance & { - blur: () => void; - focus: () => void; - setNativeProps: (props: { text: string }) => void; -}; - -const ThemedPasteInput = withUnistyles(PasteInput, (theme) => ({ - placeholderTextColor: theme.colors.surface4, -})); - -export const ComposerTextInput = forwardRef( - function ComposerTextInputNative( - { text, onChangeText, onPasteImages, onPasteError, ...props }, - ref, - ) { - const inputRef = useRef(null); - const textRef = useRef(text); - - useImperativeHandle(ref, () => ({ - focus: () => inputRef.current?.focus(), - blur: () => inputRef.current?.blur(), - getText: () => textRef.current, - replaceText: (nextText, selection) => { - textRef.current = nextText; - inputRef.current?.setNativeProps({ text: nextText }); - 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); - return; - } - if (files.length > 0) { - onPasteImages?.(files); - } - }, - [onPasteError, onPasteImages], - ); - - return ( - - ); - }, -); diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 14783da48..91dec513e 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -1,8 +1,8 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it } from "vitest"; -import { ComposerTextInput } from "./text-input.web"; -import type { ComposerTextInputHandle } from "./text-input-types"; +import { EditingTextInput as ComposerTextInput } from "@/components/ui/text-input/text-input.web"; +import type { EditingTextInputHandle as ComposerTextInputHandle } from "@/components/ui/text-input"; interface MountedInput { root: Root; @@ -30,7 +30,7 @@ function mountInput( root.render( { typeFromIme(mounted.textarea, "locally typed"); mounted.root.render( { mounted.textarea.value = "你好"; mounted.root.render( { mounted.textarea.value = "你好"; mounted.root.render( void; }; -type WebTextInput = TextInput & { - getNativeRef?: () => unknown; -}; - interface BrowserElementAnnotation { comment: string; } @@ -403,8 +403,10 @@ function clearAnnotationMarkers(webview: ElectronWebview): void { ).catch(ignoreWebviewJavaScriptError); } -function getTextInputNativeElement(current: WebTextInput | null): HTMLInputElement | null { - const native = current?.getNativeRef?.() ?? current; +function getTextInputNativeElement( + current: EditingTextInputHandle | null, +): HTMLInputElement | null { + const native = current?.getNativeRef(); return native instanceof HTMLInputElement ? native : null; } @@ -606,7 +608,7 @@ export function BrowserPane({ const webviewRef = useRef(null); const webviewHostRef = useRef(null); const webviewClipRef = useRef(null); - const urlInputRef = useRef(null); + const urlInputRef = useRef(null); const initialUrlRef = useRef(browser?.url ?? "https://example.com"); const browserIdRef = useRef(browserId); browserIdRef.current = browserId; @@ -674,6 +676,7 @@ export function BrowserPane({ useEffect(() => { const nextUrl = browser?.url ?? "https://example.com"; + urlInputRef.current?.replaceText(nextUrl); setDraftUrl((current) => (current === nextUrl ? current : nextUrl)); }, [browser?.url]); @@ -1473,7 +1476,7 @@ export function BrowserPane({ placeholderTextColor={theme.colors.foregroundMuted} ref={urlInputRef} style={urlInputStyle} - value={draftUrl} + initialValue={draftUrl} /> @@ -1629,7 +1632,7 @@ function BrowserElementAnnotationCard({ placeholder={t("workspace.browser.annotate.placeholder")} style={styles.annotationInput} uniProps={annotationInputMapping} - value={comment} + initialValue={comment} />