|
import { ChevronDown, ChevronUp } from 'lucide-react' |
|
import { forwardRef, useCallback, useEffect, useState } from 'react' |
|
import { NumericFormat, NumericFormatProps } from 'react-number-format' |
|
import Button from '@/components/ui/Button' |
|
import Input from '@/components/ui/Input' |
|
import { cn } from '@/lib/utils' |
|
|
|
export interface NumberInputProps extends Omit<NumericFormatProps, 'value' | 'onValueChange'> { |
|
stepper?: number |
|
thousandSeparator?: string |
|
placeholder?: string |
|
defaultValue?: number |
|
min?: number |
|
max?: number |
|
value?: number |
|
suffix?: string |
|
prefix?: string |
|
onValueChange?: (value: number | undefined) => void |
|
fixedDecimalScale?: boolean |
|
decimalScale?: number |
|
} |
|
|
|
const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>( |
|
( |
|
{ |
|
stepper, |
|
thousandSeparator, |
|
placeholder, |
|
defaultValue, |
|
min = -Infinity, |
|
max = Infinity, |
|
onValueChange, |
|
fixedDecimalScale = false, |
|
decimalScale = 0, |
|
className = undefined, |
|
suffix, |
|
prefix, |
|
value: controlledValue, |
|
...props |
|
}, |
|
ref |
|
) => { |
|
const [value, setValue] = useState<number | undefined>(controlledValue ?? defaultValue) |
|
|
|
const handleIncrement = useCallback(() => { |
|
setValue((prev) => |
|
prev === undefined ? (stepper ?? 1) : Math.min(prev + (stepper ?? 1), max) |
|
) |
|
}, [stepper, max]) |
|
|
|
const handleDecrement = useCallback(() => { |
|
setValue((prev) => |
|
prev === undefined ? -(stepper ?? 1) : Math.max(prev - (stepper ?? 1), min) |
|
) |
|
}, [stepper, min]) |
|
|
|
useEffect(() => { |
|
if (controlledValue !== undefined) { |
|
setValue(controlledValue) |
|
} |
|
}, [controlledValue]) |
|
|
|
const handleChange = (values: { value: string; floatValue: number | undefined }) => { |
|
const newValue = values.floatValue === undefined ? undefined : values.floatValue |
|
setValue(newValue) |
|
if (onValueChange) { |
|
onValueChange(newValue) |
|
} |
|
} |
|
|
|
const handleBlur = () => { |
|
if (value !== undefined) { |
|
if (value < min) { |
|
setValue(min) |
|
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(min) |
|
} else if (value > max) { |
|
setValue(max) |
|
;(ref as React.RefObject<HTMLInputElement>).current!.value = String(max) |
|
} |
|
} |
|
} |
|
|
|
return ( |
|
<div className="relative flex"> |
|
<NumericFormat |
|
value={value} |
|
onValueChange={handleChange} |
|
thousandSeparator={thousandSeparator} |
|
decimalScale={decimalScale} |
|
fixedDecimalScale={fixedDecimalScale} |
|
allowNegative={min < 0} |
|
valueIsNumericString |
|
onBlur={handleBlur} |
|
max={max} |
|
min={min} |
|
suffix={suffix} |
|
prefix={prefix} |
|
customInput={(props) => <Input {...props} className={cn('w-full', className)} />} |
|
placeholder={placeholder} |
|
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" |
|
getInputRef={ref} |
|
{...props} |
|
/> |
|
<div className="absolute top-0 right-0 bottom-0 flex flex-col"> |
|
<Button |
|
aria-label="Increase value" |
|
className="border-input h-1/2 rounded-l-none rounded-br-none border-b border-l px-2 focus-visible:relative" |
|
variant="outline" |
|
onClick={handleIncrement} |
|
disabled={value === max} |
|
> |
|
<ChevronUp size={15} /> |
|
</Button> |
|
<Button |
|
aria-label="Decrease value" |
|
className="border-input h-1/2 rounded-l-none rounded-tr-none border-b border-l px-2 focus-visible:relative" |
|
variant="outline" |
|
onClick={handleDecrement} |
|
disabled={value === min} |
|
> |
|
<ChevronDown size={15} /> |
|
</Button> |
|
</div> |
|
</div> |
|
) |
|
} |
|
) |
|
|
|
NumberInput.displayName = 'NumberInput' |
|
|
|
export default NumberInput |
|
|