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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { TextInput } from "react-native";
|
||||
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
|
||||
|
||||
interface WebTextInputElement extends TextInput {
|
||||
value?: string;
|
||||
setSelectionRange?: (start: number, end: number) => void;
|
||||
addEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void;
|
||||
removeEventListener(type: "compositionstart" | "compositionend", listener: EventListener): void;
|
||||
}
|
||||
|
||||
export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
|
||||
function EditingTextInputWeb(allProps, ref) {
|
||||
const {
|
||||
initialValue = "",
|
||||
onChangeText,
|
||||
onPasteImages: _,
|
||||
onPasteError: __,
|
||||
variant: ___,
|
||||
value: ____,
|
||||
defaultValue: _____,
|
||||
...props
|
||||
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const initialTextRef = useRef(initialValue);
|
||||
const textRef = useRef(initialTextRef.current);
|
||||
const isComposingRef = useRef(false);
|
||||
const onChangeTextRef = useRef(onChangeText);
|
||||
onChangeTextRef.current = onChangeText;
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
if (!input) return;
|
||||
|
||||
const startComposition = () => {
|
||||
isComposingRef.current = true;
|
||||
};
|
||||
const endComposition = () => {
|
||||
isComposingRef.current = false;
|
||||
const nextText = input.value ?? "";
|
||||
if (nextText === textRef.current) return;
|
||||
textRef.current = nextText;
|
||||
onChangeTextRef.current?.(nextText);
|
||||
};
|
||||
|
||||
input.addEventListener("compositionstart", startComposition);
|
||||
input.addEventListener("compositionend", endComposition);
|
||||
return () => {
|
||||
input.removeEventListener("compositionstart", startComposition);
|
||||
input.removeEventListener("compositionend", endComposition);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChangeText = useCallback((nextText: string) => {
|
||||
if (isComposingRef.current || nextText === textRef.current) return;
|
||||
textRef.current = nextText;
|
||||
onChangeTextRef.current?.(nextText);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
blur: () => inputRef.current?.blur(),
|
||||
isFocused: () => document.activeElement === inputRef.current,
|
||||
getText: () => {
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
const nextText = input?.value ?? textRef.current;
|
||||
textRef.current = nextText;
|
||||
return nextText;
|
||||
},
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
const input = inputRef.current as WebTextInputElement | null;
|
||||
if (input && "value" in input) input.value = nextText;
|
||||
if (selection && typeof input?.setSelectionRange === "function") {
|
||||
input.setSelectionRange(selection.start, selection.end);
|
||||
}
|
||||
},
|
||||
getNativeRef: () => inputRef.current,
|
||||
}));
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
defaultValue={initialTextRef.current}
|
||||
onChangeText={handleChangeText}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TextInputProps } from "react-native";
|
||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||
|
||||
export interface EditingTextInputHandle {
|
||||
focus(): void;
|
||||
blur(): void;
|
||||
isFocused(): boolean;
|
||||
getText(): string;
|
||||
replaceText(text: string, selection?: { start: number; end: number }): void;
|
||||
getNativeRef(): unknown;
|
||||
}
|
||||
|
||||
export interface EditingTextInputProps extends Omit<
|
||||
TextInputProps,
|
||||
"defaultValue" | "onChangeText" | "value"
|
||||
> {
|
||||
initialValue?: string;
|
||||
onChangeText?: (text: string) => void;
|
||||
onPasteImages?: (files: readonly NativePastedFile[]) => void;
|
||||
onPasteError?: (message: string) => void;
|
||||
variant?: "default" | "bottom-sheet";
|
||||
}
|
||||
Reference in New Issue
Block a user