index.tsx 21 KB

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