import React, { ChangeEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useIsTabletDeviceAndDown } from '../../../foundation/Media'; /** * A locale‑aware fixed‑point that keeps all maths in integer‑safe BigInt * while still letting users type only a dot (.) as the decimal mark. * – Desktop: numeric keypad (`inputMode="decimal"`). * – Mobile (small screens): full keyboard (`inputMode="text"`) so “.” is reachable. */ type IntlConfig = { locale: string }; export type NumberTextFieldProps = { allowNegativeValue?: boolean; autoComplete?: string; autoFocus?: boolean; className?: string; decimalsLimit?: number; defaultValue?: string | number; disabled?: boolean; id?: string; intlConfig?: IntlConfig; locale?: string; max?: number; maxLength?: number; min?: number; onBlur?: () => void; onFocus?: () => void; onValueChange?: (raw: string) => void; placeholder?: string; transformRawValue?: (raw: string) => string; value?: string; } & React.InputHTMLAttributes; export const NumberTextField = React.forwardRef( (props, ref) => { const { value, onValueChange, allowNegativeValue = false, decimalsLimit = 18, locale = navigator.language, defaultValue, disabled, max, maxLength, min, transformRawValue, id, placeholder, autoComplete = 'off', autoFocus = false, onBlur, onFocus, className, intlConfig, ...restProps } = props; const [raw, setRaw] = useState( value ?? (defaultValue !== undefined ? String(defaultValue) : ''), ); const [isFocused, setIsFocused] = useState(false); const isSmallScreen = useIsTabletDeviceAndDown(); // phones & small tablets const allowedDecimalMarks = ['.'] as const; const escapedMarks = '\\.'; // for regexes const findDecimalMark = useCallback( (s: string): string | null => (s.includes('.') ? '.' : null), [], ); type Fixed = { scale: number; value: bigint }; const parseFixed = useCallback( (txt: string): Fixed | null => { if (!txt.trim()) return null; const GROUPING_CHARS = /[\u202F\u00A0',]/g; let cleaned = txt.replace(/\s/g, ''); if (!allowNegativeValue && cleaned.includes('-')) return null; const decMark = findDecimalMark(cleaned); if (decMark) { const [intPartRaw, fracRaw] = cleaned.split(decMark); const intPart = intPartRaw.replace(GROUPING_CHARS, ''); const fracTrim = fracRaw.replace(GROUPING_CHARS, '').slice(0, decimalsLimit); if (!/^[-]?\d*$/.test(intPart) || !/^\d*$/.test(fracTrim)) return null; const scale = fracTrim.length; const asInt = BigInt(intPart + fracTrim.padEnd(scale, '0')); return { scale, value: asInt }; } cleaned = cleaned.replace(GROUPING_CHARS, ''); if (!/^[-]?\d+$/.test(cleaned)) return null; return { scale: 0, value: BigInt(cleaned) }; }, [allowNegativeValue, decimalsLimit, findDecimalMark], ); const formatFixed = useCallback( (num: Fixed | null): string => { if (!num) return ''; const { value: currValue, scale } = num; const sign = currValue < BigInt(0) ? '-' : ''; const absStr = (currValue < BigInt(0) ? -currValue : currValue) .toString() .padStart(scale + 1, '0'); const intPart = absStr.slice(0, absStr.length - scale) || '0'; const groupedInt = intPart.length < 21 ? Number(intPart).toLocaleString(locale, { useGrouping: true }) : intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); if (scale === 0) return sign + groupedInt; const fracPartRaw = absStr.slice(-scale); const fracPart = fracPartRaw.replace(/0+$/, ''); return sign + (fracPart ? `${groupedInt}.${fracPart}` : groupedInt); }, [locale], ); const toNumberSafe = (num: Fixed | null): number => { if (!num) return NaN; const { value: currValue, scale } = num; return Number(currValue) / 10 ** scale; }; const invalidRe = useMemo(() => { const sign = allowNegativeValue ? '\\-' : ''; return new RegExp(`^[${sign}]?\\d*(?:\\.${'\\d*'})?$`); }, [allowNegativeValue]); const handleChange = (e: ChangeEvent) => { let next = e.target.value; if (next && !invalidRe.test(next)) return; if ( next.length >= 1 && allowedDecimalMarks.includes(next[0] as '.') && (next.length === 1 || !/\d/.test(next[1])) ) { next = `0${next}`; } if ((next.match(new RegExp(`[${escapedMarks}]`, 'g')) || []).length > 1) return; const dm = findDecimalMark(next); if (dm) { const parts = next.split(dm); parts[1] = parts[1].slice(0, decimalsLimit); next = parts.join(dm); } if (transformRawValue) next = transformRawValue(next); setRaw(next); onValueChange?.(next); }; const handleFocus = (_e: FocusEvent) => { setIsFocused(true); const parsed = parseFixed(raw); setRaw(parsed ? toNumberSafe(parsed).toString() : raw); onFocus?.(); }; const handleBlur = (_e: FocusEvent) => { setIsFocused(false); const parsed = parseFixed(raw); setRaw(formatFixed(parsed)); onBlur?.(); }; useEffect(() => { if (value === undefined || isFocused) return; setRaw(formatFixed(parseFixed(String(value)))); }, [value, isFocused, formatFixed, parseFixed]); const displayValue = isFocused ? raw : formatFixed(parseFixed(raw)); return ( ); }, ); NumberTextField.displayName = 'NumberTextField';