import React, { useState, useEffect, useRef } from "react"; import classNames from "classnames"; import { StyledProps } from "../_type"; import { ControlledProps, useDefaultValue } from "../form/controlled"; import { getPrecision } from "../_util/get-precision"; import { useConfig } from "../_util/config-context"; import { noop } from "../_util/noop"; import { KeyMap } from "../_util/key-map"; import { mergeEventProps } from "../_util/merge-event-props"; import { forwardRefWithStatics } from "../_util/forward-ref-with-statics"; import { callBoth } from "../util"; export interface InputNumberProps extends ControlledProps, StyledProps { /** 最小值 */ min?: number; /** 最大值 */ max?: number; /** * 使用按钮增减时的步长 * @default 1 */ step?: number; /** 单位 */ unit?: string; /** * 是否禁用 * @default false */ disabled?: boolean; /** * 精度(保留小数位数) */ precision?: number; /** * 输入框大小 * @default "m" * @since 2.2.1 */ size?: "m" | "l"; /** * 输入框展示值的格式 * * *配合 `parser` 使用* * * @since 2.7.0 */ formatter?: (value: number | string) => string; /** * 从 `formatter` 格式转回数字的方法 * @since 2.7.0 */ parser?: (content: string) => number; /** * 隐藏输入框两侧按钮 * @default false * @since 2.2.2 */ hideButton?: boolean; /** * 输入值变化回调 * @since 2.5.0 */ onInputChange?: (inputValue: string) => void; /** * 自定义输入框属性 * @since 2.7.0 */ inputProps?: React.InputHTMLAttributes; /** * 是否允许不输入值 * * *使用 `null` 表示空值* * * @default false * @since 2.7.0 */ allowEmpty?: boolean; /** * 输入框聚焦事件 * @since 2.2.1 */ onFocus?: React.DOMAttributes["onFocus"]; /** * 输入框失焦事件 * @since 2.2.1 */ onBlur?: React.DOMAttributes["onBlur"]; } const DISABLED_CLS = "is-disabled"; /** * 数字输入组件 */ export const InputNumber = forwardRefWithStatics( function InputNumber( props: InputNumberProps, ref: React.Ref ) { const { classPrefix } = useConfig(); const { size, unit, disabled, style, className, onFocus = noop, onBlur = noop, hideButton, inputProps = {}, } = props; const { minus, input, plus } = useInputNumberHooks(props); return ( {!hideButton && ( - )} { onBlur(e); input.handleBlur(e); }, inputProps.onBlur)} onKeyDown={callBoth(input.handleKeyDown, inputProps.onKeyDown)} /> {!hideButton && ( + )} {unit && (
{unit}
)}
); }, { defaultLabelAlign: "middle", } ); InputNumber.displayName = "InputNumber"; function getDefaultDefaultValue(min: number, max: number) { if (min && min > 0) { return min; } if (max && max < 0) { return max; } return 0; } const defaultFormatter = (x: number | string) => String(x); const defaultParser = (x: string) => Number(x); /** * InputNumber 状态管理 */ function useInputNumberHooks(props: InputNumberProps) { let { step } = props; const { min, max, value, onChange = noop, onInputChange = noop, allowEmpty, formatter = defaultFormatter, parser = defaultParser, } = useDefaultValue(props, getDefaultDefaultValue(props.min, props.max)); const isValidNumber = (num: any) => typeof num === "number" && !Number.isNaN(num); step = isValidNumber(step) ? step : 1; const precision = props.precision ?? getPrecision(step); const hasMax = isValidNumber(max); const hasMinus = isValidNumber(min); const [isEmpty, setIsEmpty] = useState(allowEmpty && value === null); const [inputValue, setInputValue] = useState(null); const hasInputValue = inputValue !== null; const parse = (newValue: string): number => newValue.trim() === "" ? 0 : parseFloat(newValue); const getCurrentValue = () => { let currentValue = value; if (hasInputValue && /\d/.test(inputValue)) { currentValue = parse(inputValue); // 解析失败,退回当前值 if (Number.isNaN(currentValue)) { currentValue = value; } } return currentValue; }; const canMinus = (value: number) => !hasMinus || value > min; const canPlus = (value: number) => !hasMax || value < max; const commit = (_newValue: string | number, event: React.SyntheticEvent) => { let newValue = _newValue; // 从 input 回调过来的,是字符串 if (typeof newValue === "string") { if (allowEmpty && !newValue) { onChange(null, { event }); return; } newValue = parser(newValue); } // 不能小于最小值 if (hasMinus) { newValue = Math.max(min, newValue); } // 不能大于最大值 if (hasMax) { newValue = Math.min(max, newValue); } // 解析失败,还原旧值 if (Number.isNaN(newValue)) { newValue = value; } // 还原预置初始值 if (typeof newValue !== "number" || Number.isNaN(newValue)) { newValue = getDefaultDefaultValue(min, max); } // 处理精度 newValue = parse(newValue.toFixed(precision)); onChange(newValue, { event }); onInputChange(formatter(newValue)); }; // value 发生变更,清空暂存的值 useEffect(() => setInputValue(null), [value]); // 长按增减按钮会持续操作,鼠标抬起,或者组件销毁时要清空 const autoStepTimer = useRef(0); const clear = () => clearTimeout(autoStepTimer.current); useEffect(() => { window.addEventListener("mouseup", clear, true); // destroy return () => { clear(); window.removeEventListener("mouseup", clear); }; }, []); // 为按钮提供步长调整逻辑 const stepper = (step: number) => { // 步长的符号影响是否允许进行下一步长的判断方法 const canStep = step > 0 ? canPlus : canMinus; return { disabled: !canStep(value), // 点击按钮的时候,先变更一次。然后,只要鼠标不抬起,自动连续变更 handleMouseDown: (evt: React.MouseEvent) => { // 只有鼠标左键可用 if (evt.nativeEvent.which >= 2) { return; } if (allowEmpty) { setIsEmpty(false); } let currentValue = getCurrentValue(); const performStep = () => { if (canStep(currentValue)) { commit((currentValue += step), evt); } }; performStep(); clear(); // 1 秒后,开始自动递增 autoStepTimer.current = setTimeout(() => { const autoPerform = () => { performStep(); autoStepTimer.current = setTimeout(autoPerform, 50); }; autoPerform(); }, 700); // 保留事件内容,否则在自动步长执行的时候,事件不可用 evt.persist(); // 阻止事件变成 blur 事件,导致二次设置 evt.preventDefault(); }, }; }; return { minus: stepper(-step), plus: stepper(step), input: { value: hasInputValue || isEmpty ? inputValue : formatter(Number(value).toFixed(precision)), handleChange: (evt: React.ChangeEvent) => { const { value } = evt.target; if (allowEmpty) { setIsEmpty(!value); } setInputValue(value); onInputChange(value); }, handleBlur: (evt: React.FocusEvent) => { commit(evt.target.value, evt); setInputValue(null); }, handleKeyDown: (evt: React.KeyboardEvent) => { const currentValue = getCurrentValue(); if (canPlus(currentValue) && evt.key === KeyMap.Up) { // ArrowUp evt.preventDefault(); if (allowEmpty) { setIsEmpty(false); } commit(currentValue + step, evt); } else if (canMinus(currentValue) && evt.key === KeyMap.Down) { // ArrowDown evt.preventDefault(); if (allowEmpty) { setIsEmpty(false); } commit(currentValue - step, evt); } // Enter if (evt.key === KeyMap.Enter) { evt.preventDefault(); commit(evt.currentTarget.value, evt); setInputValue(null); } }, }, }; }