238 lines
7.1 KiB
TypeScript
238 lines
7.1 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"
|
|
import { useTranslation } from "react-i18next"
|
|
|
|
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 { 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<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 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 (
|
|
<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",
|
|
hasWarning && !hasError && "border-yellow-500"
|
|
)}
|
|
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",
|
|
hasWarning && !hasError && "border-yellow-500 focus-visible:ring-yellow-500"
|
|
)}
|
|
{...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",
|
|
hasWarning && !hasError && "border-yellow-500"
|
|
)}
|
|
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",
|
|
hasWarning && !hasError && "border-yellow-500"
|
|
)}
|
|
onClick={() => onChange('')}
|
|
tabIndex={-1}
|
|
title={ t('buttonClear') }
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
|
{hasError && isFocused && 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 && isFocused && warningMessage && (
|
|
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
|
|
{warningMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
)
|
|
|
|
FormNumericInput.displayName = "FormNumericInput"
|
|
|
|
export { FormNumericInput }
|