/** biome-ignore-all lint/style/noExcessiveLinesPerFile: OtpSlot helper, editing logic imports, shake/status animations, and theme merging need shared context in one file */ import { cva } from 'class-variance-authority'; import { type Ref, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Animated, Pressable, type StyleProp, TextInput, type TextInputProps, type TextProps, type TextStyle, View, type ViewStyle, } from 'react-native'; import { CheckLine as Check } from 'rn-motion-ui-icons/icons/check-line'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { useShakeAnimation } from '../../../hooks/use-shake-animation'; import { cn } from '../../../lib/cn'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { ThemedIcon } from '../../icon/themed-icon'; import { Text } from '../../typography/Text/text'; import { applyEdit, type OtpInputType, sanitize } from './otp-input.logic'; // ── Constants ───────────────────────────────────────────────────────────────── const DEFAULT_DIGITS = 6; const DEFAULT_STICK_BLINK_MS = 500; const DIGIT_DROP_MS = 220; const SUCCESS_SPRING = { stiffness: 500, damping: 28 }; const OTP_SHAKE_STEPS = [-5, 5, -3, 3, -1, 0] as const; // ── Slot styling ────────────────────────────────────────────────────────────── type SlotState = 'success' | 'error' | 'active' | 'filled' | 'idle'; /** Glyph (digit / placeholder) size per slot size. */ const DIGIT_TEXT_CLASS = { xs: 'text-xs', sm: 'text-sm', md: 'text-base', lg: 'text-xl' } as const; /** Blinking caret height per slot size. */ const CARET_HEIGHT_CLASS = { xs: 'h-3', sm: 'h-4', md: 'h-5', lg: 'h-6' } as const; const slot = cva('relative items-center justify-center rounded-interactive hairline', { variants: { state: { success: 'border-success', error: 'border-danger/60', active: 'border-foreground', filled: 'border-foreground/40', idle: 'border-border', }, size: { xs: 'h-interactive-xs w-interactive-xs', sm: 'h-interactive-sm w-interactive-sm', md: 'h-interactive-md w-interactive-md', lg: 'h-interactive-lg w-interactive-lg', }, }, defaultVariants: { state: 'idle', size: 'lg' }, }); const message = cva('text-sm', { variants: { status: { success: 'text-success', error: 'text-danger', idle: 'text-muted-foreground', }, }, defaultVariants: { status: 'idle' }, }); // ── Helpers ─────────────────────────────────────────────────────────────────── function resolveSlotState(showSuccess: boolean, status: OtpInputStatus, isActive: boolean, char: string): SlotState { if (showSuccess) return 'success'; if (status === 'error') return 'error'; if (isActive) return 'active'; if (char) return 'filled'; return 'idle'; } type ResolveHintTextParams = { showSuccess: boolean; successMessage: string | undefined; status: OtpInputStatus; errorMessage: string | undefined; hint: string | undefined; }; function resolveHintText({ showSuccess, successMessage, status, errorMessage, hint }: ResolveHintTextParams): string | undefined { if (showSuccess) return successMessage; if (status === 'error') return errorMessage; return hint; } /** Repeat a single-char placeholder across all slots. */ function expandPlaceholder(raw: string | undefined, digits: number): string | undefined { if (!raw) return; return raw.length === 1 ? raw.repeat(digits) : raw; } // ── OtpSlot ─────────────────────────────────────────────────────────────────── type OtpSlotProps = { index: number; char: string; state: SlotState; size: OtpInputSize; isActive: boolean; showSuccess: boolean; reduce: boolean; secureTextEntry: boolean; disabled: boolean; testID: string; placeholderChar: string | undefined; focusColor: string | undefined; hideStick: boolean; stickBlinkMs: number; theme: OtpInputTheme; textProps: TextProps | undefined; onPressSlot: (index: number) => void; }; function OtpSlot({ index, char, state, size, isActive, showSuccess, reduce, secureTextEntry, disabled, testID, placeholderChar, focusColor, hideStick, stickBlinkMs, theme, textProps, onPressSlot, }: OtpSlotProps) { const handlePress = useCallback(() => onPressSlot(index), [onPressSlot, index]); const slotClassName = slot({ state, size }); const slotStyleOverrides = useMemo(() => { const styles: ViewStyle[] = []; if (theme.pinCodeContainerStyle) styles.push(theme.pinCodeContainerStyle); if (isActive && focusColor) styles.push({ borderColor: focusColor }); if (isActive && theme.focusedPinCodeContainerStyle) styles.push(theme.focusedPinCodeContainerStyle); if (char && theme.filledPinCodeContainerStyle) styles.push(theme.filledPinCodeContainerStyle); if (disabled && theme.disabledPinCodeContainerStyle) styles.push(theme.disabledPinCodeContainerStyle); return styles; }, [isActive, focusColor, theme, char, disabled]); const displayChar = char && secureTextEntry ? '•' : char; const isPlaceholder = !char && Boolean(placeholderChar); const renderSlotContent = useCallback(() => { if (char) return ( {displayChar} ); if (isPlaceholder) return ( {placeholderChar} ); return null; }, [char, isPlaceholder, reduce, textProps, index, theme, displayChar, placeholderChar, size]); return ( {/* Blinking caret — shown only in an EMPTY active slot (with stick visible). Flexbox wrapper centres the stick; MotiView handles the blink. */} {isActive && !char && !showSuccess && !hideStick && !reduce ? ( ) : null} {renderSlotContent()} ); } // ── Types ──────────────────────────────────────────────────────────────────── export type { OtpInputType } from './otp-input.logic'; export type OtpInputStatus = 'idle' | 'error' | 'success'; /** Box side + glyph scale per size — the same `--spacing-interactive-*` ramp * the button family reads, so an OTP row lines up with neighbouring controls. */ export type OtpInputSize = 'xs' | 'sm' | 'md' | 'lg'; export type OtpInputTheme = { containerStyle?: ViewStyle; pinCodeContainerStyle?: ViewStyle; filledPinCodeContainerStyle?: ViewStyle; focusedPinCodeContainerStyle?: ViewStyle; disabledPinCodeContainerStyle?: ViewStyle; pinCodeTextStyle?: TextStyle; focusStickStyle?: ViewStyle; placeholderTextStyle?: TextStyle; }; export type OtpInputRef = { clear: () => void; focus: () => void; setValue: (value: string) => void; blur: () => void }; export type OtpInputProps = { // ── Reference library API ── numberOfDigits?: number; autoFocus?: boolean; focusColor?: string; onTextChange?: (text: string) => void; onFilled?: (text: string) => void; onFocus?: () => void; onBlur?: () => void; blurOnFilled?: boolean; hideStick?: boolean; focusStickBlinkingDuration?: number; secureTextEntry?: boolean; theme?: OtpInputTheme; disabled?: boolean; textInputProps?: TextInputProps; textProps?: TextProps; type?: OtpInputType; placeholder?: string; // ── Extended API (beyond the reference library) ── /** Controlled value. */ value?: string; /** Uncontrolled default value. */ defaultValue?: string; /** External validation feedback. "error" shakes, "success" draws a check. */ status?: OtpInputStatus; label?: string; /** Box size of each slot — the same `--spacing-interactive-*` ramp the button * family reads, so an OTP row lines up with neighbouring controls. @default 'lg' */ size?: OtpInputSize; /** Helper text shown below the slots while idle. */ hint?: string; successMessage?: string; errorMessage?: string; /** Additional UniWind class names merged onto the outer wrapper. */ className?: string; style?: StyleProp; testID?: string; accessibilityLabel?: string; // React 19 ref prop ref?: Ref; }; // ── OTPInput ────────────────────────────────────────────────────────────────── // biome-ignore lint/complexity/noExcessiveLinesPerFunction: slot rendering, keyboard handling, shake animation, and theme merging need shared closure state export function OTPInput({ // Reference API numberOfDigits = DEFAULT_DIGITS, autoFocus = false, focusColor, onTextChange, onFilled, onFocus, onBlur, blurOnFilled, hideStick = false, focusStickBlinkingDuration, secureTextEntry = false, theme = {}, disabled = false, textInputProps, textProps, type = 'numeric', placeholder, // Extended API value: controlledValue, defaultValue = '', status = 'idle', size = 'lg', label, hint, successMessage, errorMessage, className, style, testID, accessibilityLabel = 'One-time passcode', ref, }: OtpInputProps) { const reduce = useReducedMotion(); const inputRef = useRef(null); const shakeX = useRef(new Animated.Value(0)).current; const controlled = controlledValue !== undefined; const [internal, setInternal] = useState(() => sanitize(controlled ? controlledValue : defaultValue, numberOfDigits, type)); const [focused, setFocused] = useState(false); const [caret, setCaret] = useState(() => sanitize(controlled ? controlledValue : defaultValue, numberOfDigits, type).length); const value = controlled ? sanitize(controlledValue, numberOfDigits, type) : internal; const chars = Array.from({ length: numberOfDigits }, (_, i) => value[i] ?? ''); const clampedCaret = Math.min(caret, value.length); const activeIndex = focused ? Math.min(clampedCaret, numberOfDigits - 1) : -1; const expandedPlaceholder = useMemo(() => expandPlaceholder(placeholder, numberOfDigits), [placeholder, numberOfDigits]); // ── Imperative handle ── useImperativeHandle( ref, () => ({ clear: () => { setInternal(''); setCaret(0); inputRef.current?.focus(); }, focus: () => inputRef.current?.focus(), setValue: (next: string) => { const v = sanitize(next, numberOfDigits, type); if (!controlled) setInternal(v); setCaret(v.length); onTextChange?.(v); if (v.length >= numberOfDigits) onFilled?.(v); }, blur: () => inputRef.current?.blur(), }), [controlled, numberOfDigits, type, onTextChange, onFilled], ); // ── Editing ── const handleChange = useCallback( (raw: string) => { if (disabled) return; const { value: next, caret: nextCaret } = applyEdit({ prev: value, raw, length: numberOfDigits, anchor: clampedCaret, type, }); setCaret(nextCaret); if (next === value) return; if (!controlled) setInternal(next); onTextChange?.(next); if (next.length >= numberOfDigits) { onFilled?.(next); if (blurOnFilled) inputRef.current?.blur(); } }, [disabled, value, numberOfDigits, controlled, onTextChange, onFilled, blurOnFilled, clampedCaret, type], ); const handlePressSlot = useCallback( (index: number) => { if (disabled) return; setCaret(Math.min(index, value.length)); inputRef.current?.focus(); }, [disabled, value.length], ); const handleFocus = useCallback(() => { setFocused(true); onFocus?.(); }, [onFocus]); const handleBlur = useCallback(() => { setFocused(false); onBlur?.(); }, [onBlur]); // ── Animations ── useShakeAnimation({ trigger: status === 'error', reduce, shakeX, steps: OTP_SHAKE_STEPS }); const stickBlinkMs = focusStickBlinkingDuration ?? (reduce ? 0 : DEFAULT_STICK_BLINK_MS); const showSuccess = status === 'success'; const hintText = resolveHintText({ showSuccess, successMessage, status, errorMessage, hint }); return ( {label ? ( {label} ) : null} {/* Hidden input owns focus + keyboard. No maxLength: a keystroke on a full field must still reach onChangeText so applyEdit can overwrite the active slot (maxLength would swallow it). It sits BELOW the slots so slot taps win; focus is driven programmatically from each slot's onPress. */} {/* Slots paint above the input (relative + zIndex) so a tap lands on the Pressable, not the hidden input, letting us position the caret. */} {chars.map((char, i) => ( ))} {showSuccess ? ( ) : null} {hintText ? ( {hintText} ) : null} ); }