import { cva } from 'class-variance-authority'; import { type ReactNode, type Ref, useCallback, useRef, useState } from 'react'; import type { KeyboardTypeOptions, StyleProp, TextStyle, ViewStyle } from 'react-native'; import { Animated, Platform, TextInput, View } from 'react-native'; import { CheckLine as Check } from 'rn-motion-ui-icons/icons/check-line'; import { useMountEffect } from '../../../hooks/use-mount-effect'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { useShakeAnimation } from '../../../hooks/use-shake-animation'; import { cn } from '../../../lib/cn'; import { elevated as elevatedSurface, type SurfaceElevation } from '../../../lib/elevated'; import { INTERACTIVE_HEIGHT, INTERACTIVE_RADIUS } from '../../../lib/radius'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { TIMING_BASE } from '../../../theme/motion'; import { useThemeColor } from '../../../theme/use-theme-color'; import { Surface } from '../../display/Surface/surface'; import { ThemedIcon } from '../../icon/themed-icon'; import { Text } from '../../typography/Text/text'; import { type InputType, resolveInputState, resolveInputTypeProps } from './input.logic'; // Success green and placeholder colour are resolved from the theme at runtime. // Resolved corner radius in px for the Rim / blur clip — the field's `shape` // class is a CSS token an SVG stroke cannot read back, so the effect layer needs // the number. A square is sharp (`0`), `rounded` takes the shared interactive // radius, and `pill` / `circle` round to half the single-line height — the same // curve `rounded-full` draws. function inputRadius(shape: 'square' | 'rounded' | 'pill' | 'circle', size: 'xs' | 'sm' | 'md' | 'lg'): number { if (shape === 'square') return 0; if (shape === 'rounded') return INTERACTIVE_RADIUS; return INTERACTIVE_HEIGHT[size] / 2; } // State drives the border colour, not a shadow: the field carries a border // on web only while flat (`elevation` 0), tinted by state (border on idle, // foreground on focus, danger on error); error wins over focus. Above 0 the // `shadow-elevated-N` recipe already draws the dark-mode rim, so a border would // double up. The fill and the float are *not* in this table — they come from the // shared surface ladder (`elevation` + `floating`) and are merged on at the call // site, exactly as every other surface does it. const field = cva('relative flex-row items-center overflow-hidden', { variants: { size: { xs: 'min-h-interactive-xs', sm: 'min-h-interactive-sm', md: 'min-h-interactive-md', lg: 'min-h-interactive-lg', }, shape: { square: 'rounded-none', rounded: 'rounded-interactive', pill: 'rounded-full', circle: 'rounded-full', }, }, defaultVariants: { size: 'md', shape: 'rounded' }, }); // The state-tinted border, applied only while the field is flat (elevation 0) — // the ladder's `shadow-elevated-N` rim supersedes it above 0. Kept out of the // cva so the call site can gate it on `elevation` without fighting cva's variant // types. const stateBorder = { idle: 'web:hairline web:border-border', focused: 'web:hairline web:border-foreground/40', error: 'web:hairline web:border-danger', } as const; // Size-aware input box: font size and padding track --spacing-interactive-* tokens. // `font-sans-normal` is the same per-weight-family token the `Text` component // resolves by default, so the typed value and the placeholder both use the app's // custom typeface (e.g. Geist) instead of the platform's default font. const inputBox = cva('flex-1 bg-transparent font-sans-normal text-foreground outline-none', { variants: { left: { true: 'pl-8', false: '' }, right: { true: 'pr-8', false: '' }, size: { xs: 'py-0.5 text-xs', sm: 'py-1 text-sm', md: 'py-1.5 text-base', lg: 'py-2 text-lg' }, }, compoundVariants: [ { left: false, size: 'xs', class: 'pl-1.5' }, { left: false, size: 'sm', class: 'pl-2' }, { left: false, size: 'md', class: 'pl-2.5' }, { left: false, size: 'lg', class: 'pl-3' }, { right: false, size: 'xs', class: 'pr-1.5' }, { right: false, size: 'sm', class: 'pr-2' }, { right: false, size: 'md', class: 'pr-2.5' }, { right: false, size: 'lg', class: 'pr-3' }, ], defaultVariants: { left: false, right: false, size: 'md' }, }); type RightElementProps = { success?: boolean; rightSlot: ReactNode; reduce: boolean; successIcon?: ReactNode }; function renderRightElement({ success, rightSlot, reduce, successIcon }: RightElementProps): ReactNode { if (success) return ( {successIcon ?? } ); if (rightSlot) return {rightSlot}; return null; } type SubtextProps = { errorMessage: string | null; hint: string | undefined; reduce: boolean }; function renderSubtext({ errorMessage, hint, reduce }: SubtextProps): ReactNode { if (errorMessage) return ( {errorMessage} ); if (hint) return ( {hint} ); return null; } type FieldHostProps = { /** Pre-composed container classes — `field({size,shape})` + state border + disabled opacity. */ className: string; elevation: SurfaceElevation; floating: boolean; blurRadius: number; opacity: number; rim: boolean; rimWidth?: number; intensity?: number; inline: boolean; /** Resolved corner radius in px for the Rim + blur clip (see `inputRadius`). */ borderRadius: number; shakeX: Animated.Value; children: ReactNode; }; // The field's container host. A frosted field (`blurRadius > 0`) renders through // the shared `Surface` primitive, which owns the `glass` tint, backdrop blur and // rim; a solid field wears the elevation ladder directly. Both carry the shake // transform, so the two hosts stay pixel-identical apart from the frost. function FieldHost({ className, elevation, floating, blurRadius, opacity, rim, rimWidth, intensity, inline, borderRadius, shakeX, children, }: FieldHostProps) { const style = { transform: [{ translateX: shakeX }] }; if (blurRadius > 0) { // Spread the glass props as a pre-built object (rather than inline) so the // native-only `inline` prop doesn't trip the web Surface's excess-property // check — the same pattern Card uses. const surfaceProps = { elevation, floating, blurRadius, opacity, rim, rimWidth, intensity, inline, borderRadius }; return ( {children} ); } return ( {children} ); } export type InputProps = { /** Ref forwarded to the underlying TextInput (React 19 direct-prop style). */ ref?: Ref; label?: string; value?: string; defaultValue?: string; onChange?: (value: string) => void; placeholder?: string; /** Truthy error triggers a shake, red border and (if a string) a message. */ error?: string | boolean; /** Show the error border without a message. Useful when validation is shown elsewhere. */ invalid?: boolean; /** Helper text shown below the field (hidden when an error is present). */ hint?: string; success?: boolean; leftIcon?: ReactNode; rightIcon?: ReactNode; /** Replace the success checkmark icon. Default: ``. */ successIcon?: ReactNode; /** Semantic type — automatically wires keyboard, autoComplete, and textContentType. */ inputType?: InputType; /** Field height variant. Default: `md`. */ size?: 'xs' | 'sm' | 'md' | 'lg'; /** * Swap the field's ladder shadow for the large, diffuse halo * (`shadow-floating`) — the recipe the old `variant="floating"` wore. It * replaces the `shadow-elevated-N` rung rather than adding to it, so the field * keeps its `elevation` tint but trades the layered drop for the halo. * @default false */ floating?: boolean; /** * Surface elevation level (0–3) — drives the field fill (`bg-surface-N`) and * the `shadow-elevated-N` recipe. `0` is the flat resting surface — a * `surface-3` fill with no shadow — which is what a text field usually wants, * so unlike the panel surfaces this one rests at `0` rather than `3`. The * native flat field uses a foreground tint to remain distinct from its container. The * state-tinted web border is drawn only at `0`; above it the elevation shadow * already carries the rim. @default 0 */ elevation?: SurfaceElevation; /** * Border-radius variant. `square` for sharp corners, `rounded` (default, 8px) * for a standard input, `pill` / `circle` for a fully rounded shape. */ shape?: 'square' | 'rounded' | 'pill' | 'circle'; /** * Backdrop blur radius in px/dp. `0` keeps the field a solid surface; any * positive value frosts it — a `glass` tint over a backdrop blur, with the * specular edge light when `rim` is also set. @default 0 */ blurRadius?: number; /** Opacity of the frosted tint (0–1); only thins the fill when `blurRadius` is set. @default 1 */ opacity?: number; /** Draw the glass edge light — the `Rim` specular ring around the field. @default false */ rim?: boolean; /** Rim width in px/dp. @default 1 */ rimWidth?: number; /** Peak alpha (0–1) of the rim's specular highlight — lower is subtler. @default 0.5 */ intensity?: number; /** * Set true when the field renders inside the `BlurTarget` it blurs on Android * (a field in the page). An inline pane degrades to the tint fill rather than * crash. @default false */ inline?: boolean; disabled?: boolean; secureTextEntry?: boolean; keyboardType?: KeyboardTypeOptions; autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; multiline?: boolean; autoFocus?: boolean; onFocus?: () => void; onBlur?: () => void; /** UniWind class names merged onto the outer wrapper. */ className?: string; /** UniWind class names merged onto the label Text. */ labelClassName?: string; style?: StyleProp; /** UniWind class names applied to the TextInput element. */ inputClassName?: string; inputStyle?: StyleProp; accessibilityLabel?: string; testID?: string; }; export function Input({ label, value: valueProp, defaultValue, onChange, placeholder, error, invalid, hint, success, leftIcon, rightIcon, successIcon, inputType = 'text', size = 'md', floating = false, elevation = 0, shape = 'rounded', blurRadius = 0, opacity = 1, rim = false, rimWidth, intensity, inline = false, disabled, secureTextEntry, keyboardType, autoCapitalize, multiline, autoFocus, onFocus, onBlur, className, labelClassName, style, inputClassName, inputStyle, accessibilityLabel, testID, ref, }: InputProps) { const reduce = useReducedMotion(); const placeholderColor = useThemeColor('muted-foreground'); const controlled = valueProp !== undefined; const [internal, setInternal] = useState(defaultValue ?? ''); const value = controlled ? (valueProp ?? '') : internal; const [focused, setFocused] = useState(false); const inputRef = useRef(null); const shakeX = useRef(new Animated.Value(0)).current; const hasError = Boolean(error) || Boolean(invalid); const errorMessage = typeof error === 'string' ? error : null; // Right edge shows the success check, otherwise the caller's right icon. const rightSlot = success ? null : rightIcon; const state = resolveInputState(hasError, focused); // Shake the field when an error appears (mirrors the web keyframe sequence). useShakeAnimation({ trigger: hasError, reduce, shakeX }); // Sync the forwarded ref with the internal ref. const setRef = useCallback( (node: TextInput | null) => { inputRef.current = node; if (typeof ref === 'function') ref(node); else if (ref !== null && typeof ref === 'object' && Object.hasOwn(ref, 'current')) ref.current = node; }, [ref], ); // Auto-focus after mount, matching browser behaviour. useMountEffect(() => { if (autoFocus) inputRef.current?.focus(); }); const handleChange = useCallback( (next: string) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); const handleFocus = useCallback(() => { setFocused(true); onFocus?.(); }, [onFocus]); const handleBlur = useCallback(() => { setFocused(false); onBlur?.(); }, [onBlur]); // Resolve inputType-driven props (caller can still override individually). const semantics = resolveInputTypeProps(inputType); const resolvedKeyboardType = keyboardType ?? semantics.keyboardType; const resolvedAutoCapitalize = autoCapitalize ?? semantics.autoCapitalize; const resolvedSecureTextEntry = secureTextEntry ?? semantics.secureTextEntry; const rightElement = renderRightElement({ success, rightSlot, reduce, successIcon }); return ( {label ? ( {label} ) : null} 0 && rim) || state !== 'idle') && stateBorder[state], disabled ? 'opacity-60' : 'opacity-100', )} elevation={elevation} floating={floating} blurRadius={blurRadius} opacity={opacity} rim={rim} rimWidth={rimWidth} intensity={intensity} inline={inline} borderRadius={inputRadius(shape, size)} shakeX={shakeX} > {leftIcon ? ( {leftIcon} ) : null} {rightElement} {renderSubtext({ errorMessage, hint, reduce })} ); }