import { type InputHTMLAttributes, useState } from "react" /** * Parses "1,5" as well as "1.5" - independent of the browser language. Empty or invalid text is NaN. * Numbers are passed through (e.g. default values of react-hook-form's `setValueAs`). */ export const parseDecimal = (text: string | number) => { if (typeof text === "number") return text const trimmed = text.trim().replace(",", ".") return trimmed === "" || trimmed === "." || trimmed === "-" ? NaN : Number(trimmed) } const toText = (value: number | undefined) => // at most 4 decimals, e.g. for calculated values like 5.900000000000001 value === undefined || Number.isNaN(value) ? "" : String(+value.toFixed(4)) type Props = Omit, "type" | "value" | "onChange" | "min" | "max"> & { value: number | undefined /** NaN if the field is empty */ onChange: (value: number) => void min?: number max?: number } /** * Number input that accepts "," and "." as decimal separator. * (`type="number"` only accepts the separator of the browser language and clears the field otherwise.) * Input that isn't a number or lies outside of min/max is ignored. */ const DecimalInput = ({ value, onChange, min, max, ...props }: Props) => { const [text, setText] = useState(toText(value)) const [prevValue, setPrevValue] = useState(value) // take over changes from outside (e.g. a reset), but keep the text as typed while it means the same number ("1," = 1) if (!Object.is(value, prevValue)) { setPrevValue(value) if (!Object.is(parseDecimal(text), value ?? NaN)) setText(toText(value)) } const allowNegative = min === undefined || min < 0 const pattern = allowNegative ? /^-?\d*[.,]?\d*$/ : /^\d*[.,]?\d*$/ return ( { const newText = e.target.value.trim() if (!pattern.test(newText)) return const newValue = parseDecimal(newText) if ((min !== undefined && newValue < min) || (max !== undefined && newValue > max)) return setText(newText) onChange(newValue) }} /> ) } export default DecimalInput