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
+40
View File
@@ -94,6 +94,11 @@
"error", "error",
{ {
"paths": [ "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", "name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"], "importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
@@ -120,6 +125,11 @@
"error", "error",
{ {
"paths": [ "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", "name": "react-native-unistyles",
"importNames": ["useUnistyles"], "importNames": ["useUnistyles"],
@@ -167,6 +177,11 @@
"error", "error",
{ {
"paths": [ "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", "name": "react-native-unistyles",
"importNames": ["useUnistyles"], "importNames": ["useUnistyles"],
@@ -254,6 +269,11 @@
"error", "error",
{ {
"paths": [ "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", "name": "@tanstack/react-query",
"importNames": ["useQuery", "useInfiniteQuery", "useQueries"], "importNames": ["useQuery", "useInfiniteQuery", "useQueries"],
@@ -274,6 +294,26 @@
"packages/app/src/desktop/components/pair-device-section.tsx", "packages/app/src/desktop/components/pair-device-section.tsx",
"packages/app/src/screens/new-workspace-screen.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": { "rules": {
"no-restricted-imports": "off" "no-restricted-imports": "off"
} }
+6
View File
@@ -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 states) is owned by `components/ui/control-geometry.ts` — controls never
declare their own, and screens never nudge global component styles to align declare their own, and screens never nudge global component styles to align
a row. 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 - The form declares one size for all fields: `sm` on desktop, `md` compact
(`useIsCompactFormFactor`). (`useIsCompactFormFactor`).
- Availability hierarchy: a field whose capability doesn't apply is **hidden** - Availability hierarchy: a field whose capability doesn't apply is **hidden**
@@ -3,7 +3,6 @@ import {
Modal, Modal,
Pressable, Pressable,
Text, Text,
TextInput,
View, View,
type LayoutChangeEvent, type LayoutChangeEvent,
type ListRenderItemInfo, type ListRenderItemInfo,
@@ -18,12 +17,15 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetFlatList, BottomSheetFlatList,
BottomSheetTextInput,
type BottomSheetFlatListMethods, type BottomSheetFlatListMethods,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
import { MaterialFileIcon } from "@/components/material-file-icon"; import { MaterialFileIcon } from "@/components/material-file-icon";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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 { Shortcut } from "@/components/ui/shortcut";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
@@ -69,7 +71,7 @@ import {
} from "./results"; } from "./results";
import { useWorkspaceFileSearch } from "./workspace-file-search"; import { useWorkspaceFileSearch } from "./workspace-file-search";
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ const ThemedBottomSheetTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted, placeholderTextColor: theme.colors.foregroundMuted,
})); }));
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
@@ -211,7 +213,7 @@ interface CommandCenterState {
results: readonly CommandCenterResult[]; results: readonly CommandCenterResult[];
rowIndexByResultId: ReadonlyMap<string, number>; rowIndexByResultId: ReadonlyMap<string, number>;
offsets: readonly number[]; offsets: readonly number[];
inputRef: React.RefObject<TextInput | null>; inputRef: React.RefObject<EditingTextInputHandle | null>;
fileSearchLoading: boolean; fileSearchLoading: boolean;
fileSearchError: string | null; fileSearchError: string | null;
close(): void; close(): void;
@@ -227,7 +229,7 @@ function useCommandCenterState(): CommandCenterState {
const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope); const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope);
const snapshot = useCommandCenterContributions(); const snapshot = useCommandCenterContributions();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<EditingTextInputHandle>(null);
const previousOpenRef = useRef(open); const previousOpenRef = useRef(open);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
@@ -547,7 +549,7 @@ export function CommandCenter() {
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet); const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
const listRef = useRef<FlatList<CommandCenterListRow>>(null); const listRef = useRef<FlatList<CommandCenterListRow>>(null);
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null); const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null); const bottomSheetInputRef = useRef<EditingTextInputHandle>(null);
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 }); const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: state.open, visible: state.open,
@@ -711,7 +713,8 @@ export function CommandCenter() {
<ThemedBottomSheetTextInput <ThemedBottomSheetTextInput
testID="command-center-input" testID="command-center-input"
ref={bottomSheetInputRef} ref={bottomSheetInputRef}
value={state.query} initialValue={state.query}
variant="bottom-sheet"
onChangeText={state.setQuery} onChangeText={state.setQuery}
onKeyPress={keyPress} onKeyPress={keyPress}
onSubmitEditing={submit} onSubmitEditing={submit}
@@ -749,7 +752,7 @@ export function CommandCenter() {
<ThemedTextInput <ThemedTextInput
testID="command-center-input" testID="command-center-input"
ref={state.inputRef} ref={state.inputRef}
value={state.query} initialValue={state.query}
onChangeText={state.setQuery} onChangeText={state.setQuery}
placeholder={ placeholder={
state.scope === "files" state.scope === "files"
@@ -1,10 +1,10 @@
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode, Ref } from "react"; import type { ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import { Modal, Platform, Pressable, ScrollView, Text, View } from "react-native";
import type { StyleProp, TextInputProps, ViewStyle } from "react-native"; import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
getOverlayRoot, getOverlayRoot,
@@ -15,7 +15,6 @@ import {
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetTextInput,
KEYBOARD_STATUS, KEYBOARD_STATUS,
useBottomSheetInternal, useBottomSheetInternal,
type BottomSheetBackgroundProps, type BottomSheetBackgroundProps,
@@ -30,10 +29,11 @@ import {
getBottomSheetVisibleContentHeight, getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding, getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout"; } from "@/components/adaptive-modal-sheet-layout";
import { createControlGeometry } from "@/components/ui/control-geometry"; import { isWeb } from "@/constants/platform";
import { isNative, isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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, // Horizontal indent token shared by the sheet header (title, back arrow,
// leading icon, search input icon) and any row primitive rendered inside the // leading icon, search input icon) and any row primitive rendered inside the
@@ -211,15 +211,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between", justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
adaptiveInputOutline: {
...createControlGeometry(theme).controlFocusRingColor,
},
adaptiveInputText: {
color: theme.colors.foreground,
},
adaptiveInputPlaceholder: {
color: theme.colors.foregroundMuted,
},
})); }));
const WEB_EXIT_DURATION_MS = 160; 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({ export function SheetHeaderView({
header, header,
onClose, 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")} accessibilityLabel={t("pairing.direct.fields.host")}
initialValue={host} initialValue={host}
resetKey={`direct-host-${inputResetKey}`} resetKey={`direct-host-${inputResetKey}`}
value={host}
onChangeText={setHost} onChangeText={setHost}
placeholder="localhost" placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
@@ -522,7 +521,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.port")} accessibilityLabel={t("pairing.direct.fields.port")}
initialValue={port} initialValue={port}
resetKey={`direct-port-${inputResetKey}`} resetKey={`direct-port-${inputResetKey}`}
value={port}
onChangeText={setPort} onChangeText={setPort}
placeholder="6767" placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
@@ -565,7 +563,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.password")} accessibilityLabel={t("pairing.direct.fields.password")}
initialValue={password} initialValue={password}
resetKey={`direct-password-${inputResetKey}`} resetKey={`direct-password-${inputResetKey}`}
value={password}
onChangeText={setPassword} onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")} placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
@@ -615,7 +612,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.connectionUri")} accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri} initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`} resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri}
onChangeText={setAdvancedUri} onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true" placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
@@ -25,10 +25,13 @@ import {
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
TextInput,
View, View,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { import {
applyAvailableAddProjectHosts, applyAvailableAddProjectHosts,
@@ -371,10 +374,12 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const cloneGithubProject = useCloneGithubProject(hostId); const cloneGithubProject = useCloneGithubProject(hostId);
const upsertProject = useSessionStore((store) => store.upsertProject); const upsertProject = useSessionStore((store) => store.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<EditingTextInputHandle>(null);
const submissionInFlightRef = useRef(false); const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false); const browseInFlightRef = useRef(false);
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query; 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); const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => { useEffect(() => {
@@ -389,6 +394,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
}, [query]); }, [query]);
useEffect(() => { useEffect(() => {
inputRef.current?.replaceText(pageInputValueRef.current);
const timer = setTimeout(() => inputRef.current?.focus(), 0); const timer = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [page.kind]); }, [page.kind]);
@@ -858,7 +864,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
{page.kind === "method" && isNative ? ( {page.kind === "method" && isNative ? (
// Native hardware-keyboard events need a focused responder even without a visible field. // Native hardware-keyboard events need a focused responder even without a visible field.
<TextInput <TextInput
key={page.kind}
ref={inputRef} ref={inputRef}
onKeyPress={handleNativeKeyPress} onKeyPress={handleNativeKeyPress}
onSubmitEditing={submitActive} onSubmitEditing={submitActive}
@@ -875,9 +880,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
) : null} ) : null}
{page.kind !== "method" ? ( {page.kind !== "method" ? (
<ThemedTextInput <ThemedTextInput
key={page.kind}
ref={inputRef} ref={inputRef}
value={pageInput(page)} initialValue={pageInput(page)}
onChangeText={handleInputChange} onChangeText={handleInputChange}
onKeyPress={isWeb ? undefined : handleNativeKeyPress} onKeyPress={isWeb ? undefined : handleNativeKeyPress}
onSubmitEditing={isWeb ? undefined : submitActive} onSubmitEditing={isWeb ? undefined : submitActive}
@@ -15,7 +15,6 @@ import {
ListRenderItemInfo, ListRenderItemInfo,
Pressable, Pressable,
Text, Text,
TextInput,
View, View,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type PressableStateCallbackType, type PressableStateCallbackType,
@@ -23,6 +22,7 @@ import {
type TextInputKeyPressEventData, type TextInputKeyPressEventData,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -210,7 +210,7 @@ function EntryNameInputRow({
</View> </View>
<TextInput <TextInput
autoFocus autoFocus
value={name} initialValue={name}
onChangeText={setName} onChangeText={setName}
onSubmitEditing={commit} onSubmitEditing={commit}
onBlur={commit} onBlur={commit}
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native"; 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 { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -67,13 +68,13 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const offerUrlRef = useRef(""); const offerUrlRef = useRef("");
const inputRef = useRef<TextInput>(null); const inputRef = useRef<EditingTextInputHandle>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => { const clearInput = useCallback(() => {
offerUrlRef.current = ""; offerUrlRef.current = "";
inputRef.current?.clear(); inputRef.current?.replaceText("");
}, []); }, []);
const pairIcon = useMemo( const pairIcon = useMemo(
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; 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 { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native"; import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
@@ -13,6 +13,7 @@ import {
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
interface ProviderCatalogListProps { interface ProviderCatalogListProps {
serverId: string; serverId: string;
@@ -150,7 +151,7 @@ export function ProviderCatalogList({
</View> </View>
<ThemedTextInput <ThemedTextInput
testID="provider-catalog-search" testID="provider-catalog-search"
value={search} initialValue={search}
onChangeText={setSearch} onChangeText={setSearch}
accessibilityLabel={t("providerCatalog.search")} accessibilityLabel={t("providerCatalog.search")}
placeholder={t("providerCatalog.search")} placeholder={t("providerCatalog.search")}
@@ -210,7 +210,6 @@ function AddCustomModelSubSheet({
<AdaptiveTextInput <AdaptiveTextInput
initialValue={input} initialValue={input}
resetKey={`add-custom-${visible}`} resetKey={`add-custom-${visible}`}
value={input}
onChangeText={setInput} onChangeText={setInput}
onSubmitEditing={handleAdd} onSubmitEditing={handleAdd}
placeholder={t("settings.providers.models.modelIdPlaceholder")} placeholder={t("settings.providers.models.modelIdPlaceholder")}
@@ -1,6 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, X } from "lucide-react-native"; import { Check, X } from "lucide-react-native";
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { import {
areQuestionsAnswered, areQuestionsAnswered,
buildQuestionFormAnswers, buildQuestionFormAnswers,
@@ -304,7 +305,7 @@ function QuestionOtherInput({
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={value} initialValue={value}
onChangeText={handleChange} onChangeText={handleChange}
onSubmitEditing={onSubmit} onSubmitEditing={onSubmit}
editable={!isResponding} editable={!isResponding}
+4 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -9,6 +9,7 @@ import {
} from "@/components/adaptive-modal-sheet"; } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
export interface AdaptiveRenameModalProps { export interface AdaptiveRenameModalProps {
visible: boolean; visible: boolean;
@@ -39,7 +40,7 @@ export function AdaptiveRenameModal({
const [draft, setDraft] = useState(initialValue); const [draft, setDraft] = useState(initialValue);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<EditingTextInputHandle>(null);
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
@@ -58,7 +59,7 @@ export function AdaptiveRenameModal({
if (isWeb && node instanceof HTMLInputElement) { if (isWeb && node instanceof HTMLInputElement) {
node.setSelectionRange(0, length); node.setSelectionRange(0, length);
} else if (!isWeb && length > 0) { } else if (!isWeb && length > 0) {
node.setNativeProps({ selection: { start: 0, end: length } }); node.replaceText(node.getText(), { start: 0, end: length });
} }
}, 50); }, 50);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
@@ -110,7 +110,6 @@ export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEd
accessibilityLabel="Cron expression" accessibilityLabel="Cron expression"
initialValue={cronText} initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`} resetKey={`cadence-cron-${fieldResetKey}`}
value={cronText}
onChangeText={handleCronChange} onChangeText={handleCronChange}
placeholder="0 9 * * *" placeholder="0 9 * * *"
autoCapitalize="none" autoCapitalize="none"
@@ -497,7 +497,6 @@ function ScheduleFormFields({
testID="schedule-name-input" testID="schedule-name-input"
accessibilityLabel="Schedule name" accessibilityLabel="Schedule name"
initialValue={state.name} initialValue={state.name}
value={state.name}
onChangeText={model.setName} onChangeText={model.setName}
placeholder="Optional" placeholder="Optional"
autoCapitalize="none" autoCapitalize="none"
@@ -511,7 +510,6 @@ function ScheduleFormFields({
testID="schedule-prompt-input" testID="schedule-prompt-input"
accessibilityLabel="Prompt" accessibilityLabel="Prompt"
initialValue={state.prompt} initialValue={state.prompt}
value={state.prompt}
onChangeText={model.setPrompt} onChangeText={model.setPrompt}
placeholder="What should the agent do each run?" placeholder="What should the agent do each run?"
style={styles.multilineInput} style={styles.multilineInput}
@@ -543,7 +541,6 @@ function ScheduleFormFields({
testID="schedule-max-runs-input" testID="schedule-max-runs-input"
accessibilityLabel="Max runs" accessibilityLabel="Max runs"
initialValue={state.maxRuns} initialValue={state.maxRuns}
value={state.maxRuns}
onChangeText={model.setMaxRuns} onChangeText={model.setMaxRuns}
placeholder="Unlimited" placeholder="Unlimited"
keyboardType="number-pad" keyboardType="number-pad"
@@ -1,8 +1,9 @@
import type { StyleProp, TextStyle } from "react-native"; import type { StyleProp, TextStyle } from "react-native";
import { useMemo } from "react"; import { useMemo } from "react";
import { TextInput, View } from "react-native"; import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
interface SettingsTextAreaProps { interface SettingsTextAreaProps {
accessibilityLabel: string; accessibilityLabel: string;
@@ -29,7 +30,7 @@ export function SettingsTextArea({
testID={testID} testID={testID}
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
multiline multiline
value={value} initialValue={value}
onChangeText={onChangeText} onChangeText={onChangeText}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
+4 -18
View File
@@ -13,7 +13,6 @@ import {
Text, Text,
Pressable, Pressable,
Modal, Modal,
TextInput,
ScrollView, ScrollView,
Platform, Platform,
StatusBar, StatusBar,
@@ -26,6 +25,7 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
@@ -191,11 +191,11 @@ export function SearchInput({
onChangeText, onChangeText,
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
useBottomSheetInput = false, useBottomSheetInput: _useBottomSheetInput = false,
resetKey, resetKey,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<EditingTextInputHandle>(null);
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -209,32 +209,18 @@ export function SearchInput({
return ( return (
<View style={styles.searchInputContainer}> <View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} /> <Search size={16} color={theme.colors.foregroundMuted} />
{useBottomSheetInput ? (
<AdaptiveTextInput <AdaptiveTextInput
ref={inputRef} ref={inputRef}
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]} style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
resetKey={resetKey} resetKey={resetKey}
onChangeText={onChangeText} onChangeText={onChangeText}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
onSubmitEditing={onSubmitEditing} 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}
/>
)}
</View> </View>
); );
} }
+10 -8
View File
@@ -12,12 +12,12 @@ import {
Text, Text,
View, View,
type PressableStateCallbackType, type PressableStateCallbackType,
type TextInput,
type TextStyle, type TextStyle,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet"; import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { import {
createControlGeometry, createControlGeometry,
resolveControlInteractionStyles, resolveControlInteractionStyles,
@@ -141,7 +141,10 @@ function stripUnistylesMetadata<TStyle extends object>(style: TStyle): TStyle {
return cleanStyle as 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") { if (typeof forwardedRef === "function") {
forwardedRef(node); forwardedRef(node);
return; return;
@@ -151,17 +154,15 @@ function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInp
} }
} }
export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function FormTextInput( export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
{ size = "md", style, onFocus, onBlur, editable, ...props }, function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
ref,
) {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const isDisabled = editable === false; const isDisabled = editable === false;
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd; const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd; const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]); const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
const setInputRef = useCallback( const setInputRef = useCallback(
(node: TextInput | null) => { (node: EditingTextInputHandle | null) => {
assignTextInputRef(ref, node); assignTextInputRef(ref, node);
}, },
[ref], [ref],
@@ -218,7 +219,8 @@ export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function
/> />
</Pressable> </Pressable>
); );
}); },
);
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -1,8 +1,12 @@
import { useCallback, useState, type ReactElement } from "react"; import { useCallback, useRef, useState, type ReactElement } from "react";
import { Pressable, TextInput, View } from "react-native"; import { Pressable, View } from "react-native";
import { Search, X } from "lucide-react-native"; import { Search, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
const ThemedSearch = withUnistyles(Search); const ThemedSearch = withUnistyles(Search);
const ThemedX = withUnistyles(X); const ThemedX = withUnistyles(X);
@@ -42,16 +46,21 @@ export function SearchField({
clearTestID, clearTestID,
}: SearchFieldProps): ReactElement { }: SearchFieldProps): ReactElement {
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<EditingTextInputHandle>(null);
const handleFocus = useCallback(() => setIsFocused(true), []); const handleFocus = useCallback(() => setIsFocused(true), []);
const handleBlur = useCallback(() => setIsFocused(false), []); const handleBlur = useCallback(() => setIsFocused(false), []);
const handleClear = useCallback(() => onChangeText(""), [onChangeText]); const handleClear = useCallback(() => {
inputRef.current?.replaceText("");
onChangeText("");
}, [onChangeText]);
return ( return (
<View style={[styles.field, isFocused && styles.fieldFocused]}> <View style={[styles.field, isFocused && styles.fieldFocused]}>
<ThemedSearch size={14} uniProps={mutedColorMapping} /> <ThemedSearch size={14} uniProps={mutedColorMapping} />
<ThemedTextInput <ThemedTextInput
testID={testID} testID={testID}
value={value} ref={inputRef}
initialValue={value}
onChangeText={onChangeText} onChangeText={onChangeText}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} 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}
/>
);
},
);
@@ -1,29 +1,29 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react"; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { TextInput } from "react-native"; import { TextInput } from "react-native";
import { withUnistyles } from "react-native-unistyles"; import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types";
interface WebTextInputElement extends TextInput { interface WebTextInputElement extends TextInput {
value?: string; value?: string;
setSelectionRange?: (start: number, end: number) => void; setSelectionRange?: (start: number, end: number) => void;
addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void; addEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void;
removeEventListener: ( removeEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void;
type: "compositionstart" | "compositionend",
listener: EventListener,
) => void;
} }
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
placeholderTextColor: theme.colors.surface4, function EditingTextInputWeb(allProps, ref) {
})); const {
initialValue = "",
export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>( onChangeText,
function ComposerTextInputWeb( onPasteImages: _,
{ text, onChangeText, onPasteImages: _, onPasteError: __, ...props }, onPasteError: __,
ref, variant: ___,
) { value: ____,
defaultValue: _____,
...props
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
const inputRef = useRef<TextInput | null>(null); const inputRef = useRef<TextInput | null>(null);
const textRef = useRef(text); const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current);
const isComposingRef = useRef(false); const isComposingRef = useRef(false);
const onChangeTextRef = useRef(onChangeText); const onChangeTextRef = useRef(onChangeText);
onChangeTextRef.current = onChangeText; onChangeTextRef.current = onChangeText;
@@ -38,10 +38,9 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
const endComposition = () => { const endComposition = () => {
isComposingRef.current = false; isComposingRef.current = false;
const nextText = input.value ?? ""; const nextText = input.value ?? "";
if (nextText !== textRef.current) { if (nextText === textRef.current) return;
textRef.current = nextText; textRef.current = nextText;
onChangeTextRef.current(nextText); onChangeTextRef.current?.(nextText);
}
}; };
input.addEventListener("compositionstart", startComposition); input.addEventListener("compositionstart", startComposition);
@@ -52,33 +51,26 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
}; };
}, []); }, []);
const handleChangeText = useCallback( const handleChangeText = useCallback((nextText: string) => {
(nextText: string) => { if (isComposingRef.current || nextText === textRef.current) return;
if (isComposingRef.current) return;
if (nextText === textRef.current) return;
textRef.current = nextText; textRef.current = nextText;
onChangeText(nextText); onChangeTextRef.current?.(nextText);
}, }, []);
[onChangeText],
);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(), focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(), blur: () => inputRef.current?.blur(),
isFocused: () => document.activeElement === inputRef.current,
getText: () => { getText: () => {
const input = inputRef.current as WebTextInputElement | null; const input = inputRef.current as WebTextInputElement | null;
const nextText = input?.value ?? textRef.current; const nextText = input?.value ?? textRef.current;
if (nextText !== textRef.current) {
textRef.current = nextText; textRef.current = nextText;
}
return nextText; return nextText;
}, },
replaceText: (nextText, selection) => { replaceText: (nextText, selection) => {
textRef.current = nextText; textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null; const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) { if (input && "value" in input) input.value = nextText;
input.value = nextText;
}
if (selection && typeof input?.setSelectionRange === "function") { if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end); input.setSelectionRange(selection.start, selection.end);
} }
@@ -87,10 +79,10 @@ export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTex
})); }));
return ( return (
<ThemedTextInput <TextInput
{...props} {...props}
ref={inputRef} ref={inputRef}
defaultValue={text} defaultValue={initialTextRef.current}
onChangeText={handleChangeText} 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";
}
+11 -5
View File
@@ -2,7 +2,6 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
View, View,
Text, Text,
TextInput,
useWindowDimensions, useWindowDimensions,
NativeSyntheticEvent, NativeSyntheticEvent,
TextInputContentSizeChangeEventData, TextInputContentSizeChangeEventData,
@@ -59,8 +58,15 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeightMirror } from "./height-mirror"; import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image"; import type { NativePastedFile } from "@/composer/native-pasted-image";
import { ComposerTextInput } from "./text-input"; import {
import type { ComposerTextInputHandle } from "./text-input-types"; EditingTextInput,
type EditingTextInputHandle as ComposerTextInputHandle,
type EditingTextInputProps,
} from "@/components/ui/text-input";
const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.surface4,
}));
import { import {
resolveSendTooltipLabel, resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel, resolveSubmitAccessibilityLabel,
@@ -622,7 +628,7 @@ interface ComposerTextSurfaceProps {
readOnly: boolean; readOnly: boolean;
value: string; value: string;
textInputRef: React.Ref<ComposerTextInputHandle>; textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: React.ComponentProps<typeof TextInput>["style"]; textInputStyle: EditingTextInputProps["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"]; readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
placeholder: string; placeholder: string;
accessibilityLabel: string; accessibilityLabel: string;
@@ -662,7 +668,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
<ComposerTextInput <ComposerTextInput
ref={props.textInputRef} ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET} dataSet={COMPOSER_INPUT_DATASET}
text={props.value} initialValue={props.value}
onChangeText={props.onChangeText} onChangeText={props.onChangeText}
placeholder={props.placeholder} placeholder={props.placeholder}
accessibilityLabel={props.accessibilityLabel} accessibilityLabel={props.accessibilityLabel}
@@ -1,20 +0,0 @@
import type { TextInputProps } from "react-native";
import type { NativePastedFile } from "@/composer/native-pasted-image";
export interface ComposerTextInputHandle {
focus: () => 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;
}
-1
View File
@@ -1 +0,0 @@
export * from "./text-input.native";
@@ -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<ComposerTextInputHandle, ComposerTextInputProps>(
function ComposerTextInputNative(
{ text, onChangeText, onPasteImages, onPasteError, ...props },
ref,
) {
const inputRef = useRef<NativeInputInstance | null>(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 (
<ThemedPasteInput
{...props}
ref={inputRef}
defaultValue={text}
onChangeText={handleChangeText}
onPaste={handlePaste}
/>
);
},
);
@@ -1,8 +1,8 @@
import React, { act } from "react"; import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { ComposerTextInput } from "./text-input.web"; import { EditingTextInput as ComposerTextInput } from "@/components/ui/text-input/text-input.web";
import type { ComposerTextInputHandle } from "./text-input-types"; import type { EditingTextInputHandle as ComposerTextInputHandle } from "@/components/ui/text-input";
interface MountedInput { interface MountedInput {
root: Root; root: Root;
@@ -30,7 +30,7 @@ function mountInput(
root.render( root.render(
<ComposerTextInput <ComposerTextInput
ref={inputRef} ref={inputRef}
text="" initialValue=""
multiline={true} multiline={true}
onChangeText={onChangeText} onChangeText={onChangeText}
testID="composer-input" testID="composer-input"
@@ -90,7 +90,7 @@ describe("ComposerTextInput web IME composition", () => {
typeFromIme(mounted.textarea, "locally typed"); typeFromIme(mounted.textarea, "locally typed");
mounted.root.render( mounted.root.render(
<ComposerTextInput <ComposerTextInput
text="" initialValue=""
multiline={true} multiline={true}
onChangeText={recorder.onChangeText} onChangeText={recorder.onChangeText}
placeholder="rerender with stale publication" placeholder="rerender with stale publication"
@@ -112,7 +112,7 @@ describe("ComposerTextInput web IME composition", () => {
mounted.textarea.value = "你好"; mounted.textarea.value = "你好";
mounted.root.render( mounted.root.render(
<ComposerTextInput <ComposerTextInput
text="" initialValue=""
multiline={true} multiline={true}
onChangeText={recorder.onChangeText} onChangeText={recorder.onChangeText}
placeholder="rerender while composing" placeholder="rerender while composing"
@@ -141,7 +141,7 @@ describe("ComposerTextInput web IME composition", () => {
mounted.textarea.value = "你好"; mounted.textarea.value = "你好";
mounted.root.render( mounted.root.render(
<ComposerTextInput <ComposerTextInput
text="你" initialValue="你"
multiline={true} multiline={true}
onChangeText={ignoreTextChange} onChangeText={ignoreTextChange}
placeholder="second composition" placeholder="second composition"
@@ -10,7 +10,11 @@ import {
createElement, createElement,
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from "react-native"; import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@@ -91,10 +95,6 @@ type ElectronWebview = HTMLElement & {
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
}; };
type WebTextInput = TextInput & {
getNativeRef?: () => unknown;
};
interface BrowserElementAnnotation { interface BrowserElementAnnotation {
comment: string; comment: string;
} }
@@ -403,8 +403,10 @@ function clearAnnotationMarkers(webview: ElectronWebview): void {
).catch(ignoreWebviewJavaScriptError); ).catch(ignoreWebviewJavaScriptError);
} }
function getTextInputNativeElement(current: WebTextInput | null): HTMLInputElement | null { function getTextInputNativeElement(
const native = current?.getNativeRef?.() ?? current; current: EditingTextInputHandle | null,
): HTMLInputElement | null {
const native = current?.getNativeRef();
return native instanceof HTMLInputElement ? native : null; return native instanceof HTMLInputElement ? native : null;
} }
@@ -606,7 +608,7 @@ export function BrowserPane({
const webviewRef = useRef<ElectronWebview | null>(null); const webviewRef = useRef<ElectronWebview | null>(null);
const webviewHostRef = useRef<HTMLDivElement | null>(null); const webviewHostRef = useRef<HTMLDivElement | null>(null);
const webviewClipRef = useRef<HTMLElement | null>(null); const webviewClipRef = useRef<HTMLElement | null>(null);
const urlInputRef = useRef<WebTextInput | null>(null); const urlInputRef = useRef<EditingTextInputHandle | null>(null);
const initialUrlRef = useRef(browser?.url ?? "https://example.com"); const initialUrlRef = useRef(browser?.url ?? "https://example.com");
const browserIdRef = useRef(browserId); const browserIdRef = useRef(browserId);
browserIdRef.current = browserId; browserIdRef.current = browserId;
@@ -674,6 +676,7 @@ export function BrowserPane({
useEffect(() => { useEffect(() => {
const nextUrl = browser?.url ?? "https://example.com"; const nextUrl = browser?.url ?? "https://example.com";
urlInputRef.current?.replaceText(nextUrl);
setDraftUrl((current) => (current === nextUrl ? current : nextUrl)); setDraftUrl((current) => (current === nextUrl ? current : nextUrl));
}, [browser?.url]); }, [browser?.url]);
@@ -1473,7 +1476,7 @@ export function BrowserPane({
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
ref={urlInputRef} ref={urlInputRef}
style={urlInputStyle} style={urlInputStyle}
value={draftUrl} initialValue={draftUrl}
/> />
</View> </View>
<View style={styles.chromeRight}> <View style={styles.chromeRight}>
@@ -1629,7 +1632,7 @@ function BrowserElementAnnotationCard({
placeholder={t("workspace.browser.annotate.placeholder")} placeholder={t("workspace.browser.annotate.placeholder")}
style={styles.annotationInput} style={styles.annotationInput}
uniProps={annotationInputMapping} uniProps={annotationInputMapping}
value={comment} initialValue={comment}
/> />
<View style={styles.annotationActions}> <View style={styles.annotationActions}>
<Button variant="ghost" size="sm" onPress={onCancel}> <Button variant="ghost" size="sm" onPress={onCancel}>
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native"; import { Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode"; import * as QRCode from "qrcode";
import { SvgXml } from "react-native-svg"; import { SvgXml } from "react-native-svg";
@@ -16,6 +16,10 @@ import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
const RELAY_DOCS_URL = "https://paseo.sh/docs/security"; const RELAY_DOCS_URL = "https://paseo.sh/docs/security";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -230,6 +234,8 @@ function RelayHeroBadge() {
function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) { function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<EditingTextInputHandle>(null);
useEffect(() => inputRef.current?.replaceText(props.offer.url), [props.offer.url]);
return ( return (
<View style={styles.offer}> <View style={styles.offer}>
<Text style={styles.offerHint}>{t("pairing.device.hint")}</Text> <Text style={styles.offerHint}>{t("pairing.device.hint")}</Text>
@@ -239,8 +245,9 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
<View style={styles.linkRow}> <View style={styles.linkRow}>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<TextInput <TextInput
ref={inputRef}
style={styles.linkInput} style={styles.linkInput}
value={props.offer.url} initialValue={props.offer.url}
readOnly readOnly
selectTextOnFocus selectTextOnFocus
accessibilityLabel={t("pairing.link.label")} accessibilityLabel={t("pairing.link.label")}
+7 -3
View File
@@ -4,10 +4,13 @@ import {
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
TextInput,
View, View,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { router } from "expo-router"; import { router } from "expo-router";
import { Server } from "lucide-react-native"; import { Server } from "lucide-react-native";
import { create } from "zustand"; import { create } from "zustand";
@@ -145,7 +148,7 @@ export function HostChooserModal() {
const hosts = useHosts(); const hosts = useHosts();
const request = useHostChooserStore((state) => state.request); const request = useHostChooserStore((state) => state.request);
const close = useHostChooserStore((state) => state.close); const close = useHostChooserStore((state) => state.close);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<EditingTextInputHandle>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null); const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null);
@@ -167,6 +170,7 @@ export function HostChooserModal() {
useEffect(() => { useEffect(() => {
if (!request) return; if (!request) return;
inputRef.current?.replaceText("");
setQuery(""); setQuery("");
setActiveIndex(0); setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0); const id = setTimeout(() => inputRef.current?.focus(), 0);
@@ -246,7 +250,7 @@ export function HostChooserModal() {
<Text style={styles.title}>{request.title}</Text> <Text style={styles.title}>{request.title}</Text>
<TextInput <TextInput
ref={inputRef} ref={inputRef}
value={query} initialValue={query}
onChangeText={handleQueryChange} onChangeText={handleQueryChange}
placeholder="Search hosts..." placeholder="Search hosts..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
+5 -1
View File
@@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import * as ReactJsxRuntime from "react/jsx-runtime"; import * as ReactJsxRuntime from "react/jsx-runtime";
// eslint-disable-next-line no-restricted-imports -- the plugin runtime replaces raw TextInput with Paseo's editing primitive below.
import * as ReactNative from "react-native"; import * as ReactNative from "react-native";
// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers. // eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers.
import * as ReactQuery from "@tanstack/react-query"; import * as ReactQuery from "@tanstack/react-query";
@@ -21,6 +22,9 @@ import { createPluginContext, type PluginRegistrationCollector } from "@paseo/pl
import type { EvaluatedPlugin } from "./types"; import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons"; import { resolvePluginIcon } from "./icons";
import { EditingTextInput } from "@/components/ui/text-input";
const PluginReactNative = { ...ReactNative, TextInput: EditingTextInput };
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/; const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
@@ -147,7 +151,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
const runtimeRequire = (name: string): unknown => { const runtimeRequire = (name: string): unknown => {
if (name === "react") return React; if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative; if (name === "react-native") return PluginReactNative;
if (name === "@paseo/plugin") { if (name === "@paseo/plugin") {
return { return {
defineAttachmentSource, defineAttachmentSource,
+8 -11
View File
@@ -6,7 +6,6 @@ import {
Pressable, Pressable,
type PressableStateCallbackType, type PressableStateCallbackType,
Text, Text,
TextInput,
type TextStyle, type TextStyle,
View, View,
type StyleProp, type StyleProp,
@@ -14,6 +13,10 @@ import {
} from "react-native"; } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -30,11 +33,6 @@ import {
} from "./geometry"; } from "./geometry";
type PressableState = PressableStateCallbackType & { hovered?: boolean }; type PressableState = PressableStateCallbackType & { hovered?: boolean };
type WebTextInputRef = TextInput & {
getNativeElement?: () => unknown;
getNativeRef?: () => unknown;
};
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> { function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered]; return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
} }
@@ -43,12 +41,11 @@ function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): Style
return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered]; return [styles.iconButton, (hovered || pressed) && styles.iconButtonDestructiveHovered];
} }
function getWebTextInputElement(input: TextInput | null): HTMLElement | null { function getWebTextInputElement(input: EditingTextInputHandle | null): HTMLElement | null {
if (!isWeb || typeof HTMLElement === "undefined" || !input) { if (!isWeb || typeof HTMLElement === "undefined" || !input) {
return null; return null;
} }
const webInput = input as WebTextInputRef; const element = input.getNativeRef();
const element = webInput.getNativeElement?.() ?? webInput.getNativeRef?.() ?? input;
return element instanceof HTMLElement ? element : null; return element instanceof HTMLElement ? element : null;
} }
@@ -443,7 +440,7 @@ export function InlineReviewEditor({
testID?: string; testID?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<TextInput | null>(null); const inputRef = useRef<EditingTextInputHandle | null>(null);
const focus = useWorkspaceFocusRestoration(); const focus = useWorkspaceFocusRestoration();
const [body, setBody] = useState(initialBody); const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
@@ -512,7 +509,7 @@ export function InlineReviewEditor({
placeholder={t("review.comment.placeholder")} placeholder={t("review.comment.placeholder")}
placeholderTextColor={styles.placeholderColor.color} placeholderTextColor={styles.placeholderColor.color}
multiline multiline
value={body} initialValue={body}
onChangeText={setBody} onChangeText={setBody}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -26,6 +26,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ProjectEditSheet } from "@/components/project-edit-sheet"; import { ProjectEditSheet } from "@/components/project-edit-sheet";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
@@ -1063,7 +1064,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
<TextInput <TextInput
testID="script-edit-name" testID="script-edit-name"
accessibilityLabel={t("settings.project.scripts.nameAccessibility")} accessibilityLabel={t("settings.project.scripts.nameAccessibility")}
value={script.name} initialValue={script.name}
onChangeText={handleNameChange} onChangeText={handleNameChange}
onBlur={handleNameBlur} onBlur={handleNameBlur}
placeholder="dev" placeholder="dev"
@@ -1082,7 +1083,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
testID="script-edit-command" testID="script-edit-command"
accessibilityLabel={t("settings.project.scripts.commandAccessibility")} accessibilityLabel={t("settings.project.scripts.commandAccessibility")}
multiline multiline
value={script.commandText} initialValue={script.commandText}
onChangeText={handleCommandChange} onChangeText={handleCommandChange}
onBlur={handleCommandBlur} onBlur={handleCommandBlur}
placeholder="npm run dev" placeholder="npm run dev"
+2 -2
View File
@@ -5,10 +5,10 @@ import {
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
TextInput,
View, View,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -448,7 +448,7 @@ function GeneralSection({
</Text> </Text>
</View> </View>
<TextInput <TextInput
value={terminalScrollbackValue} initialValue={terminalScrollbackValue}
onChangeText={handleTerminalScrollbackChangeText} onChangeText={handleTerminalScrollbackChangeText}
onBlur={commitTerminalScrollback} onBlur={commitTerminalScrollback}
onSubmitEditing={commitTerminalScrollback} onSubmitEditing={commitTerminalScrollback}
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native"; import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native"; import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
import { import {
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { import {
MAX_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE,
MAX_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE,
@@ -332,7 +333,7 @@ function FontFamilyRow({
<Text style={settingsStyles.rowHint}>{hint}</Text> <Text style={settingsStyles.rowHint}>{hint}</Text>
</View> </View>
<TextInput <TextInput
value={draft} initialValue={draft}
onChangeText={onChangeDraft} onChangeText={onChangeDraft}
onBlur={handleCommit} onBlur={handleCommit}
onSubmitEditing={handleCommit} onSubmitEditing={handleCommit}
@@ -372,7 +373,7 @@ function FontSizeRow({
</View> </View>
<View style={styles.sizeField}> <View style={styles.sizeField}>
<TextInput <TextInput
value={draft} initialValue={draft}
onChangeText={onChangeDraft} onChangeText={onChangeDraft}
onBlur={onCommit} onBlur={onCommit}
onSubmitEditing={onCommit} onSubmitEditing={onCommit}
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native"; import { Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field"; import { Field, FormTextInput } from "@/components/ui/form-field";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
export interface ProfileDraft { export interface ProfileDraft {
name: string; name: string;
@@ -41,9 +42,9 @@ export function TerminalProfileEditModal({
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false); const [isPending, setIsPending] = useState(false);
const nameInputRef = useRef<TextInput>(null); const nameInputRef = useRef<EditingTextInputHandle>(null);
const commandInputRef = useRef<TextInput>(null); const commandInputRef = useRef<EditingTextInputHandle>(null);
const argsInputRef = useRef<TextInput>(null); const argsInputRef = useRef<EditingTextInputHandle>(null);
const handleNameChange = useCallback((value: string) => { const handleNameChange = useCallback((value: string) => {
setName(value); setName(value);
@@ -1,12 +1,15 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import { import {
StyleSheet, StyleSheet,
TextInput,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type StyleProp, type StyleProp,
type TextInputKeyPressEventData, type TextInputKeyPressEventData,
type TextStyle, type TextStyle,
} from "react-native"; } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events"; import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events";
export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true; export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true;
@@ -181,7 +184,7 @@ export function createTerminalTextInputState(): TerminalTextInputState {
export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>( export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>(
function TerminalInput({ isKeyboardVisible, onFocus, onInput, onTerminalKey, style }, ref) { function TerminalInput({ isKeyboardVisible, onFocus, onInput, onTerminalKey, style }, ref) {
const inputRef = useRef<TextInput>(null); const inputRef = useRef<EditingTextInputHandle>(null);
const isFocusedRef = useRef(false); const isFocusedRef = useRef(false);
const pendingFocusFrameRef = useRef<number | null>(null); const pendingFocusFrameRef = useRef<number | null>(null);
const inputState = useMemo(() => createTerminalTextInputState(), []); const inputState = useMemo(() => createTerminalTextInputState(), []);
@@ -197,7 +200,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
const resetNativeInput = useCallback(() => { const resetNativeInput = useCallback(() => {
inputState.reset(); inputState.reset();
inputRef.current?.clear(); inputRef.current?.replaceText("");
}, [inputState]); }, [inputState]);
const showNativeKeyboard = useCallback(() => { const showNativeKeyboard = useCallback(() => {
@@ -314,7 +317,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
autoCorrect={false} autoCorrect={false}
caretHidden={true} caretHidden={true}
contextMenuHidden={TERMINAL_INPUT_CONTEXT_MENU_HIDDEN} contextMenuHidden={TERMINAL_INPUT_CONTEXT_MENU_HIDDEN}
defaultValue="" initialValue=""
blurOnSubmit={false} blurOnSubmit={false}
importantForAutofill="no" importantForAutofill="no"
multiline={true} multiline={true}