/** * 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" import { useTranslation } from "react-i18next" 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 { t } = useTranslation() const [, setShowError] = React.useState(false) const [, setShowWarning] = React.useState(false) const [touched, setTouched] = React.useState(false) const [isFocused, setIsFocused] = 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 inputValue = e.target.value.trim() setTouched(true) setIsFocused(false) setShowError(false) setShowWarning(false) if (inputValue === '' && !allowEmpty) { // Update parent with empty value so validation works onChange('') return } if (inputValue !== '' && !isNaN(Number(inputValue))) { onChange(formatValue(inputValue)) } } const handleFocus = () => { setIsFocused(true) 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 && isFocused && errorMessage && (
{errorMessage}
)} {hasWarning && isFocused && warningMessage && (
{warningMessage}
)}
) } ) FormNumericInput.displayName = "FormNumericInput" export { FormNumericInput }