import { forwardRef, useState } from 'react'; import { type TextInput, type TextInputProps, type ViewProps } from 'react-native'; import { InputField as InputFieldPrimitive, InputRoot as InputRootPrimitive, dataAttributes, type IInputFieldProps, } from '@cdx-ui/primitives'; import { cn } from '@cdx-ui/utils'; import { type CurrencyInputVariantProps, currencyInputFieldVariants, currencyInputRootVariants, } from './styles'; // Hoisted at module scope — Intl instantiation is expensive (parses locale data). const currencyFormatter = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); // Cap the accumulated cents to 15 digits so the integer stays below // Number.MAX_SAFE_INTEGER (16 digits). Past that, parsing loses precision and the // amount garbles or resets — 15 digits already allows up to $9,999,999,999,999.99. const MAX_DIGITS = 15; function centsToAmountDisplay(cents: number): string { return currencyFormatter.format(cents / 100); } function valueToCents(value: number | undefined | null): number { return Math.round((value ?? 0) * 100); } export interface CurrencyInputProps extends IInputFieldProps, CurrencyInputVariantProps, Pick< TextInputProps, 'accessibilityLabel' | 'accessibilityHint' | 'testID' | 'onFocus' | 'onBlur' > { /** Current numeric value. Renders formatted zero when undefined or null. */ value?: number; /** Called with the parsed number when the user changes the amount. */ onChangeValue?: (value: number) => void; /** Currency symbol rendered as an inline prefix. Defaults to `"$"`. */ currencySymbol?: string; className?: string; style?: ViewProps['style']; } export const CurrencyInput = forwardRef( ( { value, onChangeValue, currencySymbol = '$', size = 'default', isDisabled, isInvalid, isReadOnly, isRequired, isFocused, isHovered, isFullWidth, className, style, accessibilityLabel, accessibilityHint, testID, onFocus, onBlur, }, ref, ) => { // null = display mode; number = active editing cents. // Calculator-style cents accumulation: digits are always extracted from whatever // the user types, interpreted as integer cents, then reformatted. The caret is // kept at the end at all times so entry behaves like a banking amount pad — new // digits append, backspace removes the rightmost digit. const [editingCents, setEditingCents] = useState(null); // Controlled caret, pinned to the end of the formatted string. `undefined` in // display mode so we never fight the platform when the field is not being edited. const [selection, setSelection] = useState<{ start: number; end: number }>(); type FocusHandler = NonNullable; type BlurHandler = NonNullable; type SelectionChangeHandler = NonNullable; const displayCents = editingCents !== null ? editingCents : valueToCents(value); const amountDisplay = centsToAmountDisplay(displayCents); const fullDisplay = `${currencySymbol}${amountDisplay}`; const isEmpty = displayCents === 0; // Index just past the last character of the formatted string for a given cents. // Computed from `cents` (not the render-closure `fullDisplay`) so the caret is // correct in the same commit as a value change, even when a reformat adds or // removes a grouping comma (e.g. 444.44 → 3,444.44) and changes the length. const caretIndexFor = (cents: number) => `${currencySymbol}${centsToAmountDisplay(cents)}`.length; const pinCaret = (end: number) => setSelection({ start: end, end }); const handleFocus: FocusHandler = (e) => { // Initialise from the current value so existing amounts are preserved on focus. const cents = valueToCents(value); setEditingCents(cents); pinCaret(caretIndexFor(cents)); onFocus?.(e); }; const handleBlur: BlurHandler = (e) => { setEditingCents(null); setSelection(undefined); onBlur?.(e); }; const handleChangeText = (text: string) => { // Extract only digit characters, interpret the resulting integer as cents. // e.g. user typing '1' after '0.00' → digits '0001' → 1 cent → 0.01 // Backspace on '1.23' → '1.2' → digits '12' → 12 cents → 0.12 // Digits past the cap are dropped, so extra keystrokes are ignored rather // than overflowing into an unsafe integer. const digits = text.replace(/\D/g, '').slice(0, MAX_DIGITS); const cents = parseInt(digits || '0', 10); setEditingCents(cents); pinCaret(caretIndexFor(cents)); onChangeValue?.(cents / 100); }; const handleSelectionChange: SelectionChangeHandler = (e) => { // Re-pin the caret to the end ONLY when it drifts to the LEFT of the end while // editing — e.g. web hardware-keyboard arrow keys or a click/tap into the middle // (native decimal-pad keyboards have no arrow keys, so those platforms rarely // trigger this). Value-driven changes already pin the caret via // `handleChangeText`/`handleFocus`. // // Crucially we do NOT touch the caret when it sits at or past the last-rendered // end. While typing, the native caret momentarily lands one past our last render // (the new value hasn't committed yet); re-pinning there would tug it back to the // stale, one-shorter end and yank it one position left — the classic controlled- // selection race. Ignoring forward positions sidesteps that entirely, and also // prevents a feedback loop (the pin re-emits a selectionChange already at the end). if (editingCents === null) { return; } const end = fullDisplay.length; const { selection: current } = e.nativeEvent; if (current.end < end) { pinCaret(end); } }; return ( ); }, ); CurrencyInput.displayName = 'CurrencyInput';