/** * Custom Form Component: Numeric Input with Controls * * A numeric input field with increment/decrement buttons, validation, * and error display. Built on top of shadcn/ui components. * * @author Andreas Weyer * @license MIT */ import * as React from "react" import { Minus, Plus, X } from "lucide-react" import { Button } from "./button" import { Input } from "./input" import { cn } from "../../lib/utils" interface NumericInputProps extends Omit, 'onChange' | 'value'> { value: string | number onChange: (value: string) => void increment?: number | string min?: number max?: number unit?: string align?: 'left' | 'center' | 'right' allowEmpty?: boolean clearButton?: boolean error?: boolean warning?: boolean required?: boolean errorMessage?: string warningMessage?: string } const FormNumericInput = React.forwardRef( ({ value, onChange, increment = 1, min = -Infinity, max = Infinity, unit, align = 'right', allowEmpty = false, clearButton = false, error = false, warning = false, required = false, errorMessage = 'Time is required', warningMessage, className, ...props }, ref) => { const [showError, setShowError] = React.useState(false) const [showWarning, setShowWarning] = React.useState(false) const [touched, setTouched] = React.useState(false) const containerRef = React.useRef(null) // Check if value is invalid (check validity regardless of touch state) const isInvalid = required && !allowEmpty && (value === '' || value === null || value === undefined) const hasError = error || isInvalid const hasWarning = warning && !hasError // Check validity on mount and when value changes React.useEffect(() => { if (isInvalid && touched) { setShowError(true) } else if (!isInvalid) { setShowError(false) } }, [isInvalid, touched]) // Determine decimal places based on increment const getDecimalPlaces = () => { const inc = String(increment || '1') const decimalIndex = inc.indexOf('.') if (decimalIndex === -1) return 0 return inc.length - decimalIndex - 1 } const decimalPlaces = getDecimalPlaces() // Format value for display const formatValue = (val: string | number): string => { const num = Number(val) if (isNaN(num)) return String(val) return num.toFixed(decimalPlaces) } const updateValue = (direction: number) => { const numIncrement = parseFloat(String(increment)) || 1 let numValue = Number(value) if (isNaN(numValue)) { numValue = 0 } numValue += direction * numIncrement numValue = Math.max(min, numValue) numValue = Math.min(max, numValue) onChange(formatValue(numValue)) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() updateValue(e.key === 'ArrowUp' ? 1 : -1) } } const handleChange = (e: React.ChangeEvent) => { const val = e.target.value if (val === '' || /^-?\d*\.?\d*$/.test(val)) { onChange(val) } } const handleBlur = (e: React.FocusEvent) => { const val = e.target.value setTouched(true) setShowError(false) if (val === '' && !allowEmpty) { return } if (val !== '' && !isNaN(Number(val))) { onChange(formatValue(val)) } } const handleFocus = () => { setShowError(hasError) setShowWarning(hasWarning) } const getAlignmentClass = () => { switch (align) { case 'left': return 'text-left' case 'center': return 'text-center' case 'right': return 'text-right' default: return 'text-right' } } return (
{clearButton && allowEmpty && ( )}
{unit && {unit}} {hasError && showError && errorMessage && (
{errorMessage}
)} {hasWarning && showWarning && warningMessage && (
{warningMessage}
)}
) } ) FormNumericInput.displayName = "FormNumericInput" export { FormNumericInput }