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';