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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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<string, number>;
|
||||
offsets: readonly number[];
|
||||
inputRef: React.RefObject<TextInput | null>;
|
||||
inputRef: React.RefObject<EditingTextInputHandle | null>;
|
||||
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<TextInput>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const previousOpenRef = useRef(open);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
@@ -547,7 +549,7 @@ export function CommandCenter() {
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(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 { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
@@ -711,7 +713,8 @@ export function CommandCenter() {
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
value={state.query}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
@@ -749,7 +752,7 @@ export function CommandCenter() {
|
||||
<ThemedTextInput
|
||||
testID="command-center-input"
|
||||
ref={state.inputRef}
|
||||
value={state.query}
|
||||
initialValue={state.query}
|
||||
onChangeText={state.setQuery}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { StyleProp, TextInputProps, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
getOverlayRoot,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
@@ -30,10 +29,11 @@ import {
|
||||
getBottomSheetVisibleContentHeight,
|
||||
getCompactSheetSafeAreaPadding,
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
import { createControlGeometry } from "@/components/ui/control-geometry";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
|
||||
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
// leading icon, search input icon) and any row primitive rendered inside the
|
||||
@@ -211,15 +211,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
...createControlGeometry(theme).controlFocusRingColor,
|
||||
},
|
||||
adaptiveInputText: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
adaptiveInputPlaceholder: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
const WEB_EXIT_DURATION_MS = 160;
|
||||
@@ -288,55 +279,6 @@ function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export type AdaptiveTextInputProps = TextInputProps & {
|
||||
initialValue?: string;
|
||||
resetKey?: string | number;
|
||||
};
|
||||
|
||||
// React Native controlled TextInput can replay stale JS values during fast input
|
||||
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
|
||||
// can seed it once with initialValue and remount with resetKey for real resets.
|
||||
// See https://github.com/facebook/react-native/issues/44157
|
||||
//
|
||||
// Text color and placeholder color are owned by this leaf — not the caller.
|
||||
// `@gorhom/bottom-sheet` mounts header subtrees before the sheet is visible
|
||||
// under whatever theme is active at mount time, then keeps them mounted across
|
||||
// theme changes; any caller that paints color via `StyleSheet.create((theme) =>
|
||||
// ...)` from outside this leaf ends up with stale colors in dark mode (see
|
||||
// docs/unistyles.md "Hidden Sheet Content"). withUnistyles wraps the actual
|
||||
// TextInput so theme-driven re-renders land on the wrapper.
|
||||
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
|
||||
function AdaptiveTextInputInner(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { value: _value, initialValue, resetKey, defaultValue, style, ...inputProps } = props;
|
||||
// Leaf-owned color goes LAST so callers cannot override it with a stale
|
||||
// theme read. Outline color is theme-aware on web :focus-visible.
|
||||
const textInputProps = {
|
||||
...inputProps,
|
||||
defaultValue: initialValue ?? defaultValue,
|
||||
style: [styles.adaptiveInputOutline, style, styles.adaptiveInputText],
|
||||
};
|
||||
|
||||
if (isMobile && isNative) {
|
||||
return (
|
||||
<ThemedBottomSheetTextInput
|
||||
key={resetKey}
|
||||
ref={ref as unknown as Ref<never>}
|
||||
{...textInputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ThemedTextInput key={resetKey} ref={ref} {...textInputProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
export function SheetHeaderView({
|
||||
header,
|
||||
onClose,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { forwardRef, useCallback, useEffect, useRef } from "react";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { createControlGeometry } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
EditingTextInput,
|
||||
type EditingTextInputHandle,
|
||||
type EditingTextInputProps,
|
||||
} from "@/components/ui/text-input";
|
||||
|
||||
export type AdaptiveTextInputProps = EditingTextInputProps & {
|
||||
initialValue?: string;
|
||||
resetKey?: string | number;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
outline: {
|
||||
...createControlGeometry(theme).controlFocusRingColor,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
const ThemedTextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export const AdaptiveTextInput = forwardRef<EditingTextInputHandle, AdaptiveTextInputProps>(
|
||||
function AdaptiveTextInputInner(props, ref) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { initialValue, resetKey, style, onChangeText, ...inputProps } = props;
|
||||
const inputRef = useRef<EditingTextInputHandle | null>(null);
|
||||
const replacementTextRef = useRef(initialValue ?? "");
|
||||
const previousResetKeyRef = useRef(resetKey);
|
||||
replacementTextRef.current = initialValue ?? "";
|
||||
const setInputRef = useCallback(
|
||||
(handle: EditingTextInputHandle | null) => {
|
||||
inputRef.current = handle;
|
||||
if (typeof ref === "function") ref(handle);
|
||||
else if (ref) ref.current = handle;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetKey === previousResetKeyRef.current) return;
|
||||
previousResetKeyRef.current = resetKey;
|
||||
inputRef.current?.replaceText(replacementTextRef.current);
|
||||
}, [resetKey]);
|
||||
|
||||
return (
|
||||
<ThemedTextInput
|
||||
{...inputProps}
|
||||
ref={setInputRef}
|
||||
initialValue={initialValue}
|
||||
onChangeText={onChangeText}
|
||||
style={[styles.outline, style, styles.text]}
|
||||
variant={isMobile && isNative ? "bottom-sheet" : "default"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { act, useCallback, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AdaptiveTextInput } from "./adaptive-text-input";
|
||||
|
||||
interface MountedInput {
|
||||
root: Root;
|
||||
container: HTMLDivElement;
|
||||
textarea: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
const mountedInputs: MountedInput[] = [];
|
||||
|
||||
function SchedulePromptHarness({ changes }: { changes: string[] }) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const handleChangeText = useCallback(
|
||||
(text: string) => {
|
||||
changes.push(text);
|
||||
setPrompt(text);
|
||||
},
|
||||
[changes],
|
||||
);
|
||||
return (
|
||||
<AdaptiveTextInput
|
||||
initialValue={prompt}
|
||||
multiline
|
||||
onChangeText={handleChangeText}
|
||||
testID="schedule-prompt-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function mountSchedulePrompt(changes: string[]): MountedInput {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => root.render(<SchedulePromptHarness changes={changes} />));
|
||||
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Schedule prompt did not render a textarea");
|
||||
}
|
||||
|
||||
const mounted = { root, container, textarea };
|
||||
mountedInputs.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
function typeFromIme(textarea: HTMLTextAreaElement, text: string): void {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
if (!valueSetter) {
|
||||
throw new Error("HTML textarea value setter is unavailable");
|
||||
}
|
||||
valueSetter.call(textarea, text);
|
||||
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const mounted of mountedInputs.splice(0)) {
|
||||
act(() => mounted.root.unmount());
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("AdaptiveTextInput web IME composition", () => {
|
||||
it("keeps the candidate editing-surface owned until composition commits", () => {
|
||||
const changes: string[] = [];
|
||||
const { textarea } = mountSchedulePrompt(changes);
|
||||
|
||||
act(() => {
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
|
||||
typeFromIme(textarea, "n");
|
||||
});
|
||||
|
||||
expect(textarea.value).toBe("n");
|
||||
expect(changes).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
textarea.value = "你";
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(textarea.value).toBe("你");
|
||||
expect(changes).toEqual(["你"]);
|
||||
});
|
||||
});
|
||||
@@ -502,7 +502,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityLabel={t("pairing.direct.fields.host")}
|
||||
initialValue={host}
|
||||
resetKey={`direct-host-${inputResetKey}`}
|
||||
value={host}
|
||||
onChangeText={setHost}
|
||||
placeholder="localhost"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
@@ -522,7 +521,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityLabel={t("pairing.direct.fields.port")}
|
||||
initialValue={port}
|
||||
resetKey={`direct-port-${inputResetKey}`}
|
||||
value={port}
|
||||
onChangeText={setPort}
|
||||
placeholder="6767"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
@@ -565,7 +563,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityLabel={t("pairing.direct.fields.password")}
|
||||
initialValue={password}
|
||||
resetKey={`direct-password-${inputResetKey}`}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder={t("pairing.direct.fields.optional")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
@@ -615,7 +612,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
applyAvailableAddProjectHosts,
|
||||
@@ -371,10 +374,12 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const cloneGithubProject = useCloneGithubProject(hostId);
|
||||
const upsertProject = useSessionStore((store) => store.upsertProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
const browseInFlightRef = useRef(false);
|
||||
const query = page.kind === "new-directory-name" || page.kind === "method" ? "" : page.query;
|
||||
const pageInputValueRef = useRef(page.kind === "method" ? "" : pageInput(page));
|
||||
pageInputValueRef.current = page.kind === "method" ? "" : pageInput(page);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -389,6 +394,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.replaceText(pageInputValueRef.current);
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page.kind]);
|
||||
@@ -858,7 +864,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
{page.kind === "method" && isNative ? (
|
||||
// Native hardware-keyboard events need a focused responder even without a visible field.
|
||||
<TextInput
|
||||
key={page.kind}
|
||||
ref={inputRef}
|
||||
onKeyPress={handleNativeKeyPress}
|
||||
onSubmitEditing={submitActive}
|
||||
@@ -875,9 +880,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
) : null}
|
||||
{page.kind !== "method" ? (
|
||||
<ThemedTextInput
|
||||
key={page.kind}
|
||||
ref={inputRef}
|
||||
value={pageInput(page)}
|
||||
initialValue={pageInput(page)}
|
||||
onChangeText={handleInputChange}
|
||||
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
|
||||
onSubmitEditing={isWeb ? undefined : submitActive}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ListRenderItemInfo,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
type TextInputKeyPressEventData,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -210,7 +210,7 @@ function EntryNameInputRow({
|
||||
</View>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={name}
|
||||
initialValue={name}
|
||||
onChangeText={setName}
|
||||
onSubmitEditing={commit}
|
||||
onBlur={commit}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, TextInput, View } from "react-native";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
@@ -11,6 +11,7 @@ import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -67,13 +68,13 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const offerUrlRef = useRef("");
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
offerUrlRef.current = "";
|
||||
inputRef.current?.clear();
|
||||
inputRef.current?.replaceText("");
|
||||
}, []);
|
||||
|
||||
const pairIcon = useMemo(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
|
||||
interface ProviderCatalogListProps {
|
||||
serverId: string;
|
||||
@@ -150,7 +151,7 @@ export function ProviderCatalogList({
|
||||
</View>
|
||||
<ThemedTextInput
|
||||
testID="provider-catalog-search"
|
||||
value={search}
|
||||
initialValue={search}
|
||||
onChangeText={setSearch}
|
||||
accessibilityLabel={t("providerCatalog.search")}
|
||||
placeholder={t("providerCatalog.search")}
|
||||
|
||||
@@ -210,7 +210,6 @@ function AddCustomModelSubSheet({
|
||||
<AdaptiveTextInput
|
||||
initialValue={input}
|
||||
resetKey={`add-custom-${visible}`}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleAdd}
|
||||
placeholder={t("settings.providers.models.modelIdPlaceholder")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { View, Text, TextInput, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -304,7 +305,7 @@ function QuestionOtherInput({
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
initialValue={value}
|
||||
onChangeText={handleChange}
|
||||
onSubmitEditing={onSubmit}
|
||||
editable={!isResponding}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
export interface AdaptiveRenameModalProps {
|
||||
visible: boolean;
|
||||
@@ -39,7 +40,7 @@ export function AdaptiveRenameModal({
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
@@ -58,7 +59,7 @@ export function AdaptiveRenameModal({
|
||||
if (isWeb && node instanceof HTMLInputElement) {
|
||||
node.setSelectionRange(0, length);
|
||||
} else if (!isWeb && length > 0) {
|
||||
node.setNativeProps({ selection: { start: 0, end: length } });
|
||||
node.replaceText(node.getText(), { start: 0, end: length });
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timeout);
|
||||
|
||||
@@ -110,7 +110,6 @@ export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEd
|
||||
accessibilityLabel="Cron expression"
|
||||
initialValue={cronText}
|
||||
resetKey={`cadence-cron-${fieldResetKey}`}
|
||||
value={cronText}
|
||||
onChangeText={handleCronChange}
|
||||
placeholder="0 9 * * *"
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -497,7 +497,6 @@ function ScheduleFormFields({
|
||||
testID="schedule-name-input"
|
||||
accessibilityLabel="Schedule name"
|
||||
initialValue={state.name}
|
||||
value={state.name}
|
||||
onChangeText={model.setName}
|
||||
placeholder="Optional"
|
||||
autoCapitalize="none"
|
||||
@@ -511,7 +510,6 @@ function ScheduleFormFields({
|
||||
testID="schedule-prompt-input"
|
||||
accessibilityLabel="Prompt"
|
||||
initialValue={state.prompt}
|
||||
value={state.prompt}
|
||||
onChangeText={model.setPrompt}
|
||||
placeholder="What should the agent do each run?"
|
||||
style={styles.multilineInput}
|
||||
@@ -543,7 +541,6 @@ function ScheduleFormFields({
|
||||
testID="schedule-max-runs-input"
|
||||
accessibilityLabel="Max runs"
|
||||
initialValue={state.maxRuns}
|
||||
value={state.maxRuns}
|
||||
onChangeText={model.setMaxRuns}
|
||||
placeholder="Unlimited"
|
||||
keyboardType="number-pad"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { StyleProp, TextStyle } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
|
||||
interface SettingsTextAreaProps {
|
||||
accessibilityLabel: string;
|
||||
@@ -29,7 +30,7 @@ export function SettingsTextArea({
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
multiline
|
||||
value={value}
|
||||
initialValue={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -191,11 +191,11 @@ export function SearchInput({
|
||||
onChangeText,
|
||||
onSubmitEditing,
|
||||
autoFocus = false,
|
||||
useBottomSheetInput = false,
|
||||
useBottomSheetInput: _useBottomSheetInput = false,
|
||||
resetKey,
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
@@ -209,32 +209,18 @@ export function SearchInput({
|
||||
return (
|
||||
<View style={styles.searchInputContainer}>
|
||||
<Search size={16} color={theme.colors.foregroundMuted} />
|
||||
{useBottomSheetInput ? (
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
||||
placeholder={placeholder}
|
||||
resetKey={resetKey}
|
||||
onChangeText={onChangeText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
key={resetKey}
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
onChangeText={onChangeText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
)}
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
resetKey={resetKey}
|
||||
onChangeText={onChangeText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
type TextInput,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import {
|
||||
createControlGeometry,
|
||||
resolveControlInteractionStyles,
|
||||
@@ -141,7 +141,10 @@ function stripUnistylesMetadata<TStyle extends object>(style: TStyle): TStyle {
|
||||
return cleanStyle as TStyle;
|
||||
}
|
||||
|
||||
function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInput | null): void {
|
||||
function assignTextInputRef(
|
||||
forwardedRef: ForwardedRef<EditingTextInputHandle>,
|
||||
node: EditingTextInputHandle | null,
|
||||
): void {
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(node);
|
||||
return;
|
||||
@@ -151,74 +154,73 @@ function assignTextInputRef(forwardedRef: ForwardedRef<TextInput>, node: TextInp
|
||||
}
|
||||
}
|
||||
|
||||
export const FormTextInput = forwardRef<TextInput, FormTextInputProps>(function FormTextInput(
|
||||
{ size = "md", style, onFocus, onBlur, editable, ...props },
|
||||
ref,
|
||||
) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const isDisabled = editable === false;
|
||||
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
|
||||
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
|
||||
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
|
||||
const setInputRef = useCallback(
|
||||
(node: TextInput | null) => {
|
||||
assignTextInputRef(ref, node);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus?.(event);
|
||||
},
|
||||
[onFocus],
|
||||
);
|
||||
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur?.(event);
|
||||
},
|
||||
[onBlur],
|
||||
);
|
||||
const inputStyle = useMemo(
|
||||
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
|
||||
[inputSizeStyle, splitStyle.inputStyle],
|
||||
) as AdaptiveTextInputProps["style"];
|
||||
const chromeStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
formInputStyles.chrome,
|
||||
chromeSizeStyle,
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: formInputStyles.controlRest,
|
||||
controlHover: formInputStyles.controlHover,
|
||||
controlActive: formInputStyles.controlActive,
|
||||
controlDisabled: formInputStyles.controlDisabled,
|
||||
},
|
||||
{
|
||||
hovered,
|
||||
focused,
|
||||
disabled: isDisabled,
|
||||
},
|
||||
),
|
||||
splitStyle.chromeStyle,
|
||||
],
|
||||
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
|
||||
);
|
||||
export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
|
||||
function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const isDisabled = editable === false;
|
||||
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
|
||||
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
|
||||
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
|
||||
const setInputRef = useCallback(
|
||||
(node: EditingTextInputHandle | null) => {
|
||||
assignTextInputRef(ref, node);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
const handleFocus = useCallback<NonNullable<AdaptiveTextInputProps["onFocus"]>>(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus?.(event);
|
||||
},
|
||||
[onFocus],
|
||||
);
|
||||
const handleBlur = useCallback<NonNullable<AdaptiveTextInputProps["onBlur"]>>(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur?.(event);
|
||||
},
|
||||
[onBlur],
|
||||
);
|
||||
const inputStyle = useMemo(
|
||||
() => [formInputStyles.input, inputSizeStyle, splitStyle.inputStyle],
|
||||
[inputSizeStyle, splitStyle.inputStyle],
|
||||
) as AdaptiveTextInputProps["style"];
|
||||
const chromeStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
formInputStyles.chrome,
|
||||
chromeSizeStyle,
|
||||
resolveControlInteractionStyles(
|
||||
{
|
||||
controlRest: formInputStyles.controlRest,
|
||||
controlHover: formInputStyles.controlHover,
|
||||
controlActive: formInputStyles.controlActive,
|
||||
controlDisabled: formInputStyles.controlDisabled,
|
||||
},
|
||||
{
|
||||
hovered,
|
||||
focused,
|
||||
disabled: isDisabled,
|
||||
},
|
||||
),
|
||||
splitStyle.chromeStyle,
|
||||
],
|
||||
[chromeSizeStyle, focused, isDisabled, splitStyle.chromeStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable disabled={isDisabled} style={chromeStyle}>
|
||||
<AdaptiveTextInput
|
||||
ref={setInputRef}
|
||||
editable={editable}
|
||||
{...props}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Pressable disabled={isDisabled} style={chromeStyle}>
|
||||
<AdaptiveTextInput
|
||||
ref={setInputRef}
|
||||
editable={editable}
|
||||
{...props}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useCallback, useState, type ReactElement } from "react";
|
||||
import { Pressable, TextInput, View } from "react-native";
|
||||
import { useCallback, useRef, useState, type ReactElement } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Search, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
|
||||
const ThemedSearch = withUnistyles(Search);
|
||||
const ThemedX = withUnistyles(X);
|
||||
@@ -42,16 +46,21 @@ export function SearchField({
|
||||
clearTestID,
|
||||
}: SearchFieldProps): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const handleFocus = useCallback(() => setIsFocused(true), []);
|
||||
const handleBlur = useCallback(() => setIsFocused(false), []);
|
||||
const handleClear = useCallback(() => onChangeText(""), [onChangeText]);
|
||||
const handleClear = useCallback(() => {
|
||||
inputRef.current?.replaceText("");
|
||||
onChangeText("");
|
||||
}, [onChangeText]);
|
||||
|
||||
return (
|
||||
<View style={[styles.field, isFocused && styles.fieldFocused]}>
|
||||
<ThemedSearch size={14} uniProps={mutedColorMapping} />
|
||||
<ThemedTextInput
|
||||
testID={testID}
|
||||
value={value}
|
||||
ref={inputRef}
|
||||
initialValue={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EditingTextInput } from "./text-input";
|
||||
export type { EditingTextInputHandle, EditingTextInputProps } from "./types";
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { TextInput } from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import PasteInput, {
|
||||
type PastedFile,
|
||||
type PasteTextInputInstance,
|
||||
} from "@mattermost/react-native-paste-input";
|
||||
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
|
||||
|
||||
type NativeInput = (TextInput | PasteTextInputInstance) & {
|
||||
blur(): void;
|
||||
focus(): void;
|
||||
isFocused(): boolean;
|
||||
setNativeProps(props: { text: string; selection?: { start: number; end: number } }): void;
|
||||
setSelection?(start: number, end: number): void;
|
||||
getNativeRef?(): unknown;
|
||||
};
|
||||
|
||||
export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
|
||||
function EditingTextInputNative(allProps, ref) {
|
||||
const {
|
||||
initialValue = "",
|
||||
onChangeText,
|
||||
onPasteImages,
|
||||
onPasteError,
|
||||
variant = "default",
|
||||
value: _,
|
||||
defaultValue: __,
|
||||
...props
|
||||
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
|
||||
const inputRef = useRef<NativeInput | null>(null);
|
||||
const initialTextRef = useRef(initialValue);
|
||||
const textRef = useRef(initialTextRef.current);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
blur: () => inputRef.current?.blur(),
|
||||
isFocused: () => inputRef.current?.isFocused() ?? false,
|
||||
getText: () => textRef.current,
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
inputRef.current?.setNativeProps({ text: nextText, ...(selection ? { selection } : {}) });
|
||||
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
},
|
||||
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
|
||||
}));
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(nextText: string) => {
|
||||
textRef.current = nextText;
|
||||
onChangeText?.(nextText);
|
||||
},
|
||||
[onChangeText],
|
||||
);
|
||||
const handlePaste = useCallback(
|
||||
(error: string | null | undefined, files: PastedFile[]) => {
|
||||
if (error) {
|
||||
onPasteError?.(error);
|
||||
} else if (files.length > 0) {
|
||||
onPasteImages?.(files);
|
||||
}
|
||||
},
|
||||
[onPasteError, onPasteImages],
|
||||
);
|
||||
|
||||
if (onPasteImages || onPasteError) {
|
||||
return (
|
||||
<PasteInput
|
||||
{...props}
|
||||
ref={inputRef as React.Ref<PasteTextInputInstance>}
|
||||
defaultValue={initialTextRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (variant === "bottom-sheet") {
|
||||
return (
|
||||
<BottomSheetTextInput
|
||||
{...props}
|
||||
ref={inputRef as unknown as React.Ref<never>}
|
||||
defaultValue={initialTextRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
ref={inputRef as React.Ref<TextInput>}
|
||||
defaultValue={initialTextRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
+30
-38
@@ -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<ComposerTextInputHandle, ComposerTextInputProps>(
|
||||
function ComposerTextInputWeb(
|
||||
{ text, onChangeText, onPasteImages: _, onPasteError: __, ...props },
|
||||
ref,
|
||||
) {
|
||||
export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
|
||||
function EditingTextInputWeb(allProps, ref) {
|
||||
const {
|
||||
initialValue = "",
|
||||
onChangeText,
|
||||
onPasteImages: _,
|
||||
onPasteError: __,
|
||||
variant: ___,
|
||||
value: ____,
|
||||
defaultValue: _____,
|
||||
...props
|
||||
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const 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<ComposerTextInputHandle, ComposerTex
|
||||
const endComposition = () => {
|
||||
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<ComposerTextInputHandle, ComposerTex
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(nextText: string) => {
|
||||
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<ComposerTextInputHandle, ComposerTex
|
||||
}));
|
||||
|
||||
return (
|
||||
<ThemedTextInput
|
||||
<TextInput
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
defaultValue={text}
|
||||
defaultValue={initialTextRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TextInputProps } from "react-native";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
|
||||
export interface EditingTextInputHandle {
|
||||
focus(): void;
|
||||
blur(): void;
|
||||
isFocused(): boolean;
|
||||
getText(): string;
|
||||
replaceText(text: string, selection?: { start: number; end: number }): void;
|
||||
getNativeRef(): unknown;
|
||||
}
|
||||
|
||||
export interface EditingTextInputProps extends Omit<
|
||||
TextInputProps,
|
||||
"defaultValue" | "onChangeText" | "value"
|
||||
> {
|
||||
initialValue?: string;
|
||||
onChangeText?: (text: string) => void;
|
||||
onPasteImages?: (files: readonly NativePastedFile[]) => void;
|
||||
onPasteError?: (message: string) => void;
|
||||
variant?: "default" | "bottom-sheet";
|
||||
}
|
||||
@@ -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<ComposerTextInputHandle>;
|
||||
textInputStyle: React.ComponentProps<typeof TextInput>["style"];
|
||||
textInputStyle: EditingTextInputProps["style"];
|
||||
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
|
||||
placeholder: string;
|
||||
accessibilityLabel: string;
|
||||
@@ -662,7 +668,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
||||
<ComposerTextInput
|
||||
ref={props.textInputRef}
|
||||
dataSet={COMPOSER_INPUT_DATASET}
|
||||
text={props.value}
|
||||
initialValue={props.value}
|
||||
onChangeText={props.onChangeText}
|
||||
placeholder={props.placeholder}
|
||||
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 +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 { 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(
|
||||
<ComposerTextInput
|
||||
ref={inputRef}
|
||||
text=""
|
||||
initialValue=""
|
||||
multiline={true}
|
||||
onChangeText={onChangeText}
|
||||
testID="composer-input"
|
||||
@@ -90,7 +90,7 @@ describe("ComposerTextInput web IME composition", () => {
|
||||
typeFromIme(mounted.textarea, "locally typed");
|
||||
mounted.root.render(
|
||||
<ComposerTextInput
|
||||
text=""
|
||||
initialValue=""
|
||||
multiline={true}
|
||||
onChangeText={recorder.onChangeText}
|
||||
placeholder="rerender with stale publication"
|
||||
@@ -112,7 +112,7 @@ describe("ComposerTextInput web IME composition", () => {
|
||||
mounted.textarea.value = "你好";
|
||||
mounted.root.render(
|
||||
<ComposerTextInput
|
||||
text=""
|
||||
initialValue=""
|
||||
multiline={true}
|
||||
onChangeText={recorder.onChangeText}
|
||||
placeholder="rerender while composing"
|
||||
@@ -141,7 +141,7 @@ describe("ComposerTextInput web IME composition", () => {
|
||||
mounted.textarea.value = "你好";
|
||||
mounted.root.render(
|
||||
<ComposerTextInput
|
||||
text="你"
|
||||
initialValue="你"
|
||||
multiline={true}
|
||||
onChangeText={ignoreTextChange}
|
||||
placeholder="second composition"
|
||||
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
createElement,
|
||||
} from "react";
|
||||
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 {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -91,10 +95,6 @@ type ElectronWebview = HTMLElement & {
|
||||
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => 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<ElectronWebview | null>(null);
|
||||
const webviewHostRef = useRef<HTMLDivElement | 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 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}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.chromeRight}>
|
||||
@@ -1629,7 +1632,7 @@ function BrowserElementAnnotationCard({
|
||||
placeholder={t("workspace.browser.annotate.placeholder")}
|
||||
style={styles.annotationInput}
|
||||
uniProps={annotationInputMapping}
|
||||
value={comment}
|
||||
initialValue={comment}
|
||||
/>
|
||||
<View style={styles.annotationActions}>
|
||||
<Button variant="ghost" size="sm" onPress={onCancel}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as QRCode from "qrcode";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
@@ -16,6 +16,10 @@ import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
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 FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
@@ -230,6 +234,8 @@ function RelayHeroBadge() {
|
||||
|
||||
function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
useEffect(() => inputRef.current?.replaceText(props.offer.url), [props.offer.url]);
|
||||
return (
|
||||
<View style={styles.offer}>
|
||||
<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.inputWrapper}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.linkInput}
|
||||
value={props.offer.url}
|
||||
initialValue={props.offer.url}
|
||||
readOnly
|
||||
selectTextOnFocus
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { router } from "expo-router";
|
||||
import { Server } from "lucide-react-native";
|
||||
import { create } from "zustand";
|
||||
@@ -145,7 +148,7 @@ export function HostChooserModal() {
|
||||
const hosts = useHosts();
|
||||
const request = useHostChooserStore((state) => state.request);
|
||||
const close = useHostChooserStore((state) => state.close);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null);
|
||||
@@ -167,6 +170,7 @@ export function HostChooserModal() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
inputRef.current?.replaceText("");
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
@@ -246,7 +250,7 @@ export function HostChooserModal() {
|
||||
<Text style={styles.title}>{request.title}</Text>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
initialValue={query}
|
||||
onChangeText={handleQueryChange}
|
||||
placeholder="Search hosts..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
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";
|
||||
// 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";
|
||||
@@ -21,6 +22,9 @@ import { createPluginContext, type PluginRegistrationCollector } from "@paseo/pl
|
||||
import type { EvaluatedPlugin } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { EditingTextInput } from "@/components/ui/text-input";
|
||||
|
||||
const PluginReactNative = { ...ReactNative, TextInput: EditingTextInput };
|
||||
|
||||
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 => {
|
||||
if (name === "react") return React;
|
||||
if (name === "react/jsx-runtime") return ReactJsxRuntime;
|
||||
if (name === "react-native") return ReactNative;
|
||||
if (name === "react-native") return PluginReactNative;
|
||||
if (name === "@paseo/plugin") {
|
||||
return {
|
||||
defineAttachmentSource,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
Text,
|
||||
TextInput,
|
||||
type TextStyle,
|
||||
View,
|
||||
type StyleProp,
|
||||
@@ -14,6 +13,10 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -30,11 +33,6 @@ import {
|
||||
} from "./geometry";
|
||||
|
||||
type PressableState = PressableStateCallbackType & { hovered?: boolean };
|
||||
type WebTextInputRef = TextInput & {
|
||||
getNativeElement?: () => unknown;
|
||||
getNativeRef?: () => unknown;
|
||||
};
|
||||
|
||||
function iconButtonStyle({ hovered, pressed }: PressableState): StyleProp<ViewStyle> {
|
||||
return [styles.iconButton, (hovered || pressed) && styles.iconButtonHovered];
|
||||
}
|
||||
@@ -43,12 +41,11 @@ function iconButtonDestructiveStyle({ hovered, pressed }: PressableState): Style
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
const webInput = input as WebTextInputRef;
|
||||
const element = webInput.getNativeElement?.() ?? webInput.getNativeRef?.() ?? input;
|
||||
const element = input.getNativeRef();
|
||||
return element instanceof HTMLElement ? element : null;
|
||||
}
|
||||
|
||||
@@ -443,7 +440,7 @@ export function InlineReviewEditor({
|
||||
testID?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle | null>(null);
|
||||
const focus = useWorkspaceFocusRestoration();
|
||||
const [body, setBody] = useState(initialBody);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -512,7 +509,7 @@ export function InlineReviewEditor({
|
||||
placeholder={t("review.comment.placeholder")}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
multiline
|
||||
value={body}
|
||||
initialValue={body}
|
||||
onChangeText={setBody}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "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 { StyleSheet } from "react-native-unistyles";
|
||||
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 { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { ProjectEditSheet } from "@/components/project-edit-sheet";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { SettingsGroup } from "@/screens/settings/settings-group";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
@@ -1063,7 +1064,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
<TextInput
|
||||
testID="script-edit-name"
|
||||
accessibilityLabel={t("settings.project.scripts.nameAccessibility")}
|
||||
value={script.name}
|
||||
initialValue={script.name}
|
||||
onChangeText={handleNameChange}
|
||||
onBlur={handleNameBlur}
|
||||
placeholder="dev"
|
||||
@@ -1082,7 +1083,7 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
testID="script-edit-command"
|
||||
accessibilityLabel={t("settings.project.scripts.commandAccessibility")}
|
||||
multiline
|
||||
value={script.commandText}
|
||||
initialValue={script.commandText}
|
||||
onChangeText={handleCommandChange}
|
||||
onBlur={handleCommandBlur}
|
||||
placeholder="npm run dev"
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -448,7 +448,7 @@ function GeneralSection({
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={terminalScrollbackValue}
|
||||
initialValue={terminalScrollbackValue}
|
||||
onChangeText={handleTerminalScrollbackChangeText}
|
||||
onBlur={commitTerminalScrollback}
|
||||
onSubmitEditing={commitTerminalScrollback}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "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 { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
|
||||
import {
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import {
|
||||
MAX_CODE_FONT_SIZE,
|
||||
MAX_UI_BASE_FONT_SIZE,
|
||||
@@ -332,7 +333,7 @@ function FontFamilyRow({
|
||||
<Text style={settingsStyles.rowHint}>{hint}</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={draft}
|
||||
initialValue={draft}
|
||||
onChangeText={onChangeDraft}
|
||||
onBlur={handleCommit}
|
||||
onSubmitEditing={handleCommit}
|
||||
@@ -372,7 +373,7 @@ function FontSizeRow({
|
||||
</View>
|
||||
<View style={styles.sizeField}>
|
||||
<TextInput
|
||||
value={draft}
|
||||
initialValue={draft}
|
||||
onChangeText={onChangeDraft}
|
||||
onBlur={onCommit}
|
||||
onSubmitEditing={onCommit}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
export interface ProfileDraft {
|
||||
name: string;
|
||||
@@ -41,9 +42,9 @@ export function TerminalProfileEditModal({
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const nameInputRef = useRef<TextInput>(null);
|
||||
const commandInputRef = useRef<TextInput>(null);
|
||||
const argsInputRef = useRef<TextInput>(null);
|
||||
const nameInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const commandInputRef = useRef<EditingTextInputHandle>(null);
|
||||
const argsInputRef = useRef<EditingTextInputHandle>(null);
|
||||
|
||||
const handleNameChange = useCallback((value: string) => {
|
||||
setName(value);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import {
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
type NativeSyntheticEvent,
|
||||
type StyleProp,
|
||||
type TextInputKeyPressEventData,
|
||||
type TextStyle,
|
||||
} from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { resolveNativeTerminalKey, type NativeTerminalKey } from "./terminal-key-events";
|
||||
|
||||
export const TERMINAL_INPUT_CONTEXT_MENU_HIDDEN = true;
|
||||
@@ -181,7 +184,7 @@ export function createTerminalTextInputState(): TerminalTextInputState {
|
||||
|
||||
export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>(
|
||||
function TerminalInput({ isKeyboardVisible, onFocus, onInput, onTerminalKey, style }, ref) {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
const pendingFocusFrameRef = useRef<number | null>(null);
|
||||
const inputState = useMemo(() => createTerminalTextInputState(), []);
|
||||
@@ -197,7 +200,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
|
||||
const resetNativeInput = useCallback(() => {
|
||||
inputState.reset();
|
||||
inputRef.current?.clear();
|
||||
inputRef.current?.replaceText("");
|
||||
}, [inputState]);
|
||||
|
||||
const showNativeKeyboard = useCallback(() => {
|
||||
@@ -314,7 +317,7 @@ export const TerminalInput = forwardRef<TerminalInputHandle, TerminalInputProps>
|
||||
autoCorrect={false}
|
||||
caretHidden={true}
|
||||
contextMenuHidden={TERMINAL_INPUT_CONTEXT_MENU_HIDDEN}
|
||||
defaultValue=""
|
||||
initialValue=""
|
||||
blurOnSubmit={false}
|
||||
importantForAutofill="no"
|
||||
multiline={true}
|
||||
|
||||
Reference in New Issue
Block a user