/**
* MoneyInput — an amount typed by a person, kept as exact text.
*
* What is typed is never turned into a float: "12 000,5"? No — "12,000.50" or "12000.5" are read as the
* decimal 12000.50 by removing grouping and checking the shape, and the value handed back is the decimal
* STRING ("12000.50") the API takes. Anything else is shown as an error under the field, and the value is
* null so a form cannot submit it.
*/
import { Flex, Text, TextField } from "@radix-ui/themes";
import { useId, useState } from "react";
/** "12,000.5" → "12000.50"; "" → ""; not an amount → null. At most `decimals` places, never rounded. */
export function parseMoneyInput(text: string, decimals = 2): string | null {
const t = text.trim().replace(/[\s,_']/g, "");
if (t === "") return "";
const m = /^(\d+)(?:\.(\d*))?$/.exec(t);
if (!m) return null;
const frac = m[2] ?? "";
if (frac.length > decimals) return null;
return `${m[1].replace(/^0+(?=\d)/, "")}${decimals ? "." + frac.padEnd(decimals, "0") : ""}`;
}
export interface MoneyInputProps {
label: string;
/** The decimal string, e.g. "12000.50", or "" for empty. */
value: string;
/** The parsed decimal (null when what is typed is not an amount) and the text as typed. */
onChange: (value: string | null, text: string) => void;
currency?: string;
required?: boolean;
disabled?: boolean;
/** Why it is disabled (shown under the field). */
disabledReason?: string;
hint?: string;
decimals?: number;
name?: string;
}
export function MoneyInput({ label, value, onChange, currency, required, disabled, disabledReason, hint, decimals = 2, name }: MoneyInputProps) {
const id = useId();
const [text, setText] = useState(value);
const [invalid, setInvalid] = useState(false);
// follow a value set from outside (a reset, a loaded record) without fighting what is being typed
const [last, setLast] = useState(value);
if (value !== last) {
setLast(value);
if (text !== value && parseMoneyInput(text, decimals) !== value) setText(value);
}
const note = invalid ? `Enter an amount like 1250.50 (at most ${decimals} decimals)` : disabled ? disabledReason : hint;
return (
<Flex direction="column" gap="1">
<Text as="label" size="2" weight="medium" htmlFor={id}>{label}{required ? " *" : ""}</Text>
<TextField.Root
id={id}
name={name}
inputMode="decimal"
autoComplete="off"
value={text}
disabled={disabled}
required={required}
aria-invalid={invalid || undefined}
aria-describedby={note ? `${id}-note` : undefined}
color={invalid ? "red" : undefined}
onChange={(e) => {
setText(e.target.value);
const parsed = parseMoneyInput(e.target.value, decimals);
setInvalid(parsed === null);
onChange(parsed, e.target.value);
}}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{currency && <TextField.Slot side="right"><Text size="1" color="gray">{currency}</Text></TextField.Slot>}
</TextField.Root>
{note && <Text id={`${id}-note`} size="1" color={invalid ? "red" : "gray"}>{note}</Text>}
</Flex>
);
}