Files
med-plan-assistant/src/components/ui/form-numeric-input.tsx

225 lines
6.5 KiB
TypeScript

/**
* 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<React.InputHTMLAttributes<HTMLInputElement>, '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<HTMLInputElement, NumericInputProps>(
({
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<HTMLDivElement>(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<HTMLInputElement>) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
updateValue(e.key === 'ArrowUp' ? 1 : -1)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
onChange(val)
}
}
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
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 (
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
<div className="flex items-center">
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-r-none border-r-0",
hasError && "border-destructive"
)}
onClick={() => updateValue(-1)}
tabIndex={-1}
>
<Minus className="h-4 w-4" />
</Button>
<Input
ref={ref}
type="text"
value={value}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
className={cn(
"w-20 h-9 z-20",
"rounded-none",
getAlignmentClass(),
hasError && "border-destructive focus-visible:ring-destructive"
)}
{...props}
/>
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9",
clearButton && allowEmpty ? "rounded-l-none rounded-r-none border-x-0" : "rounded-l-none border-l-0",
hasError && "border-destructive"
)}
onClick={() => updateValue(1)}
tabIndex={-1}
>
<Plus className="h-4 w-4" />
</Button>
{clearButton && allowEmpty && (
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-l-none",
hasError && "border-destructive"
)}
onClick={() => onChange('')}
tabIndex={-1}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && errorMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage}
</div>
)}
{hasWarning && showWarning && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-yellow-950 text-xs p-2 rounded-md shadow-lg">
{warningMessage}
</div>
)}
</div>
)
}
)
FormNumericInput.displayName = "FormNumericInput"
export { FormNumericInput }