├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.ts ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── react-component-ci.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets └── index.less ├── bunfig.toml ├── docs ├── api.md ├── demo │ ├── combination-key-format.tsx │ ├── custom.tsx │ ├── debug.tsx │ ├── decimal.tsx │ ├── focus.tsx │ ├── formatter.tsx │ ├── input-control.tsx │ ├── on-step.tsx │ ├── precision.tsx │ ├── simple.tsx │ ├── small-step.tsx │ └── wheel.tsx ├── example.md └── index.md ├── index.js ├── jest.config.ts ├── now.json ├── package.json ├── src ├── InputNumber.tsx ├── SemanticContext.ts ├── StepHandler.tsx ├── hooks │ ├── useCursor.ts │ └── useFrame.ts ├── index.ts └── utils │ └── numberUtil.ts ├── tests ├── __snapshots__ │ └── baseInput.test.tsx.snap ├── baseInput.test.tsx ├── click.test.tsx ├── cursor.test.tsx ├── decimal.test.tsx ├── focus.test.tsx ├── formatter.test.tsx ├── github.test.tsx ├── input.test.tsx ├── keyboard.test.tsx ├── longPress.test.tsx ├── mobile.test.tsx ├── precision.test.tsx ├── props.test.tsx ├── semantic.test.tsx ├── setup.js ├── util │ └── wrapper.ts └── wheel.test.tsx ├── tsconfig.json └── update-demo.js /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | export default defineConfig({ 4 | favicons: [ 5 | 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 6 | ], 7 | themeConfig: { 8 | name: 'InputNumber', 9 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4' 10 | }, 11 | outputPath: 'docs-dist', 12 | exportStatic: {}, 13 | styles: [`body .dumi-default-header-left { width: 230px; } body .dumi-default-hero-title { font-size: 100px; }`], 14 | }); 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@umijs/fabric/dist/eslint')], 3 | rules: { 4 | 'arrow-parens': 0, 5 | 'default-case': 0, 6 | 'react/no-array-index-key': 0, 7 | 'react/sort-comp': 0, 8 | 'react/no-access-state-in-setstate': 0, 9 | 'react/no-string-refs': 0, 10 | 'react/no-did-update-set-state': 0, 11 | 'react/no-find-dom-node': 0, 12 | '@typescript-eslint/no-explicit-any': 0, 13 | '@typescript-eslint/no-empty-interface': 0, 14 | '@typescript-eslint/no-inferrable-types': 0, 15 | '@typescript-eslint/consistent-type-imports': 0, 16 | 'react/require-default-props': 0, 17 | 'react-hooks/exhaustive-deps': 0, 18 | 'no-confusing-arrow': 0, 19 | 'no-restricted-globals': 0, 20 | 'import/no-named-as-default-member': 0, 21 | 'import/no-extraneous-dependencies': 0, 22 | 'jsx-a11y/label-has-for': 0, 23 | 'jsx-a11y/label-has-associated-control': 0, 24 | 'jsx-a11y/no-autofocus': 0, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ant-design # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: ant-design # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/react-dom" 11 | versions: 12 | - 17.0.0 13 | - 17.0.1 14 | - 17.0.2 15 | - dependency-name: "@types/react" 16 | versions: 17 | - 17.0.0 18 | - 17.0.1 19 | - 17.0.2 20 | - 17.0.3 21 | - dependency-name: less 22 | versions: 23 | - 4.1.0 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "19 0 * * 4" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/react-component-ci.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | *.iml 3 | *.log 4 | .idea 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | node_modules 22 | .cache 23 | *.css 24 | build 25 | lib 26 | es 27 | coverage 28 | yarn.lock 29 | pnpm-lock.yaml 30 | package-lock.json 31 | docs-dist/ 32 | 33 | # umi 34 | .umi 35 | .umi-production 36 | .umi-test 37 | .env.local 38 | 39 | # dumi 40 | .dumi/tmp 41 | .dumi/tmp-production 42 | 43 | bun.lockb -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "never", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | https://github.com/react-component/input-number/releases 4 | 5 | ## 5.0.0 6 | 7 | - Upgrade `rc-util` to `5.x`. 8 | 9 | ## 4.5.0 10 | 11 | - Fix React lifecycle warning. 12 | - Add `onPressEnter`. 13 | 14 | ## 4.4.0 15 | 16 | - `onChange` will return `null` instead `undefined` when it is empty. 17 | 18 | ## 4.0.0 19 | 20 | - Drop React Native support, please use https://github.com/react-component/m-input-number instead. 21 | 22 | ## 3.5.0 23 | 24 | - Added prop `precision`. 25 | 26 | ## 3.4.0 27 | 28 | - Added prop `parser`. 29 | 30 | ## 3.3.0 31 | 32 | - Added prop `formatter`. 33 | - Support changing radio using ctrl and shift. 34 | 35 | ## 3.2.0 36 | 37 | - Fixed touch events. 38 | 39 | ## 3.1.0 40 | 41 | - Added props `upHanlder` and `downHanlder`. 42 | 43 | ## 3.0.4 44 | 45 | - Fixed long press not working in Android. #42 46 | 47 | ## 3.0.3 48 | 49 | - Fixed https://github.com/ant-design/ant-design/issues/4757 50 | 51 | ## 3.0.2 52 | 53 | - Fixed `onKeyUp`. 54 | 55 | ## 3.0.1 56 | 57 | - Fixed invalid input like '11x'. 58 | 59 | ## 3.0.0 60 | 61 | - Trigger onChange when user input 62 | - support `keyboardType` prop for fixing crash on Android on textInput blur 63 | 64 | ## 2.8.0 / 2016-11-29 65 | 66 | - support tap state by rc-touchable 67 | 68 | ## 2.7.0 / 2016-09-03 69 | 70 | - support long press auto step 71 | - support `readOnly` 72 | 73 | ## 2.6.0 / 2016-07-20 74 | 75 | - support react-native 76 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-present yiminghe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-input-number 2 | 3 | Input number control. 4 | 5 | [![NPM version][npm-image]][npm-url] 6 | [![npm download][download-image]][download-url] 7 | [![build status][github-actions-image]][github-actions-url] 8 | [![Codecov][codecov-image]][codecov-url] 9 | [![bundle size][bundlephobia-image]][bundlephobia-url] 10 | [![dumi][dumi-image]][dumi-url] 11 | 12 | [npm-image]: http://img.shields.io/npm/v/rc-input-number.svg?style=flat-square 13 | [npm-url]: http://npmjs.org/package/rc-input-number 14 | [travis-image]: https://img.shields.io/travis/react-component/input-number/master?style=flat-square 15 | [travis-url]: https://travis-ci.com/react-component/input-number 16 | [github-actions-image]: https://github.com/react-component/input-number/actions/workflows/react-component-ci.yml/badge.svg 17 | [github-actions-url]: https://github.com/react-component/input-number/actions/workflows/react-component-ci.yml 18 | [codecov-image]: https://img.shields.io/codecov/c/github/react-component/input-number/master.svg?style=flat-square 19 | [codecov-url]: https://app.codecov.io/gh/react-component/input-number 20 | [david-url]: https://david-dm.org/react-component/input-number 21 | [david-image]: https://david-dm.org/react-component/input-number/status.svg?style=flat-square 22 | [david-dev-url]: https://david-dm.org/react-component/input-number?type=dev 23 | [david-dev-image]: https://david-dm.org/react-component/input-number/dev-status.svg?style=flat-square 24 | [download-image]: https://img.shields.io/npm/dm/rc-input-number.svg?style=flat-square 25 | [download-url]: https://npmjs.org/package/rc-input-number 26 | [bundlephobia-url]: https://bundlephobia.com/package/rc-input-number 27 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-input-number 28 | [dumi-url]: https://github.com/umijs/dumi 29 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square 30 | 31 | ## Screenshots 32 | 33 | 34 | 35 | ## Install 36 | 37 | [![rc-input-number](https://nodei.co/npm/rc-input-number.png)](https://npmjs.org/package/rc-input-number) 38 | 39 | ## Usage 40 | 41 | ```js 42 | import InputNumber from 'rc-input-number'; 43 | 44 | export default () => ; 45 | ``` 46 | 47 | ## Development 48 | 49 | ``` 50 | npm install 51 | npm start 52 | ``` 53 | 54 | ## Example 55 | 56 | http://127.0.0.1:8000/examples/ 57 | 58 | online example: https://input-number.vercel.app/ 59 | 60 | ## API 61 | 62 | ### props 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 |
nametypedefaultdescription
prefixClsstringrc-input-numberSpecifies the class prefix
minNumberSpecifies the minimum value
onClick
placeholderstring
maxNumberSpecifies the maximum value
stepNumber or String1Specifies the legal number intervals
precisionNumberSpecifies the precision length of value
disabledBooleanfalseSpecifies that an InputNumber should be disabled
requiredBooleanfalseSpecifies that an InputNumber is required
autoFocusBooleanfalseSpecifies that an InputNumber should automatically get focus when the page loads
readOnlyBooleanfalseSpecifies that an InputNumber is read only
controlsBooleantrueWhether to enable the control buttons
nameStringSpecifies the name of an InputNumber
idStringSpecifies the id of an InputNumber
valueNumberSpecifies the value of an InputNumber
defaultValueNumberSpecifies the defaultValue of an InputNumber
onChangeFunctionCalled when value of an InputNumber changed
onBlurFunctionCalled when user leaves an input field
onPressEnterFunctionThe callback function that is triggered when Enter key is pressed.
onFocusFunctionCalled when an element gets focus
styleObjectroot style. such as {width:100}
upHandlerReact.Nodecustom the up step element
downHandlerReact.Nodecustom the down step element
formatter(value: number|string): displayValue: stringSpecifies the format of the value presented
parser(displayValue: string) => value: number`input => input.replace(/[^\w\.-]*/g, '')`Specifies the value extracted from formatter
patternstringSpecifies a regex pattern to be added to the input number element - useful for forcing iOS to open the number pad instead of the normal keyboard (supply a regex of "\d*" to do this) or form validation
decimalSeparatorstringSpecifies the decimal separator
inputModestringSpecifies the inputmode of input
wheelBooleantrueAllows changing value with mouse wheel
250 | 251 | ## Keyboard Navigation 252 | * When you hit the or key, the input value will be increased or decreased by `step` 253 | * With the Shift key (Shift+⬆, Shift+⬇), the input value will be changed by `10 * step` 254 | * With the Ctrl or key (Ctrl+⬆ or ⌘+⬆ or Ctrl+⬇ or ⌘+⬇ ), the input value will be changed by `0.1 * step` 255 | 256 | ## Mouse Wheel 257 | * When you scroll up or down, the input value will be increased or decreased by `step` 258 | * Scrolling with the Shift key, the input value will be changed by `10 * step` 259 | 260 | ## Test Case 261 | 262 | ``` 263 | npm test 264 | npm run chrome-test 265 | ``` 266 | 267 | ## Coverage 268 | 269 | ``` 270 | npm run coverage 271 | ``` 272 | 273 | open coverage/ dir 274 | 275 | ## License 276 | 277 | rc-input-number is released under the MIT license. 278 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @inputNumberPrefixCls: rc-input-number; 2 | 3 | .@{inputNumberPrefixCls} { 4 | display: inline-block; 5 | height: 26px; 6 | margin: 0; 7 | padding: 0; 8 | font-size: 12px; 9 | line-height: 26px; 10 | vertical-align: middle; 11 | border: 1px solid #d9d9d9; 12 | border-radius: 4px; 13 | transition: all 0.3s; 14 | 15 | &-focused { 16 | border-color: #1890ff; 17 | box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); 18 | } 19 | &-out-of-range { 20 | input { 21 | color: red; 22 | } 23 | } 24 | 25 | &-handler { 26 | display: block; 27 | height: 12px; 28 | overflow: hidden; 29 | line-height: 12px; 30 | text-align: center; 31 | touch-action: none; 32 | 33 | &-active { 34 | background: #ddd; 35 | } 36 | } 37 | 38 | &-handler-up-inner, 39 | &-handler-down-inner { 40 | color: #666666; 41 | -webkit-user-select: none; 42 | user-select: none; 43 | } 44 | 45 | &:hover { 46 | border-color: #1890ff; 47 | 48 | .@{inputNumberPrefixCls}-handler-up, 49 | .@{inputNumberPrefixCls}-handler-wrap { 50 | border-color: #1890ff; 51 | } 52 | } 53 | 54 | &-disabled:hover { 55 | border-color: #d9d9d9; 56 | 57 | .@{inputNumberPrefixCls}-handler-up, 58 | .@{inputNumberPrefixCls}-handler-wrap { 59 | border-color: #d9d9d9; 60 | } 61 | } 62 | 63 | &-input-wrap { 64 | height: 100%; 65 | overflow: hidden; 66 | } 67 | 68 | &-input { 69 | width: 100%; 70 | height: 100%; 71 | padding: 0; 72 | color: #666666; 73 | line-height: 26px; 74 | text-align: center; 75 | border: 0; 76 | border-radius: 4px; 77 | outline: 0; 78 | transition: all 0.3s ease; 79 | transition: all 0.3s; 80 | -moz-appearance: textfield; 81 | } 82 | 83 | &-handler-wrap { 84 | float: right; 85 | width: 20px; 86 | height: 100%; 87 | border-left: 1px solid #d9d9d9; 88 | transition: all 0.3s; 89 | } 90 | 91 | &-handler-up { 92 | padding-top: 1px; 93 | border-bottom: 1px solid #d9d9d9; 94 | transition: all 0.3s; 95 | 96 | &-inner { 97 | &:after { 98 | content: '+'; 99 | } 100 | } 101 | } 102 | 103 | &-handler-down { 104 | transition: all 0.3s; 105 | 106 | &-inner { 107 | &:after { 108 | content: '-'; 109 | } 110 | } 111 | } 112 | 113 | .handler-disabled() { 114 | opacity: 0.3; 115 | &:hover { 116 | color: #999; 117 | border-color: #d9d9d9; 118 | } 119 | } 120 | 121 | &-handler-down-disabled, 122 | &-handler-up-disabled { 123 | .handler-disabled(); 124 | } 125 | 126 | &-disabled { 127 | .@{inputNumberPrefixCls}-input { 128 | background-color: #f3f3f3; 129 | cursor: not-allowed; 130 | opacity: 0.72; 131 | } 132 | .@{inputNumberPrefixCls}-handler { 133 | .handler-disabled(); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API 3 | nav: 4 | order: 10 5 | title: API 6 | path: /api 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 |
nametypedefaultdescription
prefixClsstringrc-input-numberSpecifies the class prefix
minNumberSpecifies the minimum value
onClick
placeholderstring
maxNumberSpecifies the maximum value
stepNumber or String1Specifies the legal number intervals
precisionNumberSpecifies the precision length of value
disabledBooleanfalseSpecifies that an InputNumber should be disabled
requiredBooleanfalseSpecifies that an InputNumber is required
autoFocusBooleanfalseSpecifies that an InputNumber should automatically get focus when the page loads
readOnlyBooleanfalseSpecifies that an InputNumber is read only
changeOnWheelBooleanfalseSpecifies that the value is set using the mouse wheel
controlsBooleantrueWhether to enable the control buttons
nameStringSpecifies the name of an InputNumber
idStringSpecifies the id of an InputNumber
valueNumberSpecifies the value of an InputNumber
defaultValueNumberSpecifies the defaultValue of an InputNumber
onChangeFunctionCalled when value of an InputNumber changed
onBlurFunctionCalled when user leaves an input field
onPressEnterFunctionThe callback function that is triggered when Enter key is pressed.
onFocusFunctionCalled when an element gets focus
onStep(value: T, info: { offset: ValueType; type: 'up' | 'down', emitter: 'handler' | 'keydown' | 'wheel' }) => voidCalled when the user clicks the arrows on the keyboard or interface and when the mouse wheel is spun.
styleObjectroot style. such as {width:100}
upHandlerReact.Nodecustom the up step element
downHandlerReact.Nodecustom the down step element
formatter(value: number|string): displayValue: stringSpecifies the format of the value presented
parser(displayValue: string) => value: number`input => input.replace(/[^\w\.-]*/g, '')`Specifies the value extracted from formatter
patternstringSpecifies a regex pattern to be added to the input number element - useful for forcing iOS to open the number pad instead of the normal keyboard (supply a regex of "\d*" to do this) or form validation
decimalSeparatorstringSpecifies the decimal separator
inputModestringSpecifies the inputmode of input
201 | 202 | ## inputRef 203 | 204 | ```tsx | pure 205 | import InputNumber, { InputNumberRef } from 'rc-input-number'; 206 | 207 | const inputRef = useRef(null); 208 | 209 | useEffect(() => { 210 | inputRef.current.focus(); // the input will get focus 211 | inputRef.current.blur(); // the input will lose focus 212 | }, []); 213 | // .... 214 | ; 215 | ``` 216 | 217 | | Property | Type | Description | 218 | | -------- | --------------------------------------- | --------------------------------- | 219 | | focus | `(options?: InputFocusOptions) => void` | The input get focus when called | 220 | | blur | `() => void` | The input loses focus when called | 221 | -------------------------------------------------------------------------------- /docs/demo/combination-key-format.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import InputNumber from '@rc-component/input-number'; 4 | import '../../assets/index.less'; 5 | 6 | class Component extends React.Component { 7 | state = { 8 | disabled: false, 9 | readOnly: false, 10 | value: 50000, 11 | }; 12 | 13 | onChange = (value) => { 14 | console.log('onChange:', value); 15 | this.setState({ value }); 16 | }; 17 | 18 | toggleDisabled = () => { 19 | this.setState({ 20 | disabled: !this.state.disabled, 21 | }); 22 | }; 23 | 24 | toggleReadOnly = () => { 25 | this.setState({ 26 | readOnly: !this.state.readOnly, 27 | }); 28 | }; 29 | 30 | numberWithCommas = (x) => { 31 | return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); 32 | }; 33 | 34 | format = (num) => { 35 | return `$ ${this.numberWithCommas(num)} boeing737`; 36 | }; 37 | 38 | parser = (num: string) => { 39 | const cells = num.toString().split(' '); 40 | if (!cells[1]) { 41 | return num; 42 | } 43 | 44 | const parsed = cells[1].replace(/,*/g, ''); 45 | 46 | return parsed; 47 | }; 48 | 49 | render() { 50 | return ( 51 |
52 |

53 | When number is validate in range, keep formatting. 54 | Else will flush when blur. 55 |

56 | 57 | 71 |

72 | 75 | 78 |

79 |
80 | ); 81 | } 82 | } 83 | 84 | export default Component; 85 | -------------------------------------------------------------------------------- /docs/demo/custom.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import InputNumber from '@rc-component/input-number'; 4 | import '../../assets/index.less'; 5 | 6 | class Component extends React.Component { 7 | state = { 8 | disabled: false, 9 | readOnly: false, 10 | value: 5, 11 | }; 12 | 13 | onChange = value => { 14 | console.log('onChange:', value); 15 | this.setState({ value }); 16 | }; 17 | 18 | toggleDisabled = () => { 19 | this.setState({ 20 | disabled: !this.state.disabled, 21 | }); 22 | }; 23 | 24 | toggleReadOnly = () => { 25 | this.setState({ 26 | readOnly: !this.state.readOnly, 27 | }); 28 | }; 29 | 30 | render() { 31 | const upHandler =
x
; 32 | const downHandler =
V
; 33 | return ( 34 |
35 | 47 |
48 | ); 49 | } 50 | } 51 | 52 | export default Component; 53 | -------------------------------------------------------------------------------- /docs/demo/debug.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React, { useEffect } from 'react'; 3 | import InputNumber from '@rc-component/input-number'; 4 | import '../../assets/index.less'; 5 | 6 | export default () => { 7 | const [value, setValue] = React.useState(5); 8 | 9 | useEffect(() => { 10 | function keyDown(event: KeyboardEvent) { 11 | if ((event.ctrlKey === true || event.metaKey) && event.keyCode === 90) { 12 | setValue(3); 13 | } 14 | } 15 | document.addEventListener('keydown', keyDown); 16 | 17 | return () => document.removeEventListener('keydown', keyDown); 18 | }, []); 19 | 20 | return ( 21 | <> 22 | { 25 | console.log('Change:', nextValue); 26 | setValue(nextValue); 27 | }} 28 | value={value} 29 | /> 30 | {value} 31 | 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /docs/demo/decimal.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import InputNumber from '@rc-component/input-number'; 4 | import '../../assets/index.less'; 5 | 6 | export default class Demo extends React.Component { 7 | state = { 8 | disabled: false, 9 | readOnly: false, 10 | value: 99, 11 | }; 12 | 13 | onChange = v => { 14 | console.log('onChange:', v); 15 | this.setState({ 16 | value: v, 17 | }); 18 | }; 19 | 20 | toggleDisabled = () => { 21 | this.setState({ 22 | disabled: !this.state.disabled, 23 | }); 24 | }; 25 | 26 | toggleReadOnly = () => { 27 | this.setState({ 28 | readOnly: !this.state.readOnly, 29 | }); 30 | }; 31 | 32 | render() { 33 | return ( 34 |
35 |

Value Range is [-8, 10], initialValue is out of range.

36 | 47 |

48 | 51 | 54 |

55 |
56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /docs/demo/focus.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import InputNumber, { InputNumberRef } from '@rc-component/input-number'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | export default () => { 7 | const inputRef = React.useRef(null); 8 | 9 | return ( 10 |
11 | 12 |
13 | 16 | 19 | 22 | 25 |
26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /docs/demo/formatter.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import InputNumber from '@rc-component/input-number'; 4 | import '../../assets/index.less'; 5 | 6 | function getSum(str) { 7 | let total = 0; 8 | str.split('').forEach((c) => { 9 | const num = Number(c); 10 | 11 | if (!Number.isNaN(num)) { 12 | total += num; 13 | } 14 | }); 15 | 16 | return total; 17 | } 18 | 19 | const CHINESE_NUMBERS = '零一二三四五六七八九'; 20 | 21 | function chineseParser(text: string) { 22 | const parsed = [...text] 23 | .map((cell) => { 24 | const index = CHINESE_NUMBERS.indexOf(cell); 25 | if (index !== -1) { 26 | return index; 27 | } 28 | 29 | return cell; 30 | }) 31 | .join(''); 32 | 33 | if (Number.isNaN(Number(parsed))) { 34 | return text; 35 | } 36 | 37 | return parsed; 38 | } 39 | 40 | function chineseFormatter(value: string) { 41 | return [...value] 42 | .map((cell) => { 43 | const index = Number(cell); 44 | if (!Number.isNaN(index)) { 45 | return CHINESE_NUMBERS[index]; 46 | } 47 | 48 | return cell; 49 | }) 50 | .join(''); 51 | } 52 | 53 | class App extends React.Component { 54 | state = { 55 | value: 1000, 56 | }; 57 | 58 | render() { 59 | return ( 60 |
61 | `$ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} 65 | onChange={console.log} 66 | /> 67 | `${value}%`} 71 | parser={(value) => value.replace('%', '')} 72 | onChange={console.log} 73 | /> 74 | `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} 78 | onChange={console.log} 79 | /> 80 | 81 |
82 |

In Control

83 | { 87 | // console.log(value); 88 | this.setState({ value }); 89 | }} 90 | formatter={(value, { userTyping, input }) => { 91 | if (userTyping) { 92 | return input; 93 | } 94 | return `$ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','); 95 | }} 96 | /> 97 | 98 | 99 | aria-label="Controlled number input demonstrating a custom format" 100 | value={this.state.value} 101 | onChange={(value) => { 102 | console.log(value); 103 | this.setState({ value }); 104 | }} 105 | parser={chineseParser} 106 | formatter={chineseFormatter} 107 | /> 108 |
109 | 110 |
111 |

Strange Format

112 | `$ ${value} - ${getSum(value)}`} 116 | parser={(value) => (value.match(/^\$ ([\d.]*) .*$/) || [])[1]} 117 | onChange={console.log} 118 | /> 119 |
120 |
121 | ); 122 | } 123 | } 124 | 125 | export default App; 126 | -------------------------------------------------------------------------------- /docs/demo/input-control.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import type { ValueType} from '@rc-component/input-number' 4 | import InputNumber from '@rc-component/input-number'; 5 | import '../../assets/index.less'; 6 | 7 | export default () => { 8 | const [value, setValue] = React.useState('aaa'); 9 | const [lock, setLock] = React.useState(false); 10 | 11 | return ( 12 |
13 | 14 | value={value} 15 | max={999} 16 | onChange={(newValue) => { 17 | console.log('Change:', newValue); 18 | }} 19 | onInput={(text) => { 20 | console.log('Input:', text); 21 | if (!lock) { 22 | setValue(text); 23 | } 24 | }} 25 | /> 26 | 27 | 28 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /docs/demo/on-step.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import InputNumber from '@rc-component/input-number'; 3 | import React, { useState } from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | export default () => { 7 | const [emitter, setEmitter] = useState('interface buttons (up)'); 8 | const [value, setValue] = React.useState(0); 9 | 10 | const onChange = (val: number) => { 11 | console.warn('onChange:', val, typeof val); 12 | setValue(val); 13 | }; 14 | 15 | const onStep = (_: number, info: { offset: number; type: 'up' | 'down', emitter: 'handler' | 'keyboard' | 'wheel' }) => { 16 | if (info.emitter === 'handler') { 17 | setEmitter(`interface buttons (${info.type})`); 18 | } 19 | 20 | if (info.emitter === 'keyboard') { 21 | setEmitter(`keyboard (${info.type})`); 22 | } 23 | 24 | if (info.emitter === 'wheel') { 25 | setEmitter(`mouse wheel (${info.type})`); 26 | } 27 | }; 28 | 29 | return ( 30 |
31 |

onStep callback

32 | 42 | 43 |
Triggered by: {emitter}
44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /docs/demo/precision.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import InputNumber from '@rc-component/input-number'; 4 | import '../../assets/index.less'; 5 | 6 | export default () => { 7 | const [value, setValue] = React.useState(null); 8 | const [precision, setPrecision] = React.useState('2'); 9 | const [decimalSeparator, setDecimalSeparator] = React.useState(','); 10 | 11 | return ( 12 |
13 | { 18 | console.log('onChange:', newValue); 19 | setValue(newValue); 20 | }} 21 | precision={Number(precision)} 22 | decimalSeparator={decimalSeparator} 23 | /> 24 |
25 | 29 | 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /docs/demo/simple.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import InputNumber from '@rc-component/input-number'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | export default () => { 7 | const [disabled, setDisabled] = React.useState(false); 8 | const [readOnly, setReadOnly] = React.useState(false); 9 | const [keyboard, setKeyboard] = React.useState(true); 10 | const [wheel, setWheel] = React.useState(true); 11 | const [stringMode, setStringMode] = React.useState(false); 12 | const [value, setValue] = React.useState(93); 13 | 14 | const onChange = (val: number) => { 15 | console.warn('onChange:', val, typeof val); 16 | setValue(val); 17 | }; 18 | 19 | return ( 20 |
21 |

Controlled

22 | 35 |

36 | 39 | 42 | 45 | 48 | 51 |

52 | 53 |
54 |

Uncontrolled

55 | 62 | 63 |
64 |

!changeOnBlur

65 | 73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /docs/demo/small-step.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import React from 'react'; 3 | import InputNum from '@rc-component/input-number'; 4 | import '../../assets/index.less'; 5 | 6 | export default () => { 7 | const [stringMode, setStringMode] = React.useState(false); 8 | const [value, setValue] = React.useState(0.000000001); 9 | 10 | return ( 11 |
12 | { 20 | console.log('onChange:', newValue); 21 | setValue(newValue); 22 | }} 23 | stringMode={stringMode} 24 | /> 25 | 26 | 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /docs/demo/wheel.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-console:0 */ 2 | import InputNumber from '@rc-component/input-number'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | export default () => { 7 | return ( 8 |
9 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /docs/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Example 3 | nav: 4 | title: Example 5 | path: /example 6 | --- 7 | 8 | ## simple 9 | 10 | 11 | 12 | ## combination-key-format 13 | 14 | 15 | 16 | ## custom 17 | 18 | 19 | 20 | ## debug 21 | 22 | 23 | 24 | ## decimal 25 | 26 | 27 | 28 | ## formatter 29 | 30 | 31 | 32 | ## input-control 33 | 34 | 35 | 36 | ## precision 37 | 38 | 39 | 40 | ## small-step 41 | 42 | 43 | 44 | ## on-step 45 | 46 | 47 | 48 | ## wheel 49 | 50 | 51 | 52 | ## focus 53 | 54 | 55 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-input-number 4 | description: React InputNumber Component 5 | --- 6 | 7 | ## Install 8 | 9 | ```sh 10 | # npm 11 | npm install --save rc-input-number 12 | 13 | # yarn 14 | yarn install rc-input-number 15 | 16 | # pnpm 17 | pnpm i rc-input-number 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```ts 23 | import InputNumber from 'rc-input-number'; 24 | 25 | export default () => ; 26 | ``` 27 | 28 | ## Development 29 | 30 | ```sh 31 | npm install 32 | npm start 33 | ``` 34 | 35 | ### Keyboard Navigation 36 | 37 | - When you hit the ⬆ or ⬇ key, the input value will be increased or decreased by step 38 | - With the Shift key (Shift+⬆, Shift+⬇), the input value will be changed by 10 * step 39 | - With the Ctrl or ⌘ key (Ctrl+⬆ or ⌘+⬆ or Ctrl+⬇ or ⌘+⬇ ), the input value will be changed by 0.1 * step 40 | 41 | ## Test Case 42 | 43 | ```sh 44 | npm test 45 | ``` 46 | 47 | ## Coverage 48 | 49 | ```sh 50 | npm run coverage 51 | ``` 52 | 53 | ## License 54 | 55 | rc-input-number is released under the MIT license. 56 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // export this package's api 2 | import InputNumber from './src/'; 3 | export default InputNumber; 4 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { createConfig, type Config } from '@umijs/test'; 2 | 3 | const defaultConfig = createConfig({ 4 | target: 'browser', 5 | jsTransformer: 'swc' 6 | }); 7 | 8 | const config: Config.InitialOptions = { 9 | ...defaultConfig, 10 | setupFiles: [ 11 | ...defaultConfig.setupFiles, 12 | './tests/setup.js' 13 | ] 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-input-number", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": "docs-dist" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/input-number", 3 | "version": "1.2.0", 4 | "description": "React input-number component", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-input-number", 9 | "input-number" 10 | ], 11 | "homepage": "https://github.com/react-component/input-number", 12 | "bugs": { 13 | "url": "https://github.com/react-component/input-number/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git@github.com:react-component/input-number.git" 18 | }, 19 | "license": "MIT", 20 | "author": "tsjxyz@gmail.com", 21 | "main": "./lib/index", 22 | "module": "./es/index", 23 | "types": "./es/index.d.ts", 24 | "files": [ 25 | "lib", 26 | "es", 27 | "assets/*.css" 28 | ], 29 | "scripts": { 30 | "compile": "father build && lessc assets/index.less assets/index.css", 31 | "coverage": "rc-test --coverage", 32 | "docs:build": "dumi build", 33 | "docs:deploy": "gh-pages -d docs-dist", 34 | "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md", 35 | "now-build": "npm run docs:build", 36 | "prepare": "husky install", 37 | "prepublishOnly": "npm run compile && rc-np", 38 | "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", 39 | "start": "dumi dev", 40 | "test": "rc-test" 41 | }, 42 | "lint-staged": { 43 | "**/*.{js,jsx,tsx,ts,md,json}": [ 44 | "prettier --write", 45 | "git add" 46 | ] 47 | }, 48 | "dependencies": { 49 | "@rc-component/mini-decimal": "^1.0.1", 50 | "classnames": "^2.2.5", 51 | "@rc-component/input": "~1.0.0", 52 | "@rc-component/util": "^1.2.0" 53 | }, 54 | "devDependencies": { 55 | "@rc-component/father-plugin": "^2.0.2", 56 | "@rc-component/np": "^1.0.3", 57 | "@swc-node/jest": "^1.5.5", 58 | "@testing-library/jest-dom": "^6.1.5", 59 | "@testing-library/react": "^16.0.0", 60 | "@types/classnames": "^2.2.9", 61 | "@types/jest": "^29.2.4", 62 | "@types/react": "^18.0.26", 63 | "@types/react-dom": "^18.0.9", 64 | "@types/responselike": "^1.0.0", 65 | "@umijs/fabric": "^4.0.1", 66 | "@umijs/test": "^4.0.36", 67 | "cross-env": "^7.0.3", 68 | "dumi": "^2.0.13", 69 | "eslint": "^8.54.0", 70 | "eslint-plugin-jest": "^28.10.0", 71 | "eslint-plugin-unicorn": "^56.0.0", 72 | "expect.js": "~0.3.1", 73 | "father": "^4.5.5", 74 | "glob": "^11.0.0", 75 | "husky": "^9.1.7", 76 | "jest-environment-jsdom": "^29.3.1", 77 | "less": "^4.1.3", 78 | "lint-staged": "^15.1.0", 79 | "np": "^10.0.5", 80 | "rc-test": "^7.0.14", 81 | "rc-tooltip": "^6.0.1", 82 | "react": "^18.2.0", 83 | "react-dom": "^18.2.0", 84 | "regenerator-runtime": "^0.14.1", 85 | "ts-node": "^10.9.1", 86 | "typescript": "^5.1.6" 87 | }, 88 | "peerDependencies": { 89 | "react": ">=16.9.0", 90 | "react-dom": ">=16.9.0" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/InputNumber.tsx: -------------------------------------------------------------------------------- 1 | import getMiniDecimal, { 2 | DecimalClass, 3 | getNumberPrecision, 4 | num2str, 5 | toFixed, 6 | validateNumber, 7 | ValueType, 8 | } from '@rc-component/mini-decimal'; 9 | import clsx from 'classnames'; 10 | import { BaseInput } from '@rc-component/input'; 11 | import { useLayoutUpdateEffect } from '@rc-component/util/lib/hooks/useLayoutEffect'; 12 | import proxyObject from '@rc-component/util/lib/proxyObject'; 13 | import { composeRef } from '@rc-component/util/lib/ref'; 14 | import * as React from 'react'; 15 | import useCursor from './hooks/useCursor'; 16 | import StepHandler from './StepHandler'; 17 | import { getDecupleSteps } from './utils/numberUtil'; 18 | import SemanticContext from './SemanticContext'; 19 | 20 | import type { HolderRef } from '@rc-component/input/lib/BaseInput'; 21 | import { BaseInputProps } from '@rc-component/input/lib/interface'; 22 | import { InputFocusOptions, triggerFocus } from '@rc-component/input/lib/utils/commonUtils'; 23 | import useFrame from './hooks/useFrame'; 24 | 25 | export type { ValueType }; 26 | 27 | export interface InputNumberRef extends HTMLInputElement { 28 | focus: (options?: InputFocusOptions) => void; 29 | blur: () => void; 30 | nativeElement: HTMLElement; 31 | } 32 | 33 | /** 34 | * We support `stringMode` which need handle correct type when user call in onChange 35 | * format max or min value 36 | * 1. if isInvalid return null 37 | * 2. if precision is undefined, return decimal 38 | * 3. format with precision 39 | * I. if max > 0, round down with precision. Example: max= 3.5, precision=0 afterFormat: 3 40 | * II. if max < 0, round up with precision. Example: max= -3.5, precision=0 afterFormat: -4 41 | * III. if min > 0, round up with precision. Example: min= 3.5, precision=0 afterFormat: 4 42 | * IV. if min < 0, round down with precision. Example: max= -3.5, precision=0 afterFormat: -3 43 | */ 44 | const getDecimalValue = (stringMode: boolean, decimalValue: DecimalClass) => { 45 | if (stringMode || decimalValue.isEmpty()) { 46 | return decimalValue.toString(); 47 | } 48 | 49 | return decimalValue.toNumber(); 50 | }; 51 | 52 | const getDecimalIfValidate = (value: ValueType) => { 53 | const decimal = getMiniDecimal(value); 54 | return decimal.isInvalidate() ? null : decimal; 55 | }; 56 | 57 | type SemanticName = 'actions' | 'input'; 58 | export interface InputNumberProps 59 | extends Omit< 60 | React.InputHTMLAttributes, 61 | 'value' | 'defaultValue' | 'onInput' | 'onChange' | 'prefix' | 'suffix' 62 | > { 63 | /** value will show as string */ 64 | stringMode?: boolean; 65 | 66 | defaultValue?: T; 67 | value?: T | null; 68 | 69 | prefixCls?: string; 70 | className?: string; 71 | style?: React.CSSProperties; 72 | min?: T; 73 | max?: T; 74 | step?: ValueType; 75 | tabIndex?: number; 76 | controls?: boolean; 77 | prefix?: React.ReactNode; 78 | suffix?: React.ReactNode; 79 | addonBefore?: React.ReactNode; 80 | addonAfter?: React.ReactNode; 81 | classNames?: BaseInputProps['classNames'] & Partial>; 82 | styles?: BaseInputProps['styles'] & Partial>; 83 | 84 | // Customize handler node 85 | upHandler?: React.ReactNode; 86 | downHandler?: React.ReactNode; 87 | keyboard?: boolean; 88 | changeOnWheel?: boolean; 89 | 90 | /** Parse display value to validate number */ 91 | parser?: (displayValue: string | undefined) => T; 92 | /** Transform `value` to display value show in input */ 93 | formatter?: (value: T | undefined, info: { userTyping: boolean; input: string }) => string; 94 | /** Syntactic sugar of `formatter`. Config precision of display. */ 95 | precision?: number; 96 | /** Syntactic sugar of `formatter`. Config decimal separator of display. */ 97 | decimalSeparator?: string; 98 | 99 | onInput?: (text: string) => void; 100 | onChange?: (value: T | null) => void; 101 | onPressEnter?: React.KeyboardEventHandler; 102 | 103 | onStep?: ( 104 | value: T, 105 | info: { offset: ValueType; type: 'up' | 'down'; emitter: 'handler' | 'keyboard' | 'wheel' }, 106 | ) => void; 107 | 108 | /** 109 | * Trigger change onBlur event. 110 | * If disabled, user must press enter or click handler to confirm the value update 111 | */ 112 | changeOnBlur?: boolean; 113 | } 114 | 115 | type InternalInputNumberProps = Omit & { 116 | domRef: React.Ref; 117 | }; 118 | 119 | const InternalInputNumber = React.forwardRef( 120 | (props: InternalInputNumberProps, ref: React.Ref) => { 121 | const { 122 | prefixCls, 123 | className, 124 | style, 125 | min, 126 | max, 127 | step = 1, 128 | defaultValue, 129 | value, 130 | disabled, 131 | readOnly, 132 | upHandler, 133 | downHandler, 134 | keyboard, 135 | changeOnWheel = false, 136 | controls = true, 137 | 138 | stringMode, 139 | 140 | parser, 141 | formatter, 142 | precision, 143 | decimalSeparator, 144 | 145 | onChange, 146 | onInput, 147 | onPressEnter, 148 | onStep, 149 | 150 | changeOnBlur = true, 151 | 152 | domRef, 153 | 154 | ...inputProps 155 | } = props; 156 | 157 | const inputClassName = `${prefixCls}-input`; 158 | 159 | const inputRef = React.useRef(null); 160 | 161 | const [focus, setFocus] = React.useState(false); 162 | 163 | const userTypingRef = React.useRef(false); 164 | const compositionRef = React.useRef(false); 165 | const shiftKeyRef = React.useRef(false); 166 | 167 | // ============================ Value ============================= 168 | // Real value control 169 | const [decimalValue, setDecimalValue] = React.useState(() => 170 | getMiniDecimal(value ?? defaultValue), 171 | ); 172 | 173 | function setUncontrolledDecimalValue(newDecimal: DecimalClass) { 174 | if (value === undefined) { 175 | setDecimalValue(newDecimal); 176 | } 177 | } 178 | 179 | // ====================== Parser & Formatter ====================== 180 | /** 181 | * `precision` is used for formatter & onChange. 182 | * It will auto generate by `value` & `step`. 183 | * But it will not block user typing. 184 | * 185 | * Note: Auto generate `precision` is used for legacy logic. 186 | * We should remove this since we already support high precision with BigInt. 187 | * 188 | * @param number Provide which number should calculate precision 189 | * @param userTyping Change by user typing 190 | */ 191 | const getPrecision = React.useCallback( 192 | (numStr: string, userTyping: boolean) => { 193 | if (userTyping) { 194 | return undefined; 195 | } 196 | 197 | if (precision >= 0) { 198 | return precision; 199 | } 200 | 201 | return Math.max(getNumberPrecision(numStr), getNumberPrecision(step)); 202 | }, 203 | [precision, step], 204 | ); 205 | 206 | // >>> Parser 207 | const mergedParser = React.useCallback( 208 | (num: string | number) => { 209 | const numStr = String(num); 210 | 211 | if (parser) { 212 | return parser(numStr); 213 | } 214 | 215 | let parsedStr = numStr; 216 | if (decimalSeparator) { 217 | parsedStr = parsedStr.replace(decimalSeparator, '.'); 218 | } 219 | 220 | // [Legacy] We still support auto convert `$ 123,456` to `123456` 221 | return parsedStr.replace(/[^\w.-]+/g, ''); 222 | }, 223 | [parser, decimalSeparator], 224 | ); 225 | 226 | // >>> Formatter 227 | const inputValueRef = React.useRef(''); 228 | const mergedFormatter = React.useCallback( 229 | (number: string, userTyping: boolean) => { 230 | if (formatter) { 231 | return formatter(number, { userTyping, input: String(inputValueRef.current) }); 232 | } 233 | 234 | let str = typeof number === 'number' ? num2str(number) : number; 235 | 236 | // User typing will not auto format with precision directly 237 | if (!userTyping) { 238 | const mergedPrecision = getPrecision(str, userTyping); 239 | 240 | if (validateNumber(str) && (decimalSeparator || mergedPrecision >= 0)) { 241 | // Separator 242 | const separatorStr = decimalSeparator || '.'; 243 | 244 | str = toFixed(str, separatorStr, mergedPrecision); 245 | } 246 | } 247 | 248 | return str; 249 | }, 250 | [formatter, getPrecision, decimalSeparator], 251 | ); 252 | 253 | // ========================== InputValue ========================== 254 | /** 255 | * Input text value control 256 | * 257 | * User can not update input content directly. It updates with follow rules by priority: 258 | * 1. controlled `value` changed 259 | * * [SPECIAL] Typing like `1.` should not immediately convert to `1` 260 | * 2. User typing with format (not precision) 261 | * 3. Blur or Enter trigger revalidate 262 | */ 263 | const [inputValue, setInternalInputValue] = React.useState(() => { 264 | const initValue = defaultValue ?? value; 265 | if (decimalValue.isInvalidate() && ['string', 'number'].includes(typeof initValue)) { 266 | return Number.isNaN(initValue) ? '' : initValue; 267 | } 268 | return mergedFormatter(decimalValue.toString(), false); 269 | }); 270 | inputValueRef.current = inputValue; 271 | 272 | // Should always be string 273 | function setInputValue(newValue: DecimalClass, userTyping: boolean) { 274 | setInternalInputValue( 275 | mergedFormatter( 276 | // Invalidate number is sometime passed by external control, we should let it go 277 | // Otherwise is controlled by internal interactive logic which check by userTyping 278 | // You can ref 'show limited value when input is not focused' test for more info. 279 | newValue.isInvalidate() ? newValue.toString(false) : newValue.toString(!userTyping), 280 | userTyping, 281 | ), 282 | ); 283 | } 284 | 285 | // >>> Max & Min limit 286 | const maxDecimal = React.useMemo(() => getDecimalIfValidate(max), [max, precision]); 287 | const minDecimal = React.useMemo(() => getDecimalIfValidate(min), [min, precision]); 288 | 289 | const upDisabled = React.useMemo(() => { 290 | if (!maxDecimal || !decimalValue || decimalValue.isInvalidate()) { 291 | return false; 292 | } 293 | 294 | return maxDecimal.lessEquals(decimalValue); 295 | }, [maxDecimal, decimalValue]); 296 | 297 | const downDisabled = React.useMemo(() => { 298 | if (!minDecimal || !decimalValue || decimalValue.isInvalidate()) { 299 | return false; 300 | } 301 | 302 | return decimalValue.lessEquals(minDecimal); 303 | }, [minDecimal, decimalValue]); 304 | 305 | // Cursor controller 306 | const [recordCursor, restoreCursor] = useCursor(inputRef.current, focus); 307 | 308 | // ============================= Data ============================= 309 | /** 310 | * Find target value closet within range. 311 | * e.g. [11, 28]: 312 | * 3 => 11 313 | * 23 => 23 314 | * 99 => 28 315 | */ 316 | const getRangeValue = (target: DecimalClass) => { 317 | // target > max 318 | if (maxDecimal && !target.lessEquals(maxDecimal)) { 319 | return maxDecimal; 320 | } 321 | 322 | // target < min 323 | if (minDecimal && !minDecimal.lessEquals(target)) { 324 | return minDecimal; 325 | } 326 | 327 | return null; 328 | }; 329 | 330 | /** 331 | * Check value is in [min, max] range 332 | */ 333 | const isInRange = (target: DecimalClass) => !getRangeValue(target); 334 | 335 | /** 336 | * Trigger `onChange` if value validated and not equals of origin. 337 | * Return the value that re-align in range. 338 | */ 339 | const triggerValueUpdate = (newValue: DecimalClass, userTyping: boolean): DecimalClass => { 340 | let updateValue = newValue; 341 | 342 | let isRangeValidate = isInRange(updateValue) || updateValue.isEmpty(); 343 | 344 | // Skip align value when trigger value is empty. 345 | // We just trigger onChange(null) 346 | // This should not block user typing 347 | if (!updateValue.isEmpty() && !userTyping) { 348 | // Revert value in range if needed 349 | updateValue = getRangeValue(updateValue) || updateValue; 350 | isRangeValidate = true; 351 | } 352 | 353 | if (!readOnly && !disabled && isRangeValidate) { 354 | const numStr = updateValue.toString(); 355 | const mergedPrecision = getPrecision(numStr, userTyping); 356 | if (mergedPrecision >= 0) { 357 | updateValue = getMiniDecimal(toFixed(numStr, '.', mergedPrecision)); 358 | 359 | // When to fixed. The value may out of min & max range. 360 | // 4 in [0, 3.8] => 3.8 => 4 (toFixed) 361 | if (!isInRange(updateValue)) { 362 | updateValue = getMiniDecimal(toFixed(numStr, '.', mergedPrecision, true)); 363 | } 364 | } 365 | 366 | // Trigger event 367 | if (!updateValue.equals(decimalValue)) { 368 | setUncontrolledDecimalValue(updateValue); 369 | onChange?.(updateValue.isEmpty() ? null : getDecimalValue(stringMode, updateValue)); 370 | 371 | // Reformat input if value is not controlled 372 | if (value === undefined) { 373 | setInputValue(updateValue, userTyping); 374 | } 375 | } 376 | 377 | return updateValue; 378 | } 379 | 380 | return decimalValue; 381 | }; 382 | 383 | // ========================== User Input ========================== 384 | const onNextPromise = useFrame(); 385 | 386 | // >>> Collect input value 387 | const collectInputValue = (inputStr: string) => { 388 | recordCursor(); 389 | 390 | // Update inputValue in case input can not parse as number 391 | // Refresh ref value immediately since it may used by formatter 392 | inputValueRef.current = inputStr; 393 | setInternalInputValue(inputStr); 394 | 395 | // Parse number 396 | if (!compositionRef.current) { 397 | const finalValue = mergedParser(inputStr); 398 | const finalDecimal = getMiniDecimal(finalValue); 399 | if (!finalDecimal.isNaN()) { 400 | triggerValueUpdate(finalDecimal, true); 401 | } 402 | } 403 | 404 | // Trigger onInput later to let user customize value if they want to handle something after onChange 405 | onInput?.(inputStr); 406 | 407 | // optimize for chinese input experience 408 | // https://github.com/ant-design/ant-design/issues/8196 409 | onNextPromise(() => { 410 | let nextInputStr = inputStr; 411 | if (!parser) { 412 | nextInputStr = inputStr.replace(/。/g, '.'); 413 | } 414 | 415 | if (nextInputStr !== inputStr) { 416 | collectInputValue(nextInputStr); 417 | } 418 | }); 419 | }; 420 | 421 | // >>> Composition 422 | const onCompositionStart = () => { 423 | compositionRef.current = true; 424 | }; 425 | 426 | const onCompositionEnd = () => { 427 | compositionRef.current = false; 428 | 429 | collectInputValue(inputRef.current.value); 430 | }; 431 | 432 | // >>> Input 433 | const onInternalInput: React.ChangeEventHandler = (e) => { 434 | collectInputValue(e.target.value); 435 | }; 436 | 437 | // ============================= Step ============================= 438 | const onInternalStep = (up: boolean, emitter: 'handler' | 'keyboard' | 'wheel') => { 439 | // Ignore step since out of range 440 | if ((up && upDisabled) || (!up && downDisabled)) { 441 | return; 442 | } 443 | 444 | // Clear typing status since it may be caused by up & down key. 445 | // We should sync with input value. 446 | userTypingRef.current = false; 447 | 448 | let stepDecimal = getMiniDecimal(shiftKeyRef.current ? getDecupleSteps(step) : step); 449 | if (!up) { 450 | stepDecimal = stepDecimal.negate(); 451 | } 452 | 453 | const target = (decimalValue || getMiniDecimal(0)).add(stepDecimal.toString()); 454 | 455 | const updatedValue = triggerValueUpdate(target, false); 456 | 457 | onStep?.(getDecimalValue(stringMode, updatedValue), { 458 | offset: shiftKeyRef.current ? getDecupleSteps(step) : step, 459 | type: up ? 'up' : 'down', 460 | emitter, 461 | }); 462 | 463 | inputRef.current?.focus(); 464 | }; 465 | 466 | // ============================ Flush ============================= 467 | /** 468 | * Flush current input content to trigger value change & re-formatter input if needed. 469 | * This will always flush input value for update. 470 | * If it's invalidate, will fallback to last validate value. 471 | */ 472 | const flushInputValue = (userTyping: boolean) => { 473 | const parsedValue = getMiniDecimal(mergedParser(inputValue)); 474 | let formatValue: DecimalClass; 475 | 476 | if (!parsedValue.isNaN()) { 477 | // Only validate value or empty value can be re-fill to inputValue 478 | // Reassign the formatValue within ranged of trigger control 479 | formatValue = triggerValueUpdate(parsedValue, userTyping); 480 | } else { 481 | formatValue = triggerValueUpdate(decimalValue, userTyping); 482 | } 483 | 484 | if (value !== undefined) { 485 | // Reset back with controlled value first 486 | setInputValue(decimalValue, false); 487 | } else if (!formatValue.isNaN()) { 488 | // Reset input back since no validate value 489 | setInputValue(formatValue, false); 490 | } 491 | }; 492 | 493 | // Solve the issue of the event triggering sequence when entering numbers in chinese input (Safari) 494 | const onBeforeInput = () => { 495 | userTypingRef.current = true; 496 | }; 497 | 498 | const onKeyDown: React.KeyboardEventHandler = (event) => { 499 | const { key, shiftKey } = event; 500 | userTypingRef.current = true; 501 | 502 | shiftKeyRef.current = shiftKey; 503 | 504 | if (key === 'Enter') { 505 | if (!compositionRef.current) { 506 | userTypingRef.current = false; 507 | } 508 | flushInputValue(false); 509 | onPressEnter?.(event); 510 | } 511 | 512 | if (keyboard === false) { 513 | return; 514 | } 515 | 516 | // Do step 517 | if (!compositionRef.current && ['Up', 'ArrowUp', 'Down', 'ArrowDown'].includes(key)) { 518 | onInternalStep(key === 'Up' || key === 'ArrowUp', 'keyboard'); 519 | event.preventDefault(); 520 | } 521 | }; 522 | 523 | const onKeyUp = () => { 524 | userTypingRef.current = false; 525 | shiftKeyRef.current = false; 526 | }; 527 | 528 | React.useEffect(() => { 529 | if (changeOnWheel && focus) { 530 | const onWheel = (event) => { 531 | // moving mouse wheel rises wheel event with deltaY < 0 532 | // scroll value grows from top to bottom, as screen Y coordinate 533 | onInternalStep(event.deltaY < 0, 'wheel'); 534 | event.preventDefault(); 535 | }; 536 | const input = inputRef.current; 537 | if (input) { 538 | // React onWheel is passive and we can't preventDefault() in it. 539 | // That's why we should subscribe with DOM listener 540 | // https://stackoverflow.com/questions/63663025/react-onwheel-handler-cant-preventdefault-because-its-a-passive-event-listenev 541 | input.addEventListener('wheel', onWheel, { passive: false }); 542 | return () => input.removeEventListener('wheel', onWheel); 543 | } 544 | } 545 | }); 546 | 547 | // >>> Focus & Blur 548 | const onBlur = () => { 549 | if (changeOnBlur) { 550 | flushInputValue(false); 551 | } 552 | 553 | setFocus(false); 554 | 555 | userTypingRef.current = false; 556 | }; 557 | 558 | // ========================== Controlled ========================== 559 | // Input by precision & formatter 560 | useLayoutUpdateEffect(() => { 561 | if (!decimalValue.isInvalidate()) { 562 | setInputValue(decimalValue, false); 563 | } 564 | }, [precision, formatter]); 565 | 566 | // Input by value 567 | useLayoutUpdateEffect(() => { 568 | const newValue = getMiniDecimal(value); 569 | setDecimalValue(newValue); 570 | 571 | const currentParsedValue = getMiniDecimal(mergedParser(inputValue)); 572 | 573 | // When user typing from `1.2` to `1.`, we should not convert to `1` immediately. 574 | // But let it go if user set `formatter` 575 | if (!newValue.equals(currentParsedValue) || !userTypingRef.current || formatter) { 576 | // Update value as effect 577 | setInputValue(newValue, userTypingRef.current); 578 | } 579 | }, [value]); 580 | 581 | // ============================ Cursor ============================ 582 | useLayoutUpdateEffect(() => { 583 | if (formatter) { 584 | restoreCursor(); 585 | } 586 | }, [inputValue]); 587 | 588 | // ============================ Render ============================ 589 | return ( 590 |
{ 601 | setFocus(true); 602 | }} 603 | onBlur={onBlur} 604 | onKeyDown={onKeyDown} 605 | onKeyUp={onKeyUp} 606 | onCompositionStart={onCompositionStart} 607 | onCompositionEnd={onCompositionEnd} 608 | onBeforeInput={onBeforeInput} 609 | > 610 | {controls && ( 611 | 619 | )} 620 |
621 | 636 |
637 |
638 | ); 639 | }, 640 | ); 641 | 642 | const InputNumber = React.forwardRef((props, ref) => { 643 | const { 644 | disabled, 645 | style, 646 | prefixCls = 'rc-input-number', 647 | value, 648 | prefix, 649 | suffix, 650 | addonBefore, 651 | addonAfter, 652 | className, 653 | classNames, 654 | styles, 655 | ...rest 656 | } = props; 657 | 658 | const holderRef = React.useRef(null); 659 | const inputNumberDomRef = React.useRef(null); 660 | const inputFocusRef = React.useRef(null); 661 | 662 | const focus = (option?: InputFocusOptions) => { 663 | if (inputFocusRef.current) { 664 | triggerFocus(inputFocusRef.current, option); 665 | } 666 | }; 667 | 668 | React.useImperativeHandle(ref, () => 669 | proxyObject(inputFocusRef.current, { 670 | focus, 671 | nativeElement: holderRef.current.nativeElement || inputNumberDomRef.current, 672 | }), 673 | ); 674 | const memoizedValue = React.useMemo(() => ({ classNames, styles }), [classNames, styles]); 675 | return ( 676 | 677 | 698 | 707 | 708 | 709 | ); 710 | }) as (( 711 | props: React.PropsWithChildren> & { 712 | ref?: React.Ref; 713 | }, 714 | ) => React.ReactElement) & { displayName?: string }; 715 | 716 | if (process.env.NODE_ENV !== 'production') { 717 | InputNumber.displayName = 'InputNumber'; 718 | } 719 | 720 | export default InputNumber; 721 | -------------------------------------------------------------------------------- /src/SemanticContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { InputNumberProps } from './InputNumber'; 3 | 4 | interface SemanticContextProps { 5 | classNames?: InputNumberProps['classNames']; 6 | styles?: InputNumberProps['styles']; 7 | } 8 | 9 | const SemanticContext = React.createContext(undefined); 10 | 11 | export default SemanticContext; 12 | -------------------------------------------------------------------------------- /src/StepHandler.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-unknown-property */ 2 | import * as React from 'react'; 3 | import cls from 'classnames'; 4 | import useMobile from '@rc-component/util/lib/hooks/useMobile'; 5 | import raf from '@rc-component/util/lib/raf'; 6 | import SemanticContext from './SemanticContext'; 7 | 8 | /** 9 | * When click and hold on a button - the speed of auto changing the value. 10 | */ 11 | const STEP_INTERVAL = 200; 12 | 13 | /** 14 | * When click and hold on a button - the delay before auto changing the value. 15 | */ 16 | const STEP_DELAY = 600; 17 | 18 | export interface StepHandlerProps { 19 | prefixCls: string; 20 | upNode?: React.ReactNode; 21 | downNode?: React.ReactNode; 22 | upDisabled?: boolean; 23 | downDisabled?: boolean; 24 | onStep: (up: boolean, emitter: 'handler' | 'keyboard' | 'wheel') => void; 25 | } 26 | export default function StepHandler({ 27 | prefixCls, 28 | upNode, 29 | downNode, 30 | upDisabled, 31 | downDisabled, 32 | onStep, 33 | }: StepHandlerProps) { 34 | // ======================== Step ======================== 35 | const stepTimeoutRef = React.useRef(); 36 | const frameIds = React.useRef([]); 37 | 38 | const onStepRef = React.useRef(); 39 | onStepRef.current = onStep; 40 | 41 | const { classNames, styles } = React.useContext(SemanticContext) || {}; 42 | 43 | const onStopStep = () => { 44 | clearTimeout(stepTimeoutRef.current); 45 | }; 46 | 47 | // We will interval update step when hold mouse down 48 | const onStepMouseDown = (e: React.MouseEvent, up: boolean) => { 49 | e.preventDefault(); 50 | onStopStep(); 51 | 52 | onStepRef.current(up, 'handler'); 53 | 54 | // Loop step for interval 55 | function loopStep() { 56 | onStepRef.current(up, 'handler'); 57 | 58 | stepTimeoutRef.current = setTimeout(loopStep, STEP_INTERVAL); 59 | } 60 | 61 | // First time press will wait some time to trigger loop step update 62 | stepTimeoutRef.current = setTimeout(loopStep, STEP_DELAY); 63 | }; 64 | 65 | React.useEffect( 66 | () => () => { 67 | onStopStep(); 68 | frameIds.current.forEach((id) => raf.cancel(id)); 69 | }, 70 | [], 71 | ); 72 | 73 | // ======================= Render ======================= 74 | const isMobile = useMobile(); 75 | if (isMobile) { 76 | return null; 77 | } 78 | 79 | const handlerClassName = `${prefixCls}-handler`; 80 | 81 | const upClassName = cls(handlerClassName, `${handlerClassName}-up`, { 82 | [`${handlerClassName}-up-disabled`]: upDisabled, 83 | }); 84 | const downClassName = cls(handlerClassName, `${handlerClassName}-down`, { 85 | [`${handlerClassName}-down-disabled`]: downDisabled, 86 | }); 87 | 88 | // fix: https://github.com/ant-design/ant-design/issues/43088 89 | // In Safari, When we fire onmousedown and onmouseup events in quick succession, 90 | // there may be a problem that the onmouseup events are executed first, 91 | // resulting in a disordered program execution. 92 | // So, we need to use requestAnimationFrame to ensure that the onmouseup event is executed after the onmousedown event. 93 | const safeOnStopStep = () => frameIds.current.push(raf(onStopStep)); 94 | 95 | const sharedHandlerProps = { 96 | unselectable: 'on' as const, 97 | role: 'button', 98 | onMouseUp: safeOnStopStep, 99 | onMouseLeave: safeOnStopStep, 100 | }; 101 | 102 | return ( 103 |
104 | { 107 | onStepMouseDown(e, true); 108 | }} 109 | aria-label="Increase Value" 110 | aria-disabled={upDisabled} 111 | className={upClassName} 112 | > 113 | {upNode || } 114 | 115 | { 118 | onStepMouseDown(e, false); 119 | }} 120 | aria-label="Decrease Value" 121 | aria-disabled={downDisabled} 122 | className={downClassName} 123 | > 124 | {downNode || } 125 | 126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/hooks/useCursor.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import warning from '@rc-component/util/lib/warning'; 3 | /** 4 | * Keep input cursor in the correct position if possible. 5 | * Is this necessary since we have `formatter` which may mass the content? 6 | */ 7 | export default function useCursor( 8 | input: HTMLInputElement, 9 | focused: boolean, 10 | ): [() => void, () => void] { 11 | const selectionRef = useRef<{ 12 | start?: number; 13 | end?: number; 14 | value?: string; 15 | beforeTxt?: string; 16 | afterTxt?: string; 17 | }>(null); 18 | 19 | function recordCursor() { 20 | // Record position 21 | try { 22 | const { selectionStart: start, selectionEnd: end, value } = input; 23 | const beforeTxt = value.substring(0, start); 24 | const afterTxt = value.substring(end); 25 | 26 | selectionRef.current = { 27 | start, 28 | end, 29 | value, 30 | beforeTxt, 31 | afterTxt, 32 | }; 33 | } catch (e) { 34 | // Fix error in Chrome: 35 | // Failed to read the 'selectionStart' property from 'HTMLInputElement' 36 | // http://stackoverflow.com/q/21177489/3040605 37 | } 38 | } 39 | 40 | /** 41 | * Restore logic: 42 | * 1. back string same 43 | * 2. start string same 44 | */ 45 | function restoreCursor() { 46 | if (input && selectionRef.current && focused) { 47 | try { 48 | const { value } = input; 49 | const { beforeTxt, afterTxt, start } = selectionRef.current; 50 | 51 | let startPos = value.length; 52 | 53 | if (value.startsWith(beforeTxt)) { 54 | startPos = beforeTxt.length; 55 | } else if (value.endsWith(afterTxt)) { 56 | startPos = value.length - selectionRef.current.afterTxt.length; 57 | } else { 58 | const beforeLastChar = beforeTxt[start - 1]; 59 | const newIndex = value.indexOf(beforeLastChar, start - 1); 60 | if (newIndex !== -1) { 61 | startPos = newIndex + 1; 62 | } 63 | } 64 | 65 | input.setSelectionRange(startPos, startPos); 66 | } catch (e) { 67 | warning( 68 | false, 69 | `Something warning of cursor restore. Please fire issue about this: ${e.message}`, 70 | ); 71 | } 72 | } 73 | } 74 | 75 | return [recordCursor, restoreCursor]; 76 | } 77 | -------------------------------------------------------------------------------- /src/hooks/useFrame.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import raf from '@rc-component/util/lib/raf'; 3 | 4 | /** 5 | * Always trigger latest once when call multiple time 6 | */ 7 | export default () => { 8 | const idRef = useRef(0); 9 | 10 | const cleanUp = () => { 11 | raf.cancel(idRef.current); 12 | }; 13 | 14 | useEffect(() => cleanUp, []); 15 | 16 | return (callback: () => void) => { 17 | cleanUp(); 18 | 19 | idRef.current = raf(() => { 20 | callback(); 21 | }); 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { InputNumberProps, ValueType, InputNumberRef } from './InputNumber'; 2 | import InputNumber from './InputNumber'; 3 | 4 | export type { InputNumberProps, ValueType, InputNumberRef }; 5 | 6 | export default InputNumber; 7 | -------------------------------------------------------------------------------- /src/utils/numberUtil.ts: -------------------------------------------------------------------------------- 1 | import { trimNumber, num2str } from '@rc-component/mini-decimal'; 2 | 3 | export function getDecupleSteps(step: string | number) { 4 | const stepStr = typeof step === 'number' ? num2str(step) : trimNumber(step).fullStr; 5 | const hasPoint = stepStr.includes('.'); 6 | if (!hasPoint) { 7 | return step + '0'; 8 | } 9 | return trimNumber(stepStr.replace(/(\d)\.(\d)/g, '$1$2.')).fullStr; 10 | } 11 | -------------------------------------------------------------------------------- /tests/__snapshots__/baseInput.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`baseInput addon should render properly 1`] = ` 4 |
5 |
6 |
9 |
12 |
15 | 16 | Addon Before 17 | 18 |
19 |
22 |
25 | 32 | 36 | 37 | 44 | 48 | 49 |
50 |
53 | 60 |
61 |
62 |
63 |
64 |
65 |
66 |
69 |
72 |
75 |
78 | 85 | 89 | 90 | 97 | 101 | 102 |
103 |
106 | 113 |
114 |
115 |
118 | 119 | Addon After 120 | 121 |
122 |
123 |
124 |
125 |
126 | `; 127 | 128 | exports[`baseInput prefix should render properly 1`] = ` 129 |
130 |
133 | 136 | 137 | Prefix 138 | 139 | 140 |
143 |
146 | 153 | 157 | 158 | 165 | 169 | 170 |
171 |
174 | 181 |
182 |
183 |
184 |
185 | `; 186 | -------------------------------------------------------------------------------- /tests/baseInput.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import InputNumber from '../src'; 3 | 4 | describe('baseInput', () => { 5 | it('prefix should render properly', () => { 6 | const prefix = Prefix; 7 | 8 | const { container } = render(); 9 | expect(container).toMatchSnapshot(); 10 | }); 11 | 12 | it('addon should render properly', () => { 13 | const addonBefore = Addon Before; 14 | const addonAfter = Addon After; 15 | 16 | const { container } = render( 17 |
18 | 19 |
20 |
21 | 22 |
, 23 | ); 24 | expect(container).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/click.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, fireEvent, act } from '@testing-library/react'; 3 | import InputNumber, { InputNumberProps } from '../src'; 4 | import KeyCode from '@rc-component/util/lib/KeyCode'; 5 | 6 | jest.mock('@rc-component/mini-decimal/lib/supportUtil'); 7 | const { supportBigInt } = require('@rc-component/mini-decimal/lib/supportUtil'); 8 | // jest.mock('../src/utils/supportUtil'); 9 | // const { supportBigInt } = require('../src/utils/supportUtil'); 10 | 11 | describe('InputNumber.Click', () => { 12 | beforeEach(() => { 13 | supportBigInt.mockImplementation(() => true); 14 | }); 15 | 16 | afterEach(() => { 17 | supportBigInt.mockRestore(); 18 | }); 19 | 20 | function testInputNumber( 21 | name: string, 22 | props: Partial, 23 | selector: string, 24 | changedValue: string | number, 25 | stepType: 'up' | 'down', 26 | emitter: 'handler' | 'keyboard' | 'wheel', 27 | ) { 28 | it(name, () => { 29 | const onChange = jest.fn(); 30 | const onStep = jest.fn(); 31 | const { container, unmount } = render( 32 | , 33 | ); 34 | fireEvent.focus(container.querySelector('input')); 35 | fireEvent.mouseDown(container.querySelector(selector)); 36 | fireEvent.mouseUp(container.querySelector(selector)); 37 | fireEvent.click(container.querySelector(selector)); 38 | expect(onChange).toHaveBeenCalledTimes(1); 39 | expect(onChange).toHaveBeenCalledWith(changedValue); 40 | expect(onStep).toHaveBeenCalledWith(changedValue, { offset: 1, type: stepType, emitter }); 41 | unmount(); 42 | }); 43 | } 44 | 45 | describe('basic work', () => { 46 | testInputNumber('up button', { defaultValue: 10 }, '.rc-input-number-handler-up', 11, 'up', 'handler'); 47 | 48 | testInputNumber('down button', { value: 10 }, '.rc-input-number-handler-down', 9, 'down', 'handler'); 49 | }); 50 | 51 | describe('empty input', () => { 52 | testInputNumber('up button', {}, '.rc-input-number-handler-up', 1, 'up', 'handler'); 53 | 54 | testInputNumber('down button', {}, '.rc-input-number-handler-down', -1, 'down', 'handler'); 55 | }); 56 | 57 | describe('empty with min & max', () => { 58 | testInputNumber('up button', { min: 6, max: 10 }, '.rc-input-number-handler-up', 6, 'up', 'handler'); 59 | 60 | testInputNumber('down button', { min: 6, max: 10 }, '.rc-input-number-handler-down', 6, 'down', 'handler'); 61 | }); 62 | 63 | describe('null with min & max', () => { 64 | testInputNumber( 65 | 'up button', 66 | { value: null, min: 6, max: 10 }, 67 | '.rc-input-number-handler-up', 68 | 6, 69 | 'up', 70 | 'handler', 71 | ); 72 | 73 | testInputNumber( 74 | 'down button', 75 | { value: null, min: 6, max: 10 }, 76 | '.rc-input-number-handler-down', 77 | 6, 78 | 'down', 79 | 'handler', 80 | ); 81 | }); 82 | 83 | describe('disabled', () => { 84 | it('none', () => { 85 | const { container } = render(); 86 | expect(container.querySelector('.rc-input-number-handler-up-disabled')).toBeFalsy(); 87 | expect(container.querySelector('.rc-input-number-handler-down-disabled')).toBeFalsy(); 88 | }); 89 | 90 | it('min', () => { 91 | const { container } = render(); 92 | expect(container.querySelector('.rc-input-number-handler-down-disabled')).toBeTruthy(); 93 | }); 94 | 95 | it('max', () => { 96 | const { container } = render(); 97 | expect(container.querySelector('.rc-input-number-handler-up-disabled')).toBeTruthy(); 98 | }); 99 | }); 100 | 101 | describe('safe integer', () => { 102 | it('back to max safe when BigInt not support', () => { 103 | supportBigInt.mockImplementation(() => false); 104 | 105 | const onChange = jest.fn(); 106 | const { container } = render(); 107 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 108 | expect(onChange).toHaveBeenCalledWith(Number.MAX_SAFE_INTEGER); 109 | 110 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 111 | expect(onChange).toHaveBeenCalledWith(Number.MAX_SAFE_INTEGER - 1); 112 | 113 | supportBigInt.mockRestore(); 114 | }); 115 | 116 | it('back to min safe when BigInt not support', () => { 117 | supportBigInt.mockImplementation(() => false); 118 | 119 | const onChange = jest.fn(); 120 | const { container } = render(); 121 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 122 | expect(onChange).toHaveBeenCalledWith(Number.MIN_SAFE_INTEGER); 123 | 124 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 125 | expect(onChange).toHaveBeenCalledWith(Number.MIN_SAFE_INTEGER + 1); 126 | 127 | supportBigInt.mockRestore(); 128 | }); 129 | 130 | it('no limit max safe when BigInt support', () => { 131 | const onChange = jest.fn(); 132 | const { container } = render( 133 | , 134 | ); 135 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 136 | expect(onChange).toHaveBeenCalledWith('999999999999999983222785'); 137 | }); 138 | 139 | it('no limit min safe when BigInt support', () => { 140 | const onChange = jest.fn(); 141 | const { container } = render( 142 | , 143 | ); 144 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 145 | expect(onChange).toHaveBeenCalledWith('-10000000000000000905969665'); 146 | }); 147 | }); 148 | 149 | it('focus input when click up/down button', async () => { 150 | jest.useFakeTimers(); 151 | 152 | const onFocus = jest.fn(); 153 | const onBlur = jest.fn(); 154 | const { container } = render(); 155 | 156 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 157 | act(() => { 158 | jest.advanceTimersByTime(100); 159 | }); 160 | expect(document.activeElement).toBe(container.querySelector('input')); 161 | 162 | // jsdom not trigger onFocus with `.focus()`, let's trigger it manually 163 | fireEvent.focus(document.querySelector('input')); 164 | expect(container.querySelector('.rc-input-number-focused')).toBeTruthy(); 165 | expect(onFocus).toHaveBeenCalled(); 166 | 167 | fireEvent.blur(container.querySelector('input')); 168 | expect(onBlur).toHaveBeenCalled(); 169 | expect(container.querySelector('.rc-input-number-focused')).toBeFalsy(); 170 | 171 | jest.useRealTimers(); 172 | }); 173 | 174 | it('click down button with pressing shift key', () => { 175 | const onChange = jest.fn(); 176 | const onStep = jest.fn(); 177 | const { container } = render( 178 | , 179 | ); 180 | fireEvent.keyDown(container.querySelector('input'), { 181 | shiftKey: true, 182 | which: KeyCode.DOWN, 183 | key: 'ArrowDown', 184 | code: 'ArrowDown', 185 | keyCode: KeyCode.DOWN, 186 | }); 187 | 188 | expect(onChange).toHaveBeenCalledWith(1.1); 189 | expect(onStep).toHaveBeenCalledWith(1.1, { offset: '0.1', type: 'down', emitter: 'keyboard' }); 190 | }); 191 | 192 | it('click up button with pressing shift key', () => { 193 | const onChange = jest.fn(); 194 | const onStep = jest.fn(); 195 | const { container } = render( 196 | , 197 | ); 198 | 199 | fireEvent.keyDown(container.querySelector('input'), { 200 | shiftKey: true, 201 | which: KeyCode.UP, 202 | key: 'ArrowUp', 203 | code: 'ArrowUp', 204 | keyCode: KeyCode.UP, 205 | }); 206 | expect(onChange).toHaveBeenCalledWith(1.3); 207 | expect(onStep).toHaveBeenCalledWith(1.3, { offset: '0.1', type: 'up', emitter: 'keyboard' }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /tests/cursor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import KeyCode from '@rc-component/util/lib/KeyCode'; 3 | import { render, fireEvent } from './util/wrapper'; 4 | import InputNumber from '../src'; 5 | 6 | describe('InputNumber.Cursor', () => { 7 | function cursorInput(input: HTMLInputElement, pos?: number) { 8 | 9 | if (pos !== undefined) { 10 | input.setSelectionRange(pos, pos); 11 | } 12 | return input.selectionStart; 13 | } 14 | 15 | function changeOnPos( 16 | input: HTMLInputElement, 17 | changeValue: string, 18 | cursorPos: number, 19 | which?: number, 20 | key?: number|string, 21 | ) { 22 | fireEvent.focus(input) 23 | fireEvent.keyDown(input,{which,keyCode:which,key}) 24 | fireEvent.change(input,{ target: { value: changeValue, selectionStart: 1 }}) 25 | fireEvent.keyUp(input,{which,keyCode:which,key}) 26 | } 27 | 28 | // https://github.com/react-component/input-number/issues/235 29 | // We use post update position that not record before keyDown. 30 | // Origin test suite: 31 | // https://github.com/react-component/input-number/blob/e72ee088bdc8a8df32383b8fc0de562574e8616c/tests/index.test.js#L1490 32 | it('DELETE (not backspace)', () => { 33 | const { container } = render(); 34 | const input = container.querySelector('input'); 35 | changeOnPos(input, '12', 1, KeyCode.DELETE); 36 | expect(cursorInput(input)).toEqual(1); 37 | }); 38 | 39 | // https://github.com/ant-design/ant-design/issues/28366 40 | // Origin test suite: 41 | // https://github.com/react-component/input-number/blob/e72ee088bdc8a8df32383b8fc0de562574e8616c/tests/index.test.js#L1584 42 | describe('pre-pend string', () => { 43 | it('quick typing', () => { 44 | // `$ ` => `9$ ` => `$ 9` 45 | const { container } = render( `$ ${val}`} />); 46 | const input = container.querySelector('input'); 47 | fireEvent.focus(input) 48 | cursorInput(input, 0); 49 | changeOnPos(input, '9$ ', 1, KeyCode.NUM_ONE,'1'); 50 | expect(cursorInput(input,3)).toEqual(3); 51 | }); 52 | 53 | describe('[LEGACY]', () => { 54 | const setUpCursorTest = (initValue: string, prependValue: string) => { 55 | const Demo = () => { 56 | const [value, setValue] = React.useState(initValue); 57 | 58 | return ( 59 | 60 | stringMode 61 | value={value} 62 | onChange={(newValue) => { 63 | setValue(newValue); 64 | }} 65 | formatter={(val) => `$ ${val}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} 66 | parser={(val) => val.replace(/\$\s?|(,*)/g, '')} 67 | /> 68 | ); 69 | }; 70 | 71 | const { container } = render(); 72 | const input = container.querySelector('input'); 73 | fireEvent.focus(input) 74 | for (let i = 0; i < prependValue.length; i += 1) { 75 | fireEvent.keyDown(input,{which: KeyCode.ONE,keyCode: KeyCode.ONE}) 76 | } 77 | 78 | const finalValue = prependValue + initValue; 79 | cursorInput(input, prependValue.length); 80 | fireEvent.change(input,{ target: { value: finalValue } }); 81 | 82 | return input; 83 | }; 84 | 85 | it('should fix caret position on case 1', () => { 86 | // '$ 1' 87 | const input = setUpCursorTest('', '1'); 88 | expect(cursorInput(input,3)).toEqual(3); 89 | }); 90 | 91 | it('should fix caret position on case 2', () => { 92 | // '$ 111' 93 | const input = setUpCursorTest('', '111'); 94 | expect(cursorInput(input,5)).toEqual(5); 95 | }); 96 | 97 | it('should fix caret position on case 3', () => { 98 | // '$ 111' 99 | const input = setUpCursorTest('1', '11'); 100 | expect(cursorInput(input,4)).toEqual(4); 101 | }); 102 | 103 | it('should fix caret position on case 4', () => { 104 | // '$ 123,456' 105 | const input = setUpCursorTest('456', '123'); 106 | expect(cursorInput(input,6)).toEqual(6); 107 | }); 108 | }); 109 | }); 110 | 111 | describe('append string', () => { 112 | it('position caret before appended characters', () => { 113 | const { container } = render( `${value}%`} parser={(value) => value.replace('%', '')} />); 114 | const input = container.querySelector('input'); 115 | fireEvent.focus(input); 116 | fireEvent.change(input,{ target: { value: '5' } }); 117 | expect(cursorInput(input)).toEqual(1); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /tests/decimal.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from './util/wrapper'; 3 | import InputNumber from '../src'; 4 | 5 | describe('InputNumber.Decimal', () => { 6 | it('decimal value', () => { 7 | const { container } = render(); 8 | expect(container.querySelector('input').value).toEqual('2.1'); 9 | }); 10 | 11 | it('decimal defaultValue', () => { 12 | const { container } = render(); 13 | expect(container.querySelector('input').value).toEqual('2.1'); 14 | }); 15 | 16 | it('increase and decrease decimal InputNumber by integer step', () => { 17 | const { container } = render(); 18 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 19 | expect(container.querySelector('input').value).toEqual('3.1'); 20 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 21 | expect(container.querySelector('input').value).toEqual('2.1'); 22 | }); 23 | 24 | it('small value and step', () => { 25 | const Demo = () => { 26 | const [value, setValue] = React.useState(0.000000001); 27 | 28 | return ( 29 | { 35 | setValue(newValue); 36 | }} 37 | /> 38 | ); 39 | }; 40 | 41 | const { container } = render(); 42 | const input = container.querySelector('input'); 43 | expect(input.value).toEqual('0.000000001'); 44 | 45 | for (let i = 0; i < 10; i += 1) { 46 | // plus until change precision 47 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 48 | } 49 | 50 | fireEvent.blur(input); 51 | expect(input.value).toEqual('0.000000011'); 52 | }); 53 | 54 | it('small step with integer value', () => { 55 | const { container } = render(); 56 | expect(container.querySelector('input').value).toEqual('1.000000000'); 57 | }); 58 | 59 | it('small step with empty value', () => { 60 | const { container } = render(); 61 | expect(container.querySelector('input').value).toEqual(''); 62 | 63 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 64 | expect(container.querySelector('input').value).toEqual('0.1'); 65 | }); 66 | 67 | it('custom decimal separator', () => { 68 | const onChange = jest.fn(); 69 | const { container } = render(); 70 | 71 | const input = container.querySelector('input'); 72 | fireEvent.focus(input); 73 | fireEvent.change(input, { target: { value: '1,1' } }); 74 | fireEvent.blur(input); 75 | 76 | expect(container.querySelector('input').value).toEqual('1,1'); 77 | expect(onChange).toHaveBeenCalledWith(1.1); 78 | }); 79 | 80 | describe('precision', () => { 81 | it('decimal step should not display complete precision', () => { 82 | const { container } = render(); 83 | expect(container.querySelector('input').value).toEqual('2.10'); 84 | }); 85 | 86 | it('string step should display complete precision', () => { 87 | const { container } = render(); 88 | expect(container.querySelector('input').value).toEqual('2.100'); 89 | }); 90 | 91 | it('prop precision is specified', () => { 92 | const onChange = jest.fn(); 93 | const { container } = render( 94 | , 95 | ); 96 | const input = container.querySelector('input'); 97 | expect(input.value).toEqual('2.00'); 98 | 99 | fireEvent.change(input, { target: { value: '3.456' } }); 100 | fireEvent.blur(input); 101 | expect(onChange).toHaveBeenCalledWith(3.46); 102 | expect(container.querySelector('input').value).toEqual('3.46'); 103 | 104 | onChange.mockReset(); 105 | fireEvent.change(input, { target: { value: '3.465' } }); 106 | fireEvent.blur(input); 107 | expect(onChange).toHaveBeenCalledWith(3.47); 108 | expect(container.querySelector('input').value).toEqual('3.47'); 109 | 110 | onChange.mockReset(); 111 | fireEvent.change(input, { target: { value: '3.455' } }); 112 | fireEvent.blur(input); 113 | expect(onChange).toHaveBeenCalledWith(3.46); 114 | expect(container.querySelector('input').value).toEqual('3.46'); 115 | 116 | onChange.mockReset(); 117 | fireEvent.change(input, { target: { value: '1' } }); 118 | fireEvent.blur(input); 119 | expect(onChange).toHaveBeenCalledWith(1); 120 | expect(container.querySelector('input').value).toEqual('1.00'); 121 | }); 122 | 123 | it('zero precision should work', () => { 124 | const onChange = jest.fn(); 125 | const { container } = render(); 126 | const input = container.querySelector('input'); 127 | fireEvent.change(input, { target: { value: '1.44' } }); 128 | fireEvent.blur(input); 129 | expect(onChange).toHaveBeenCalledWith(1); 130 | expect(container.querySelector('input').value).toEqual('1'); 131 | }); 132 | 133 | it('should not trigger onChange when blur InputNumber with precision', () => { 134 | const onChange = jest.fn(); 135 | const { container } = render( 136 | , 137 | ); 138 | const input = container.querySelector('input'); 139 | fireEvent.focus(input); 140 | fireEvent.blur(input); 141 | 142 | expect(onChange).toHaveBeenCalledTimes(0); 143 | }); 144 | 145 | it('uncontrolled precision should not format immediately', () => { 146 | const { container } = render(); 147 | const input = container.querySelector('input'); 148 | fireEvent.focus(input); 149 | fireEvent.change(input, { target: { value: '3' } }); 150 | 151 | expect(container.querySelector('input').value).toEqual('3'); 152 | }); 153 | 154 | it('should empty value after removing value', () => { 155 | const onChange = jest.fn(); 156 | const { container } = render(); 157 | const input = container.querySelector('input'); 158 | fireEvent.focus(input); 159 | fireEvent.change(input, { target: { value: '3' } }); 160 | fireEvent.change(input, { target: { value: '' } }); 161 | 162 | expect(container.querySelector('input').value).toEqual(''); 163 | 164 | fireEvent.blur(input); 165 | expect(onChange).toHaveBeenCalledWith(null); 166 | expect(container.querySelector('input').value).toEqual(''); 167 | }); 168 | 169 | it('should trigger onChange when removing value', () => { 170 | const onChange = jest.fn(); 171 | const { container, rerender } = render(); 172 | const input = container.querySelector('input'); 173 | fireEvent.focus(input); 174 | fireEvent.change(input, { target: { value: '1' } }); 175 | expect(container.querySelector('input').value).toEqual('1'); 176 | expect(onChange).toHaveBeenCalledWith(1); 177 | 178 | fireEvent.change(input, { target: { value: '' } }); 179 | expect(container.querySelector('input').value).toEqual(''); 180 | expect(onChange).toHaveBeenCalledWith(null); 181 | 182 | rerender(); 183 | fireEvent.change(input, { target: { value: '2' } }); 184 | expect(container.querySelector('input').value).toEqual('2'); 185 | expect(onChange).toHaveBeenCalledWith(2); 186 | 187 | fireEvent.change(input, { target: { value: '' } }); 188 | expect(container.querySelector('input').value).toEqual(''); 189 | expect(onChange).toHaveBeenCalledWith(null); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /tests/focus.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react'; 2 | import InputNumber, { InputNumberRef } from '../src'; 3 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 4 | import React from 'react'; 5 | 6 | const getInputRef = () => { 7 | const ref = React.createRef(); 8 | render(); 9 | return ref; 10 | }; 11 | 12 | describe('InputNumber.Focus', () => { 13 | let inputSpy: ReturnType; 14 | let focus: ReturnType; 15 | let setSelectionRange: ReturnType; 16 | 17 | beforeEach(() => { 18 | focus = jest.fn(); 19 | setSelectionRange = jest.fn(); 20 | inputSpy = spyElementPrototypes(HTMLInputElement, { 21 | focus, 22 | setSelectionRange, 23 | }); 24 | }); 25 | 26 | afterEach(() => { 27 | inputSpy.mockRestore(); 28 | }); 29 | 30 | it('start', () => { 31 | const input = getInputRef(); 32 | input.current?.focus({ cursor: 'start' }); 33 | 34 | expect(focus).toHaveBeenCalled(); 35 | expect(setSelectionRange).toHaveBeenCalledWith(expect.anything(), 0, 0); 36 | }); 37 | 38 | it('end', () => { 39 | const input = getInputRef(); 40 | input.current?.focus({ cursor: 'end' }); 41 | 42 | expect(focus).toHaveBeenCalled(); 43 | expect(setSelectionRange).toHaveBeenCalledWith(expect.anything(), 5, 5); 44 | }); 45 | 46 | it('all', () => { 47 | const input = getInputRef(); 48 | input.current?.focus({ cursor: 'all' }); 49 | 50 | expect(focus).toHaveBeenCalled(); 51 | expect(setSelectionRange).toHaveBeenCalledWith(expect.anything(), 0, 5); 52 | }); 53 | 54 | it('disabled should reset focus', () => { 55 | const { container, rerender } = render(); 56 | const input = container.querySelector('input')!; 57 | 58 | fireEvent.focus(input); 59 | expect(container.querySelector('.rc-input-number-focused')).toBeTruthy(); 60 | 61 | rerender(); 62 | fireEvent.blur(input); 63 | 64 | expect(container.querySelector('.rc-input-number-focused')).toBeFalsy(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/formatter.test.tsx: -------------------------------------------------------------------------------- 1 | import KeyCode from '@rc-component/util/lib/KeyCode'; 2 | import React from 'react'; 3 | import InputNumber from '../src'; 4 | import { fireEvent, render } from './util/wrapper'; 5 | 6 | describe('InputNumber.Formatter', () => { 7 | it('formatter on default', () => { 8 | const { container } = render( 9 | `$ ${num}`} />, 10 | ); 11 | const input = container.querySelector('input'); 12 | expect(input.value).toEqual('$ 5'); 13 | }); 14 | 15 | it('formatter on mousedown', () => { 16 | const { container } = render( `$ ${num}`} />); 17 | const input = container.querySelector('input'); 18 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 19 | expect(input.value).toEqual('$ 6'); 20 | 21 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 22 | expect(input.value).toEqual('$ 5'); 23 | }); 24 | 25 | it('formatter on keydown', () => { 26 | const onChange = jest.fn(); 27 | const { container } = render( 28 | `$ ${num} ¥`} />, 29 | ); 30 | 31 | const input = container.querySelector('input'); 32 | fireEvent.focus(input); 33 | fireEvent.keyDown(input, { 34 | which: KeyCode.UP, 35 | key: 'ArrowUp', 36 | code: 'ArrowUp', 37 | keyCode: KeyCode.UP, 38 | }); 39 | 40 | expect(input.value).toEqual('$ 6 ¥'); 41 | expect(onChange).toHaveBeenCalledWith(6); 42 | 43 | fireEvent.keyDown(input, { 44 | which: KeyCode.DOWN, 45 | key: 'ArrowDown', 46 | code: 'ArrowDown', 47 | keyCode: KeyCode.DOWN, 48 | }); 49 | expect(input.value).toEqual('$ 5 ¥'); 50 | expect(onChange).toHaveBeenCalledWith(5); 51 | }); 52 | 53 | it('formatter on direct input', () => { 54 | const onChange = jest.fn(); 55 | const { container } = render( 56 | `$ ${num}`} onChange={onChange} />, 57 | ); 58 | const input = container.querySelector('input'); 59 | fireEvent.focus(input); 60 | 61 | fireEvent.change(input, { target: { value: '100' } }); 62 | expect(input.value).toEqual('$ 100'); 63 | expect(onChange).toHaveBeenCalledWith(100); 64 | }); 65 | 66 | it('formatter and parser', () => { 67 | const onChange = jest.fn(); 68 | const { container } = render( 69 | `$ ${num} boeing 737`} 72 | parser={(num) => num.toString().split(' ')[1]} 73 | onChange={onChange} 74 | />, 75 | ); 76 | const input = container.querySelector('input'); 77 | fireEvent.focus(input); 78 | fireEvent.keyDown(input, { 79 | which: KeyCode.UP, 80 | key: 'ArrowUp', 81 | code: 'ArrowUp', 82 | keyCode: KeyCode.UP, 83 | }); 84 | expect(input.value).toEqual('$ 6 boeing 737'); 85 | expect(onChange).toHaveBeenLastCalledWith(6); 86 | 87 | fireEvent.keyDown(input, { 88 | which: KeyCode.DOWN, 89 | key: 'ArrowDown', 90 | code: 'ArrowDown', 91 | keyCode: KeyCode.DOWN, 92 | }); 93 | 94 | expect(input.value).toEqual('$ 5 boeing 737'); 95 | expect(onChange).toHaveBeenLastCalledWith(5); 96 | 97 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up'), { 98 | which: KeyCode.DOWN, 99 | }); 100 | expect(input.value).toEqual('$ 6 boeing 737'); 101 | expect(onChange).toHaveBeenLastCalledWith(6); 102 | }); 103 | 104 | it('control not block user input', async () => { 105 | let numValue; 106 | const Demo = () => { 107 | const [value, setValue] = React.useState(null); 108 | 109 | return ( 110 | 111 | value={value} 112 | onChange={setValue} 113 | formatter={(num, info) => { 114 | if (info.userTyping) { 115 | return info.input; 116 | } 117 | 118 | return String(num); 119 | }} 120 | parser={(num) => { 121 | numValue = num; 122 | return Number(num); 123 | }} 124 | /> 125 | ); 126 | }; 127 | 128 | const { container } = render(); 129 | const input = container.querySelector('input'); 130 | fireEvent.focus(input); 131 | fireEvent.change(input, { target: { value: '-' } }); 132 | fireEvent.change(input, { target: { value: '-0' } }); 133 | 134 | expect(numValue).toEqual('-0'); 135 | 136 | fireEvent.blur(input); 137 | expect(input.value).toEqual('0'); 138 | }); 139 | 140 | it('in strictMode render correct defaultValue ', () => { 141 | const Demo = () => { 142 | return ( 143 | 144 |
145 | `$ ${num}`} /> 146 |
147 |
148 | ); 149 | }; 150 | const { container } = render(); 151 | const input = container.querySelector('input'); 152 | expect(input.value).toEqual('$ 5'); 153 | 154 | fireEvent.change(input, { target: { value: 3 } }); 155 | expect(input.value).toEqual('$ 3'); 156 | }); 157 | 158 | it('formatter info should be correct', () => { 159 | const formatter = jest.fn(); 160 | const { container } = render(); 161 | 162 | formatter.mockReset(); 163 | 164 | fireEvent.change(container.querySelector('input'), { target: { value: '1' } }); 165 | expect(formatter).toHaveBeenCalledTimes(1); 166 | expect(formatter).toHaveBeenCalledWith('1', { userTyping: true, input: '1' }); 167 | }); 168 | 169 | describe('dynamic formatter', () => { 170 | it('uncontrolled', () => { 171 | const { container, rerender } = render( 172 | `$${val}`} />, 173 | ); 174 | 175 | expect(container.querySelector('.rc-input-number-input').value).toEqual( 176 | '$93', 177 | ); 178 | 179 | rerender( `*${val}`} />); 180 | expect(container.querySelector('.rc-input-number-input').value).toEqual( 181 | '*93', 182 | ); 183 | }); 184 | 185 | it('controlled', () => { 186 | const { container, rerender } = render( 187 | `$${val}`} />, 188 | ); 189 | 190 | expect(container.querySelector('.rc-input-number-input').value).toEqual( 191 | '$510', 192 | ); 193 | 194 | rerender( `*${val}`} />); 195 | expect(container.querySelector('.rc-input-number-input').value).toEqual( 196 | '*510', 197 | ); 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /tests/github.test.tsx: -------------------------------------------------------------------------------- 1 | import KeyCode from '@rc-component/util/lib/KeyCode'; 2 | import React from 'react'; 3 | import InputNumber from '../src'; 4 | import { act, fireEvent, render, screen, waitFor } from './util/wrapper'; 5 | 6 | // Github issues 7 | describe('InputNumber.Github', () => { 8 | beforeEach(() => { 9 | jest.useFakeTimers(); 10 | }); 11 | 12 | afterEach(() => { 13 | jest.clearAllTimers(); 14 | jest.useRealTimers(); 15 | }); 16 | 17 | // https://github.com/react-component/input-number/issues/32 18 | it('issue 32', () => { 19 | const { container } = render(); 20 | const input = container.querySelector('input'); 21 | fireEvent.focus(input); 22 | fireEvent.change(input, { target: { value: '2' } }); 23 | expect(input.value).toEqual('2'); 24 | 25 | fireEvent.blur(input); 26 | expect(input.value).toEqual('2.0'); 27 | }); 28 | 29 | // https://github.com/react-component/input-number/issues/197 30 | it('issue 197', () => { 31 | const Demo = () => { 32 | const [value, setValue] = React.useState(NaN); 33 | 34 | return ( 35 | { 39 | setValue(newValue); 40 | }} 41 | /> 42 | ); 43 | }; 44 | const { container } = render(); 45 | const input = container.querySelector('input'); 46 | fireEvent.focus(input); 47 | fireEvent.change(input, { target: { value: 'foo' } }); 48 | }); 49 | 50 | // https://github.com/react-component/input-number/issues/222 51 | it('issue 222', () => { 52 | const Demo = () => { 53 | const [value, setValue] = React.useState(1); 54 | 55 | return ( 56 | { 61 | setValue(newValue); 62 | }} 63 | /> 64 | ); 65 | }; 66 | const { container } = render(); 67 | const input = container.querySelector('input'); 68 | fireEvent.focus(input); 69 | 70 | fireEvent.change(input, { target: { value: 'foo' } }); 71 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 72 | 73 | expect(input.value).toEqual('2'); 74 | }); 75 | 76 | // https://github.com/react-component/input-number/issues/35 77 | it('issue 35', () => { 78 | let num: string | number; 79 | 80 | const { container } = render( 81 | { 85 | num = value; 86 | }} 87 | />, 88 | ); 89 | 90 | for (let i = 1; i <= 400; i += 1) { 91 | fireEvent.keyDown(container.querySelector('input'), { 92 | key: 'ArrowDown', 93 | keyCode: KeyCode.DOWN, 94 | which: KeyCode.DOWN, 95 | }); 96 | const input = container.querySelector('input'); 97 | // no number like 1.5499999999999999 98 | expect((num.toString().split('.')[1] || '').length < 3).toBeTruthy(); 99 | const expectedValue = Number(((200 - i) / 100).toFixed(2)); 100 | expect(input.value).toEqual(String(expectedValue.toFixed(2))); 101 | expect(num).toEqual(expectedValue); 102 | } 103 | 104 | for (let i = 1; i <= 300; i += 1) { 105 | fireEvent.keyDown(container.querySelector('input'), { 106 | key: 'ArrowUp', 107 | keyCode: KeyCode.UP, 108 | which: KeyCode.UP, 109 | code: 'ArrowUp', 110 | }); 111 | const input = container.querySelector('input'); 112 | // no number like 1.5499999999999999 113 | expect((num.toString().split('.')[1] || '').length < 3).toBeTruthy(); 114 | const expectedValue = Number(((i - 200) / 100).toFixed(2)); 115 | expect(input.value).toEqual(String(expectedValue.toFixed(2))); 116 | expect(num).toEqual(expectedValue); 117 | } 118 | }); 119 | 120 | // https://github.com/ant-design/ant-design/issues/4229 121 | it('long press not trigger onChange in uncontrolled component', () => { 122 | const onChange = jest.fn(); 123 | const { container } = render(); 124 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 125 | 126 | act(() => { 127 | jest.advanceTimersByTime(500); 128 | }); 129 | expect(onChange).toHaveBeenCalledWith(1); 130 | 131 | act(() => { 132 | jest.advanceTimersByTime(200); 133 | }); 134 | expect(onChange).toHaveBeenCalledWith(2); 135 | }); 136 | 137 | // https://github.com/ant-design/ant-design/issues/4757 138 | it('should allow to input text like "1."', () => { 139 | const Demo = () => { 140 | const [value, setValue] = React.useState(1.1); 141 | return ( 142 | { 145 | setValue(newValue); 146 | }} 147 | /> 148 | ); 149 | }; 150 | 151 | const { container } = render(); 152 | 153 | const input = container.querySelector('input'); 154 | fireEvent.focus(input); 155 | fireEvent.keyDown(input, { which: KeyCode.ONE }); 156 | fireEvent.keyDown(input, { which: KeyCode.PERIOD }); 157 | fireEvent.change(input, { target: { value: '1.' } }); 158 | expect(input.value).toEqual('1.'); 159 | 160 | fireEvent.blur(input); 161 | expect(input.value).toEqual('1'); 162 | }); 163 | 164 | // https://github.com/ant-design/ant-design/issues/5012 165 | // https://github.com/react-component/input-number/issues/64 166 | it('controller InputNumber should be able to input number like 1.00* and 1.10*', () => { 167 | let num; 168 | 169 | const Demo = () => { 170 | const [value, setValue] = React.useState(2); 171 | 172 | return ( 173 | { 176 | num = newValue; 177 | setValue(newValue); 178 | }} 179 | /> 180 | ); 181 | }; 182 | 183 | const { container, rerender } = render(); 184 | 185 | const input = container.querySelector('input'); 186 | fireEvent.focus(input); 187 | // keydown => 6.0 188 | fireEvent.keyDown(input, { keyCode: KeyCode.SIX }); 189 | fireEvent.keyDown(input, { which: KeyCode.PERIOD }); 190 | fireEvent.keyDown(input, { which: KeyCode.ZERO }); 191 | fireEvent.change(input, { target: { value: '6.0' } }); 192 | expect(input.value).toEqual('6.0'); 193 | expect(num).toEqual(6); 194 | 195 | fireEvent.blur(input); 196 | expect(input.value).toEqual('6'); 197 | expect(num).toEqual(6); 198 | 199 | rerender(); 200 | fireEvent.focus(input); 201 | fireEvent.keyDown(input, { which: KeyCode.SIX }); 202 | fireEvent.keyDown(input, { which: KeyCode.PERIOD }); 203 | fireEvent.keyDown(input, { which: KeyCode.ONE }); 204 | fireEvent.keyDown(input, { which: KeyCode.ZERO }); 205 | fireEvent.change(input, { target: { value: '6.10' } }); 206 | expect(input.value).toEqual('6.10'); 207 | expect(num).toEqual(6.1); 208 | 209 | fireEvent.blur(input); 210 | expect(input.value).toEqual('6.1'); 211 | expect(num).toEqual(6.1); 212 | }); 213 | 214 | it('onChange should not be called when input is not changed', () => { 215 | const onChange = jest.fn(); 216 | const onInput = jest.fn(); 217 | 218 | const { container } = render(); 219 | 220 | const input = container.querySelector('input'); 221 | fireEvent.focus(input); 222 | fireEvent.change(input, { target: { value: '1' } }); 223 | expect(onChange).toHaveBeenCalledTimes(1); 224 | expect(onChange).toHaveBeenCalledWith(1); 225 | expect(onInput).toHaveBeenCalledTimes(1); 226 | expect(onInput).toHaveBeenCalledWith('1'); 227 | 228 | fireEvent.blur(input); 229 | expect(onChange).toHaveBeenCalledTimes(1); 230 | expect(onInput).toHaveBeenCalledTimes(1); 231 | 232 | fireEvent.focus(input); 233 | fireEvent.change(input, { target: { value: '' } }); 234 | expect(onChange).toHaveBeenCalledTimes(2); 235 | expect(onInput).toHaveBeenCalledTimes(2); 236 | expect(onInput).toHaveBeenCalledWith(''); 237 | 238 | fireEvent.blur(input); 239 | expect(onChange).toHaveBeenCalledTimes(2); 240 | expect(onInput).toHaveBeenCalledTimes(2); 241 | expect(onChange).toHaveBeenLastCalledWith(null); 242 | 243 | fireEvent.focus(input); 244 | fireEvent.blur(input); 245 | expect(onChange).toHaveBeenCalledTimes(2); 246 | expect(onInput).toHaveBeenCalledTimes(2); 247 | }); 248 | 249 | // https://github.com/ant-design/ant-design/issues/5235 250 | it('input long number', () => { 251 | const { container } = render(); 252 | const input = container.querySelector('input'); 253 | fireEvent.focus(input); 254 | fireEvent.change(input, { target: { value: '111111111111111111111' } }); 255 | expect(input.value).toEqual('111111111111111111111'); 256 | fireEvent.change(input, { target: { value: '11111111111111111111111111111' } }); 257 | expect(input.value).toEqual('11111111111111111111111111111'); 258 | }); 259 | 260 | // https://github.com/ant-design/ant-design/issues/7363 261 | it('uncontrolled input should trigger onChange always when blur it', () => { 262 | const onChange = jest.fn(); 263 | const onInput = jest.fn(); 264 | const { container } = render( 265 | , 266 | ); 267 | 268 | const input = container.querySelector('input'); 269 | fireEvent.focus(input); 270 | fireEvent.change(input, { target: { value: '123' } }); 271 | expect(onChange).toHaveBeenCalledTimes(0); 272 | expect(onInput).toHaveBeenCalledTimes(1); 273 | expect(onInput).toHaveBeenCalledWith('123'); 274 | 275 | fireEvent.blur(input); 276 | expect(onChange).toHaveBeenCalledTimes(1); 277 | expect(onChange).toHaveBeenCalledWith(10); 278 | expect(onInput).toHaveBeenCalledTimes(1); 279 | 280 | // repeat it, it should works in same way 281 | fireEvent.focus(input); 282 | fireEvent.change(input, { target: { value: '123' } }); 283 | expect(onChange).toHaveBeenCalledTimes(1); 284 | expect(onInput).toHaveBeenCalledTimes(2); 285 | expect(onInput).toHaveBeenCalledWith('123'); 286 | 287 | fireEvent.blur(input); 288 | expect(onChange).toHaveBeenCalledTimes(1); 289 | expect(onInput).toHaveBeenCalledTimes(2); 290 | }); 291 | 292 | // https://github.com/ant-design/ant-design/issues/30465 293 | it('not block user input with min & max', () => { 294 | const onChange = jest.fn(); 295 | const { container } = render(); 296 | 297 | const input = container.querySelector('input'); 298 | fireEvent.focus(input); 299 | 300 | fireEvent.change(input, { target: { value: '2' } }); 301 | expect(onChange).not.toHaveBeenCalled(); 302 | 303 | fireEvent.change(input, { target: { value: '20' } }); 304 | expect(onChange).not.toHaveBeenCalled(); 305 | 306 | fireEvent.change(input, { target: { value: '200' } }); 307 | expect(onChange).not.toHaveBeenCalled(); 308 | 309 | fireEvent.change(input, { target: { value: '2000' } }); 310 | expect(onChange).toHaveBeenCalledWith(2000); 311 | onChange.mockRestore(); 312 | 313 | fireEvent.change(input, { target: { value: '1' } }); 314 | expect(onChange).not.toHaveBeenCalled(); 315 | 316 | fireEvent.blur(input); 317 | expect(onChange).toHaveBeenCalledWith(1900); 318 | }); 319 | 320 | // https://github.com/ant-design/ant-design/issues/7867 321 | it('focus should not cut precision of input value', () => { 322 | const Demo = () => { 323 | const [value, setValue] = React.useState(2); 324 | return ( 325 | { 329 | setValue(2); 330 | }} 331 | /> 332 | ); 333 | }; 334 | 335 | const { container } = render(); 336 | 337 | const input = container.querySelector('input'); 338 | fireEvent.focus(input); 339 | fireEvent.blur(input); 340 | 341 | expect(input.value).toEqual('2.0'); 342 | 343 | fireEvent.focus(input); 344 | expect(input.value).toEqual('2.0'); 345 | }); 346 | 347 | // https://github.com/ant-design/ant-design/issues/7940 348 | it('should not format during input', () => { 349 | let num; 350 | const Demo = () => { 351 | const [value, setValue] = React.useState(''); 352 | return ( 353 | { 357 | setValue(newValue); 358 | num = newValue; 359 | }} 360 | /> 361 | ); 362 | }; 363 | 364 | const { container } = render(); 365 | 366 | const input = container.querySelector('input'); 367 | fireEvent.focus(input); 368 | fireEvent.change(input, { target: { value: '1' } }); 369 | 370 | fireEvent.blur(input); 371 | expect(input.value).toEqual('1.0'); 372 | expect(num).toEqual(1); 373 | }); 374 | 375 | // https://github.com/ant-design/ant-design/issues/8196 376 | it('Allow input 。', async () => { 377 | const onChange = jest.fn(); 378 | const { container } = render(); 379 | const input = container.querySelector('input'); 380 | fireEvent.change(input, { target: { value: '8。1' } }); 381 | fireEvent.blur(input); 382 | 383 | await waitFor(() => expect(input.value).toEqual('8.1')); 384 | await waitFor(() => expect(onChange).toHaveBeenCalledWith(8.1)); 385 | }); 386 | 387 | // https://github.com/ant-design/ant-design/issues/25614 388 | it("focus value should be '' when clear the input", () => { 389 | let targetValue: string; 390 | 391 | const { container } = render( 392 | { 396 | targetValue = e.target.value; 397 | }} 398 | value={1} 399 | />, 400 | ); 401 | const input = container.querySelector('input'); 402 | fireEvent.focus(input); 403 | fireEvent.change(input, { target: { value: '' } }); 404 | fireEvent.blur(input); 405 | expect(targetValue).toEqual(''); 406 | }); 407 | 408 | it('should set input value as formatted when blur', () => { 409 | let valueOnBlur: string; 410 | 411 | const { container } = render( 412 | { 414 | valueOnBlur = e.target.value; 415 | }} 416 | formatter={(value) => `${Number(value) * 100}%`} 417 | value={1} 418 | />, 419 | ); 420 | const input = container.querySelector('input'); 421 | fireEvent.blur(input); 422 | expect(input.value).toEqual('100%'); 423 | expect(valueOnBlur).toEqual('100%'); 424 | }); 425 | 426 | // https://github.com/ant-design/ant-design/issues/11574 427 | // Origin: should trigger onChange when max or min change 428 | it('warning UI when max or min change', () => { 429 | const onChange = jest.fn(); 430 | const { container, rerender } = render( 431 | , 432 | ); 433 | const input = container.querySelector('input'); 434 | expect(container.querySelector('.rc-input-number-out-of-range')).toBe(null); 435 | rerender(); 436 | expect(input.value).toEqual('10'); 437 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 438 | expect(onChange).toHaveBeenCalledTimes(0); 439 | 440 | rerender(); 441 | // wrapper.update(); 442 | 443 | expect(input.value).toEqual('15'); 444 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 445 | expect(onChange).toHaveBeenCalledTimes(0); 446 | }); 447 | 448 | // https://github.com/react-component/input-number/issues/120 449 | it('should not reset value when parent re-render with the same `value` prop', () => { 450 | const Demo = () => { 451 | const [, forceUpdate] = React.useState({}); 452 | 453 | return ( 454 | { 457 | forceUpdate({}); 458 | }} 459 | /> 460 | ); 461 | }; 462 | 463 | const { container } = render(); 464 | const input = container.querySelector('input'); 465 | fireEvent.focus(input); 466 | fireEvent.change(input, { target: { value: '401' } }); 467 | 468 | // Demo re-render and the `value` prop is still 40, but the user input should be retained 469 | expect(input.value).toEqual('401'); 470 | }); 471 | 472 | // https://github.com/ant-design/ant-design/issues/16710 473 | it('should use correct precision when change it to 0', () => { 474 | const Demo = () => { 475 | const [precision, setPrecision] = React.useState(2); 476 | 477 | return ( 478 |
479 | { 481 | setPrecision(newPrecision); 482 | }} 483 | data-testid="first" 484 | /> 485 | 486 |
487 | ); 488 | }; 489 | 490 | render(); 491 | fireEvent.change(screen.getByTestId('last'), { target: { value: '1.23' } }); 492 | fireEvent.change(screen.getByTestId('first'), { target: { value: '0' } }); 493 | 494 | expect(screen.getByTestId('last').value).toEqual('1'); 495 | }); 496 | 497 | // https://github.com/ant-design/ant-design/issues/30478 498 | it('-0 should input able', () => { 499 | const { container } = render(); 500 | const input = container.querySelector('input'); 501 | fireEvent.change(input, { target: { value: '-' } }); 502 | fireEvent.change(input, { target: { value: '-0' } }); 503 | expect(input.value).toEqual('-0'); 504 | }); 505 | 506 | // https://github.com/ant-design/ant-design/issues/32274 507 | it('global modify when typing', () => { 508 | const Demo = ({ value }: { value?: number }) => { 509 | const [val, setVal] = React.useState(7); 510 | 511 | React.useEffect(() => { 512 | if (value) { 513 | setVal(value); 514 | } 515 | }, [value]); 516 | 517 | return ; 518 | }; 519 | const { container, rerender } = render(); 520 | const input = container.querySelector('input'); 521 | // Click 522 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 523 | expect(input.value).toEqual('8'); 524 | 525 | // Keyboard change 526 | rerender(); 527 | expect(input.value).toEqual('3'); 528 | }); 529 | 530 | // https://github.com/ant-design/ant-design/issues/36641 531 | it('min value should be worked as expect', () => { 532 | const onChange = jest.fn(); 533 | const { container } = render( 534 | , 535 | ); 536 | 537 | expect(container.querySelector('input').value).toEqual('0.00'); 538 | 539 | fireEvent.change(container.querySelector('input'), { 540 | target: { 541 | value: '0', 542 | }, 543 | }); 544 | fireEvent.blur(container.querySelector('input')); 545 | 546 | expect(onChange).toHaveBeenCalledWith(0); 547 | }); 548 | }); 549 | -------------------------------------------------------------------------------- /tests/input.test.tsx: -------------------------------------------------------------------------------- 1 | import KeyCode from '@rc-component/util/lib/KeyCode'; 2 | import React from 'react'; 3 | import InputNumber, { InputNumberProps, InputNumberRef } from '../src'; 4 | import { fireEvent, render } from './util/wrapper'; 5 | 6 | describe('InputNumber.Input', () => { 7 | function loopInput(input: HTMLElement, text: string) { 8 | for (let i = 0; i < text.length; i += 1) { 9 | const inputTxt = text.slice(0, i + 1); 10 | fireEvent.change(input, { target: { value: inputTxt } }); 11 | } 12 | } 13 | 14 | function prepareWrapper(text: string, props?: Partial, skipInputCheck = false) { 15 | const { container } = render(); 16 | const input = container.querySelector('input'); 17 | fireEvent.focus(input); 18 | for (let i = 0; i < text.length; i += 1) { 19 | const inputTxt = text.slice(0, i + 1); 20 | fireEvent.change(input, { target: { value: inputTxt } }); 21 | } 22 | 23 | if (!skipInputCheck) { 24 | expect(input.value).toEqual(text); 25 | } 26 | fireEvent.blur(input); 27 | return input; 28 | } 29 | 30 | it('input valid number', () => { 31 | const wrapper = prepareWrapper('6'); 32 | 33 | expect(wrapper.value).toEqual('6'); 34 | }); 35 | 36 | it('input invalid number', () => { 37 | const wrapper = prepareWrapper('xx'); 38 | 39 | expect(wrapper.value).toEqual(''); 40 | }); 41 | 42 | it('input invalid string with number', () => { 43 | const wrapper = prepareWrapper('2x'); 44 | 45 | expect(wrapper.value).toEqual('2'); 46 | }); 47 | 48 | it('input invalid decimal point with max number', () => { 49 | const wrapper = prepareWrapper('15.', { max: 10 }); 50 | expect(wrapper.value).toEqual('10'); 51 | }); 52 | 53 | it('input invalid decimal point with min number', () => { 54 | const wrapper = prepareWrapper('3.', { min: 5 }); 55 | expect(wrapper.value).toEqual('5'); 56 | }); 57 | 58 | it('input negative symbol', () => { 59 | const wrapper = prepareWrapper('-'); 60 | expect(wrapper.value).toEqual(''); 61 | }); 62 | 63 | it('input negative number', () => { 64 | const wrapper = prepareWrapper('-98'); 65 | expect(wrapper.value).toEqual('-98'); 66 | }); 67 | 68 | it('negative min with higher precision', () => { 69 | const wrapper = prepareWrapper('-4', { min: -3.5, precision: 0 }); 70 | expect(wrapper.value).toEqual('-3'); 71 | }); 72 | 73 | it('positive min with higher precision', () => { 74 | const wrapper = prepareWrapper('4', { min: 3.5, precision: 0 }); 75 | expect(wrapper.value).toEqual('4'); 76 | }); 77 | 78 | it('negative max with higher precision', () => { 79 | const wrapper = prepareWrapper('-4', { max: -3.5, precision: 0 }); 80 | expect(wrapper.value).toEqual('-4'); 81 | }); 82 | 83 | it('positive max with higher precision', () => { 84 | const wrapper = prepareWrapper('4', { max: 3.5, precision: 0 }); 85 | expect(wrapper.value).toEqual('3'); 86 | }); 87 | 88 | // https://github.com/ant-design/ant-design/issues/9439 89 | it('input negative zero', async () => { 90 | const wrapper = await prepareWrapper('-0', {}, true); 91 | expect(wrapper.value).toEqual('0'); 92 | }); 93 | 94 | it('input decimal number with integer step', () => { 95 | const wrapper = prepareWrapper('1.2', { step: 1.2 }); 96 | expect(wrapper.value).toEqual('1.2'); 97 | }); 98 | 99 | it('input decimal number with decimal step', () => { 100 | const wrapper = prepareWrapper('1.2', { step: 0.1 }); 101 | expect(wrapper.value).toEqual('1.2'); 102 | }); 103 | 104 | it('input empty text and blur', () => { 105 | const wrapper = prepareWrapper(''); 106 | expect(wrapper.value).toEqual(''); 107 | }); 108 | 109 | it('blur on default input', () => { 110 | const onChange = jest.fn(); 111 | const { container } = render(); 112 | fireEvent.blur(container.querySelector('input')); 113 | expect(onChange).not.toHaveBeenCalled(); 114 | }); 115 | 116 | it('pressEnter works', () => { 117 | const onPressEnter = jest.fn(); 118 | const { container } = render(); 119 | fireEvent.keyDown(container.querySelector('.rc-input-number'), { 120 | key: 'Enter', 121 | keyCode: KeyCode.ENTER, 122 | which: KeyCode.ENTER, 123 | }); 124 | expect(onPressEnter).toHaveBeenCalled(); 125 | expect(onPressEnter).toHaveBeenCalledTimes(1); 126 | }); 127 | 128 | it('pressEnter value should be ok', () => { 129 | const Demo = () => { 130 | const [value, setValue] = React.useState(1); 131 | const inputRef = React.useRef(null); 132 | return ( 133 | { 137 | setValue(Number(inputRef.current.value)); 138 | }} 139 | /> 140 | ); 141 | }; 142 | 143 | const { container } = render(); 144 | const input = container.querySelector('input'); 145 | fireEvent.focus(input); 146 | fireEvent.change(input, { target: { value: '3' } }); 147 | fireEvent.keyDown(input, { which: KeyCode.ENTER }); 148 | expect(input.value).toEqual('3'); 149 | fireEvent.change(input, { target: { value: '5' } }); 150 | fireEvent.keyDown(input, { which: KeyCode.ENTER }); 151 | expect(input.value).toEqual('5'); 152 | }); 153 | 154 | it('keydown Tab, after change value should be ok', () => { 155 | let outSetValue; 156 | 157 | const Demo = () => { 158 | const [value, setValue] = React.useState(1); 159 | outSetValue = setValue; 160 | return setValue(val)} />; 161 | }; 162 | 163 | const { container } = render(); 164 | const input = container.querySelector('input'); 165 | fireEvent.keyDown(input, { which: KeyCode.TAB }); 166 | fireEvent.blur(input); 167 | expect(input.value).toEqual('1'); 168 | outSetValue(5); 169 | fireEvent.focus(input); 170 | expect(input.value).toEqual('5'); 171 | }); 172 | 173 | // https://github.com/ant-design/ant-design/issues/40733 174 | it('input combo should be correct', () => { 175 | const onChange = jest.fn(); 176 | const input = prepareWrapper('', { 177 | onChange, 178 | precision: 0, 179 | }); 180 | 181 | onChange.mockReset(); 182 | 183 | fireEvent.focus(input); 184 | loopInput(input, '1.55.55'); 185 | expect(onChange).not.toHaveBeenCalledWith(2); 186 | 187 | fireEvent.blur(input); 188 | expect(input.value).toEqual('2'); 189 | expect(onChange).toHaveBeenCalledWith(2); 190 | }); 191 | 192 | describe('empty on blur should trigger null', () => { 193 | it('basic', () => { 194 | const onChange = jest.fn(); 195 | const { container } = render(); 196 | const input = container.querySelector('input'); 197 | fireEvent.change(input, { target: { value: '' } }); 198 | expect(onChange).toHaveBeenCalledWith(null); 199 | 200 | fireEvent.blur(input); 201 | expect(onChange).toHaveBeenLastCalledWith(null); 202 | }); 203 | 204 | it('min range', () => { 205 | const onChange = jest.fn(); 206 | const { container } = render(); 207 | const input = container.querySelector('input'); 208 | fireEvent.change(input, { target: { value: '' } }); 209 | expect(onChange).toHaveBeenCalled(); 210 | 211 | fireEvent.blur(input); 212 | expect(onChange).toHaveBeenLastCalledWith(null); 213 | }); 214 | }); 215 | 216 | it('!changeOnBlur', () => { 217 | const onChange = jest.fn(); 218 | 219 | const { container } = render( 220 | , 221 | ); 222 | 223 | fireEvent.blur(container.querySelector('input')); 224 | expect(onChange).not.toHaveBeenCalled(); 225 | }); 226 | 227 | describe('nativeElement', () => { 228 | it('basic', () => { 229 | const ref = React.createRef(); 230 | const { container } = render(); 231 | expect(ref.current.nativeElement).toBe(container.querySelector('.rc-input-number')); 232 | }); 233 | 234 | it('wrapper', () => { 235 | const ref = React.createRef(); 236 | const { container } = render(); 237 | expect(ref.current.nativeElement).toBe( 238 | container.querySelector('.rc-input-number-affix-wrapper'), 239 | ); 240 | }); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /tests/keyboard.test.tsx: -------------------------------------------------------------------------------- 1 | import KeyCode from '@rc-component/util/lib/KeyCode'; 2 | import InputNumber from '../src'; 3 | import { fireEvent, render } from './util/wrapper'; 4 | 5 | describe('InputNumber.Keyboard', () => { 6 | it('up', () => { 7 | const onChange = jest.fn(); 8 | const { container } = render(); 9 | fireEvent.keyDown(container.querySelector('input'), { 10 | which: KeyCode.UP, 11 | key: 'ArrowUp', 12 | keyCode: KeyCode.UP, 13 | }); 14 | expect(onChange).toHaveBeenCalledWith(1); 15 | }); 16 | 17 | it('up with pressing shift key', () => { 18 | const onChange = jest.fn(); 19 | const { container } = render(); 20 | fireEvent.keyDown(container.querySelector('input'), { 21 | which: KeyCode.UP, 22 | key: 'ArrowUp', 23 | keyCode: KeyCode.UP, 24 | shiftKey: true, 25 | }); 26 | expect(onChange).toHaveBeenCalledWith(1.3); 27 | }); 28 | 29 | it('down', () => { 30 | const onChange = jest.fn(); 31 | const { container } = render(); 32 | fireEvent.keyDown(container.querySelector('input'), { 33 | which: KeyCode.DOWN, 34 | key: 'ArrowDown', 35 | keyCode: KeyCode.DOWN, 36 | }); 37 | expect(onChange).toHaveBeenCalledWith(-1); 38 | }); 39 | 40 | it('down with pressing shift key', () => { 41 | const onChange = jest.fn(); 42 | const { container } = render(); 43 | fireEvent.keyDown(container.querySelector('input'), { 44 | which: KeyCode.DOWN, 45 | key: 'ArrowDown', 46 | keyCode: KeyCode.DOWN, 47 | shiftKey: true, 48 | }); 49 | expect(onChange).toHaveBeenCalledWith(1.1); 50 | }); 51 | 52 | // shift + 10, ctrl + 0.1 test case removed 53 | 54 | it('disabled keyboard', () => { 55 | const onChange = jest.fn(); 56 | const { container } = render(); 57 | 58 | fireEvent.keyDown(container.querySelector('input'), { which: KeyCode.UP, key: 'ArrowUp' }); 59 | expect(onChange).not.toHaveBeenCalled(); 60 | 61 | fireEvent.keyDown(container.querySelector('input'), { which: KeyCode.DOWN, key: 'ArrowDown' }); 62 | expect(onChange).not.toHaveBeenCalled(); 63 | }); 64 | 65 | it('enter to trigger onChange with precision', () => { 66 | const onChange = jest.fn(); 67 | const { container } = render(); 68 | const input = container.querySelector('input'); 69 | fireEvent.change(input, { target: { value: '2.3333' } }); 70 | expect(onChange).toHaveBeenCalledWith(2.3333); 71 | onChange.mockReset(); 72 | 73 | fireEvent.keyDown(input, { which: KeyCode.ENTER, key: 'Enter', keyCode: KeyCode.ENTER }); 74 | expect(onChange).toHaveBeenCalledWith(2); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/longPress.test.tsx: -------------------------------------------------------------------------------- 1 | import InputNumber from '../src'; 2 | import { act, fireEvent, render, waitFor } from './util/wrapper'; 3 | 4 | // Jest will mass of advanceTimersByTime if other test case not use fakeTimer. 5 | // Let's create a pure file here for test. 6 | 7 | describe('InputNumber.LongPress', () => { 8 | beforeAll(() => { 9 | jest.useFakeTimers(); 10 | }); 11 | 12 | afterAll(() => { 13 | jest.useRealTimers(); 14 | }); 15 | 16 | it('up button works', async () => { 17 | const onChange = jest.fn(); 18 | const { container } = render(); 19 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 20 | act(() => { 21 | jest.advanceTimersByTime(600 + 200 * 5 + 100); 22 | }); 23 | await waitFor(() => expect(onChange).toHaveBeenCalledWith(26)); 24 | }); 25 | 26 | it('down button works', async () => { 27 | const onChange = jest.fn(); 28 | const { container } = render(); 29 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 30 | 31 | act(() => { 32 | jest.advanceTimersByTime(600 + 200 * 5 + 100); 33 | }); 34 | await waitFor(() => expect(onChange).toHaveBeenCalledWith(14)); 35 | }); 36 | 37 | it('Simulates event calls out of order in Safari', async () => { 38 | const onChange = jest.fn(); 39 | const { container } = render(); 40 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 41 | act(() => { 42 | jest.advanceTimersByTime(10); 43 | }); 44 | fireEvent.mouseUp(container.querySelector('.rc-input-number-handler-up')); 45 | act(() => { 46 | jest.advanceTimersByTime(10); 47 | }); 48 | fireEvent.mouseUp(container.querySelector('.rc-input-number-handler-up')); 49 | act(() => { 50 | jest.advanceTimersByTime(10); 51 | }); 52 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 53 | act(() => { 54 | jest.advanceTimersByTime(10); 55 | }); 56 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 57 | act(() => { 58 | jest.advanceTimersByTime(10); 59 | }); 60 | fireEvent.mouseUp(container.querySelector('.rc-input-number-handler-up')); 61 | 62 | act(() => { 63 | jest.advanceTimersByTime(600 + 200 * 5 + 100); 64 | }); 65 | 66 | await waitFor(() => expect(onChange).toBeCalledTimes(3)); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /tests/mobile.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from './util/wrapper'; 3 | import InputNumber from '../src'; 4 | import { renderToString } from 'react-dom/server'; 5 | 6 | jest.mock('@rc-component/util/lib/isMobile', () => () => true); 7 | 8 | // Mobile touch experience is not user-friendly which not apply in antd. 9 | // Let's hide operator instead. 10 | 11 | describe('InputNumber.Mobile', () => { 12 | it('not show steps when mobile', () => { 13 | const {container} = render(); 14 | expect(container.querySelector('.rc-input-number-handler-wrap')).toBeFalsy(); 15 | }); 16 | 17 | it('should render in server side', () => { 18 | const serverHTML = renderToString(); 19 | expect(serverHTML).toContain('rc-input-number-handler-wrap'); 20 | }) 21 | }); 22 | -------------------------------------------------------------------------------- /tests/precision.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom'; 3 | import { render, fireEvent } from '@testing-library/react'; 4 | import KeyCode from '@rc-component/util/lib/KeyCode'; 5 | import InputNumber from '../src'; 6 | 7 | describe('InputNumber.Precision', () => { 8 | // https://github.com/react-component/input-number/issues/506 9 | it('Safari bug of input', async () => { 10 | const Demo = () => { 11 | const [value, setValue] = React.useState(null); 12 | 13 | return ; 14 | }; 15 | 16 | const { container } = render(); 17 | const input = container.querySelector('input'); 18 | 19 | // React use SyntheticEvent to handle `onBeforeInput`, let's mock this 20 | fireEvent.keyPress(input, { 21 | which: KeyCode.TWO, 22 | keyCode: KeyCode.TWO, 23 | char: '2', 24 | }); 25 | 26 | fireEvent.change(input, { 27 | target: { 28 | value: '2', 29 | }, 30 | }); 31 | 32 | expect(input.value).toEqual('2'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/props.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '@testing-library/jest-dom'; 3 | import { render, fireEvent } from '@testing-library/react'; 4 | import KeyCode from '@rc-component/util/lib/KeyCode'; 5 | import type { ValueType } from '../src' 6 | import InputNumber from '../src'; 7 | 8 | describe('InputNumber.Props', () => { 9 | 10 | it('max', () => { 11 | const onChange = jest.fn(); 12 | const { container } = render(); 13 | for (let i = 0; i < 100; i += 1) { 14 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 15 | } 16 | 17 | expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toEqual(10); 18 | 19 | expect(container.querySelector('input')).toHaveAttribute('aria-valuemax', '10'); 20 | expect(container.querySelector('input')).toHaveAttribute('aria-valuenow', '10'); 21 | }); 22 | 23 | it('min', () => { 24 | const onChange = jest.fn(); 25 | const { container } = render(); 26 | for (let i = 0; i < 100; i += 1) { 27 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 28 | } 29 | 30 | expect(onChange.mock.calls[onChange.mock.calls.length - 1][0]).toEqual(-10); 31 | 32 | expect(container.querySelector('input')).toHaveAttribute('aria-valuemin', '-10'); 33 | expect(container.querySelector('input')).toHaveAttribute('aria-valuenow', '-10'); 34 | }); 35 | 36 | it('disabled', () => { 37 | const onChange = jest.fn(); 38 | const { container } = render(); 39 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 40 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 41 | expect(container.querySelector('.rc-input-number-disabled')).toBeTruthy(); 42 | expect(onChange).not.toHaveBeenCalled(); 43 | }); 44 | 45 | it('readOnly', () => { 46 | const onChange = jest.fn(); 47 | const { container } = render(); 48 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 49 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 50 | fireEvent.keyDown(container.querySelector('input'), { which: KeyCode.UP }); 51 | fireEvent.keyDown(container.querySelector('input'), { which: KeyCode.DOWN }); 52 | expect(container.querySelector('.rc-input-number-readonly')).toBeTruthy(); 53 | expect(onChange).not.toHaveBeenCalled(); 54 | }); 55 | 56 | it('autofocus', (done) => { 57 | const onFocus = jest.fn(); 58 | const { container } = render(); 59 | const input = container.querySelector('input'); 60 | setTimeout(() => { 61 | expect(input).toHaveFocus(); 62 | done(); 63 | }, 500); 64 | 65 | }); 66 | 67 | describe('step', () => { 68 | it('basic', () => { 69 | const onChange = jest.fn(); 70 | 71 | const { container } = render(); 72 | for (let i = 0; i < 3; i += 1) { 73 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 74 | expect(onChange).toHaveBeenCalledWith(-5 * (i + 1)); 75 | } 76 | expect(container.querySelector('input')).toHaveAttribute('step', '5'); 77 | }); 78 | 79 | it('basic with pressing shift key', () => { 80 | const onChange = jest.fn(); 81 | const { container } = render(); 82 | 83 | for (let i = 0; i < 3; i += 1) { 84 | fireEvent.keyDown(container.querySelector('.rc-input-number-handler-down'), { 85 | shiftKey: true, 86 | }); 87 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 88 | 89 | expect(onChange).toHaveBeenCalledWith(-5 * (i + 1) * 10); 90 | } 91 | }); 92 | 93 | it('stringMode', () => { 94 | const onChange = jest.fn(); 95 | const { container } = render( 96 | , 102 | ); 103 | 104 | for (let i = 0; i < 11; i += 1) { 105 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 106 | } 107 | 108 | expect(onChange).toHaveBeenCalledWith('-0.00000001'); 109 | }); 110 | 111 | it('stringMode with pressing shift key', () => { 112 | const onChange = jest.fn(); 113 | const { container } = render( 114 | , 120 | ); 121 | 122 | for (let i = 0; i < 11; i += 1) { 123 | fireEvent.keyDown(container.querySelector('.rc-input-number-handler-down'), { 124 | shiftKey: true, 125 | }); 126 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 127 | } 128 | 129 | expect(onChange).toHaveBeenCalledWith('-0.00000001'); // -1e-8 130 | }); 131 | 132 | it('decimal', () => { 133 | const onChange = jest.fn(); 134 | const { container } = render( 135 | , 136 | ); 137 | for (let i = 0; i < 3; i += 1) { 138 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 139 | } 140 | expect(onChange).toHaveBeenCalledWith(1.2); 141 | }); 142 | 143 | it('decimal with pressing shift key', () => { 144 | const onChange = jest.fn(); 145 | const { container } = render( 146 | , 147 | ); 148 | for (let i = 0; i < 3; i += 1) { 149 | fireEvent.keyDown(container.querySelector('input'), { 150 | shiftKey: true, 151 | which: KeyCode.UP, 152 | key: 'ArrowUp', 153 | code: 'ArrowUp', 154 | keyCode: KeyCode.UP, 155 | }); 156 | } 157 | expect(onChange).toHaveBeenCalledWith(3.9); 158 | }); 159 | }); 160 | 161 | describe('controlled', () => { 162 | it('restore when blur input', () => { 163 | const { container } = render(); 164 | const input = container.querySelector('input'); 165 | fireEvent.focus(input); 166 | fireEvent.change(input, { target: { value: '3' } }); 167 | expect(input.value).toEqual('3'); 168 | 169 | fireEvent.blur(input); 170 | expect(input.value).toEqual('9'); 171 | }); 172 | 173 | it('dynamic change value', () => { 174 | const { container, rerender } = render(); 175 | const input = container.querySelector('input'); 176 | rerender(); 177 | expect(input.value).toEqual('3'); 178 | }); 179 | 180 | // Origin https://github.com/ant-design/ant-design/issues/7334 181 | // zombieJ: We should error this instead of auto change back to a normal value since it makes un-controlled 182 | it('show limited value when input is not focused', () => { 183 | const Demo = () => { 184 | const [value, setValue] = React.useState(2); 185 | 186 | return ( 187 |
188 | 196 | 197 |
198 | ); 199 | }; 200 | 201 | const { container } = render(); 202 | const input = container.querySelector('input'); 203 | expect(input.value).toEqual('2'); 204 | 205 | fireEvent.click(container.querySelector('button')); 206 | expect(input.value).toEqual('103aa'); 207 | expect(container.querySelector('.rc-input-number-not-a-number')).toBeTruthy(); 208 | }); 209 | 210 | // https://github.com/ant-design/ant-design/issues/7358 211 | it('controlled component should accept undefined value', () => { 212 | const Demo = () => { 213 | const [value, setValue] = React.useState(2); 214 | 215 | return ( 216 |
217 | 225 | 226 |
227 | ); 228 | }; 229 | 230 | const { container } = render(); 231 | const input = container.querySelector('input'); 232 | expect(input.value).toEqual('2'); 233 | 234 | fireEvent.click(container.querySelector('button')); 235 | expect(input.value).toEqual(''); 236 | }); 237 | }); 238 | 239 | describe('defaultValue', () => { 240 | it('default value should be empty', () => { 241 | const { container } = render(); 242 | const input = container.querySelector('input'); 243 | expect(input.value).toEqual(''); 244 | }); 245 | 246 | it('default value should be empty when step is decimal', () => { 247 | const { container } = render(); 248 | const input = container.querySelector('input'); 249 | expect(input.value).toEqual(''); 250 | }); 251 | 252 | it('default value should be 1', () => { 253 | const { container } = render(); 254 | const input = container.querySelector('input'); 255 | expect(input.value).toEqual('1'); 256 | }); 257 | 258 | it('default value could be null', () => { 259 | const { container } = render(); 260 | const input = container.querySelector('input'); 261 | expect(input.value).toEqual(''); 262 | }); 263 | 264 | it('warning when defaultValue higher than max', () => { 265 | const { container } = render(); 266 | const input = container.querySelector('input'); 267 | expect(input.value).toEqual('13'); 268 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 269 | }); 270 | 271 | it('warning when defaultValue lower than min', () => { 272 | const { container } = render(); 273 | const input = container.querySelector('input'); 274 | expect(input.value).toEqual('-1'); 275 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 276 | }); 277 | 278 | it('default value can be a string greater than 16 characters', () => { 279 | const { container } = render( max={10} defaultValue='-3.637978807091713e-12' />); 280 | const input = container.querySelector('input'); 281 | expect(input.value).toEqual('-0.000000000003637978807091713'); 282 | }); 283 | 284 | it('invalidate defaultValue', () => { 285 | const { container } = render(); 286 | const input = container.querySelector('input'); 287 | expect(input.value).toEqual('light'); 288 | }); 289 | }); 290 | 291 | describe('value', () => { 292 | it('value shouldn\'t higher than max', () => { 293 | const { container } = render(); 294 | const input = container.querySelector('input'); 295 | expect(input.value).toEqual('13'); 296 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 297 | }); 298 | 299 | it('value shouldn\'t lower than min', () => { 300 | const { container } = render(); 301 | const input = container.querySelector('input'); 302 | expect(input.value).toEqual('-1'); 303 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 304 | }); 305 | 306 | it('value can be a string greater than 16 characters', () => { 307 | const { container } = render( max={10} value='-3.637978807091713e-12' />); 308 | const input = container.querySelector('input'); 309 | expect(input.value).toEqual('-0.000000000003637978807091713'); 310 | }); 311 | 312 | it('value decimal over six decimal not be scientific notation', () => { 313 | const onChange = jest.fn(); 314 | const { container } = render( 315 | , 316 | ); 317 | const input = container.querySelector('input'); 318 | for (let i = 1; i <= 9; i += 1) { 319 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-up')); 320 | expect(input.value).toEqual(`0.000000${i}`); 321 | expect(onChange).toHaveBeenCalledWith(0.0000001 * i); 322 | } 323 | 324 | for (let i = 8; i >= 1; i -= 1) { 325 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 326 | expect(input.value).toEqual(`0.000000${i}`); 327 | expect(onChange).toHaveBeenCalledWith(0.0000001 * i); 328 | } 329 | 330 | fireEvent.mouseDown(container.querySelector('.rc-input-number-handler-down')); 331 | expect(input.value).toEqual(`0.0000000`); 332 | expect(onChange).toHaveBeenCalledWith(0); 333 | }); 334 | 335 | it('value can be changed when dynamic setting max', () => { 336 | const { container, rerender } = render(); 337 | const input = container.querySelector('input'); 338 | 339 | // Origin logic shows `10` as `max`. But it breaks form logic. 340 | expect(input.value).toEqual('11'); 341 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 342 | 343 | rerender(); 344 | expect(input.value).toEqual('11'); 345 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeFalsy(); 346 | }); 347 | 348 | it('value can be changed when dynamic setting min', () => { 349 | const { container, rerender } = render(); 350 | const input = container.querySelector('input'); 351 | 352 | // Origin logic shows `10` as `max`. But it breaks form logic. 353 | expect(input.value).toEqual('9'); 354 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeTruthy(); 355 | 356 | rerender(); 357 | expect(input.value).toEqual('9'); 358 | expect(container.querySelector('.rc-input-number-out-of-range')).toBeFalsy(); 359 | }); 360 | 361 | it('value can override given defaultValue', () => { 362 | const { container } = render(); 363 | const input = container.querySelector('input'); 364 | expect(input.value).toEqual('2'); 365 | }); 366 | }); 367 | 368 | describe(`required prop`, () => { 369 | it(`should add required attr to the input tag when get passed as true`, () => { 370 | const { container } = render(); 371 | expect(container.querySelector('input')).toHaveAttribute('required'); 372 | }); 373 | 374 | it(`should not add required attr to the input as default props when not being supplied`, () => { 375 | const { container } = render(); 376 | expect(container.querySelector('input')).not.toHaveAttribute('required'); 377 | }); 378 | 379 | it(`should not add required attr to the input tag when get passed as false`, () => { 380 | const { container } = render(); 381 | expect(container.querySelector('input')).not.toHaveAttribute('required'); 382 | }); 383 | }); 384 | 385 | describe('Pattern prop', () => { 386 | it(`should render with a pattern attribute if the pattern prop is supplied`, () => { 387 | const { container } = render(); 388 | expect(container.querySelector('input')).toHaveAttribute('pattern', '\\d*'); 389 | }); 390 | 391 | it(`should render with no pattern attribute if the pattern prop is not supplied`, () => { 392 | const { container } = render(); 393 | expect(container.querySelector('input')).not.toHaveAttribute('pattern', '\\d*'); 394 | 395 | }); 396 | }); 397 | 398 | describe('onPaste props', () => { 399 | it('passes onPaste event handler', () => { 400 | const onPaste = jest.fn(); 401 | const { container } = render(); 402 | const input = container.querySelector('input'); 403 | fireEvent.paste(input); 404 | // wrapper.findInput().simulate('paste'); 405 | expect(onPaste).toHaveBeenCalled(); 406 | }); 407 | }); 408 | 409 | describe('aria and data props', () => { 410 | it('passes data-* attributes', () => { 411 | const { container } = render(); 412 | const input = container.querySelector('input'); 413 | 414 | expect(input).toHaveAttribute('data-test', 'test-id'); 415 | expect(input).toHaveAttribute('data-id', '12345'); 416 | }); 417 | 418 | it('passes aria-* attributes', () => { 419 | const { container } = render( 420 | , 421 | ); 422 | const input = container.querySelector('input'); 423 | expect(input).toHaveAttribute('aria-labelledby', 'test-id'); 424 | expect(input).toHaveAttribute('aria-label', 'some-label'); 425 | 426 | }); 427 | 428 | it('passes role attribute', () => { 429 | const { container } = render(); 430 | expect(container.querySelector('input')).toHaveAttribute('role', 'searchbox'); 431 | 432 | }); 433 | }); 434 | }); 435 | -------------------------------------------------------------------------------- /tests/semantic.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import InputNumber from '../src'; 3 | import React from 'react'; 4 | 5 | describe('InputNumber.Semantic', () => { 6 | it('support classNames and styles', () => { 7 | const testClassNames = { 8 | prefix: 'test-prefix', 9 | input: 'test-input', 10 | suffix: 'test-suffix', 11 | actions: 'test-handle', 12 | }; 13 | const testStyles = { 14 | prefix: { color: 'red' }, 15 | input: { color: 'blue' }, 16 | suffix: { color: 'green' }, 17 | actions: { color: 'yellow' }, 18 | }; 19 | const { container } = render( 20 | suffix} 24 | styles={testStyles} 25 | classNames={testClassNames} 26 | />, 27 | ); 28 | 29 | const input = container.querySelector('.rc-input-number')!; 30 | const prefix = container.querySelector('.rc-input-number-prefix')!; 31 | const suffix = container.querySelector('.rc-input-number-suffix')!; 32 | const actions = container.querySelector('.rc-input-number-handler-wrap')!; 33 | expect(input.className).toContain(testClassNames.input); 34 | expect(prefix.className).toContain(testClassNames.prefix); 35 | expect(suffix.className).toContain(testClassNames.suffix); 36 | expect(actions.className).toContain(testClassNames.actions); 37 | expect(prefix).toHaveStyle(testStyles.prefix); 38 | expect(input).toHaveStyle(testStyles.input); 39 | expect(suffix).toHaveStyle(testStyles.suffix); 40 | expect(actions).toHaveStyle(testStyles.actions); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = (cb) => setTimeout(cb, 0); 2 | require('regenerator-runtime'); 3 | -------------------------------------------------------------------------------- /tests/util/wrapper.ts: -------------------------------------------------------------------------------- 1 | import type { RenderOptions } from '@testing-library/react'; 2 | import { act, render } from '@testing-library/react'; 3 | import type { ReactElement } from 'react'; 4 | 5 | const globalTimeout = global.setTimeout; 6 | 7 | export const sleep = async (timeout = 0) => { 8 | await act(async () => { 9 | await new Promise((resolve) => { 10 | globalTimeout(resolve, timeout); 11 | }); 12 | }); 13 | }; 14 | 15 | const customRender = (ui: ReactElement, options?: Omit) => 16 | render(ui, { ...options }); 17 | 18 | export * from '@testing-library/react'; 19 | export { customRender as render }; 20 | -------------------------------------------------------------------------------- /tests/wheel.test.tsx: -------------------------------------------------------------------------------- 1 | import KeyCode from '@rc-component/util/lib/KeyCode'; 2 | import InputNumber from '../src'; 3 | import { fireEvent, render } from './util/wrapper'; 4 | 5 | describe('InputNumber.Wheel', () => { 6 | it('wheel up', () => { 7 | const onChange = jest.fn(); 8 | const { container } = render(); 9 | fireEvent.focus(container.firstChild); 10 | fireEvent.wheel(container.querySelector('input'), {deltaY: -1}); 11 | expect(onChange).toHaveBeenCalledWith(1); 12 | }); 13 | 14 | it('wheel up with pressing shift key', () => { 15 | const onChange = jest.fn(); 16 | const { container } = render(); 17 | fireEvent.focus(container.firstChild); 18 | fireEvent.keyDown(container.querySelector('input'), { 19 | which: KeyCode.SHIFT, 20 | key: 'Shift', 21 | keyCode: KeyCode.SHIFT, 22 | shiftKey: true, 23 | }); 24 | fireEvent.wheel(container.querySelector('input'), {deltaY: -1}); 25 | expect(onChange).toHaveBeenCalledWith(1.3); 26 | }); 27 | 28 | it('wheel down', () => { 29 | const onChange = jest.fn(); 30 | const { container } = render(); 31 | fireEvent.focus(container.firstChild); 32 | fireEvent.wheel(container.querySelector('input'), {deltaY: 1}); 33 | expect(onChange).toHaveBeenCalledWith(-1); 34 | }); 35 | 36 | it('wheel down with pressing shift key', () => { 37 | const onChange = jest.fn(); 38 | const { container } = render(); 39 | fireEvent.focus(container.firstChild); 40 | fireEvent.keyDown(container.querySelector('input'), { 41 | which: KeyCode.SHIFT, 42 | key: 'Shift', 43 | keyCode: KeyCode.SHIFT, 44 | shiftKey: true, 45 | }); 46 | fireEvent.wheel(container.querySelector('input'), {deltaY: 1}); 47 | expect(onChange).toHaveBeenCalledWith(1.1); 48 | }); 49 | 50 | it('disabled wheel', () => { 51 | const onChange = jest.fn(); 52 | const { container, rerender } = render(); 53 | fireEvent.focus(container.firstChild); 54 | 55 | fireEvent.wheel(container.querySelector('input'), {deltaY: -1}); 56 | expect(onChange).not.toHaveBeenCalled(); 57 | 58 | fireEvent.wheel(container.querySelector('input'), {deltaY: 1}); 59 | expect(onChange).not.toHaveBeenCalled(); 60 | 61 | rerender(); 62 | fireEvent.focus(container.firstChild); 63 | 64 | fireEvent.wheel(container.querySelector('input'), {deltaY: 1}); 65 | expect(onChange).toHaveBeenCalledWith(-1); 66 | }); 67 | 68 | it('wheel is limited to range', () => { 69 | const onChange = jest.fn(); 70 | const { container } = render(); 71 | fireEvent.focus(container.firstChild); 72 | fireEvent.keyDown(container.querySelector('input'), { 73 | which: KeyCode.SHIFT, 74 | key: 'Shift', 75 | keyCode: KeyCode.SHIFT, 76 | shiftKey: true, 77 | }); 78 | fireEvent.wheel(container.querySelector('input'), {deltaY: -1}); 79 | expect(onChange).toHaveBeenCalledWith(3); 80 | fireEvent.wheel(container.querySelector('input'), {deltaY: 1}); 81 | expect(onChange).toHaveBeenCalledWith(-3); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "baseUrl": "./", 6 | "jsx": "preserve", 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@@/*": ["src/.umi/*"], 13 | "@rc-component/input-number": ["src/index.ts"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /update-demo.js: -------------------------------------------------------------------------------- 1 | /* 2 | 用于 dumi 改造使用, 3 | 可用于将 examples 的文件批量修改为 demo 引入形式, 4 | 其他项目根据具体情况使用。 5 | */ 6 | 7 | const fs = require('fs'); 8 | const glob = require('glob'); 9 | 10 | const paths = glob.sync('./docs/examples/*.tsx'); 11 | 12 | paths.forEach(path => { 13 | const name = path.split('/').pop().split('.')[0]; 14 | fs.writeFile( 15 | `./docs/demo/${name}.md`, 16 | `## ${name} 17 | 18 | 19 | `, 20 | 'utf8', 21 | function(error) { 22 | if(error){ 23 | console.log(error); 24 | return false; 25 | } 26 | console.log(`${name} 更新成功~`); 27 | } 28 | ) 29 | }); 30 | --------------------------------------------------------------------------------