import { Box, Input, Span, Text, useUiHost } from "../../ui"; import { useEffect, useRef, useState, type ComponentType, type RefObject } from "react"; import { type InputRenderable } from "../../ui"; import { useThemeColors } from "../../theme/theme-context"; import { useRemoteUiNode } from "../../remote/semantic-tree"; import { remoteStringValue } from "../../remote/semantic-helpers"; export interface TextFieldProps { label?: string; value?: string; placeholder?: string; focused?: boolean; width?: number; inputRef?: RefObject; onChange?: (value: string) => void; onSubmit?: (value: string) => void; onBlur?: (value: string) => void; hint?: string; type?: "text" | "password" | "date" | "email"; /** DOM autofill hint, e.g. "email" or "current-password"; terminal hosts ignore it. */ autoComplete?: string; variant?: "default" | "plain"; /** * `comfortable` is the form-page size on the DOM renderer: a taller field * with a tight label gap, for dialogs a user fills in rather than dense pane * chrome. Terminal hosts have one cell height and ignore it. */ size?: "default" | "comfortable"; backgroundColor?: string; textColor?: string; placeholderColor?: string; onMouseDown?: () => void; onKeyDown?: (event: { name?: string; shift?: boolean; defaultPrevented?: boolean; preventDefault(): void; stopPropagation(): void; }) => void; } const PASSWORD_MASK_CHAR = "*"; function maskPassword(value: string): string { return PASSWORD_MASK_CHAR.repeat(value.length); } export function TextField({ label, value, placeholder, focused, width, inputRef, onChange, onSubmit, onBlur, hint, type = "text", autoComplete, variant = "default", size = "default", backgroundColor, textColor, placeholderColor, onMouseDown, onKeyDown, }: TextFieldProps) { const colors = useThemeColors(); backgroundColor ??= colors.bg; textColor ??= colors.text; placeholderColor ??= colors.textDim; useRemoteUiNode({ role: "text-field", label: label ?? placeholder, actions: { setValue: (input) => { const nextValue = remoteStringValue(input); onChange?.(nextValue); }, submit: (input) => { const nextValue = remoteStringValue(input, value ?? ""); onSubmit?.(nextValue); }, blur: () => onBlur?.(value ?? ""), }, metadata: { value, placeholder, focused, type, variant }, }); const HostTextField = useUiHost().TextField as ComponentType | undefined; if (HostTextField) { return ( ); } const localInputRef = useRef(null); const resolvedInputRef = inputRef ?? localInputRef; const currentValueRef = useRef(value ?? ""); const [cursorOffset, setCursorOffset] = useState((value ?? "").length); const isPassword = type === "password"; const currentValue = value ?? ""; const maskedValue = maskPassword(currentValue); const maskedDisplay = maskedValue.length > 0 ? maskedValue : (placeholder ?? ""); const maskedTextColor = maskedValue.length > 0 ? textColor : placeholderColor; useEffect(() => { currentValueRef.current = currentValue; setCursorOffset(resolvedInputRef.current?.cursorOffset ?? currentValue.length); }, [currentValue, resolvedInputRef]); const syncCursorOffset = (fallbackValue = currentValueRef.current) => { setCursorOffset(resolvedInputRef.current?.cursorOffset ?? fallbackValue.length); }; const maskedCursorOffset = currentValue.length > 0 ? Math.max(0, Math.min(cursorOffset, currentValue.length)) : 0; const maskedBefore = maskedDisplay.slice(0, maskedCursorOffset); const maskedCursorChar = maskedDisplay[maskedCursorOffset] ?? " "; const maskedAfter = maskedDisplay.slice(maskedCursorOffset + (maskedCursorOffset < maskedDisplay.length ? 1 : 0)); return ( {label && ( {label} )} { onMouseDown?.(); resolvedInputRef.current?.focus?.(); }}> syncCursorOffset()} onInput={(nextValue: string) => { currentValueRef.current = nextValue; syncCursorOffset(nextValue); onChange?.(nextValue); }} onChange={(nextValue: string) => { currentValueRef.current = nextValue; syncCursorOffset(nextValue); onChange?.(nextValue); }} onSubmit={() => onSubmit?.(currentValueRef.current)} onBlur={() => onBlur?.(currentValueRef.current)} /> {isPassword && ( { onMouseDown?.(); resolvedInputRef.current?.focus?.(); }} > {maskedBefore} {focused && ( {maskedCursorChar} )} {focused ? maskedAfter : maskedDisplay.slice(maskedBefore.length)} )} {hint && ( {hint} )} ); } function sanitizeNumberInput(value: string, allowDecimal: boolean, allowNegative: boolean): string { const allowed = allowDecimal ? /[0-9.-]/g : /[0-9-]/g; let next = (value.match(allowed) ?? []).join(""); if (!allowNegative) next = next.replace(/-/g, ""); if (allowNegative) next = next.replace(/(?!^)-/g, ""); if (allowDecimal) { const [whole, ...rest] = next.split("."); next = rest.length === 0 ? next : `${whole}.${rest.join("")}`; } else { next = next.replace(/\./g, ""); } return next; } export interface NumberFieldProps extends Omit { allowDecimal?: boolean; allowNegative?: boolean; onChange?: (value: string) => void; onSubmit?: (value: string) => void; onBlur?: (value: string) => void; } export function NumberField({ allowDecimal = true, allowNegative = false, onChange, onSubmit, onBlur, ...props }: NumberFieldProps) { return ( onChange?.(sanitizeNumberInput(nextValue, allowDecimal, allowNegative))} onSubmit={(nextValue) => onSubmit?.(sanitizeNumberInput(nextValue, allowDecimal, allowNegative))} onBlur={(nextValue) => onBlur?.(sanitizeNumberInput(nextValue, allowDecimal, allowNegative))} /> ); }