1
0

index.tsx 20 KB

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