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} />