| 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) |
|
|
| |
| |
| |
| useEffect(() => { |
| |
| if (controlledValue !== undefined) setValue(controlledValue) |
| }, [controlledValue]) |
|
|
| 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]) |
|
|
| 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 |
|
|