/** * @file number input * @description 用于整数输入的组件 */ import cns from 'classnames' import React, { InputHTMLAttributes, ReactNode, Ref, useCallback, useEffect, useState, KeyboardEvent } from 'react' import { UpIcon, DownIcon } from '../icons' import { concat } from '../utils' import { useFocused } from '../utils/hooks' import { usePrefix } from '../ConfigProvider' import { CommonProps } from './input' import TextInput from './TextInput' import { WithRef } from '../utils/type' export interface Props extends CommonProps { /** 最小值 */ min?: number /** 最大值 */ max?: number /** 保留的小数位数 */ digits?: number /** * 每次增减操作的变更绝对值 * 若不指定,则默认与当前保留的小数位数对应,例如: * 保留两位小数(`digits: 2`),则 step 默认为 `0.01` */ step?: number /** 前缀 */ prefix?: ReactNode /** 后缀 */ suffix?: ReactNode /** 空白值,若用户将输入框清空,`NumberInput` 会自动设置为该值,默认会使用 `defaultValue` 的值 */ emptyValue?: number | null /** * 获取 input 引用 * * @deprecated use inputProps.ref instead */ inputRef?: Ref /** 需要传给 `input` 的属性 */ inputProps?: WithRef, HTMLInputElement> } export default function NumberInput({ value: valueFromProps, defaultValue = null, onChange, disabled = false, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, digits = 0, step = 1 / (10 ** digits), suffix, emptyValue = defaultValue, inputProps, ...otherProps }: Props) { const initialValue = valueFromProps !== undefined ? valueFromProps : defaultValue const [textValue, setTextValue] = useState(stringify(initialValue)) const [innerValue, setInnerValue] = useState(initialValue) const value = valueFromProps !== undefined ? valueFromProps : innerValue const { focused, focusedHandlers } = useFocused(inputProps, disabled) // 同步值(value)到界面(textValue) const syncValue = useCallback((v = value) => { setTextValue(stringify(v)) }, [value]) // 失焦时(focused 变为 false) 或在失焦状态下 value 变化时自动 syncValue useEffect(() => { if (!focused) syncValue() }, [syncValue, focused]) const setValue = useCallback((v: number | null) => { if (v != null && v > max) v = max if (v != null && v < min) v = min if (v !== innerValue) setInnerValue(v) if (v !== value) onChange?.(v) return v }, [innerValue, onChange, value, min, max]) const inputClassName = usePrefix('number-input') const className = cns( inputClassName, // 若没有自定义 suffix,只有 ctrl 放在 TextInput 的 suffix 里 // 因为 ctrl 是绝对定位,这时需要把 TextInput suffix 的 margin 干掉 suffix == null && concat(inputClassName, 'no-suffix'), otherProps.className ) const increaseDisabled = disabled || (value != null && value >= max) const decreaseDisabled = disabled || (value != null && value <= min) const handleIncrease = useCallback(() => { if (increaseDisabled) return const newValue = setValue(add(value ?? 0, step, digits)) // 注意这里需要手动做下 value 到 text value 的同步,因为 value 到 text value 的自动同步(见上方 effect) // 只在非输入(focus)的状态下保持,而 TextInput keyUp 导致的 increase 发生在 focus 状态下, // 此时需要这里的同步来让用户看到 value 发生了 increase / decrease syncValue(newValue) }, [increaseDisabled, setValue, value, step, digits, syncValue]) const handleDecrease = useCallback(() => { if (decreaseDisabled) return const newValue = setValue(sub(value ?? 0, step, digits)) syncValue(newValue) // 这里需要 syncValue 的原因同上边 handleIncrease }, [decreaseDisabled, setValue, value, step, digits, syncValue]) const increaseClassName = cns( concat(inputClassName, 'ctrl-increase'), increaseDisabled && concat(inputClassName, 'ctrl-disabled') ) const decreaseClassName = cns( concat(inputClassName, 'ctrl-decrease'), decreaseDisabled && concat(inputClassName, 'ctrl-disabled') ) const suffixView = ( <> {suffix}
) const handleChange = useCallback((v: string) => { setTextValue(v) v = v.trim() if (v === '') setValue(emptyValue) else if (isNumberText(v)) setValue(parse(v, digits)) }, [setValue, emptyValue, digits]) const keyboardHandlers = useKeyboard(handleIncrease, handleDecrease, inputProps) return ( ) } const numberPattern = /^-?\d+(\.\d+)?$/ export function isNumberText(v: string) { return numberPattern.test(v) } export function parse(v: string, digits: number) { const rawNum = parseFloat(v) return parseFloat(rawNum.toFixed(digits)) } function stringify(v: number | null) { return v != null ? v + '' : '' } // 对键盘操作进行响应的逻辑 function useKeyboard( onIncrease: () => void, onDecrease: () => void, inputProps?: InputHTMLAttributes ): InputHTMLAttributes { const handleKeyDown = useCallback((e: KeyboardEvent) => { inputProps?.onKeyDown?.(e) if (e.defaultPrevented) return switch (e.key) { case 'Down': // IE/Edge specific value case 'ArrowDown': e.preventDefault() onDecrease() break case 'Up': // IE/Edge specific value case 'ArrowUp': e.preventDefault() onIncrease() break default: } }, [onIncrease, onDecrease, inputProps]) return { onKeyDown: handleKeyDown } } // 特别实现 add & sub,以避免 javascript 浮点数精度导致的 Round-off errors(如 0.1 + 0.2 = 0.30000000000000004) // 注意这里基于 `number` & `by` 的小数位数都不会超过 `digits` 值的假设,简单地处理了下这个问题 export function add(num: number, by: number, digits: number) { const factor = 10 ** digits return Math.round(num * factor + by * factor) / factor } export function sub(num: number, by: number, digits: number) { const factor = 10 ** digits return Math.round(num * factor - by * factor) / factor }