返回 AiToEarn
number-input.tsx
根目录 / project / aitoearn-web / src / components / ui / number-input.tsx
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
92 lines Plain Text