| 1 | /** |
| 2 | * NumberInput - 数字输入框 |
| 3 | * 基于 react-number-format,解决原生 type="number" 的浏览器行为不一致问题 |
| 4 | */ |
| 5 | 'use client' |
| 6 | |
| 7 | import type { NumericFormatProps } from 'react-number-format' |
| 8 | import * as React from 'react' |
| 9 | import { NumericFormat } from 'react-number-format' |
| 10 | import { cn } from '@/utils/className' |
| 11 | |
| 12 | // 基础样式,与 src/components/ui/input.tsx 保持一致 |
| 13 | const inputBaseClass |
| 14 | = 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm' |
| 15 | |
| 16 | interface NumberInputProps |
| 17 | extends Omit<NumericFormatProps, 'value' | 'onValueChange' | 'customInput' | 'isAllowed'> { |
| 18 | value?: number | null |
| 19 | onValueChange?: (value: number | undefined) => void |
| 20 | /** 最小值:聚焦编辑时允许中间态,失焦后低于最小值会自动校正 */ |
| 21 | min?: number |
| 22 | /** 最大值:聚焦编辑时允许中间态,失焦后高于最大值会自动校正 */ |
| 23 | max?: number |
| 24 | className?: string |
| 25 | } |
| 26 | |
| 27 | const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>( |
| 28 | ({ className, value, onValueChange, min, max, allowNegative, onBlur, onFocus, ...props }, ref) => { |
| 29 | const [isFocused, setIsFocused] = React.useState(false) |
| 30 | const [draftValue, setDraftValue] = React.useState<string>(value == null ? '' : String(value)) |
| 31 | const draftNumberRef = React.useRef<number | undefined>(value ?? undefined) |
| 32 | |
| 33 | React.useEffect(() => { |
| 34 | if (!isFocused) { |
| 35 | draftNumberRef.current = value ?? undefined |
| 36 | setDraftValue(value == null ? '' : String(value)) |
| 37 | } |
| 38 | }, [isFocused, value]) |
| 39 | |
| 40 | const resolveBlurValue = () => { |
| 41 | const draftNumber = draftNumberRef.current |
| 42 | if (draftNumber === undefined) { |
| 43 | if (min !== undefined) |
| 44 | return min |
| 45 | return draftValue.trim() && allowNegative === false ? 0 : undefined |
| 46 | } |
| 47 | |
| 48 | if (min !== undefined && draftNumber < min) |
| 49 | return min |
| 50 | if (max !== undefined && draftNumber > max) |
| 51 | return max |
| 52 | if (allowNegative === false && draftNumber < 0) |
| 53 | return 0 |
| 54 | return draftNumber |
| 55 | } |
| 56 | |
| 57 | return ( |
| 58 | <NumericFormat |
| 59 | {...props} |
| 60 | getInputRef={ref} |
| 61 | className={cn(inputBaseClass, className)} |
| 62 | value={isFocused ? draftValue : value ?? ''} |
| 63 | allowNegative={isFocused ? true : allowNegative} |
| 64 | onFocus={(event) => { |
| 65 | setIsFocused(true) |
| 66 | setDraftValue(value == null ? '' : String(value)) |
| 67 | draftNumberRef.current = value ?? undefined |
| 68 | onFocus?.(event) |
| 69 | }} |
| 70 | onValueChange={(values) => { |
| 71 | draftNumberRef.current = values.floatValue |
| 72 | setDraftValue(values.formattedValue) |
| 73 | }} |
| 74 | onBlur={(event) => { |
| 75 | const nextValue = resolveBlurValue() |
| 76 | const currentValue = value ?? undefined |
| 77 | draftNumberRef.current = nextValue |
| 78 | setDraftValue(nextValue == null ? '' : String(nextValue)) |
| 79 | if (nextValue !== currentValue) { |
| 80 | onValueChange?.(nextValue) |
| 81 | } |
| 82 | setIsFocused(false) |
| 83 | onBlur?.(event) |
| 84 | }} |
| 85 | /> |
| 86 | ) |
| 87 | }, |
| 88 | ) |
| 89 | NumberInput.displayName = 'NumberInput' |
| 90 | |
| 91 | export { NumberInput, type NumberInputProps } |
| 92 |