index.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. /* eslint-disable @typescript-eslint/no-unused-vars */
  2. /* eslint-disable no-unused-vars */
  3. /* eslint-disable max-depth */
  4. /* eslint-disable react/no-did-update-set-state */
  5. /* eslint-disable max-len */
  6. import React from 'react';
  7. import PropTypes from 'prop-types';
  8. import classnames from 'classnames';
  9. import Input, { InputProps } from '../input';
  10. import { forwardStatics } from '@douyinfe/semi-foundation/utils/object';
  11. import isNullOrUndefined from '@douyinfe/semi-foundation/utils/isNullOrUndefined';
  12. import isBothNaN from '@douyinfe/semi-foundation/utils/isBothNaN';
  13. import InputNumberFoundation, { InputNumberAdapter } from '@douyinfe/semi-foundation/inputNumber/foundation';
  14. import BaseComponent from '../_base/baseComponent';
  15. import { cssClasses, numbers, strings } from '@douyinfe/semi-foundation/inputNumber/constants';
  16. import { IconChevronUp, IconChevronDown } from '@douyinfe/semi-icons';
  17. import '@douyinfe/semi-foundation/inputNumber/inputNumber.scss';
  18. import { isNaN, noop } from 'lodash';
  19. import { ArrayElement } from '../_base/base';
  20. export interface InputNumberProps extends InputProps {
  21. autofocus?: boolean;
  22. className?: string;
  23. defaultValue?: number | string;
  24. disabled?: boolean;
  25. formatter?: (value: number | string) => string;
  26. forwardedRef?: React.MutableRefObject<HTMLInputElement> | ((instance: HTMLInputElement) => void);
  27. hideButtons?: boolean;
  28. innerButtons?: boolean;
  29. insetLabel?: React.ReactNode;
  30. keepFocus?: boolean;
  31. max?: number;
  32. min?: number;
  33. parser?: (value: string) => string;
  34. precision?: number;
  35. prefixCls?: string;
  36. pressInterval?: number;
  37. pressTimeout?: number;
  38. shiftStep?: number;
  39. showClear?: boolean;
  40. size?: ArrayElement<typeof strings.SIZE>;
  41. step?: number;
  42. style?: React.CSSProperties;
  43. suffix?: React.ReactNode;
  44. value?: number | string;
  45. onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
  46. onChange?: (value: number | string, e?: React.ChangeEvent) => void;
  47. onDownClick?: (value: string, e: React.MouseEvent<HTMLButtonElement>) => void;
  48. onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
  49. onKeyDown?: React.KeyboardEventHandler;
  50. onNumberChange?: (value: number, e?: React.ChangeEvent) => void;
  51. onUpClick?: (value: string, e: React.MouseEvent<HTMLButtonElement>) => void;
  52. }
  53. export interface InputNumberState {
  54. value?: number | string;
  55. number?: number | null; // Current parsed numbers
  56. focusing?: boolean;
  57. hovering?: boolean;
  58. }
  59. class InputNumber extends BaseComponent<InputNumberProps, InputNumberState> {
  60. static propTypes = {
  61. autofocus: PropTypes.bool,
  62. className: PropTypes.string,
  63. defaultValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  64. disabled: PropTypes.bool,
  65. formatter: PropTypes.func,
  66. forwardedRef: PropTypes.any,
  67. hideButtons: PropTypes.bool,
  68. innerButtons: PropTypes.bool,
  69. insetLabel: PropTypes.node,
  70. keepFocus: PropTypes.bool,
  71. max: PropTypes.number,
  72. min: PropTypes.number,
  73. parser: PropTypes.func,
  74. precision: PropTypes.number,
  75. prefixCls: PropTypes.string,
  76. pressInterval: PropTypes.number,
  77. pressTimeout: PropTypes.number,
  78. shiftStep: PropTypes.number,
  79. step: PropTypes.number,
  80. style: PropTypes.object,
  81. suffix: PropTypes.any,
  82. value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  83. onBlur: PropTypes.func,
  84. onChange: PropTypes.func,
  85. onDownClick: PropTypes.func,
  86. onKeyDown: PropTypes.func,
  87. onNumberChange: PropTypes.func,
  88. onUpClick: PropTypes.func,
  89. };
  90. static defaultProps: InputNumberProps = {
  91. disabled: false,
  92. forwardedRef: noop,
  93. innerButtons: false,
  94. keepFocus: false,
  95. max: Infinity,
  96. min: -Infinity,
  97. prefixCls: cssClasses.PREFIX,
  98. pressInterval: numbers.DEFAULT_PRESS_TIMEOUT,
  99. pressTimeout: numbers.DEFAULT_PRESS_TIMEOUT,
  100. shiftStep: numbers.DEFAULT_SHIFT_STEP,
  101. size: strings.DEFAULT_SIZE,
  102. step: numbers.DEFAULT_STEP,
  103. onBlur: noop,
  104. onChange: noop,
  105. onDownClick: noop,
  106. onFocus: noop,
  107. onKeyDown: noop,
  108. onNumberChange: noop,
  109. onUpClick: noop,
  110. };
  111. get adapter(): InputNumberAdapter {
  112. return {
  113. ...super.adapter,
  114. setValue: (value, cb) => this.setState({ value }, cb),
  115. setNumber: (number, cb) => this.setState({ number }, cb),
  116. setFocusing: (focusing, cb) => this.setState({ focusing }, cb),
  117. setHovering: hovering => this.setState({ hovering }),
  118. notifyChange: (...args) => this.props.onChange(...args),
  119. notifyNumberChange: (...args) => this.props.onNumberChange(...args),
  120. notifyBlur: e => this.props.onBlur(e),
  121. notifyFocus: e => this.props.onFocus(e),
  122. notifyUpClick: (value, e) => this.props.onUpClick(value, e),
  123. notifyDownClick: (value, e) => this.props.onDownClick(value, e),
  124. notifyKeyDown: e => this.props.onKeyDown(e),
  125. registerGlobalEvent: (eventName, handler) => {
  126. if (eventName && typeof handler === 'function') {
  127. this.adapter.unregisterGlobalEvent(eventName);
  128. this.adapter.setCache(eventName, handler);
  129. document.addEventListener(eventName, handler);
  130. }
  131. },
  132. unregisterGlobalEvent: eventName => {
  133. if (eventName) {
  134. const handler = this.adapter.getCache(eventName);
  135. document.removeEventListener(eventName, handler);
  136. this.adapter.setCache(eventName, null);
  137. }
  138. },
  139. recordCursorPosition: () => {
  140. // Record position
  141. try {
  142. if (this.inputNode) {
  143. this.cursorStart = this.inputNode.selectionStart;
  144. this.cursorEnd = this.inputNode.selectionEnd;
  145. this.currentValue = this.inputNode.value;
  146. this.cursorBefore = this.inputNode.value.substring(0, this.cursorStart);
  147. this.cursorAfter = this.inputNode.value.substring(this.cursorEnd);
  148. }
  149. } catch (e) {
  150. console.warn(e);
  151. // Fix error in Chrome:
  152. // Failed to read the 'selectionStart' property from 'HTMLInputElement'
  153. // http://stackoverflow.com/q/21177489/3040605
  154. }
  155. },
  156. restoreByAfter: str => {
  157. if (isNullOrUndefined(str)) {
  158. return false;
  159. }
  160. const fullStr = this.inputNode.value;
  161. const index = fullStr.lastIndexOf(str);
  162. if (index === -1) {
  163. return false;
  164. }
  165. if (index + str.length === fullStr.length) {
  166. this.adapter.fixCaret(index, index);
  167. return true;
  168. }
  169. return false;
  170. },
  171. restoreCursor: (str = this.cursorAfter) => {
  172. if (isNullOrUndefined(str)) {
  173. return false;
  174. }
  175. // For loop from full str to the str with last char to map. e.g. 123
  176. // -> 123
  177. // -> 23
  178. // -> 3
  179. return Array.prototype.some.call(str, (_: any, start: number) => {
  180. const partStr = str.substring(start);
  181. return this.adapter.restoreByAfter(partStr);
  182. });
  183. },
  184. fixCaret: (start, end) => {
  185. if (start === undefined || end === undefined || !this.inputNode || !this.inputNode.value) {
  186. return;
  187. }
  188. try {
  189. const currentStart = this.inputNode.selectionStart;
  190. const currentEnd = this.inputNode.selectionEnd;
  191. if (start !== currentStart || end !== currentEnd) {
  192. this.inputNode.setSelectionRange(start, end);
  193. }
  194. } catch (e) {
  195. // Fix error in Chrome:
  196. // Failed to read the 'selectionStart' property from 'HTMLInputElement'
  197. // http://stackoverflow.com/q/21177489/3040605
  198. }
  199. },
  200. setClickUpOrDown: value => {
  201. this.clickUpOrDown = value;
  202. }
  203. };
  204. }
  205. inputNode: HTMLInputElement;
  206. clickUpOrDown: boolean;
  207. cursorStart!: number;
  208. cursorEnd!: number;
  209. currentValue!: number | string;
  210. cursorBefore!: string;
  211. cursorAfter!: string;
  212. foundation: InputNumberFoundation;
  213. constructor(props: InputNumberProps) {
  214. super(props);
  215. this.state = {
  216. value: '',
  217. number: null, // Current parsed numbers
  218. focusing: Boolean(props.autofocus) || false,
  219. hovering: false,
  220. };
  221. this.inputNode = null;
  222. this.foundation = new InputNumberFoundation(this.adapter);
  223. this.clickUpOrDown = false;
  224. }
  225. componentDidUpdate(prevProps: InputNumberProps) {
  226. const { value } = this.props;
  227. const { focusing } = this.state;
  228. /**
  229. * To determine whether the front and back are equal
  230. * NaN need to check whether both are NaN
  231. */
  232. if (value !== prevProps.value && !isBothNaN(value, prevProps.value)) {
  233. if (isNullOrUndefined(value) || value === '') {
  234. this.setState({ value: '', number: null });
  235. } else {
  236. let valueStr = value;
  237. if (typeof value === 'number') {
  238. valueStr = value.toString();
  239. }
  240. const parsedNum = this.foundation.doParse(valueStr, false, true, true);
  241. const toNum = typeof value === 'number' ? value : this.foundation.doParse(valueStr, false, false, false);
  242. /**
  243. * focusing 状态为输入状态,输入状态的受控值要特殊处理
  244. * 如:
  245. * - 输入合法值
  246. * 123 => input value 也应该是 123,同时需要设置 number 为 123
  247. * - 输入非法值,只设置 input value,不设置非法的number
  248. * abc => input value 这时是 abc,但失焦后会进行格式化
  249. * 100(超出范围) => input value 应该是 100,但不设置 number
  250. *
  251. * 保持输入态有三种方式
  252. * 1. 输入框输入
  253. * - 输入可以解析为合法数字,input value根据输入值确定,失焦时更新input value
  254. * - 输入不可解析为合法数字,进行格式化后显示在input框
  255. * 2. 键盘点击上下按钮(input value根据受控值进行更改)
  256. * 3. keepFocus+鼠标点击上下按钮(input value根据受控值进行更改)
  257. *
  258. * The focusing state is the input state, and the controlled value of the input state needs special treatment
  259. * For example:
  260. * - input legal value
  261. * 123 = > input value should also be 123, and the number should be set to 123
  262. * - input illegal value, only set the input value, do not set the illegal number
  263. * abc = > input value This is abc at this time, but it will be formatted after being out of focus
  264. * 100 (out of range) = > input value should be 100, but no number
  265. *
  266. * There are three ways to maintain the input state
  267. * 1. input box input
  268. * - 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
  269. * - input cannot be resolved into legal numbers, and it will be displayed in the input box after formatting
  270. * 2. Keyboard click on the up and down button (input value is changed according to the controlled value)
  271. * 3.keepFocus + mouse click on the up and down button (input value is changed according to the controlled value)
  272. */
  273. if (focusing) {
  274. if (this.foundation.isValidNumber(parsedNum) && parsedNum !== this.state.number) {
  275. const obj: { number?: number; value?: string } = { number: parsedNum };
  276. /**
  277. * If you are clicking the button, it will automatically format once
  278. * We need to set the status to false after trigger focus event
  279. */
  280. if (this.clickUpOrDown) {
  281. obj.value = this.foundation.doFormat(valueStr, true);
  282. }
  283. this.setState(obj, () => this.adapter.restoreCursor());
  284. } else if (!isNaN(toNum)) {
  285. // Update input content when controlled input is illegal and not NaN
  286. this.setState({ value: this.foundation.doFormat(toNum, false) });
  287. } else {
  288. // Update input content when controlled input NaN
  289. this.setState({ value: this.foundation.doFormat(valueStr, false) });
  290. }
  291. } else if (this.foundation.isValidNumber(parsedNum)) {
  292. this.setState({ number: parsedNum, value: this.foundation.doFormat(parsedNum) });
  293. } else {
  294. // Invalid digital analog blurring effect instead of controlled failure
  295. this.setState({ number: null, value: '' });
  296. }
  297. }
  298. }
  299. if (!this.clickUpOrDown) {
  300. return;
  301. }
  302. if (this.props.keepFocus && this.state.focusing) {
  303. if (document.activeElement !== this.inputNode) {
  304. this.inputNode.focus();
  305. }
  306. }
  307. }
  308. setInputRef = (node: HTMLInputElement) => {
  309. const { forwardedRef } = this.props;
  310. this.inputNode = node;
  311. if (forwardedRef && typeof forwardedRef === 'object') {
  312. forwardedRef.current = node;
  313. } else if (typeof forwardedRef === 'function') {
  314. forwardedRef(node);
  315. }
  316. };
  317. handleInputFocus = (e: React.FocusEvent<HTMLInputElement>) => this.foundation.handleInputFocus(e);
  318. handleInputChange = (value: string, event: React.ChangeEvent<HTMLInputElement>) => this.foundation.handleInputChange(value, event);
  319. handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => this.foundation.handleInputBlur(e);
  320. handleInputKeyDown = (e: React.KeyboardEvent) => this.foundation.handleInputKeyDown(e);
  321. handleInputMouseEnter = (e: React.MouseEvent) => this.foundation.handleInputMouseEnter(e);
  322. handleInputMouseLeave = (e: React.MouseEvent) => this.foundation.handleInputMouseLeave(e);
  323. handleInputMouseMove = (e: React.MouseEvent) => this.foundation.handleInputMouseMove(e);
  324. handleUpClick = (e: React.KeyboardEvent) => this.foundation.handleUpClick(e);
  325. handleDownClick = (e: React.KeyboardEvent) => this.foundation.handleDownClick(e);
  326. handleMouseUp = (e: React.MouseEvent) => this.foundation.handleMouseUp(e);
  327. handleMouseLeave = (e: React.MouseEvent) => this.foundation.handleMouseLeave(e);
  328. renderButtons = () => {
  329. const { prefixCls, disabled, innerButtons, max, min } = this.props;
  330. const { hovering, focusing, number } = this.state;
  331. const notAllowedUp = disabled ? disabled : number === max;
  332. const notAllowedDown = disabled ? disabled : number === min;
  333. const suffixChildrenCls = classnames(`${prefixCls}-number-suffix-btns`, {
  334. [`${prefixCls}-number-suffix-btns-inner`]: innerButtons,
  335. [`${prefixCls}-number-suffix-btns-inner-hover`]: innerButtons && hovering && !focusing
  336. });
  337. const upClassName = classnames(`${prefixCls}-number-button`, `${prefixCls}-number-button-up`, {
  338. [`${prefixCls}-number-button-up-disabled`]: disabled,
  339. [`${prefixCls}-number-button-up-not-allowed`]: notAllowedUp,
  340. });
  341. const downClassName = classnames(`${prefixCls}-number-button`, `${prefixCls}-number-button-down`, {
  342. [`${prefixCls}-number-button-down-disabled`]: disabled,
  343. [`${prefixCls}-number-button-down-not-allowed`]: notAllowedDown,
  344. });
  345. return (
  346. <div className={suffixChildrenCls}>
  347. <span
  348. className={upClassName}
  349. onMouseDown={notAllowedUp ? noop : this.handleUpClick}
  350. onMouseUp={this.handleMouseUp}
  351. onMouseLeave={this.handleMouseLeave}
  352. >
  353. <IconChevronUp size="extra-small" />
  354. </span>
  355. <span
  356. className={downClassName}
  357. onMouseDown={notAllowedDown ? noop : this.handleDownClick}
  358. onMouseUp={this.handleMouseUp}
  359. onMouseLeave={this.handleMouseLeave}
  360. >
  361. <IconChevronDown size="extra-small" />
  362. </span>
  363. </div>
  364. );
  365. };
  366. renderSuffix = () => {
  367. const { innerButtons, suffix } = this.props;
  368. const { hovering, focusing } = this.state;
  369. if (innerButtons && (hovering || focusing)) {
  370. const buttons = this.renderButtons();
  371. return buttons;
  372. }
  373. return suffix;
  374. };
  375. render() {
  376. const {
  377. disabled,
  378. className,
  379. prefixCls,
  380. min,
  381. max,
  382. step,
  383. shiftStep,
  384. precision,
  385. formatter,
  386. parser,
  387. forwardedRef,
  388. onUpClick,
  389. onDownClick,
  390. pressInterval,
  391. pressTimeout,
  392. suffix,
  393. size,
  394. hideButtons,
  395. innerButtons,
  396. style,
  397. onNumberChange,
  398. keepFocus,
  399. ...rest
  400. } = this.props;
  401. const { value } = this.state;
  402. const inputNumberCls = classnames(className, `${prefixCls}-number`, {
  403. [`${prefixCls}-number-size-${size}`]: size,
  404. });
  405. const buttons = this.renderButtons();
  406. const input = (
  407. <div
  408. className={inputNumberCls}
  409. style={style}
  410. onMouseMove={e => this.handleInputMouseMove(e)}
  411. onMouseEnter={e => this.handleInputMouseEnter(e)}
  412. onMouseLeave={e => this.handleInputMouseLeave(e)}
  413. >
  414. <Input
  415. {...rest}
  416. size={size}
  417. disabled={disabled}
  418. ref={this.setInputRef}
  419. value={value}
  420. onFocus={this.handleInputFocus}
  421. onChange={this.handleInputChange}
  422. onBlur={this.handleInputBlur}
  423. onKeyDown={this.handleInputKeyDown}
  424. suffix={this.renderSuffix()}
  425. />
  426. {(hideButtons || innerButtons) ? null : (
  427. buttons
  428. )}
  429. </div>
  430. );
  431. return input;
  432. }
  433. }
  434. export default forwardStatics(
  435. React.forwardRef<HTMLInputElement, InputNumberProps>(function SemiInputNumber(props, ref) {
  436. return <InputNumber {...props} forwardedRef={ref} />;
  437. }),
  438. InputNumber
  439. );
  440. export { InputNumber };