diff --git a/frontend/src/components/FormNumberStepper.tsx b/frontend/src/components/FormNumberStepper.tsx new file mode 100644 index 0000000..ef29383 --- /dev/null +++ b/frontend/src/components/FormNumberStepper.tsx @@ -0,0 +1,117 @@ +import { Minus, Plus } from "lucide-react"; + +interface FormNumberStepperProps { + value: string; + onChange: (nextValue: string) => void; + min?: number; + max?: number; + step?: number; + allowDecimal?: boolean; + decrementLabel: string; + incrementLabel: string; + className?: string; +} + +const DECIMAL_ROUNDING_FACTOR = 1000; + +function clamp(value: number, min: number, max?: number): number { + const clampedMin = Math.max(min, value); + if (max == null) return clampedMin; + return Math.min(max, clampedMin); +} + +function normalizeDecimal(value: number): number { + return Math.round(value * DECIMAL_ROUNDING_FACTOR) / DECIMAL_ROUNDING_FACTOR; +} + +function toDisplayValue(value: number, allowDecimal: boolean): string { + if (!allowDecimal) return String(Math.max(0, Math.trunc(value))); + const normalized = normalizeDecimal(value); + return normalized.toString(); +} + +function sanitizeRawInput(raw: string, allowDecimal: boolean): string { + const normalizedRaw = raw.replace(",", "."); + if (allowDecimal) { + const cleaned = normalizedRaw.replace(/[^\d.]/g, ""); + const [integerPart = "", ...fractionalParts] = cleaned.split("."); + if (fractionalParts.length === 0) return integerPart; + return `${integerPart}.${fractionalParts.join("")}`; + } + return normalizedRaw.replace(/\D/g, ""); +} + +function parseInputValue(raw: string, allowDecimal: boolean): number | null { + if (raw.trim() === "") return null; + const parsed = allowDecimal ? Number.parseFloat(raw) : Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) return null; + return parsed; +} + +export function FormNumberStepper({ + value, + onChange, + min = 0, + max, + step = 1, + allowDecimal = false, + decrementLabel, + incrementLabel, + className = "", +}: FormNumberStepperProps) { + const parsed = parseInputValue(value, allowDecimal); + const baseValue = parsed ?? min; + const canDecrement = baseValue > min; + const canIncrement = max == null || baseValue < max; + + const normalizedClassName = ["number-stepper", "form-number-stepper", className].filter(Boolean).join(" "); + + const handleStep = (direction: -1 | 1) => { + const nextRaw = clamp(baseValue + direction * step, min, max); + onChange(toDisplayValue(nextRaw, allowDecimal)); + }; + + const handleInputChange = (nextRaw: string) => { + onChange(sanitizeRawInput(nextRaw, allowDecimal)); + }; + + const handleBlur = () => { + const nextParsed = parseInputValue(value, allowDecimal); + if (nextParsed == null) return; + const clamped = clamp(nextParsed, min, max); + onChange(toDisplayValue(clamped, allowDecimal)); + }; + + return ( +