dodil/erp-ui

src/components/form/MoneyInput.tsx

/**
 * 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>
  );
}