index.tsx 22 KB

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