/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-unused-vars */ /* eslint-disable max-depth */ /* eslint-disable react/no-did-update-set-state */ /* eslint-disable max-len */ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import Input, { InputProps } from '../input'; import { forwardStatics } from '@douyinfe/semi-foundation/utils/object'; import isNullOrUndefined from '@douyinfe/semi-foundation/utils/isNullOrUndefined'; import isBothNaN from '@douyinfe/semi-foundation/utils/isBothNaN'; import InputNumberFoundation, { InputNumberAdapter } from '@douyinfe/semi-foundation/inputNumber/foundation'; import BaseComponent from '../_base/baseComponent'; import { cssClasses, numbers, strings } from '@douyinfe/semi-foundation/inputNumber/constants'; import { IconChevronUp, IconChevronDown } from '@douyinfe/semi-icons'; import '@douyinfe/semi-foundation/inputNumber/inputNumber.scss'; import { isNaN, noop } from 'lodash'; import { ArrayElement } from '../_base/base'; export interface InputNumberProps extends InputProps { autofocus?: boolean; className?: string; defaultValue?: number | string; disabled?: boolean; formatter?: (value: number | string) => string; forwardedRef?: React.MutableRefObject | ((instance: HTMLInputElement) => void); hideButtons?: boolean; innerButtons?: boolean; insetLabel?: React.ReactNode; insetLabelId?: string; keepFocus?: boolean; max?: number; min?: number; parser?: (value: string) => string; precision?: number; prefixCls?: string; pressInterval?: number; pressTimeout?: number; shiftStep?: number; showClear?: boolean; size?: ArrayElement; step?: number; style?: React.CSSProperties; suffix?: React.ReactNode; value?: number | string; onBlur?: (e: React.FocusEvent) => void; onChange?: (value: number | string, e?: React.ChangeEvent) => void; onDownClick?: (value: string, e: React.MouseEvent) => void; onFocus?: (e: React.FocusEvent) => void; onKeyDown?: React.KeyboardEventHandler; onNumberChange?: (value: number, e?: React.ChangeEvent) => void; onUpClick?: (value: string, e: React.MouseEvent) => void; } export interface InputNumberState { value?: number | string; number?: number | null; // Current parsed numbers focusing?: boolean; hovering?: boolean; } class InputNumber extends BaseComponent { static propTypes = { 'aria-label': PropTypes.string, 'aria-labelledby': PropTypes.string, 'aria-invalid': PropTypes.bool, 'aria-errormessage': PropTypes.string, 'aria-describedby': PropTypes.string, 'aria-required': PropTypes.bool, autofocus: PropTypes.bool, className: PropTypes.string, defaultValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), disabled: PropTypes.bool, formatter: PropTypes.func, forwardedRef: PropTypes.any, hideButtons: PropTypes.bool, innerButtons: PropTypes.bool, insetLabel: PropTypes.node, insetLabelId: PropTypes.string, keepFocus: PropTypes.bool, max: PropTypes.number, min: PropTypes.number, parser: PropTypes.func, precision: PropTypes.number, prefixCls: PropTypes.string, pressInterval: PropTypes.number, pressTimeout: PropTypes.number, shiftStep: PropTypes.number, step: PropTypes.number, style: PropTypes.object, suffix: PropTypes.any, value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), onBlur: PropTypes.func, onChange: PropTypes.func, onDownClick: PropTypes.func, onKeyDown: PropTypes.func, onNumberChange: PropTypes.func, onUpClick: PropTypes.func, }; static defaultProps: InputNumberProps = { disabled: false, forwardedRef: noop, innerButtons: false, keepFocus: false, max: Infinity, min: -Infinity, prefixCls: cssClasses.PREFIX, pressInterval: numbers.DEFAULT_PRESS_TIMEOUT, pressTimeout: numbers.DEFAULT_PRESS_TIMEOUT, shiftStep: numbers.DEFAULT_SHIFT_STEP, size: strings.DEFAULT_SIZE, step: numbers.DEFAULT_STEP, onBlur: noop, onChange: noop, onDownClick: noop, onFocus: noop, onKeyDown: noop, onNumberChange: noop, onUpClick: noop, }; get adapter(): InputNumberAdapter { return { ...super.adapter, setValue: (value, cb) => this.setState({ value }, cb), setNumber: (number, cb) => this.setState({ number }, cb), setFocusing: (focusing, cb) => this.setState({ focusing }, cb), setHovering: hovering => this.setState({ hovering }), notifyChange: (...args) => this.props.onChange(...args), notifyNumberChange: (...args) => this.props.onNumberChange(...args), notifyBlur: e => this.props.onBlur(e), notifyFocus: e => this.props.onFocus(e), notifyUpClick: (value, e) => this.props.onUpClick(value, e), notifyDownClick: (value, e) => this.props.onDownClick(value, e), notifyKeyDown: e => this.props.onKeyDown(e), registerGlobalEvent: (eventName, handler) => { if (eventName && typeof handler === 'function') { this.adapter.unregisterGlobalEvent(eventName); this.adapter.setCache(eventName, handler); document.addEventListener(eventName, handler); } }, unregisterGlobalEvent: eventName => { if (eventName) { const handler = this.adapter.getCache(eventName); document.removeEventListener(eventName, handler); this.adapter.setCache(eventName, null); } }, recordCursorPosition: () => { // Record position try { if (this.inputNode) { this.cursorStart = this.inputNode.selectionStart; this.cursorEnd = this.inputNode.selectionEnd; this.currentValue = this.inputNode.value; this.cursorBefore = this.inputNode.value.substring(0, this.cursorStart); this.cursorAfter = this.inputNode.value.substring(this.cursorEnd); } } catch (e) { console.warn(e); // Fix error in Chrome: // Failed to read the 'selectionStart' property from 'HTMLInputElement' // http://stackoverflow.com/q/21177489/3040605 } }, restoreByAfter: str => { if (isNullOrUndefined(str)) { return false; } const fullStr = this.inputNode.value; const index = fullStr.lastIndexOf(str); if (index === -1) { return false; } if (index + str.length === fullStr.length) { this.adapter.fixCaret(index, index); return true; } return false; }, restoreCursor: (str = this.cursorAfter) => { if (isNullOrUndefined(str)) { return false; } // For loop from full str to the str with last char to map. e.g. 123 // -> 123 // -> 23 // -> 3 return Array.prototype.some.call(str, (_: any, start: number) => { const partStr = str.substring(start); return this.adapter.restoreByAfter(partStr); }); }, fixCaret: (start, end) => { if (start === undefined || end === undefined || !this.inputNode || !this.inputNode.value) { return; } try { const currentStart = this.inputNode.selectionStart; const currentEnd = this.inputNode.selectionEnd; if (start !== currentStart || end !== currentEnd) { this.inputNode.setSelectionRange(start, end); } } catch (e) { // Fix error in Chrome: // Failed to read the 'selectionStart' property from 'HTMLInputElement' // http://stackoverflow.com/q/21177489/3040605 } }, setClickUpOrDown: value => { this.clickUpOrDown = value; } }; } inputNode: HTMLInputElement; clickUpOrDown: boolean; cursorStart!: number; cursorEnd!: number; currentValue!: number | string; cursorBefore!: string; cursorAfter!: string; foundation: InputNumberFoundation; constructor(props: InputNumberProps) { super(props); this.state = { value: '', number: null, // Current parsed numbers focusing: Boolean(props.autofocus) || false, hovering: false, }; this.inputNode = null; this.foundation = new InputNumberFoundation(this.adapter); this.clickUpOrDown = false; } componentDidUpdate(prevProps: InputNumberProps) { const { value } = this.props; const { focusing } = this.state; /** * To determine whether the front and back are equal * NaN need to check whether both are NaN */ if (value !== prevProps.value && !isBothNaN(value, prevProps.value)) { if (isNullOrUndefined(value) || value === '') { this.setState({ value: '', number: null }); } else { let valueStr = value; if (typeof value === 'number') { valueStr = value.toString(); } const parsedNum = this.foundation.doParse(valueStr, false, true, true); const toNum = typeof value === 'number' ? value : this.foundation.doParse(valueStr, false, false, false); /** * focusing 状态为输入状态,输入状态的受控值要特殊处理 * 如: * - 输入合法值 * 123 => input value 也应该是 123,同时需要设置 number 为 123 * - 输入非法值,只设置 input value,不设置非法的number * abc => input value 这时是 abc,但失焦后会进行格式化 * 100(超出范围) => input value 应该是 100,但不设置 number * * 保持输入态有三种方式 * 1. 输入框输入 * - 输入可以解析为合法数字,input value根据输入值确定,失焦时更新input value * - 输入不可解析为合法数字,进行格式化后显示在input框 * 2. 键盘点击上下按钮(input value根据受控值进行更改) * 3. keepFocus+鼠标点击上下按钮(input value根据受控值进行更改) * * The focusing state is the input state, and the controlled value of the input state needs special treatment * For example: * - input legal value * 123 = > input value should also be 123, and the number should be set to 123 * - input illegal value, only set the input value, do not set the illegal number * abc = > input value This is abc at this time, but it will be formatted after being out of focus * 100 (out of range) = > input value should be 100, but no number * * There are three ways to maintain the input state * 1. input box input * - input can be resolved into legal numbers, input value is determined according to the input value, and input value is updated when out of focus * - input cannot be resolved into legal numbers, and it will be displayed in the input box after formatting * 2. Keyboard click on the up and down button (input value is changed according to the controlled value) * 3.keepFocus + mouse click on the up and down button (input value is changed according to the controlled value) */ if (focusing) { if (this.foundation.isValidNumber(parsedNum) && parsedNum !== this.state.number) { const obj: { number?: number; value?: string } = { number: parsedNum }; /** * If you are clicking the button, it will automatically format once * We need to set the status to false after trigger focus event */ if (this.clickUpOrDown) { obj.value = this.foundation.doFormat(valueStr, true); } this.setState(obj, () => this.adapter.restoreCursor()); } else if (!isNaN(toNum)) { // Update input content when controlled input is illegal and not NaN this.setState({ value: this.foundation.doFormat(toNum, false) }); } else { // Update input content when controlled input NaN this.setState({ value: this.foundation.doFormat(valueStr, false) }); } } else if (this.foundation.isValidNumber(parsedNum)) { this.setState({ number: parsedNum, value: this.foundation.doFormat(parsedNum) }); } else { // Invalid digital analog blurring effect instead of controlled failure this.setState({ number: null, value: '' }); } } } if (!this.clickUpOrDown) { return; } if (this.props.keepFocus && this.state.focusing) { if (document.activeElement !== this.inputNode) { this.inputNode.focus(); } } } setInputRef = (node: HTMLInputElement) => { const { forwardedRef } = this.props; this.inputNode = node; if (forwardedRef && typeof forwardedRef === 'object') { forwardedRef.current = node; } else if (typeof forwardedRef === 'function') { forwardedRef(node); } }; handleInputFocus = (e: React.FocusEvent) => this.foundation.handleInputFocus(e); handleInputChange = (value: string, event: React.ChangeEvent) => this.foundation.handleInputChange(value, event); handleInputBlur = (e: React.FocusEvent) => this.foundation.handleInputBlur(e); handleInputKeyDown = (e: React.KeyboardEvent) => this.foundation.handleInputKeyDown(e); handleInputMouseEnter = (e: React.MouseEvent) => this.foundation.handleInputMouseEnter(e); handleInputMouseLeave = (e: React.MouseEvent) => this.foundation.handleInputMouseLeave(e); handleInputMouseMove = (e: React.MouseEvent) => this.foundation.handleInputMouseMove(e); handleUpClick = (e: React.KeyboardEvent) => this.foundation.handleUpClick(e); handleDownClick = (e: React.KeyboardEvent) => this.foundation.handleDownClick(e); handleMouseUp = (e: React.MouseEvent) => this.foundation.handleMouseUp(e); handleMouseLeave = (e: React.MouseEvent) => this.foundation.handleMouseLeave(e); renderButtons = () => { const { prefixCls, disabled, innerButtons, max, min } = this.props; const { hovering, focusing, number } = this.state; const notAllowedUp = disabled ? disabled : number === max; const notAllowedDown = disabled ? disabled : number === min; const suffixChildrenCls = classnames(`${prefixCls}-number-suffix-btns`, { [`${prefixCls}-number-suffix-btns-inner`]: innerButtons, [`${prefixCls}-number-suffix-btns-inner-hover`]: innerButtons && hovering && !focusing }); const upClassName = classnames(`${prefixCls}-number-button`, `${prefixCls}-number-button-up`, { [`${prefixCls}-number-button-up-disabled`]: disabled, [`${prefixCls}-number-button-up-not-allowed`]: notAllowedUp, }); const downClassName = classnames(`${prefixCls}-number-button`, `${prefixCls}-number-button-down`, { [`${prefixCls}-number-button-down-disabled`]: disabled, [`${prefixCls}-number-button-down-not-allowed`]: notAllowedDown, }); return (
); }; renderSuffix = () => { const { innerButtons, suffix } = this.props; const { hovering, focusing } = this.state; if (innerButtons && (hovering || focusing)) { const buttons = this.renderButtons(); return buttons; } return suffix; }; render() { const { disabled, className, prefixCls, min, max, step, shiftStep, precision, formatter, parser, forwardedRef, onUpClick, onDownClick, pressInterval, pressTimeout, suffix, size, hideButtons, innerButtons, style, onNumberChange, keepFocus, defaultValue, ...rest } = this.props; const { value, number } = this.state; const inputNumberCls = classnames(className, `${prefixCls}-number`, { [`${prefixCls}-number-size-${size}`]: size, }); const buttons = this.renderButtons(); const ariaProps = { 'aria-disabled': disabled, step, }; if (number) { ariaProps['aria-valuenow'] = number; } if (max !== Infinity) { ariaProps['aria-valuemax'] = max; } if (min !== -Infinity) { ariaProps['aria-valuemin'] = min; } const input = (
this.handleInputMouseMove(e)} onMouseEnter={e => this.handleInputMouseEnter(e)} onMouseLeave={e => this.handleInputMouseLeave(e)} > {(hideButtons || innerButtons) ? null : ( buttons )}
); return input; } } export default forwardStatics( React.forwardRef(function SemiInputNumber(props, ref) { return ; }), InputNumber ); export { InputNumber };