├── .github └── FUNDING.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .storybook ├── main.ts └── preview.ts ├── .vscode └── settings.json ├── README.md ├── babel.config.json ├── eslint.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── packages ├── core │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── Input.ts │ │ ├── SyntheticChangeError.ts │ │ ├── createProxy.ts │ │ ├── index.ts │ │ ├── types.ts │ │ ├── useConnectedRef.ts │ │ └── useInput.ts │ └── tests │ │ └── core.test.tsx ├── mask │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── InputMask.tsx │ │ ├── Mask.ts │ │ ├── examples │ │ │ ├── phone-login.tsx │ │ │ └── phone.tsx │ │ ├── index.ts │ │ ├── types.ts │ │ ├── useMask.ts │ │ ├── utils.ts │ │ └── utils │ │ │ ├── filter.ts │ │ │ ├── format.ts │ │ │ ├── formatToParts.ts │ │ │ ├── formatToReplacementObject.ts │ │ │ ├── resolveSelection.ts │ │ │ ├── unformat.ts │ │ │ └── validate.ts │ ├── stories │ │ ├── Component.stories.tsx │ │ └── TestProps.stories.tsx │ └── tests │ │ └── mask.test.tsx └── number-format │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ ├── InputNumberFormat.tsx │ ├── NumberFormat.ts │ ├── index.ts │ ├── types.ts │ ├── useNumberFormat.ts │ ├── utils.ts │ └── utils │ │ ├── exec.ts │ │ ├── filter.ts │ │ ├── format.ts │ │ ├── localizeValues.ts │ │ ├── normalize.ts │ │ ├── resolveOptions.ts │ │ └── resolveSelection.ts │ ├── stories │ ├── Component.stories.tsx │ └── Hook.stories.tsx │ └── tests │ └── number-format.test.tsx ├── rollup.config.js ├── scripts ├── build.ts ├── release.ts └── update-deps.ts ├── tsconfig.json └── utils ├── readdir.js └── style.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: react-input 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | 4 | # production 5 | **/@types 6 | **/cdn 7 | **/module 8 | **/node 9 | 10 | # misc 11 | .DS_Store 12 | 13 | *storybook.log -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | 4 | # production 5 | **/@types 6 | **/cdn 7 | **/module 8 | **/node 9 | 10 | # misc 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import { dirname, join } from 'path'; 2 | 3 | import type { StorybookConfig } from '@storybook/react-webpack5'; 4 | 5 | /** 6 | * This function is used to resolve the absolute path of a package. 7 | * It is needed in projects that use Yarn PnP or are set up within a monorepo. 8 | */ 9 | function getAbsolutePath(value: string) { 10 | return dirname(require.resolve(join(value, 'package.json'))); 11 | } 12 | 13 | export default { 14 | stories: ['../packages/**/*.mdx', '../packages/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 15 | 16 | addons: [ 17 | getAbsolutePath('@storybook/addon-webpack5-compiler-swc'), 18 | getAbsolutePath('@storybook/addon-onboarding'), 19 | getAbsolutePath('@storybook/addon-links'), 20 | getAbsolutePath('@storybook/addon-essentials'), 21 | getAbsolutePath('@chromatic-com/storybook'), 22 | getAbsolutePath('@storybook/addon-interactions'), 23 | '@chromatic-com/storybook', 24 | ], 25 | 26 | framework: { 27 | name: getAbsolutePath('@storybook/react-webpack5'), 28 | options: {}, 29 | }, 30 | 31 | swc: () => ({ 32 | jsc: { 33 | transform: { 34 | react: { 35 | runtime: 'automatic', 36 | }, 37 | }, 38 | }, 39 | }), 40 | 41 | docs: {}, 42 | 43 | typescript: { 44 | reactDocgen: 'react-docgen-typescript', 45 | }, 46 | } satisfies StorybookConfig; 47 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | 3 | export default { 4 | parameters: { 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/i, 9 | }, 10 | }, 11 | }, 12 | 13 | tags: ['autodocs'], 14 | } satisfies Preview; 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "conventionalCommits.scopes": ["core", "mask", "number-format"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-input 2 | 3 | This repository includes packages: 4 | 5 | - [`core`](https://github.com/GoncharukOrg/react-input/tree/main/packages/core) - package for the service use of other packages included in the `@react-input` scope; 6 | - [`mask`](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask) - apply any mask to the input using a provided component or a hook bound to the input element; 7 | - [`number-format`](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format) - apply locale-specific number, currency, and percentage formatting to input using a provided component or hook bound to the input element. 8 | 9 | ## Feedback 10 | 11 | If you find a bug or want to make a suggestion for improving the package, [open the issues on GitHub](https://github.com/GoncharukOrg/react-input/issues) or email [goncharuk.bro@gmail.com](mailto:goncharuk.bro@gmail.com). 12 | 13 | Support the project with a star ⭐ on [GitHub](https://github.com/GoncharukOrg/react-input). 14 | 15 | You can also support the authors by donating 🪙 to [Open Collective](https://opencollective.com/react-input): 16 | 17 | [![Donate to our collective](https://opencollective.com/react-input/donate/button.png)](https://opencollective.com/react-input/donate) 18 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript", 5 | [ 6 | "@babel/preset-react", 7 | { 8 | "runtime": "classic" 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | 4 | import babelParser from '@babel/eslint-parser'; 5 | import { fixupConfigRules, fixupPluginRules } from '@eslint/compat'; 6 | import { FlatCompat } from '@eslint/eslintrc'; 7 | import js from '@eslint/js'; 8 | import stylistic from '@stylistic/eslint-plugin'; 9 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 10 | import tsParser from '@typescript-eslint/parser'; 11 | import _import from 'eslint-plugin-import'; 12 | import jsxA11Y from 'eslint-plugin-jsx-a11y'; 13 | import prettier from 'eslint-plugin-prettier'; 14 | import promise from 'eslint-plugin-promise'; 15 | import react from 'eslint-plugin-react'; 16 | import reactHooks from 'eslint-plugin-react-hooks'; 17 | import globals from 'globals'; 18 | 19 | const __filename = fileURLToPath(import.meta.url); 20 | const __dirname = dirname(__filename); 21 | 22 | const compat = new FlatCompat({ 23 | baseDirectory: __dirname, 24 | recommendedConfig: js.configs.recommended, 25 | allConfig: js.configs.all, 26 | }); 27 | 28 | export default [ 29 | { 30 | ignores: ['**/node_modules', '**/@types', '**/cdn', '**/module', '**/node', '**/*.test.ts?(x)', '**/.DS_Store'], 31 | }, 32 | ...fixupConfigRules( 33 | compat.extends( 34 | 'eslint:recommended', 35 | 'plugin:import/recommended', 36 | 'plugin:promise/recommended', 37 | 'plugin:react/recommended', 38 | 'plugin:react/jsx-runtime', 39 | 'plugin:react-hooks/recommended', 40 | 'plugin:jsx-a11y/strict', 41 | 'plugin:@stylistic/recommended-extends', 42 | 'plugin:prettier/recommended', 43 | ), 44 | ), 45 | { 46 | plugins: { 47 | import: fixupPluginRules(_import), 48 | promise: fixupPluginRules(promise), 49 | react: fixupPluginRules(react), 50 | 'react-hooks': fixupPluginRules(reactHooks), 51 | 'jsx-a11y': fixupPluginRules(jsxA11Y), 52 | '@stylistic': fixupPluginRules(stylistic), 53 | prettier: fixupPluginRules(prettier), 54 | }, 55 | languageOptions: { 56 | globals: { 57 | ...globals.commonjs, 58 | ...globals.browser, 59 | ...globals.jest, 60 | ...globals.node, 61 | }, 62 | parser: babelParser, 63 | ecmaVersion: 'latest', 64 | sourceType: 'module', 65 | parserOptions: { 66 | project: true, 67 | ecmaFeatures: { 68 | jsx: true, 69 | }, 70 | }, 71 | }, 72 | settings: { 73 | 'import/parsers': { 74 | '@babel/eslint-parser': ['.js', '.jsx'], 75 | '@typescript-eslint/parser': ['.ts', '.tsx'], 76 | }, 77 | 'import/resolver': { 78 | typescript: { 79 | alwaysTryTypes: true, 80 | }, 81 | node: true, 82 | }, 83 | react: { 84 | createClass: 'createReactClass', 85 | pragma: 'React', 86 | fragment: 'Fragment', 87 | version: 'detect', 88 | flowVersion: '0.53', 89 | }, 90 | }, 91 | rules: { 92 | eqeqeq: 'error', 93 | 'no-lone-blocks': 'error', 94 | 'no-restricted-imports': [ 95 | 'error', 96 | { 97 | name: 'react-redux', 98 | importNames: ['useSelector', 'useDispatch'], 99 | message: 'Use the `useDispatch` and `useSelector` typed hooks from the `store` directory.', 100 | }, 101 | ], 102 | 'no-unused-vars': 'off', 103 | 'object-shorthand': 'error', 104 | 'prefer-template': 'error', 105 | 'sort-imports': [ 106 | 'error', 107 | { 108 | ignoreDeclarationSort: true, 109 | }, 110 | ], 111 | '@stylistic/arrow-parens': ['error', 'always'], 112 | '@stylistic/brace-style': ['error', '1tbs'], 113 | '@stylistic/indent-binary-ops': 'off', 114 | '@stylistic/jsx-self-closing-comp': 'error', 115 | '@stylistic/jsx-sort-props': [ 116 | 'error', 117 | { 118 | reservedFirst: true, 119 | callbacksLast: true, 120 | }, 121 | ], 122 | '@stylistic/member-delimiter-style': [ 123 | 'error', 124 | { 125 | multiline: { 126 | delimiter: 'semi', 127 | requireLast: true, 128 | }, 129 | singleline: { 130 | delimiter: 'semi', 131 | requireLast: false, 132 | }, 133 | multilineDetection: 'brackets', 134 | }, 135 | ], 136 | '@stylistic/operator-linebreak': 'off', 137 | '@stylistic/quote-props': ['error', 'as-needed'], 138 | '@stylistic/semi': ['error', 'always'], 139 | 'import/default': 'off', 140 | 'import/named': 'off', 141 | 'import/namespace': 'off', 142 | 'import/no-named-as-default': 'off', 143 | 'import/no-named-as-default-member': 'off', 144 | 'import/no-unresolved': 'off', 145 | 'import/order': [ 146 | 'error', 147 | { 148 | 'newlines-between': 'always', 149 | alphabetize: { 150 | order: 'asc', 151 | orderImportKind: 'asc', 152 | }, 153 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], 154 | pathGroups: [ 155 | { 156 | pattern: '{react,react/**,react-dom,react-dom/**}', 157 | group: 'external', 158 | position: 'before', 159 | }, 160 | { 161 | pattern: '{@react-input,@react-input/**}', 162 | group: 'internal', 163 | position: 'before', 164 | }, 165 | { 166 | pattern: '{errors,errors/**}', 167 | group: 'internal', 168 | position: 'before', 169 | }, 170 | { 171 | pattern: '{hooks,hooks/**}', 172 | group: 'internal', 173 | position: 'before', 174 | }, 175 | { 176 | pattern: '{utils,utils/**}', 177 | group: 'internal', 178 | position: 'before', 179 | }, 180 | { 181 | pattern: '{types,types/**}', 182 | group: 'internal', 183 | position: 'after', 184 | }, 185 | ], 186 | pathGroupsExcludedImportTypes: ['builtin', 'object', 'type'], 187 | }, 188 | ], 189 | 'jsx-a11y/no-autofocus': 'off', 190 | 'react/button-has-type': 'error', 191 | 'react/jsx-no-useless-fragment': 'error', 192 | }, 193 | }, 194 | ...fixupConfigRules( 195 | compat.extends( 196 | 'plugin:import/typescript', 197 | 'plugin:@typescript-eslint/strict-type-checked', 198 | 'plugin:@typescript-eslint/stylistic-type-checked', 199 | ), 200 | ).map((config) => ({ ...config, files: ['**/*.ts?(x)'] })), 201 | { 202 | files: ['**/*.ts?(x)'], 203 | plugins: { 204 | '@typescript-eslint': fixupPluginRules(typescriptEslint), 205 | }, 206 | languageOptions: { 207 | parser: tsParser, 208 | }, 209 | rules: { 210 | '@typescript-eslint/ban-ts-comment': 'off', 211 | '@typescript-eslint/consistent-type-imports': 'error', 212 | '@typescript-eslint/no-non-null-assertion': 'off', 213 | '@typescript-eslint/no-unnecessary-condition': 'off', 214 | '@typescript-eslint/no-unused-vars': [ 215 | 'error', 216 | { 217 | varsIgnorePattern: '^React$', 218 | }, 219 | ], 220 | '@typescript-eslint/restrict-template-expressions': [ 221 | 'error', 222 | { 223 | allowNumber: true, 224 | }, 225 | ], 226 | }, 227 | }, 228 | ...compat.extends('plugin:jest-dom/recommended', 'plugin:testing-library/react').map((config) => ({ 229 | ...config, 230 | files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], 231 | })), 232 | ...fixupConfigRules(compat.extends('plugin:storybook/recommended')).map((config) => ({ 233 | ...config, 234 | files: ['**/stories/**/*.[jt]s?(x)', '**/?(*.)+stories.[jt]s?(x)'], 235 | })), 236 | ]; 237 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @returns {Promise} 3 | */ 4 | export default async () => ({ 5 | clearMocks: true, 6 | testEnvironment: 'jsdom', 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-input", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "funding": { 8 | "type": "opencollective", 9 | "url": "https://opencollective.com/react-input" 10 | }, 11 | "type": "module", 12 | "scripts": { 13 | "test": "jest", 14 | "eslint": "npx eslint .", 15 | "eslint:fix": "npx eslint --fix .", 16 | "prettier:write": "npx prettier --write .", 17 | "ts:check": "tsc --noEmit", 18 | "update:deps": "tsx ./scripts/update-deps.ts", 19 | "storybook": "storybook dev -p 6006", 20 | "build-storybook": "storybook build" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.25.7", 24 | "@babel/eslint-parser": "^7.25.7", 25 | "@babel/preset-env": "^7.25.7", 26 | "@babel/preset-react": "^7.25.7", 27 | "@babel/preset-typescript": "^7.25.7", 28 | "@chromatic-com/storybook": "^2.0.2", 29 | "@eslint/compat": "^1.2.0", 30 | "@eslint/eslintrc": "^3.1.0", 31 | "@eslint/js": "^9.12.0", 32 | "@rollup/plugin-babel": "^6.0.4", 33 | "@rollup/plugin-commonjs": "^28.0.0", 34 | "@rollup/plugin-node-resolve": "^15.3.0", 35 | "@rollup/plugin-replace": "^6.0.1", 36 | "@rollup/plugin-terser": "^0.4.4", 37 | "@rollup/plugin-typescript": "^12.1.0", 38 | "@storybook/addon-essentials": "^8.3.5", 39 | "@storybook/addon-interactions": "^8.3.5", 40 | "@storybook/addon-links": "^8.3.5", 41 | "@storybook/addon-onboarding": "^8.3.5", 42 | "@storybook/addon-webpack5-compiler-swc": "^1.0.5", 43 | "@storybook/blocks": "^8.3.5", 44 | "@storybook/react": "^8.3.5", 45 | "@storybook/react-webpack5": "^8.3.5", 46 | "@storybook/test": "^8.3.5", 47 | "@stylistic/eslint-plugin": "^2.9.0", 48 | "@testing-library/dom": "^10.4.0", 49 | "@testing-library/jest-dom": "^6.5.0", 50 | "@testing-library/react": "^16.0.1", 51 | "@testing-library/user-event": "^14.5.2", 52 | "@types/jest": "^29.5.13", 53 | "@types/node": "^22.7.4", 54 | "@types/react": "^18.3.11", 55 | "@types/react-dom": "^18.3.0", 56 | "@typescript-eslint/eslint-plugin": "^8.8.0", 57 | "@typescript-eslint/parser": "^8.8.0", 58 | "babel-jest": "^29.7.0", 59 | "babel-loader": "^9.2.1", 60 | "eslint": "^9.12.0", 61 | "eslint-config-prettier": "^9.1.0", 62 | "eslint-import-resolver-typescript": "^3.6.3", 63 | "eslint-plugin-import": "^2.31.0", 64 | "eslint-plugin-jest-dom": "^5.4.0", 65 | "eslint-plugin-jsx-a11y": "^6.10.0", 66 | "eslint-plugin-prettier": "^5.2.1", 67 | "eslint-plugin-promise": "^7.1.0", 68 | "eslint-plugin-react": "^7.37.1", 69 | "eslint-plugin-react-hooks": "^4.6.2", 70 | "eslint-plugin-storybook": "^0.9.0", 71 | "eslint-plugin-testing-library": "^6.3.0", 72 | "globals": "^15.10.0", 73 | "jest": "^29.7.0", 74 | "jest-environment-jsdom": "^29.7.0", 75 | "prettier": "^3.3.3", 76 | "prop-types": "^15.8.1", 77 | "react": "^18.3.1", 78 | "react-dom": "^18.3.1", 79 | "rollup": "^4.24.0", 80 | "storybook": "^8.3.5", 81 | "tslib": "^2.7.0", 82 | "tsx": "^4.19.1", 83 | "typescript": "^5.5.4" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/core/LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Nikolay Goncharuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @react-input/core 2 | 3 | ✨ A package for the service use of other packages included in the `@react-input` scope: 4 | 5 | - [`@react-input/mask`](https://www.npmjs.com/package/@react-input/mask) - apply any mask to the input using a provided component or a hook bound to the input element; 6 | - [`@react-input/number-format`](https://www.npmjs.com/package/@react-input/number-format) - apply locale-specific number, currency, and percentage formatting to input using a provided component or hook bound to the input element. 7 | 8 | ## Feedback 9 | 10 | If you find a bug or want to make a suggestion for improving the package, [open the issues on GitHub](https://github.com/GoncharukOrg/react-input/issues) or email [goncharuk.bro@gmail.com](mailto:goncharuk.bro@gmail.com). 11 | 12 | Support the project with a star ⭐ on [GitHub](https://github.com/GoncharukOrg/react-input). 13 | 14 | You can also support the authors by donating 🪙 to [Open Collective](https://opencollective.com/react-input): 15 | 16 | [![Donate to our collective](https://opencollective.com/react-input/donate/button.png)](https://opencollective.com/react-input/donate) 17 | 18 | ## License 19 | 20 | [MIT](https://github.com/GoncharukOrg/react-input/blob/main/packages/core/LICENSE) © [Nikolay Goncharuk](https://github.com/GoncharukOrg) 21 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-input/core", 3 | "version": "2.0.2", 4 | "license": "MIT", 5 | "author": "Nikolay Goncharuk ", 6 | "description": "The core of the packages included in the `@react-input` scope.", 7 | "keywords": [ 8 | "react", 9 | "react-hook", 10 | "input", 11 | "input-element", 12 | "input-control", 13 | "input-event" 14 | ], 15 | "funding": { 16 | "type": "opencollective", 17 | "url": "https://opencollective.com/react-input" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/GoncharukOrg/react-input.git", 22 | "directory": "packages/core" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/GoncharukOrg/react-input/issues" 26 | }, 27 | "homepage": "https://github.com/GoncharukOrg/react-input/tree/main/packages/core#readme", 28 | "files": [ 29 | "@types", 30 | "cdn", 31 | "module", 32 | "node" 33 | ], 34 | "sideEffects": false, 35 | "type": "module", 36 | "types": "@types/index.d.ts", 37 | "module": "module/index.js", 38 | "main": "node/index.cjs", 39 | "exports": { 40 | ".": { 41 | "types": "./@types/index.d.ts", 42 | "import": "./module/index.js", 43 | "require": "./node/index.cjs" 44 | }, 45 | "./*": { 46 | "types": "./@types/*.d.ts", 47 | "import": "./module/*.js", 48 | "require": "./node/*.cjs" 49 | }, 50 | "./*.js": { 51 | "types": "./@types/*.d.ts", 52 | "import": "./module/*.js", 53 | "require": "./node/*.cjs" 54 | } 55 | }, 56 | "typesVersions": { 57 | "*": { 58 | "@types/index.d.ts": [ 59 | "@types/index.d.ts" 60 | ], 61 | "*": [ 62 | "@types/*" 63 | ] 64 | } 65 | }, 66 | "publishConfig": { 67 | "access": "public" 68 | }, 69 | "scripts": { 70 | "build": "tsx ../../scripts/build.ts", 71 | "release:major": "tsx ../../scripts/release.ts major", 72 | "release:minor": "tsx ../../scripts/release.ts minor", 73 | "release:patch": "tsx ../../scripts/release.ts patch" 74 | }, 75 | "peerDependencies": { 76 | "@types/react": ">=16.8", 77 | "react": ">=16.8 || ^19.0.0-rc", 78 | "react-dom": ">=16.8 || ^19.0.0-rc" 79 | }, 80 | "peerDependenciesMeta": { 81 | "@types/react": { 82 | "optional": true 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/core/src/Input.ts: -------------------------------------------------------------------------------- 1 | import SyntheticChangeError from './SyntheticChangeError'; 2 | 3 | import type { InputOptions, InputType } from './types'; 4 | 5 | const ALLOWED_TYPES = ['text', 'email', 'tel', 'search', 'url']; 6 | 7 | interface ContextValue { 8 | onFocus: (event: FocusEvent) => void; 9 | onBlur: (event: FocusEvent) => void; 10 | onInput: (event: Event) => void; 11 | } 12 | 13 | export default class Input { 14 | static { 15 | Object.defineProperty(this.prototype, Symbol.toStringTag, { 16 | writable: false, 17 | enumerable: false, 18 | configurable: true, 19 | value: 'Input', 20 | }); 21 | } 22 | 23 | register: (element: HTMLInputElement) => void; 24 | unregister: (element: HTMLInputElement) => void; 25 | 26 | constructor({ init, tracking }: InputOptions) { 27 | const handlersMap = new WeakMap(); 28 | 29 | this.register = (element) => { 30 | if (!ALLOWED_TYPES.includes(element.type)) { 31 | if (process.env.NODE_ENV !== 'production') { 32 | console.warn(`Warn: The input element type does not match one of the types: ${ALLOWED_TYPES.join(', ')}.`); 33 | } 34 | 35 | return; 36 | } 37 | 38 | const { initialValue = '', controlled = false } = 39 | (element as { _wrapperState?: { initialValue?: string; controlled?: boolean } })._wrapperState ?? {}; 40 | 41 | // При создании `input` элемента возможно программное изменение свойства `value`, что может 42 | // сказаться на отображении состояния элемента, поэтому важно учесть свойство `value` в приоритете. 43 | // ISSUE: https://github.com/GoncharukOrg/react-input/issues/3 44 | const { value, options } = init({ 45 | initialValue: element.value || initialValue, 46 | controlled, 47 | }); 48 | 49 | const cache = { 50 | value, 51 | options, 52 | fallbackOptions: options, 53 | }; 54 | 55 | const timeout = { 56 | id: -1, 57 | cachedId: -1, 58 | }; 59 | 60 | const tracker = { 61 | value: '', 62 | selectionStart: 0, 63 | selectionEnd: 0, 64 | }; 65 | 66 | // Важно сохранить дескриптор создаваемый React 67 | const descriptor = Object.getOwnPropertyDescriptor( 68 | '_valueTracker' in element ? element : HTMLInputElement.prototype, 69 | 'value', 70 | ); 71 | 72 | // Поскольку значение элемента может быть изменено вне текущей логики, 73 | // нам важно перехватывать каждое изменение для обновления `tracker.value`. 74 | // `tracker.value` служит заменой `_valueTracker.getValue()` предоставляемый React. 75 | Object.defineProperty(element, 'value', { 76 | ...descriptor, 77 | set: (value: string) => { 78 | tracker.value = value; 79 | descriptor?.set?.call(element, value); 80 | }, 81 | }); 82 | 83 | // Поскольку в `init` возможно изменение инициализированного значения, мы 84 | // также должны изменить значение элемента, при этом мы не должны устанавливать 85 | // позицию каретки, так как установка позиции здесь приведёт к автофокусу. 86 | element.value = value; 87 | 88 | /** 89 | * Handle focus 90 | */ 91 | const onFocus = () => { 92 | const setSelection = () => { 93 | tracker.selectionStart = element.selectionStart ?? 0; 94 | tracker.selectionEnd = element.selectionEnd ?? 0; 95 | 96 | timeout.id = window.setTimeout(setSelection); 97 | }; 98 | 99 | timeout.id = window.setTimeout(setSelection); 100 | }; 101 | 102 | /** 103 | * Handle blur 104 | */ 105 | const onBlur = () => { 106 | window.clearTimeout(timeout.id); 107 | 108 | timeout.id = -1; 109 | timeout.cachedId = -1; 110 | }; 111 | 112 | /** 113 | * Handle input 114 | */ 115 | const onInput = (event: Event) => { 116 | try { 117 | // Если событие вызывается слишком часто, смена курсора может не поспеть за новым событием, 118 | // поэтому сравниваем `timeoutId` кэшированный и текущий для избежания некорректного поведения маски 119 | if (timeout.cachedId === timeout.id) { 120 | throw new SyntheticChangeError('The input selection has not been updated.'); 121 | } 122 | 123 | timeout.cachedId = timeout.id; 124 | 125 | const { value, selectionStart, selectionEnd } = element; 126 | 127 | if (selectionStart === null || selectionEnd === null) { 128 | throw new SyntheticChangeError('The selection attributes have not been initialized.'); 129 | } 130 | 131 | const previousValue = tracker.value; 132 | let inputType: InputType | undefined; 133 | 134 | // При автоподстановке значения браузер заменяет значение полностью, как если бы мы 135 | // выделили значение и вставили новое, однако `tracker.selectionStart` и `tracker.selectionEnd` 136 | // не изменятся что приведёт к не правильному определению типа ввода, например, при 137 | // автоподстановке значения меньше чем предыдущее, тип ввода будет определён как `deleteBackward`. 138 | // Учитывая что при автоподстановке `inputType` не определён и значение заменяется полностью, 139 | // нам надо имитировать выделение всего значения, для этого переопределяем позиции выделения 140 | // @ts-expect-error 141 | if (event.inputType === undefined) { 142 | tracker.selectionStart = 0; 143 | tracker.selectionEnd = previousValue.length; 144 | } 145 | 146 | // Определяем тип ввода (ручное определение типа ввода способствует кроссбраузерности) 147 | if (selectionStart > tracker.selectionStart) { 148 | inputType = 'insert'; 149 | } else if (selectionStart <= tracker.selectionStart && selectionStart < tracker.selectionEnd) { 150 | inputType = 'deleteBackward'; 151 | } else if (selectionStart === tracker.selectionEnd && value.length < previousValue.length) { 152 | inputType = 'deleteForward'; 153 | } 154 | 155 | if ( 156 | inputType === undefined || 157 | ((inputType === 'deleteBackward' || inputType === 'deleteForward') && value.length > previousValue.length) 158 | ) { 159 | throw new SyntheticChangeError('Input type detection error.'); 160 | } 161 | 162 | let addedValue = ''; 163 | let changeStart = tracker.selectionStart; 164 | let changeEnd = tracker.selectionEnd; 165 | 166 | if (inputType === 'insert') { 167 | addedValue = value.slice(tracker.selectionStart, selectionStart); 168 | } else { 169 | // Для `delete` нам необходимо определить диапазон удаленных символов, так как 170 | // при удалении без выделения позиция каретки "до" и "после" будут совпадать 171 | const countDeleted = previousValue.length - value.length; 172 | 173 | changeStart = selectionStart; 174 | changeEnd = selectionStart + countDeleted; 175 | } 176 | 177 | // Предыдущее значение всегда должно соответствовать маскированному значению из кэша. Обратная ситуация может 178 | // возникнуть при контроле значения, если значение не было изменено после ввода. Для предотвращения подобных 179 | // ситуаций, нам важно синхронизировать предыдущее значение с кэшированным значением, если они различаются 180 | if (cache.value !== previousValue) { 181 | cache.options = cache.fallbackOptions; 182 | } else { 183 | cache.fallbackOptions = cache.options; 184 | } 185 | 186 | const previousOptions = cache.options; 187 | 188 | const { options, ...attributes } = tracking({ 189 | inputType, 190 | previousValue, 191 | previousOptions, 192 | value, 193 | addedValue, 194 | changeStart, 195 | changeEnd, 196 | selectionStart, 197 | selectionEnd, 198 | }); 199 | 200 | element.value = attributes.value; 201 | element.setSelectionRange(attributes.selectionStart, attributes.selectionEnd); 202 | 203 | cache.value = attributes.value; 204 | cache.options = options; 205 | 206 | tracker.selectionStart = attributes.selectionStart; 207 | tracker.selectionEnd = attributes.selectionEnd; 208 | 209 | // Действие необходимо только при работе React, для правильной работы события `change`! 210 | // После изменения значения с помощью `setInputAttributes` значение в свойстве `_valueTracker` также 211 | // изменится и будет соответствовать значению в элементе что приведёт к несрабатыванию события `change`. 212 | // Чтобы обойти эту проблему с версии React 16, устанавливаем предыдущее состояние на отличное от текущего. 213 | (element as { _valueTracker?: { setValue?: (value: string) => void } })._valueTracker?.setValue?.( 214 | previousValue, 215 | ); 216 | } catch (error) { 217 | element.value = tracker.value; 218 | element.setSelectionRange(tracker.selectionStart, tracker.selectionEnd); 219 | 220 | event.preventDefault(); 221 | event.stopPropagation(); 222 | 223 | if ((error as SyntheticChangeError).name !== 'SyntheticChangeError') { 224 | throw error; 225 | } 226 | } 227 | }; 228 | 229 | // Событие `focus` не сработает при рендере, даже если включено свойство `autoFocus`, 230 | // поэтому нам необходимо запустить определение позиции курсора вручную при автофокусе. 231 | if (document.activeElement === element) { 232 | onFocus(); 233 | } 234 | 235 | element.addEventListener('focus', onFocus); 236 | element.addEventListener('blur', onBlur); 237 | element.addEventListener('input', onInput); 238 | 239 | handlersMap.set(element, { onFocus, onBlur, onInput }); 240 | }; 241 | 242 | this.unregister = (element) => { 243 | const handlers = handlersMap.get(element); 244 | 245 | if (handlers !== undefined) { 246 | element.removeEventListener('focus', handlers.onFocus); 247 | element.removeEventListener('blur', handlers.onBlur); 248 | element.removeEventListener('input', handlers.onInput); 249 | 250 | handlersMap.delete(element); 251 | } 252 | }; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /packages/core/src/SyntheticChangeError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Кастомная ошибка обрабатывается в хуке `useInput`. 3 | */ 4 | export default class SyntheticChangeError extends Error { 5 | constructor(message: string) { 6 | super(message); 7 | this.name = 'SyntheticChangeError'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/core/src/createProxy.ts: -------------------------------------------------------------------------------- 1 | import type Input from './Input'; 2 | 3 | export default function createProxy(ref: React.MutableRefObject, instanse: Input) { 4 | return new Proxy(ref, { 5 | set(target, property, element: HTMLInputElement | null) { 6 | if (property !== 'current') { 7 | return false; 8 | } 9 | 10 | if (element !== ref.current) { 11 | if (ref.current !== null) { 12 | instanse.unregister(ref.current); 13 | } 14 | 15 | if (element !== null) { 16 | instanse.register(element); 17 | } 18 | } 19 | 20 | target[property] = element; 21 | 22 | return true; 23 | }, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createProxy } from './createProxy'; 2 | export { default as Input } from './Input'; 3 | export { default as SyntheticChangeError } from './SyntheticChangeError'; 4 | export { default as useConnectedRef } from './useConnectedRef'; 5 | export { default as useInput } from './useInput'; 6 | 7 | export type * from './types'; 8 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | export type InputType = 'insert' | 'deleteBackward' | 'deleteForward'; 2 | 3 | export type InitFunction = (param: { initialValue: string; controlled: boolean }) => { 4 | value: string; 5 | options: T; 6 | }; 7 | 8 | export type TrackingFunction = (param: { 9 | inputType: InputType; 10 | previousValue: string; 11 | previousOptions: T; 12 | value: string; 13 | addedValue: string; 14 | changeStart: number; 15 | changeEnd: number; 16 | selectionStart: number; 17 | selectionEnd: number; 18 | }) => { 19 | value: string; 20 | selectionStart: number; 21 | selectionEnd: number; 22 | options: T; 23 | }; 24 | 25 | export interface InputOptions { 26 | init: InitFunction; 27 | tracking: TrackingFunction; 28 | } 29 | 30 | export type InputComponentProps = { 31 | /** **Not used in the hook**. Serves to enable the use of custom components, for example, if you want to use your own styled component with the ability to format the value. */ 32 | component?: C; 33 | } & (C extends React.ComponentType ? P : React.InputHTMLAttributes); 34 | 35 | // https://github.com/GoncharukOrg/react-input/issues/15 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | export type InputComponent

= | undefined = undefined>( 38 | props: P & InputComponentProps & React.RefAttributes, 39 | ) => React.JSX.Element; 40 | -------------------------------------------------------------------------------- /packages/core/src/useConnectedRef.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | /** 4 | * Объединяет ссылки на dom-элементы (`ref`). Полезно когда необходимо хранить 5 | * ссылку на один и тот же элемент с помощью хука `useRef` в разных компонентах. 6 | * @returns 7 | */ 8 | export default function useConnectedRef( 9 | ref: React.MutableRefObject, 10 | forwardedRef: React.ForwardedRef, 11 | ) { 12 | return useCallback( 13 | (element: T | null) => { 14 | ref.current = element; 15 | 16 | if (typeof forwardedRef === 'function') { 17 | forwardedRef(element); 18 | } else if (typeof forwardedRef === 'object' && forwardedRef !== null) { 19 | forwardedRef.current = element; 20 | } 21 | }, 22 | [ref, forwardedRef], 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/src/useInput.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from 'react'; 2 | 3 | import Input from './Input'; 4 | import createProxy from './createProxy'; 5 | 6 | import type { InputOptions } from './types'; 7 | 8 | /** 9 | * Хук контроля события изменения ввода позволяет выполнять логику и изменять 10 | * аттрибуты `input` элемента на определённых этапах трекинга события. 11 | * @param param 12 | * @returns 13 | */ 14 | export default function useInput({ init, tracking }: InputOptions) { 15 | const $ref = useRef(null); 16 | const $options = useRef({ init, tracking }); 17 | 18 | $options.current.init = init; 19 | $options.current.tracking = tracking; 20 | 21 | return useMemo(() => { 22 | return createProxy($ref, new Input($options.current)); 23 | }, []); 24 | } 25 | -------------------------------------------------------------------------------- /packages/core/tests/core.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from '@testing-library/react'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | import { useInput } from '@react-input/core'; 7 | 8 | import '@testing-library/jest-dom'; 9 | 10 | function Input(props: React.InputHTMLAttributes) { 11 | const ref = useInput({ 12 | init: ({ initialValue }) => ({ 13 | value: initialValue, 14 | options: {}, 15 | }), 16 | tracking: ({ value, selectionStart, selectionEnd }) => ({ 17 | value, 18 | selectionStart, 19 | selectionEnd, 20 | options: {}, 21 | }), 22 | }); 23 | 24 | return ; 25 | } 26 | 27 | /** 28 | * INSERT 29 | */ 30 | 31 | test('Insert with autofocus', async () => { 32 | render(); 33 | 34 | const input = screen.getByTestId('testing-input'); 35 | 36 | await userEvent.type(input, '9123456789'); 37 | expect(input).toHaveValue('9123456789'); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/mask/LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Nikolay Goncharuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/mask/README.md: -------------------------------------------------------------------------------- 1 | # @react-input/mask 2 | 3 | ✨ Apply any mask to the input using a provided component or a hook bound to the input element. 4 | 5 | ![npm](https://img.shields.io/npm/dt/@react-input/mask?style=flat-square) 6 | ![npm](https://img.shields.io/npm/v/@react-input/mask?style=flat-square) 7 | ![npm bundle size](https://img.shields.io/bundlephobia/min/@react-input/mask?style=flat-square) 8 | 9 | [![Donate to our collective](https://opencollective.com/react-input/donate/button.png)](https://opencollective.com/react-input/donate) 10 | 11 | [![Edit @react-input/mask](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/p/sandbox/react-input-mask-r5jmmm?file=%2Fsrc%2FInput.tsx) 12 | 13 | ## What's new? 14 | 15 | Usage via CDN is available (see «[Usage with CDN](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#usage-with-cdn)»). 16 | 17 | The `input-mask` event and `onMask` method are no longer available in newer versions, focusing work on only using React's own events and methods such as `onChange`, since the `input-mask` event and `onMask` method cannot be explicitly coordinated with React's events and methods, making such usage and event firing order non-obvious. 18 | 19 | To use the useful data from the `detail` property of the `input-mask` (`onMask`) event object, you can also use the utilities described in the «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#utils)» section. 20 | 21 | **Documentation for version `v1` is available [here](https://github.com/GoncharukOrg/react-input/tree/v1/packages/mask).** 22 | 23 | ## Installation 24 | 25 | ```bash 26 | npm i @react-input/mask 27 | ``` 28 | 29 | or using **yarn**: 30 | 31 | ```bash 32 | yarn add @react-input/mask 33 | ``` 34 | 35 | or using **CDN** (for more information, see [UNPKG](https://unpkg.com/)): 36 | 37 | ```html 38 | 39 | ``` 40 | 41 | ## Unique properties 42 | 43 | | Name | Type | Default | Description | 44 | | ------------- | :------------------: | :-----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 45 | | `component` | `Component` | | **Not used in the useMask hook**. Serves to enable the use of custom components, for example, if you want to use your own styled component with the ability to mask the value (see «[Integration with custom components](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#integration-with-custom-components)»). | 46 | | `mask` | `string` | `""` | Input mask, `replacement` is used to replace characters. | 47 | | `replacement` | `string` \| `object` | `{}` | Sets the characters replaced in the mask, where "key" is the replaced character, "value" is the regular expression to which the input character must match (see «[Replacement](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#replacement)»). It is possible to pass the replacement character as a string, then `replacement="_"` will default to `replacement={{ _: /./ }}`. Keys are ignored as you type. | 48 | | `showMask` | `boolean` | `false` | Controls the display of the mask, for example, `+0 (123) ___-__-__` instead of `+0 (123`. | 49 | | `separate` | `boolean` | `false` | Stores the position of the entered characters. By default, input characters are non-breaking, which means that if you remove characters in the middle of the value, the characters are shifted to the left, forming a non-breaking value, which is the behavior of `input`. For example, with `true`, the possible value is `+0 (123) ___-45-__`, with `false` - `+0 (123) 45_-__-__`. | 50 | | `track` | `function` | | The `track` function is run before masking, allowing the entered value to be conditionally changed (see «[Track](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#track)»). | 51 | | `modify` | `function` | | Function triggered before masking. Allows you conditionally change the properties of the component that affect masking. Valid values ​​for modification are `mask`, `replacement`, `showMask` and `separate`. This is useful when you need conditionally tweak the displayed value to improve UX (see «[Modify](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#modify)»). | 52 | 53 | > You can also pass other properties available element `input` default or your own components, when integrated across the property `component`. 54 | 55 | ## Usage with React 56 | 57 | The `@react-input/mask` package provides two options for using a mask. The first is the `InputMask` component, which is a standard input element with additional logic to handle the input. The second is using the `useMask` hook, which needs to be linked to the `input` element through the `ref` property. 58 | 59 | One of the key features of the `@react-input/mask` package is that it only relies on user-supplied characters, so you can safely include any character in the mask without fear of the «unexpected behavior». 60 | 61 | Let's see how you can easily implement a mask for entering a phone number using the `InputMask` component: 62 | 63 | ```tsx 64 | import { InputMask } from '@react-input/mask'; 65 | 66 | export default function App() { 67 | return ; 68 | } 69 | ``` 70 | 71 | You can work with the `InputMask` component in the same way as with the `input` element, with the difference that the `InputMask` component uses additional logic to process the value. 72 | 73 | Now the same thing, but using the `useMask` hook: 74 | 75 | ```tsx 76 | import { useMask } from '@react-input/mask'; 77 | 78 | export default function App() { 79 | const inputRef = useMask({ 80 | mask: '+0 (___) ___-__-__', 81 | replacement: { _: /\d/ }, 82 | }); 83 | 84 | return ; 85 | } 86 | ``` 87 | 88 | The `useMask` hook takes the same properties as the `InputMask` component, except for the `component` properties. Both approaches are equivalent, but the use of the `InputMask` component provides additional capabilities, which will be discussed in the section «[Integration with custom components](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#integration-with-custom-components)». 89 | 90 | ## Usage with CDN 91 | 92 | To use the library's capabilities, you can also load it via CDN. 93 | 94 | When loading, you get the global class `ReactInput.Mask`, calling it with the specified mask parameters will create a new object with two methods, where the first is `register`, which applies masking when inputting to the specified element, the second is `unregister`, which cancels the previous action. The following example illustrates this use: 95 | 96 | ```js 97 | const mask = new ReactInput.Mask({ 98 | mask: '+0 (___) ___-__-__', 99 | replacement: { _: /\d/ }, 100 | }); 101 | 102 | const elements = document.getElementsByName('phone'); 103 | 104 | elements.forEach((element) => { 105 | mask.register(element); 106 | }); 107 | 108 | // If necessary, you can disable masking as you type. 109 | // elements.forEach((element) => { 110 | // mask.unregister(element); 111 | // }); 112 | ``` 113 | 114 | Please note that in this way you can register multiple elements to which the mask will be applied. 115 | 116 | > Although you can use a class to mask input, using a hook or component in the React environment is preferable due to the optimizations applied, where you do not have to think about when to call `register` and `unregister` for input masking to work. 117 | 118 | ## Initializing the value 119 | 120 | To support the concept of controlled input, `@react-input/mask` does not change the value passed in the `value` or `defaultValue` properties of the `input` element, so set the initialized value to something that can match the masked value at any point in the input. If you make a mistake, you'll see a warning in the console about it. 121 | 122 | In cases where the input contains an unmasked value, you should use the `format` utility described in the chapter «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#utils)» to substitute the correct value, for example: 123 | 124 | ```tsx 125 | import { useMask, format } from '@react-input/mask'; 126 | 127 | const options = { 128 | mask: '+0 (___) ___-__-__', 129 | replacement: { _: /\d/ }, 130 | }; 131 | 132 | export default function App() { 133 | const inputRef = useMask(options); 134 | const defaultValue = format('1234567890', options); 135 | 136 | return ; 137 | } 138 | ``` 139 | 140 | For consistent and correct behavior, the `type` property of the `input` element or the `InputMask` component must be set to `"text"` (the default), `"email"`, `"tel"`, `"search"`, or `"url"`. If you use other values, the mask will not be applied and you will see a warning in the console. 141 | 142 | ## Replacement 143 | 144 | The `replacement` property sets the characters to be replaced in the mask, where "key" is the replaced character, "value" is the regular expression to which the input character must match. You can set one or more replaceable characters with different regexps, 145 | 146 | like this: 147 | 148 | ```tsx 149 | import { InputMask } from '@react-input/mask'; 150 | 151 | export default function App() { 152 | return ; 153 | } 154 | ``` 155 | 156 | It is possible to pass the replacement character as a string, then any characters will be allowed. For example, `replacement="_"` is the same as `replacement={{ _: /./ }}`. 157 | 158 | > Do not use entered characters as `replacement` keys. For example, if you only allow numbers to be entered, given that the user can enter "9", then you should not set `replacement` to `{ 9: /\d/ }`, because keys are ignored when typing. Thus, the input of any numbers except "9" will be allowed. 159 | 160 | ## Track 161 | 162 | The `track` function is run before masking, allowing the entered value to be conditionally changed. 163 | 164 | You can intercept input to change the entered value every time the value in the input element changes. This is useful in cases where you need to provide a uniform data format, but at the same time you do not want to limit the user to the set of valid characters for input. 165 | 166 | The `track` function takes the following parameters: 167 | 168 | | Name | Type | Description | 169 | | ---------------- | :---------------------------------------------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 170 | | `inputType` | `insert` \| `deleteBackward` \| `deleteForward` | Input type, where `insert` is any event that affects the input of new characters, `deleteBackward` is deleting characters to the left of the cursor, `deleteForward` is deleting characters to the right of the cursor. | 171 | | `value` | `string` | Corresponds to the value before modification, that is, the value before the character input or character removal events were raised. | 172 | | `data` | `string` \| `null` | In the case of input - the entered characters, in the case of deletion - `null`. | 173 | | `selectionStart` | `number` | The index of the beginning of the range of change in the value, in the case of input corresponds to the initial position of the cursor in `value` at the time the input event is called, in the case of deletion it corresponds to the index of the first deleted character. | 174 | | `selectionEnd` | `number` | The index of the end of the range of change in the value, in the case of input corresponds to the final position of the cursor in `value` at the time the input event is called, in the case of deletion it corresponds to the index of the character after the last deleted character. | 175 | 176 | The `track` function expects to return a string corresponding to the new or current input value, allowing you to change the user's input value. This can be done both when entering and when deleting characters. You can also return `false`, which will allow you to stop the input process and not cause the value and cursor offset to change. `null` will correspond to returning an empty string. `true` or `undefined` will cause the input value to not be changed. 177 | 178 | Let's look at a simple example where we need to automatically substitute the country code when entering a phone number: 179 | 180 | ```tsx 181 | import { InputMask, type Track } from '@react-input/mask'; 182 | 183 | export default function App() { 184 | const track: Track = ({ inputType, value, data, selectionStart, selectionEnd }) => { 185 | if (inputType === 'insert' && !/^\D*1/.test(data) && selectionStart <= 1) { 186 | return `1${data}`; 187 | } 188 | 189 | if (inputType !== 'insert' && selectionStart <= 1 && selectionEnd < value.length) { 190 | if (selectionEnd > 2) { 191 | return '1'; 192 | } 193 | if (selectionEnd === 2) { 194 | return false; 195 | } 196 | } 197 | 198 | return data; 199 | }; 200 | 201 | return ; 202 | } 203 | ``` 204 | 205 | You can insert this example into your project and look at the result! 206 | 207 | You don't need to think about creating a separate state, controlling cursor behavior or registering new events, you just need to specify what value will be used for input, and everything else will happen synchronously. 208 | 209 | > Of course, this behavior requires you to write a little code yourself, this is because `@react-input/mask` is not intended to provide declarative solutions for a narrow circle of users, instead it allows all users to implement any idea using a simple api. 210 | 211 | ## Modify 212 | 213 | The `modify` function is triggered before masking and allows you conditionally change the properties of the component that affect the masking. 214 | 215 | The `modify` function expects to return an object containing the data to modify, optionally including `mask`, `replacement`, `showMask` and `separate`, or to return `undefined`. Changes will be only applied to those properties that were returned, so you can change any property as you like, or not change any property by passing `undefined`. 216 | 217 | The `modify` function takes the following parameters: 218 | 219 | | Name | Type | Description | 220 | | ---------------- | :---------------------------------------------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 221 | | `inputType` | `insert` \| `deleteBackward` \| `deleteForward` | Input type, where `insert` is any event that affects the input of new characters, `deleteBackward` is deleting characters to the left of the cursor, `deleteForward` is deleting characters to the right of the cursor. | 222 | | `value` | `string` | Corresponds to the value before modification, that is, the value before the character input or character removal events were raised. | 223 | | `data` | `string` \| `null` | In the case of input - the entered characters, in the case of deletion - `null`. | 224 | | `selectionStart` | `number` | The index of the beginning of the range of change in the value, in the case of input corresponds to the initial position of the cursor in `value` at the time the input event is called, in the case of deletion it corresponds to the index of the first deleted character. | 225 | | `selectionEnd` | `number` | The index of the end of the range of change in the value, in the case of input corresponds to the final position of the cursor in `value` at the time the input event is called, in the case of deletion it corresponds to the index of the character after the last deleted character. | 226 | 227 | The advantage of this approach is that you do not need to store the state of the component to change its properties, the modification happens in the already running masking process. 228 | 229 | An example of using the `modify` function can be found in the `phone-login` example, which removes the change of the mask depending on the input. See [`phone-login.tsx`](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask/src/examples/phone-login.tsx). 230 | 231 | ## Integration with custom components 232 | 233 | The `InputMask` component makes it easy to integrate with custom components allowing you to use your own styled components. To do this, you need to pass the custom component to the `forwardRef` method provided by React. `forwardRef` allows you automatically pass a `ref` value to a child element ([more on `forwardRef`](https://reactjs.org/docs/forwarding-refs.html)). 234 | 235 | Then place your own component in the `component` property. The value for the `component` property can be either function components or class components. 236 | 237 | With this approach, the `InputMask` component acts as a HOC, adding additional logic to the `input` element. 238 | 239 | Here's how to do it: 240 | 241 | ```tsx 242 | import { forwardRef } from 'react'; 243 | 244 | import { InputMask } from '@react-input/mask'; 245 | 246 | interface CustomInputProps { 247 | label: string; 248 | } 249 | 250 | // Custom input component 251 | const CustomInput = forwardRef(({ label }, forwardedRef) => { 252 | return ( 253 | <> 254 | 255 | 256 | 257 | ); 258 | }); 259 | 260 | // Component with InputMask 261 | export default function App() { 262 | return ; 263 | } 264 | ``` 265 | 266 | > The `InputMask` component will not forward properties available only to the `InputMask`, so as not to break the logic of your own component. 267 | 268 | ## Integration with Material UI 269 | 270 | If you are using [Material UI](https://mui.com/), you need to create a component that returns a `InputMask` and pass it as a value to the `inputComponent` property of the Material UI component. 271 | 272 | In this case, the Material UI component expects your component to be wrapped in a `forwardRef`, where you will need to pass the reference directly to the `ref` property of the `InputMask` component. 273 | 274 | Here's how to do it using the `InputMask` component: 275 | 276 | ```tsx 277 | import { forwardRef } from 'react'; 278 | 279 | import { InputMask, type InputMaskProps } from '@react-input/mask'; 280 | import { TextField } from '@mui/material'; 281 | 282 | // Component with InputMask 283 | const ForwardedInputMask = forwardRef((props, forwardedRef) => { 284 | return ; 285 | }); 286 | 287 | // Component with Material UI 288 | export default function App() { 289 | return ( 290 | 295 | ); 296 | } 297 | ``` 298 | 299 | or using the `useMask` hook: 300 | 301 | ```tsx 302 | import { useMask } from '@react-input/mask'; 303 | import { TextField } from '@mui/material'; 304 | 305 | export default function App() { 306 | const inputRef = useMask({ mask: '___-___', replacement: '_' }); 307 | 308 | return ; 309 | } 310 | ``` 311 | 312 | > The examples correspond to Material UI version 5. If you are using a different version, please read the [Material UI documentation](https://mui.com/material-ui/). 313 | 314 | ## Usage with TypeScript 315 | 316 | The `@react-input/mask` package is written in [TypeScript](https://www.typescriptlang.org/), so you have full type support out of the box. Additionally, you can import the types you need via `@react-input/mask` or `@react-input/mask/types`. 317 | 318 | ### Property type support 319 | 320 | Since the `InputMask` component supports two use cases (as an `input` element and as an HOC for your own component), `InputMask` takes both use cases into account to support property types. 321 | 322 | By default, the `InputMask` component is an `input` element and supports all the attributes supported by the `input` element. But if the `component` property was passed, the `InputMask` will additionally support the properties available to the integrated component. This approach allows you to integrate your own component as conveniently as possible, not forcing you to rewrite its logic, but using a mask where necessary. 323 | 324 | ```tsx 325 | import { InputMask, type InputMaskProps, type MaskOptions } from '@react-input/mask'; 326 | 327 | export default function App() { 328 | // Here, since no `component` property was passed, 329 | // `InputMask` returns an `input` element and takes the type: 330 | // `MaskOptions & React.InputHTMLAttributes` (the same as `InputMaskProps`) 331 | return ; 332 | } 333 | ``` 334 | 335 | ```tsx 336 | import { InputMask, type InputMaskProps, type MaskOptions } from '@react-input/mask'; 337 | 338 | import { CustomInput, type CustomInputProps } from './CustomInput'; 339 | 340 | export default function App() { 341 | // Here, since the `component` property was passed, 342 | // `InputMask` returns the `CustomInput` component and takes the type: 343 | // `MaskOptions & CustomInputProps` (the same as `InputMaskProps`) 344 | return ; 345 | } 346 | ``` 347 | 348 | You may run into a situation where you need to pass rest parameters (`...rest`) to the `InputMask` component. If the rest parameters is of type `any`, the `component` property will not be typed correctly, as well as the properties of the component being integrated. this is typical TypeScript behavior for dynamic type inference. 349 | 350 | To fix this situation and help the `InputMask` correctly inject your component's properties, you can pass your component's type directly to the `InputMask` component. 351 | 352 | ```tsx 353 | import { InputMask } from '@react-input/mask'; 354 | 355 | import { CustomInput } from './CustomInput'; 356 | 357 | export default function Component(props: any) { 358 | return component={CustomInput} mask="___-___" replacement="_" {...props} />; 359 | } 360 | ``` 361 | 362 | ## Testing and development 363 | 364 | To make it easier to work with the library, you will receive corresponding messages in the console when errors occur, which is good during development, but not needed in a production application. To avoid receiving error messages in a production application, make sure that the `NODE_ENV` variable is set to `"production"` when building the application. 365 | 366 | When testing a component with `showMask`, make sure that you set the initial cursor position (`selectionStart`). Value entry in testing tools starts from the end of the value, and without specifying `selectionStart` the entry will be made from a position equal to the length of the mask, since the `showMask` property actually inserts the value into the input element. 367 | 368 | ## Utils 369 | 370 | `@react-input/mask` provides utilities to make things easier when processing a value. You can use them regardless of using the `InputMask` component or the `useMask` hook. 371 | 372 | You can use utilities by importing them from the package or calling them from an instance of the `Mask` class. With the second option, you don't need to pass parameters to the methods, as shown in the examples below, for example when using with a CDN: 373 | 374 | ```js 375 | const mask = new ReactInput.Mask({ 376 | mask: '+__', 377 | replacement: { _: /\d/ }, 378 | }); 379 | 380 | mask.unformat('+1_'); // returns: "1" 381 | ``` 382 | 383 | ### `format` 384 | 385 | Masks a value using the specified mask. 386 | 387 | Takes two parameters, where the first is the unmasked value, the second is an object with the `mask` and `replacement` properties, the values of which you use when masking. 388 | 389 | The result fully corresponds to the value obtained when entering. Useful when you need to get a masked value without calling an input event. 390 | 391 | Since the principle of operation of `InputMask` is fully consistent with the operation of the `input` element, `InputMask` will not change the value outside the input event, so you may find yourself in a situation where the `input` element will have a value that does not correspond to the mask, for example when initializing the value of the received from the backend (see «[Initializing the value](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#initializing-the-value)» for more details). 392 | 393 | ```ts 394 | format('1', { mask: '+__', replacement: { _: /\d/ } }); 395 | // returns: "+1" 396 | ``` 397 | 398 | ### `unformat` 399 | 400 | Unmasks the value using the specified mask. 401 | 402 | Takes two parameters, where the first is the masked value, the second is an object with the `mask` and `replacement` properties, the values of which you use when masking. 403 | 404 | Returns all characters entered by the user. Essentially does the opposite of the `format` utility. It is important to note that characters that do not match the replacement characters will also be deleted. 405 | 406 | ```ts 407 | unformat('+1_', { mask: '+__', replacement: { _: /\d/ } }); 408 | // returns: "1" 409 | ``` 410 | 411 | ### `formatToParts` 412 | 413 | Specifies the parts of the masked value. 414 | 415 | Takes two parameters, where the first is the unmasked value, the second is an object with the `mask` and `replacement` properties, the values of which you use when masking. 416 | 417 | The masked value parts are an array of objects, where each object contains the necessary information about each character of the value. Parts of the masked value are used to manipulate a character or group of characters in a point-by-point manner. 418 | 419 | Parts of the masked value, where each object contains the character type: 420 | 421 | - `replacement` - the replacement character; 422 | - `mask` - the mask character; 423 | - `input` - the character entered by the user. 424 | 425 | ```ts 426 | formatToParts('1', { mask: '+__', replacement: { _: /\d/ } }); 427 | // returns: [ 428 | // { index: 0, value: '+', type: 'mask' }, 429 | // { index: 1, value: '1', type: 'input' }, 430 | // { index: 2, value: '_', type: 'replacement' }, 431 | // ] 432 | ``` 433 | 434 | ### `generatePattern` 435 | 436 | Generates a regular expression to match a masked value. 437 | 438 | Takes two parameters, where the first is a flag (`full` | `full-inexact` | `partial` | `partial-inexact`) telling the utility how exactly you want to match the value with the generated regular expression, the second is an object with `mask` and `replacement` properties, the values ​​of which you use when masking. 439 | 440 | If the first parameter is `full`, then the regular expression will match the entire length of the mask. Otherwise, if `partial` is specified as the first parameter, then the regular value can also match a partial value. 441 | 442 | Additionally, it is possible to generate an inexact match. So if the first parameter has the `-inexact` postfix, then the regular expression search will not take into account the `replacement` parameter key, i.e. the character in the index of the replacement character in the value can be any character that matches the `replacement` value, except for the `replacement` key itself. 443 | 444 | So, if `mask: '###'` and `replacement: { '#': /\D/ }`, then: 445 | 446 | - if the first parameter is `full`, then the regular expression (pattern) will match all non-digits except "#" and `RegExp(pattern).test('ab#')` will return `false`; 447 | - if the first parameter is `full-inexact`,then the regular expression (pattern) will match all non-digits including "#" and `RegExp(pattern).test('ab#')` will return `true`; 448 | - if the first parameter is `partial`, then the regular expression (pattern) will match all non-digits except "#" taking into account the partial value and `RegExp(pattern).test('a#')` will return `false`; 449 | - if the first parameter is `partial-inexact`, then the regular expression (pattern) will match all non-digits including "#" taking into account the partial value and `RegExp(pattern).test('a#')` will return `true`. 450 | 451 | ```ts 452 | const options = { 453 | mask: '###', 454 | replacement: { '#': /\D/ }, 455 | }; 456 | 457 | const pattern$1 = generatePattern('full', options); 458 | RegExp(pattern$1).test('ab#'); // false 459 | RegExp(pattern$1).test('abc'); // true 460 | 461 | const pattern$2 = generatePattern('full-inexact', options); 462 | RegExp(pattern$2).test('ab#'); // true 463 | RegExp(pattern$2).test('abc'); // true 464 | 465 | const pattern$3 = generatePattern('partial', options); 466 | RegExp(pattern$3).test('a#'); // false 467 | RegExp(pattern$3).test('ab'); // true 468 | 469 | const pattern$4 = generatePattern('partial-inexact', options); 470 | RegExp(pattern$4).test('a#'); // true 471 | RegExp(pattern$4).test('ab'); // true 472 | ``` 473 | 474 | ## Migration to v2 475 | 476 | If you are upgrading from version 1 to version 2, there are a number of important changes you need to take into account. 477 | 478 | ### `onMask` 479 | 480 | The `input-mask` event and `onMask` method are no longer available in newer versions, focusing work on only using React's own events and methods such as `onChange`, since the `input-mask` event and `onMask` method cannot be explicitly coordinated with React's events and methods, making such usage and event firing order non-obvious. 481 | 482 | Thus, you should use `onChange` instead of the `onMask` method. 483 | 484 | Additionally, if you are referencing data in the `detail` property of the `onMask` event object, you should use the utilities described in the [`Utils`](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#utils) section instead, for example: 485 | 486 | instead of 487 | 488 | ```tsx 489 | import { InputMask } from '@react-input/mask'; 490 | 491 | // ... 492 | 493 | const options = { 494 | mask: '___-___', 495 | replacement: { _: /\d/ }, 496 | }; 497 | 498 | return ( 499 | { 502 | const { value, input, parts, pattern, isValid } = event.detail; 503 | }} 504 | /> 505 | ); 506 | ``` 507 | 508 | use 509 | 510 | ```tsx 511 | import { InputMask, unformat, formatToParts, generatePattern } from '@react-input/mask'; 512 | 513 | // ... 514 | 515 | const options = { 516 | mask: '___-___', 517 | replacement: { _: /\d/ }, 518 | }; 519 | 520 | return ( 521 | { 524 | const value = event.target.value; 525 | const input = unformat(value, options); 526 | const parts = formatToParts(value, options); 527 | const pattern = generatePattern('full-inexact', options); 528 | const isValid = RegExp(pattern).test(value); 529 | }} 530 | /> 531 | ); 532 | ``` 533 | 534 | For more information on using utilities, see [`Utils`](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#utils). 535 | 536 | ### `modify` 537 | 538 | The `modify` method now uses the new API, so that it takes an input object similar to the `track` method instead of unmasked values. 539 | This approach allows for more flexible control and the ability to choose masking. For more information on the `modify` method, see [`Modify`](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#modify). 540 | 541 | ### `generatePattern` 542 | 543 | The `generatePattern` utility now has a new API, so: 544 | 545 | - if you use `generatePattern(options, true)` (with `true` as the second argument), you should change to `generatePattern('full', options)`; 546 | - if you use `generatePattern(options)` (without `true` as the second argument), you should change to `generatePattern('full-inexact', options)`. 547 | 548 | For more information on using the `generatePattern` utility, see [`Utils`](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#generatepattern). 549 | 550 | ### `TrackParam` 551 | 552 | The `TrackParam` type has been renamed to `TrackingData`. 553 | 554 | ## Examples 555 | 556 | Check out the usage examples that take into account different cases that may be useful in your particular case. [See examples](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask/src/examples). 557 | 558 | ## Other packages from `@react-input` 559 | 560 | - [`@react-input/number-format`](https://www.npmjs.com/package/@react-input/number-format) - apply locale-specific number, currency, and percentage formatting to input using a provided component or hook bound to the input element. 561 | 562 | ## Feedback 563 | 564 | If you find a bug or want to make a suggestion for improving the package, [open the issues on GitHub](https://github.com/GoncharukOrg/react-input/issues) or email [goncharuk.bro@gmail.com](mailto:goncharuk.bro@gmail.com). 565 | 566 | Support the project with a star ⭐ on [GitHub](https://github.com/GoncharukOrg/react-input). 567 | 568 | You can also support the authors by donating 🪙 to [Open Collective](https://opencollective.com/react-input): 569 | 570 | [![Donate to our collective](https://opencollective.com/react-input/donate/button.png)](https://opencollective.com/react-input/donate) 571 | 572 | ## License 573 | 574 | [MIT](https://github.com/GoncharukOrg/react-input/blob/main/packages/mask/LICENSE) © [Nikolay Goncharuk](https://github.com/GoncharukOrg) 575 | -------------------------------------------------------------------------------- /packages/mask/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-input/mask", 3 | "version": "2.0.4", 4 | "license": "MIT", 5 | "author": "Nikolay Goncharuk ", 6 | "description": "React input component for masked input.", 7 | "keywords": [ 8 | "react", 9 | "react-component", 10 | "react-hook", 11 | "react-mask", 12 | "react-input-mask", 13 | "input", 14 | "input-mask", 15 | "text-field", 16 | "mask", 17 | "masked", 18 | "format", 19 | "pattern", 20 | "replace" 21 | ], 22 | "funding": { 23 | "type": "opencollective", 24 | "url": "https://opencollective.com/react-input" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/GoncharukOrg/react-input.git", 29 | "directory": "packages/mask" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/GoncharukOrg/react-input/issues" 33 | }, 34 | "homepage": "https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#readme", 35 | "files": [ 36 | "@types", 37 | "cdn", 38 | "module", 39 | "node" 40 | ], 41 | "sideEffects": false, 42 | "type": "module", 43 | "types": "@types/index.d.ts", 44 | "module": "module/index.js", 45 | "main": "node/index.cjs", 46 | "exports": { 47 | ".": { 48 | "types": "./@types/index.d.ts", 49 | "import": "./module/index.js", 50 | "require": "./node/index.cjs" 51 | }, 52 | "./*": { 53 | "types": "./@types/*.d.ts", 54 | "import": "./module/*.js", 55 | "require": "./node/*.cjs" 56 | }, 57 | "./*.js": { 58 | "types": "./@types/*.d.ts", 59 | "import": "./module/*.js", 60 | "require": "./node/*.cjs" 61 | } 62 | }, 63 | "typesVersions": { 64 | "*": { 65 | "@types/index.d.ts": [ 66 | "@types/index.d.ts" 67 | ], 68 | "*": [ 69 | "@types/*" 70 | ] 71 | } 72 | }, 73 | "publishConfig": { 74 | "access": "public" 75 | }, 76 | "scripts": { 77 | "build": "tsx ../../scripts/build.ts", 78 | "release:major": "tsx ../../scripts/release.ts major", 79 | "release:minor": "tsx ../../scripts/release.ts minor", 80 | "release:patch": "tsx ../../scripts/release.ts patch" 81 | }, 82 | "dependencies": { 83 | "@react-input/core": "^2.0.2" 84 | }, 85 | "peerDependencies": { 86 | "@types/react": ">=16.8", 87 | "react": ">=16.8 || ^19.0.0-rc", 88 | "react-dom": ">=16.8 || ^19.0.0-rc" 89 | }, 90 | "peerDependenciesMeta": { 91 | "@types/react": { 92 | "optional": true 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/mask/src/InputMask.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 2 | import React, { forwardRef } from 'react'; 3 | 4 | import { useConnectedRef } from '@react-input/core'; 5 | 6 | import useMask from './useMask'; 7 | 8 | import type { MaskOptions } from './types'; 9 | import type { InputComponent, InputComponentProps } from '@react-input/core'; 10 | 11 | export type InputMaskProps = MaskOptions & 12 | InputComponentProps; 13 | 14 | function ForwardedInputMask( 15 | { component: Component, mask, replacement, showMask, separate, track, modify, ...props }: InputMaskProps, 16 | forwardedRef: React.ForwardedRef, 17 | ) { 18 | const ref = useMask({ mask, replacement, showMask, separate, track, modify }); 19 | 20 | const connectedRef = useConnectedRef(ref, forwardedRef); 21 | 22 | if (Component) { 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | return ; 25 | } 26 | 27 | return ; 28 | } 29 | 30 | const InputMask = forwardRef(ForwardedInputMask) as InputComponent; 31 | 32 | export default InputMask; 33 | -------------------------------------------------------------------------------- /packages/mask/src/Mask.ts: -------------------------------------------------------------------------------- 1 | import { Input, SyntheticChangeError } from '@react-input/core'; 2 | 3 | import * as utils from './utils'; 4 | import filter from './utils/filter'; 5 | import format from './utils/format'; 6 | import formatToReplacementObject from './utils/formatToReplacementObject'; 7 | import resolveSelection from './utils/resolveSelection'; 8 | import unformat from './utils/unformat'; 9 | import validate from './utils/validate'; 10 | 11 | import type { MaskOptions, MaskPart, Overlap, Replacement } from './types'; 12 | 13 | function normalizeOptions(options: MaskOptions) { 14 | return { 15 | mask: options.mask ?? '', 16 | replacement: 17 | typeof options.replacement === 'string' 18 | ? formatToReplacementObject(options.replacement) 19 | : (options.replacement ?? {}), 20 | showMask: options.showMask ?? false, 21 | separate: options.separate ?? false, 22 | track: options.track, 23 | modify: options.modify, 24 | }; 25 | } 26 | 27 | export default class Mask extends Input<{ mask: string; replacement: Replacement; separate: boolean }> { 28 | static { 29 | Object.defineProperty(this.prototype, Symbol.toStringTag, { 30 | writable: false, 31 | enumerable: false, 32 | configurable: true, 33 | value: 'Mask', 34 | }); 35 | } 36 | 37 | format: (value: string) => string; 38 | formatToParts: (value: string) => MaskPart[]; 39 | unformat: (value: string) => string; 40 | generatePattern: (overlap: Overlap) => string; 41 | 42 | constructor(options: MaskOptions = {}) { 43 | super({ 44 | /** 45 | * Init 46 | */ 47 | init: ({ initialValue, controlled }) => { 48 | const { mask, replacement, separate, showMask } = normalizeOptions(options); 49 | 50 | initialValue = controlled || initialValue ? initialValue : showMask ? mask : ''; 51 | 52 | if (process.env.NODE_ENV !== 'production') { 53 | validate({ initialValue, mask, replacement }); 54 | } 55 | 56 | return { value: initialValue, options: { mask, replacement, separate } }; 57 | }, 58 | /** 59 | * Tracking 60 | */ 61 | tracking: ({ inputType, previousValue, previousOptions, addedValue, changeStart, changeEnd }) => { 62 | const { track, modify, ...normalizedOptions } = normalizeOptions(options); 63 | 64 | let { mask, replacement, showMask, separate } = normalizedOptions; 65 | 66 | const _data = inputType === 'insert' ? { inputType, data: addedValue } : { inputType, data: null }; 67 | const trackingData = { ..._data, value: previousValue, selectionStart: changeStart, selectionEnd: changeEnd }; 68 | 69 | const trackingValue = track?.(trackingData); 70 | 71 | if (trackingValue === false) { 72 | throw new SyntheticChangeError('Custom tracking stop.'); 73 | } else if (trackingValue === null) { 74 | addedValue = ''; 75 | } else if (trackingValue !== true && trackingValue !== undefined) { 76 | addedValue = trackingValue; 77 | } 78 | 79 | const modifiedOptions = modify?.(trackingData); 80 | 81 | if (modifiedOptions?.mask !== undefined) mask = modifiedOptions.mask; 82 | if (modifiedOptions?.replacement !== undefined) 83 | replacement = 84 | typeof modifiedOptions?.replacement === 'string' 85 | ? formatToReplacementObject(modifiedOptions?.replacement) 86 | : modifiedOptions.replacement; 87 | if (modifiedOptions?.showMask !== undefined) showMask = modifiedOptions.showMask; 88 | if (modifiedOptions?.separate !== undefined) separate = modifiedOptions.separate; 89 | 90 | // Дополнительно учитываем, что добавление/удаление символов не затрагивают значения до и после диапазона 91 | // изменения, поэтому нам важно получить их немаскированные значения на основе предыдущего значения и 92 | // закэшированных пропсов, то есть тех которые были применены к значению на момент предыдущего маскирования 93 | let beforeChangeValue = unformat(previousValue, { end: changeStart, ...previousOptions }); 94 | let afterChangeValue = unformat(previousValue, { start: changeEnd, ...previousOptions }); 95 | 96 | // Регулярное выражение по поиску символов кроме ключей `replacement` 97 | const regExp$1 = RegExp(`[^${Object.keys(replacement).join('')}]`, 'g'); 98 | // Находим все заменяемые символы для фильтрации пользовательского значения. 99 | // Важно определить корректное значение на данном этапе 100 | let replacementChars = mask.replace(regExp$1, ''); 101 | 102 | if (beforeChangeValue) { 103 | beforeChangeValue = filter(beforeChangeValue, { replacementChars, replacement, separate }); 104 | replacementChars = replacementChars.slice(beforeChangeValue.length); 105 | } 106 | 107 | if (addedValue) { 108 | // Поскольку нас интересуют только "полезные" символы, фильтруем без учёта заменяемых символов 109 | addedValue = filter(addedValue, { replacementChars, replacement, separate: false }); 110 | replacementChars = replacementChars.slice(addedValue.length); 111 | } 112 | 113 | if (inputType === 'insert' && addedValue === '') { 114 | throw new SyntheticChangeError('The character does not match the key value of the `replacement` object.'); 115 | } 116 | 117 | // Модифицируем `afterChangeValue` чтобы позиция символов не смещалась. Необходимо выполнять 118 | // после фильтрации `addedValue` и перед фильтрацией `afterChangeValue` 119 | if (separate) { 120 | // Находим заменяемые символы в диапазоне изменяемых символов 121 | const separateChars = mask.slice(changeStart, changeEnd).replace(regExp$1, ''); 122 | // Получаем количество символов для сохранения перед `afterChangeValue`. Возможные значения: 123 | // `меньше ноля` - обрезаем значение от начала на количество символов; 124 | // `ноль` - не меняем значение; 125 | // `больше ноля` - добавляем заменяемые символы к началу значения. 126 | const countSeparateChars = separateChars.length - addedValue.length; 127 | 128 | if (countSeparateChars < 0) { 129 | afterChangeValue = afterChangeValue.slice(-countSeparateChars); 130 | } else if (countSeparateChars > 0) { 131 | afterChangeValue = separateChars.slice(-countSeparateChars) + afterChangeValue; 132 | } 133 | } 134 | 135 | if (afterChangeValue) { 136 | afterChangeValue = filter(afterChangeValue, { replacementChars, replacement, separate }); 137 | } 138 | 139 | const input = beforeChangeValue + addedValue + afterChangeValue; 140 | const value = format(input, { mask, replacement, separate, showMask }); 141 | 142 | const selection = resolveSelection({ 143 | inputType, 144 | value, 145 | addedValue, 146 | beforeChangeValue, 147 | mask, 148 | replacement, 149 | separate, 150 | }); 151 | 152 | return { 153 | value, 154 | selectionStart: selection, 155 | selectionEnd: selection, 156 | options: { mask, replacement, separate }, 157 | }; 158 | }, 159 | }); 160 | 161 | this.format = (value) => { 162 | return utils.format(value, normalizeOptions(options)); 163 | }; 164 | 165 | this.formatToParts = (value) => { 166 | return utils.formatToParts(value, normalizeOptions(options)); 167 | }; 168 | 169 | this.unformat = (value) => { 170 | return utils.unformat(value, normalizeOptions(options)); 171 | }; 172 | 173 | this.generatePattern = (overlap) => { 174 | return utils.generatePattern(overlap, normalizeOptions(options)); 175 | }; 176 | } 177 | } 178 | 179 | if (process.env.__OUTPUT__ === 'cdn') { 180 | interface Context { 181 | ReactInput?: { 182 | Mask?: typeof Mask & Partial; 183 | }; 184 | } 185 | 186 | const _global: typeof globalThis & Context = typeof globalThis !== 'undefined' ? globalThis : global || self; 187 | 188 | _global.ReactInput = _global.ReactInput ?? {}; 189 | _global.ReactInput.Mask = Mask; 190 | _global.ReactInput.Mask.format = utils.format; 191 | _global.ReactInput.Mask.formatToParts = utils.formatToParts; 192 | _global.ReactInput.Mask.unformat = utils.unformat; 193 | _global.ReactInput.Mask.generatePattern = utils.generatePattern; 194 | } 195 | -------------------------------------------------------------------------------- /packages/mask/src/examples/phone-login.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * One input for both login and phone number. 3 | * 4 | * Login can consist of underscores and Latin letters. 5 | * 6 | * If the phone number input starts with "8", "8" -> "7" is replaced, otherwise, 7 | * if the input starts with a digit not equal to "7" or "8", "7" is substituted at the beginning of the value. 8 | * 9 | * If there is at least one user character not equal to a digit, the input mask is changed. 10 | */ 11 | 12 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 13 | import React, { useState } from 'react'; 14 | 15 | import { InputMask, format } from '@react-input/mask'; 16 | 17 | import type { Modify, Track } from '@react-input/mask'; 18 | 19 | const phoneOptions = { 20 | mask: '+# (###) ###-##-##', 21 | replacement: { '#': /\d/ }, 22 | }; 23 | 24 | const loginOptions = { 25 | mask: '#'.repeat(30), 26 | replacement: { '#': /[\da-zA-Z_]/ }, 27 | }; 28 | 29 | const track: Track = ({ inputType, value, data, selectionStart, selectionEnd }) => { 30 | let _value = value.slice(0, selectionStart) + (data ?? '') + value.slice(selectionEnd); 31 | _value = _value.replace(/[\s()+-]/g, ''); 32 | 33 | const isPhoneNumber = _value === '' || /^\d+$/.test(_value); 34 | 35 | if (isPhoneNumber && inputType === 'insert' && selectionStart <= 1) { 36 | const _data = data.replace(/[^\d]/g, ''); 37 | return /^[78]/.test(_data) ? `7${_data.slice(1)}` : /^[0-69]/.test(_data) ? `7${_data}` : data; 38 | } 39 | 40 | if (isPhoneNumber && inputType !== 'insert' && selectionStart <= 1 && selectionEnd < value.length) { 41 | return selectionEnd > 2 ? '7' : selectionEnd === 2 && value.startsWith('+') ? false : data; 42 | } 43 | 44 | return data; 45 | }; 46 | 47 | const modify: Modify = ({ value, data, selectionStart, selectionEnd }) => { 48 | const _value = value.slice(0, selectionStart) + (data ?? '') + value.slice(selectionEnd); 49 | const isPhone = /^\d+$/.test(_value.replace(/[^\da-zA-Z_]/g, '')); 50 | 51 | return isPhone ? phoneOptions : loginOptions; 52 | }; 53 | 54 | export default function Component() { 55 | const [value, setValue] = useState(''); 56 | 57 | const handleChange = (event: React.ChangeEvent) => { 58 | let value = event.target.value; 59 | 60 | if (value.startsWith('+8')) { 61 | value = `+7${value.slice(2)}`; 62 | } else if (/^\+[^7]/.test(value)) { 63 | value = format(`7${value.replace(/\D/g, '')}`, phoneOptions); 64 | } 65 | 66 | if (value.startsWith('+') && (event.target.selectionStart ?? 0) < 2) { 67 | requestAnimationFrame(() => { 68 | event.target.setSelectionRange(2, 2); 69 | }); 70 | } 71 | 72 | setValue(value); 73 | }; 74 | 75 | return ; 76 | } 77 | -------------------------------------------------------------------------------- /packages/mask/src/examples/phone.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Input for entering a phone number. 3 | * 4 | * If the input of a phone number starts with "8", the replacement "8" -> "7" occurs, otherwise, 5 | * if the input starts with a digit not equal to "7" or "8", then "7" is substituted at the beginning of the value. 6 | */ 7 | 8 | import React from 'react'; 9 | 10 | import { InputMask } from '@react-input/mask'; 11 | 12 | import type { Track } from '@react-input/mask'; 13 | 14 | const track: Track = ({ inputType, value, data, selectionStart, selectionEnd }) => { 15 | if (inputType === 'insert' && selectionStart <= 1) { 16 | const _data = data.replace(/[^\d]/g, ''); 17 | return /^[78]/.test(_data) ? `7${_data.slice(1)}` : /^[0-69]/.test(_data) ? `7${_data}` : data; 18 | } 19 | 20 | if (inputType !== 'insert' && selectionStart <= 1 && selectionEnd < value.length) { 21 | return selectionEnd > 2 ? '7' : selectionEnd === 2 ? false : data; 22 | } 23 | 24 | return data; 25 | }; 26 | 27 | export default function Component() { 28 | return ; 29 | } 30 | -------------------------------------------------------------------------------- /packages/mask/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as InputMask } from './InputMask'; 2 | export { default as Mask } from './Mask'; 3 | export { default as useMask } from './useMask'; 4 | export * from './utils'; 5 | 6 | export type { InputMaskProps } from './InputMask'; 7 | export type { MaskOptions, Replacement, TrackingData, Track, ModifiedData, Modify } from './types'; 8 | -------------------------------------------------------------------------------- /packages/mask/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Overlap = 'full' | 'full-inexact' | 'partial' | 'partial-inexact'; 2 | 3 | export interface MaskPart { 4 | type: 'replacement' | 'mask' | 'input'; 5 | value: string; 6 | index: number; 7 | } 8 | 9 | export type Replacement = Record; 10 | 11 | export type TrackingData = ( 12 | | { inputType: 'insert'; data: string } 13 | | { inputType: 'deleteBackward' | 'deleteForward'; data: null } 14 | ) & { 15 | value: string; 16 | selectionStart: number; 17 | selectionEnd: number; 18 | }; 19 | 20 | export type Track = (data: TrackingData) => string | boolean | null | undefined; 21 | 22 | export interface ModifiedData { 23 | mask?: string; 24 | replacement?: string | Replacement; 25 | showMask?: boolean; 26 | separate?: boolean; 27 | } 28 | 29 | export type Modify = (data: TrackingData) => ModifiedData | undefined; 30 | 31 | export interface MaskOptions { 32 | /** Input mask, `replacement` is used to replace characters. */ 33 | mask?: string; 34 | /** Sets the characters replaced in the mask, where "key" is the replaced character, "value" is the regular expression to which the input character must match (see «[Replacement](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#replacement)»). It is possible to pass the replacement character as a string, then `replacement="_"` will default to `replacement={{ _: /./ }}`. Keys are ignored as you type. */ 35 | replacement?: string | Replacement; 36 | /** Controls the display of the mask, for example, `+0 (123) ___-__-__` instead of `+0 (123`. */ 37 | showMask?: boolean; 38 | /** Stores the position of the entered characters. By default, input characters are non-breaking, which means that if you remove characters in the middle of the value, the characters are shifted to the left, forming a non-breaking value, which is the behavior of `input`. For example, with `true`, the possible value is `+0 (123) ___-45-__`, with `false` - `+0 (123) 45_-__-__`. */ 39 | separate?: boolean; 40 | /** The function is activated before masking. Allows you to conditionally change the entered value (see «[Track](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#track)»). */ 41 | track?: Track; 42 | /** Function triggered before masking. Allows you conditionally change the properties of the component that affect masking. Valid values for modification are `mask`, `replacement`, `showMask` and `separate`. This is useful when you need conditionally tweak the displayed value to improve UX (see «[Modify](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#modify)»). */ 43 | modify?: Modify; 44 | } 45 | -------------------------------------------------------------------------------- /packages/mask/src/useMask.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from 'react'; 2 | 3 | import { createProxy } from '@react-input/core'; 4 | 5 | import Mask from './Mask'; 6 | 7 | import type { MaskOptions } from './types'; 8 | 9 | export default function useMask({ mask, replacement, showMask, separate, track, modify }: MaskOptions = {}) { 10 | const $ref = useRef(null); 11 | const $options = useRef({ mask, replacement, showMask, separate, track, modify }); 12 | 13 | $options.current.mask = mask; 14 | $options.current.replacement = replacement; 15 | $options.current.showMask = showMask; 16 | $options.current.separate = separate; 17 | $options.current.track = track; 18 | $options.current.modify = modify; 19 | 20 | return useMemo(() => { 21 | return createProxy($ref, new Mask($options.current)); 22 | }, []); 23 | } 24 | -------------------------------------------------------------------------------- /packages/mask/src/utils.ts: -------------------------------------------------------------------------------- 1 | import _filter from './utils/filter'; 2 | import _format from './utils/format'; 3 | import _formatToParts from './utils/formatToParts'; 4 | import _formatToReplacementObject from './utils/formatToReplacementObject'; 5 | import _unformat from './utils/unformat'; 6 | 7 | import type { MaskPart, Overlap, Replacement } from './types'; 8 | 9 | interface Options { 10 | mask: string; 11 | replacement: string | Replacement; 12 | } 13 | 14 | /** 15 | * Masks a value using the specified mask (see «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#format)»). 16 | * 17 | * The result fully corresponds to the value obtained when entering. 18 | * Useful when you need to get a masked value without calling an input event. 19 | * 20 | * Since the principle of operation of `InputMask` is fully consistent with the operation 21 | * of the `input` element, `InputMask` will not change the value outside the input event, so 22 | * you may find yourself in a situation where the `input` element will have a value that does not 23 | * correspond to the mask, for example when initializing the value of the received from the backend. 24 | * 25 | * `format('1', { mask: '+__', replacement: { _: /\d/ } })` → "+1" 26 | */ 27 | export function format(value: string, { mask, replacement }: Options): string { 28 | const replacementObject = typeof replacement === 'string' ? _formatToReplacementObject(replacement) : replacement; 29 | 30 | const regExp$1 = RegExp(`[^${Object.keys(replacementObject).join('')}]`, 'g'); 31 | const replacementChars = mask.replace(regExp$1, ''); 32 | 33 | const input = _filter(value, { replacementChars, replacement: replacementObject, separate: false }); 34 | 35 | return _format(input, { mask, replacement: replacementObject, separate: false, showMask: false }); 36 | } 37 | 38 | /** 39 | * Unmasks the value using the specified mask (see «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#unformat)»). 40 | * 41 | * Returns all characters entered by the user. Essentially does the opposite of the `format` utility. 42 | * 43 | * `unformat('+1_', { mask: '+__', replacement: { _: /\d/ } })` → "1" 44 | */ 45 | export function unformat(value: string, { mask, replacement }: Options): string { 46 | const replacementObject = typeof replacement === 'string' ? _formatToReplacementObject(replacement) : replacement; 47 | 48 | const unformattedValue = _unformat(value, { mask, replacement: replacementObject, separate: false }); 49 | 50 | const regExp$1 = RegExp(`[^${Object.keys(replacementObject).join('')}]`, 'g'); 51 | const replacementChars = mask.replace(regExp$1, ''); 52 | 53 | return _filter(unformattedValue, { replacementChars, replacement: replacementObject, separate: false }); 54 | } 55 | 56 | /** 57 | * Specifies the parts of the masked value (see «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#formattoparts)»). 58 | * 59 | * The masked value parts are an array of objects, where each object contains the 60 | * necessary information about each character of the value. Parts of the masked value 61 | * are used to manipulate a character or group of characters in a point-by-point manner. 62 | * 63 | * Parts of the masked value, where each object contains the character type: 64 | * - `replacement` - the replacement character; 65 | * - `mask` - the mask character; 66 | * - `input` - the character entered by the user. 67 | * 68 | * `formatToParts('1', { mask: '+__', replacement: { _: /\d/ } })` → 69 | * ``` 70 | * [ 71 | * { index: 0, value: '+', type: 'mask' }, 72 | * { index: 1, value: '1', type: 'input' }, 73 | * { index: 2, value: '_', type: 'replacement' }, 74 | * ] 75 | * ``` 76 | */ 77 | export function formatToParts(value: string, { mask, replacement }: Options): MaskPart[] { 78 | const replacementObject = typeof replacement === 'string' ? _formatToReplacementObject(replacement) : replacement; 79 | 80 | const formattedValue = format(value, { mask, replacement: replacementObject }); 81 | 82 | return _formatToParts(formattedValue, { mask, replacement: replacementObject }); 83 | } 84 | 85 | const SPECIAL = ['[', ']', '\\', '/', '^', '$', '.', '|', '?', '*', '+', '(', ')', '{', '}']; 86 | 87 | function resolveSpecial(char: string) { 88 | return SPECIAL.includes(char) ? `\\${char}` : char; 89 | } 90 | 91 | /** 92 | * Generates a regular expression to match a masked value (see «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#generatepattern)»). 93 | * 94 | * If the first parameter is `full`, then the regular expression will match the entire length of the mask. Otherwise, if `partial` is specified as the first 95 | * parameter, then the regular value can also match a partial value. 96 | * 97 | * Additionally, it is possible to generate an inexact match. So if the first parameter has the `-inexact` postfix, then the regular expression search will 98 | * not take into account the `replacement` parameter key, i.e. the character in the index of the replacement character in the value can be any character that 99 | * matches the `replacement` value, except for the `replacement` key itself. 100 | * 101 | * So, if `mask: '###'` and `replacement: { '#': /\D/ }`, then: 102 | * - if `overlap: 'full'`, the regular expression (pattern) will match all non-digits except "#" and `RegExp(pattern).test('ab#')` will return `false`; 103 | * - if `overlap: 'full-inexact'`, the regular expression (pattern) will match all non-digits including "#" and `RegExp(pattern).test('ab#')` will return `true`; 104 | * - if `overlap: 'partial'`, the regular expression (pattern) will match all non-digits except "#" taking into account the partial value and `RegExp(pattern).test('a#')` will return `false`; 105 | * - if `overlap: 'partial-inexact'`, the regular expression (pattern) will match all non-digits including "#" taking into account the partial value and `RegExp(pattern).test('a#')` will return `true`. 106 | */ 107 | export function generatePattern(overlap: Overlap, { mask, replacement }: Options): string { 108 | if (overlap !== 'full' && overlap !== 'full-inexact' && overlap !== 'partial' && overlap !== 'partial-inexact') { 109 | new TypeError('The overlap value can be "full", "full-inexact", "partial" or "partial-inexact".'); 110 | } 111 | 112 | const replacementObject = typeof replacement === 'string' ? _formatToReplacementObject(replacement) : replacement; 113 | const isPartial = overlap === 'partial' || overlap === 'partial-inexact'; 114 | const isExact = overlap === 'full' || overlap === 'partial'; 115 | 116 | let pattern = ''; 117 | 118 | for (let i = 0; i < mask.length; i++) { 119 | const char = mask[i]; 120 | const isReplacementKey = Object.prototype.hasOwnProperty.call(replacementObject, char); 121 | 122 | if (i === 0) { 123 | pattern = '^'; 124 | } 125 | 126 | if (isPartial) { 127 | pattern += '('; 128 | } 129 | 130 | pattern += isReplacementKey 131 | ? `${isExact ? `(?!${resolveSpecial(char)})` : ''}(${replacementObject[char].source})` 132 | : resolveSpecial(char); 133 | 134 | if (i === mask.length - 1) { 135 | if (isPartial) { 136 | pattern += ')?'.repeat(mask.length); 137 | } 138 | 139 | pattern += '$'; 140 | } 141 | } 142 | 143 | return pattern; 144 | } 145 | -------------------------------------------------------------------------------- /packages/mask/src/utils/filter.ts: -------------------------------------------------------------------------------- 1 | import type { Replacement } from '../types'; 2 | 3 | interface Options { 4 | replacementChars: string; 5 | replacement: Replacement; 6 | separate: boolean; 7 | } 8 | 9 | /** 10 | * Фильтрует символы для соответствия значениям `replacement` 11 | * @param value 12 | * @param options 13 | * @returns 14 | */ 15 | export default function filter(value: string, { replacementChars, replacement, separate }: Options): string { 16 | let __replacementChars = replacementChars; 17 | 18 | let filteredValue = ''; 19 | 20 | for (const char of value) { 21 | const isReplacementKey = Object.prototype.hasOwnProperty.call(replacement, char); 22 | const isValidChar = !isReplacementKey && replacement[__replacementChars[0]]?.test(char); 23 | 24 | if ((separate && char === __replacementChars[0]) || isValidChar) { 25 | __replacementChars = __replacementChars.slice(1); 26 | filteredValue += char; 27 | } 28 | } 29 | 30 | return filteredValue; 31 | } 32 | -------------------------------------------------------------------------------- /packages/mask/src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import type { Replacement } from '../types'; 2 | 3 | interface Options { 4 | mask: string; 5 | replacement: Replacement; 6 | separate: boolean; 7 | showMask: boolean; 8 | } 9 | 10 | /** 11 | * Форматирует значение по заданной маске 12 | * @param input 13 | * @param options 14 | * @returns 15 | */ 16 | export default function format(input: string, { mask, replacement, separate, showMask }: Options): string { 17 | let position = 0; 18 | let formattedValue = ''; 19 | 20 | for (const char of mask) { 21 | if (!showMask && input[position] === undefined) { 22 | break; 23 | } 24 | 25 | const isReplacementKey = Object.prototype.hasOwnProperty.call(replacement, char); 26 | 27 | if (isReplacementKey && input[position] !== undefined) { 28 | formattedValue += input[position++]; 29 | } else { 30 | formattedValue += char; 31 | } 32 | } 33 | 34 | if (separate && !showMask) { 35 | let index = mask.length - 1; 36 | 37 | for (; index >= 0; index--) { 38 | if (formattedValue[index] !== mask[index]) { 39 | break; 40 | } 41 | } 42 | 43 | formattedValue = formattedValue.slice(0, index + 1); 44 | } 45 | 46 | return formattedValue; 47 | } 48 | -------------------------------------------------------------------------------- /packages/mask/src/utils/formatToParts.ts: -------------------------------------------------------------------------------- 1 | import type { MaskPart, Replacement } from '../types'; 2 | 3 | interface Options { 4 | mask: string; 5 | replacement: Replacement; 6 | } 7 | 8 | /** 9 | * Форматирует значение по заданной маске возвращая массив объектов, 10 | * где каждый объект представляет собой информацию о символе: 11 | * - `replacement` - символ замены; 12 | * - `mask` - символ маски; 13 | * - `input` - символ, введенный пользователем. 14 | * @param formattedValue 15 | * @param options 16 | * @returns 17 | */ 18 | export default function formatToParts(formattedValue: string, { mask, replacement }: Options): MaskPart[] { 19 | const result: MaskPart[] = []; 20 | 21 | for (let i = 0; i < mask.length; i++) { 22 | const value = formattedValue[i] ?? mask[i]; 23 | 24 | const isReplacementKey = Object.prototype.hasOwnProperty.call(replacement, value); 25 | const type: MaskPart['type'] = isReplacementKey 26 | ? 'replacement' 27 | : formattedValue[i] !== undefined && formattedValue[i] !== mask[i] 28 | ? 'input' 29 | : 'mask'; 30 | 31 | result.push({ type, value, index: i }); 32 | } 33 | 34 | return result; 35 | } 36 | -------------------------------------------------------------------------------- /packages/mask/src/utils/formatToReplacementObject.ts: -------------------------------------------------------------------------------- 1 | import type { Replacement } from '../types'; 2 | 3 | export default function formatToReplacementObject(replacement: string): Replacement { 4 | return replacement.length > 0 ? { [replacement]: /./ } : {}; 5 | } 6 | -------------------------------------------------------------------------------- /packages/mask/src/utils/resolveSelection.ts: -------------------------------------------------------------------------------- 1 | import formatToParts from './formatToParts'; 2 | 3 | import type { Replacement } from '../types'; 4 | import type { InputType } from '@react-input/core'; 5 | 6 | interface ResolveSelectionParam { 7 | inputType: InputType; 8 | value: string; 9 | addedValue: string; 10 | beforeChangeValue: string; 11 | mask: string; 12 | replacement: Replacement; 13 | separate: boolean; 14 | } 15 | 16 | /** 17 | * Определяет позицию курсора для последующей установки 18 | * @param param 19 | * @returns 20 | */ 21 | export default function resolveSelection({ 22 | inputType, 23 | value, 24 | addedValue, 25 | beforeChangeValue, 26 | mask, 27 | replacement, 28 | separate, 29 | }: ResolveSelectionParam): number { 30 | const parts = formatToParts(value, { mask, replacement }); 31 | const unformattedChars = parts.filter(({ type }) => type === 'input' || (separate && type === 'replacement')); 32 | 33 | const lastAddedValueIndex = unformattedChars[beforeChangeValue.length + addedValue.length - 1]?.index; 34 | const lastBeforeChangeValueIndex = unformattedChars[beforeChangeValue.length - 1]?.index; 35 | const firstAfterChangeValueIndex = unformattedChars[beforeChangeValue.length + addedValue.length]?.index; 36 | 37 | if (inputType === 'insert') { 38 | if (lastAddedValueIndex !== undefined) return lastAddedValueIndex + 1; 39 | if (firstAfterChangeValueIndex !== undefined) return firstAfterChangeValueIndex; 40 | if (lastBeforeChangeValueIndex !== undefined) return lastBeforeChangeValueIndex + 1; 41 | } 42 | 43 | if (inputType === 'deleteForward') { 44 | if (firstAfterChangeValueIndex !== undefined) return firstAfterChangeValueIndex; 45 | if (lastBeforeChangeValueIndex !== undefined) return lastBeforeChangeValueIndex + 1; 46 | } 47 | 48 | if (inputType === 'deleteBackward') { 49 | if (lastBeforeChangeValueIndex !== undefined) return lastBeforeChangeValueIndex + 1; 50 | if (firstAfterChangeValueIndex !== undefined) return firstAfterChangeValueIndex; 51 | } 52 | 53 | // Находим первый индекс символа замены указанного в свойстве `replacement` 54 | const replacementCharIndex = value 55 | .split('') 56 | .findIndex((char) => Object.prototype.hasOwnProperty.call(replacement, char)); 57 | 58 | return replacementCharIndex !== -1 ? replacementCharIndex : value.length; 59 | } 60 | -------------------------------------------------------------------------------- /packages/mask/src/utils/unformat.ts: -------------------------------------------------------------------------------- 1 | import type { Replacement } from '../types'; 2 | 3 | interface Options { 4 | start?: number; 5 | end?: number; 6 | mask: string; 7 | replacement: Replacement; 8 | separate: boolean; 9 | } 10 | 11 | /** 12 | * 13 | * @param formattedValue 14 | * @param options 15 | * @returns 16 | */ 17 | export default function unformat( 18 | formattedValue: string, 19 | { start = 0, end, mask, replacement, separate }: Options, 20 | ): string { 21 | const slicedFormattedValue = formattedValue.slice(start, end); 22 | const slicedMask = mask.slice(start, end); 23 | 24 | let unformattedValue = ''; 25 | 26 | for (let i = 0; i < slicedMask.length; i++) { 27 | const isReplacementKey = Object.prototype.hasOwnProperty.call(replacement, slicedMask[i]); 28 | 29 | if (isReplacementKey && slicedFormattedValue[i] !== undefined && slicedFormattedValue[i] !== slicedMask[i]) { 30 | unformattedValue += slicedFormattedValue[i]; 31 | } else if (isReplacementKey && separate) { 32 | unformattedValue += slicedMask[i]; 33 | } 34 | } 35 | 36 | return unformattedValue; 37 | } 38 | -------------------------------------------------------------------------------- /packages/mask/src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | import type { Replacement } from '../types'; 2 | 3 | const createError = (ErrorType: ErrorConstructor) => { 4 | return (...messages: string[]) => { 5 | return new ErrorType(`${messages.join('\n\n')}\n`); 6 | }; 7 | }; 8 | 9 | interface ValidateParam { 10 | initialValue: string; 11 | mask: string; 12 | replacement: Replacement; 13 | } 14 | 15 | /** 16 | * Выводит в консоль сообщения об ошибках. 17 | * Сообщения выводятся на этапе инициализации элеменета. 18 | * @param param 19 | */ 20 | export default function validate({ initialValue, mask, replacement }: ValidateParam) { 21 | if (initialValue.length > mask.length) { 22 | console.error( 23 | createError(Error)( 24 | 'The initialized value of the `value` or `defaultValue` property is longer than the value specified in the `mask` property. Check the correctness of the initialized value in the specified property.', 25 | `Invalid value: "${initialValue}".`, 26 | 'To initialize an unmasked value, use the `format` utility. More details https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#initializing-the-value.', 27 | ), 28 | ); 29 | } 30 | 31 | const invalidReplacementKeys = Object.keys(replacement).filter((key) => key.length > 1); 32 | 33 | if (invalidReplacementKeys.length > 0) { 34 | console.error( 35 | createError(Error)( 36 | 'Object keys in the `replacement` property are longer than one character. Replacement keys must be one character long. Check the correctness of the value in the specified property.', 37 | `Invalid keys: ${invalidReplacementKeys.join(', ')}.`, 38 | 'To initialize an unmasked value, use the `format` utility. More details https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#initializing-the-value.', 39 | ), 40 | ); 41 | } 42 | 43 | const value = mask.slice(0, initialValue.length); 44 | 45 | let invalidCharIndex = -1; 46 | 47 | for (let i = 0; i < value.length; i++) { 48 | const isReplacementKey = Object.prototype.hasOwnProperty.call(replacement, value[i]); 49 | const hasInvalidChar = value[i] !== initialValue[i]; 50 | 51 | if (hasInvalidChar && (!isReplacementKey || !replacement[value[i]].test(initialValue[i]))) { 52 | invalidCharIndex = i; 53 | break; 54 | } 55 | } 56 | 57 | if (invalidCharIndex !== -1) { 58 | console.error( 59 | createError(Error)( 60 | `An invalid character was found in the initialized property value \`value\` or \`defaultValue\` (index: ${invalidCharIndex}). Check the correctness of the initialized value in the specified property.`, 61 | `Invalid value: "${initialValue}".`, 62 | 'To initialize an unmasked value, use the `format` utility. More details https://github.com/GoncharukOrg/react-input/tree/main/packages/mask#initializing-the-value.', 63 | ), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/mask/stories/Component.stories.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 2 | import React, { forwardRef, useState } from 'react'; 3 | 4 | import InputMask from '../src/InputMask'; 5 | 6 | import type { InputMaskProps, TrackingData } from '../src'; 7 | import type { Meta, StoryObj } from '@storybook/react'; 8 | 9 | const meta = { 10 | title: 'mask/InputMask', 11 | component: InputMask, 12 | parameters: { 13 | layout: 'centered', 14 | }, 15 | tags: ['autodocs'], 16 | } satisfies Meta; 17 | 18 | export default meta; 19 | 20 | type Story = StoryObj; 21 | 22 | interface CustomComponentProps extends React.InputHTMLAttributes { 23 | controlled?: boolean; 24 | label?: string; 25 | } 26 | 27 | const CustomComponent = forwardRef(function CustomComponent( 28 | { controlled, label, ...props }, 29 | ref, 30 | ) { 31 | const [value, setValue] = useState(''); 32 | 33 | const control = { 34 | value, 35 | onChange: (event: React.ChangeEvent) => { 36 | setValue(event.target.value); 37 | }, 38 | }; 39 | 40 | return ( 41 |

42 | 43 | 44 |
45 | ); 46 | }); 47 | 48 | function Component({ controlled = false, ...props }: InputMaskProps & { controlled?: boolean }) { 49 | const [value, setValue] = useState(''); 50 | 51 | const control = { 52 | value, 53 | onChange: (event: React.ChangeEvent) => { 54 | setValue(event.target.value); 55 | }, 56 | }; 57 | 58 | return ; 59 | } 60 | 61 | export const UncontrolledComponent: Story = { 62 | args: { 63 | mask: '+7 (___) ___-__-__', 64 | replacement: { _: /\d/ }, 65 | defaultValue: '+7 (___) ___-__-__', 66 | autoFocus: true, 67 | }, 68 | }; 69 | 70 | export const ControlledComponent: Story = { 71 | render: Component, 72 | args: { 73 | controlled: true, 74 | mask: '+7 (___) ___-__-__', 75 | replacement: { _: /\d/ }, 76 | }, 77 | }; 78 | 79 | export const ControlledComponentWithModifyPhone: Story = { 80 | render: Component, 81 | args: { 82 | controlled: true, 83 | mask: '+_ (___) ___-__-__', 84 | replacement: { _: /\d/ }, 85 | modify: ({ value, data, selectionStart, selectionEnd }: TrackingData) => { 86 | const beforeChange = value.slice(0, selectionStart); 87 | const afterChange = value.slice(selectionEnd); 88 | 89 | const beforeStart = beforeChange.replace(/\D/, ''); 90 | const afterStart = ((data ?? '') + afterChange).replace(/\D/, ''); 91 | 92 | return { 93 | mask: 94 | beforeStart.startsWith('7') || (beforeStart === '' && afterStart.startsWith('7')) 95 | ? '+_ (___) ___-__-__' 96 | : '+_ __________', 97 | }; 98 | }, 99 | }, 100 | }; 101 | 102 | export const ControlledComponentWithTrackPhone: Story = { 103 | render: Component, 104 | args: { 105 | controlled: true, 106 | mask: '+_ (___) ___-__-__', 107 | replacement: { _: /\d/ }, 108 | track: ({ inputType, value, data, selectionStart, selectionEnd }: TrackingData) => { 109 | if (inputType === 'insert' && selectionStart <= 1) { 110 | const _data = data.replace(/\D/g, ''); 111 | return /^[78]/.test(_data) ? `7${_data.slice(1)}` : /^[0-69]/.test(_data) ? `7${_data}` : data; 112 | } 113 | 114 | if (inputType !== 'insert' && selectionStart <= 1 && selectionEnd < value.length) { 115 | return selectionEnd > 2 ? '7' : selectionEnd === 2 ? false : data; 116 | } 117 | 118 | return data; 119 | }, 120 | }, 121 | }; 122 | 123 | export const CustomComponentWithOuterState: Story = { 124 | render: Component, 125 | args: { 126 | controlled: true, 127 | component: CustomComponent, 128 | label: 'Мой заголовок', 129 | mask: '+_ (___) ___-__-__', 130 | replacement: { _: /\d/ }, 131 | }, 132 | }; 133 | 134 | export const CustomComponentWithInnerState: Story = { 135 | render: Component, 136 | args: { 137 | component: forwardRef(function ForwardedCustomComponent(props, ref) { 138 | return ; 139 | }), 140 | label: 'Мой заголовок', 141 | mask: '+_ (___) ___-__-__', 142 | replacement: { _: /\d/ }, 143 | }, 144 | }; 145 | -------------------------------------------------------------------------------- /packages/mask/stories/TestProps.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { InputMask } from '../src'; 4 | 5 | import type { Meta, StoryObj } from '@storybook/react'; 6 | 7 | export default { 8 | title: 'Mask', 9 | component: InputMask, 10 | } satisfies Meta; 11 | 12 | function Component() { 13 | const [state, setState] = useState({ 14 | mask: '+7 (___) ___-__-__', 15 | replacement: 'd', 16 | showMask: true, 17 | separate: false, 18 | }); 19 | 20 | const [value, setValue] = useState('fegoj0fwfwe'); 21 | 22 | return ( 23 | <> 24 | { 32 | setValue(event.target.value); 33 | }} 34 | /> 35 | 36 | 47 | 48 | 56 | 57 | 65 | 66 | 74 | 75 |
{JSON.stringify({ state }, null, 2)}
76 | 77 | ); 78 | } 79 | 80 | export const TestProps = { 81 | render: Component, 82 | } satisfies StoryObj; 83 | -------------------------------------------------------------------------------- /packages/mask/tests/mask.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from '@testing-library/react'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | import InputMask from '@react-input/mask/InputMask'; 7 | 8 | import type { Track } from '@react-input/mask'; 9 | import type { InputMaskProps } from '@react-input/mask/InputMask'; 10 | 11 | import '@testing-library/jest-dom'; 12 | 13 | const init = (props: InputMaskProps = {}) => { 14 | render(); 15 | return screen.getByTestId('input-mask'); 16 | }; 17 | 18 | const initWithDefaultType = async (props: InputMaskProps = {}) => { 19 | const input = init(props); 20 | await userEvent.type(input, '9123456789'); 21 | return input; 22 | }; 23 | 24 | /** 25 | * INSERT 26 | */ 27 | 28 | test('Insert', async () => { 29 | const input = await initWithDefaultType(); 30 | expect(input).toHaveValue('+7 (912) 345-67-89'); 31 | }); 32 | 33 | test('Insert with selection range', async () => { 34 | const input = await initWithDefaultType(); 35 | await userEvent.type(input, '0', { initialSelectionStart: 4, initialSelectionEnd: 8 }); 36 | expect(input).toHaveValue('+7 (034) 567-89'); 37 | }); 38 | 39 | /** 40 | * BACKSPACE 41 | */ 42 | 43 | test('Backspace after user character', async () => { 44 | const input = await initWithDefaultType(); 45 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 5, initialSelectionEnd: 5 }); 46 | expect(input).toHaveValue('+7 (123) 456-78-9'); 47 | }); 48 | 49 | test('Backspace after mask character', async () => { 50 | const input = await initWithDefaultType(); 51 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 8, initialSelectionEnd: 8 }); 52 | expect(input).toHaveValue('+7 (912) 345-67-89'); 53 | }); 54 | 55 | /** 56 | * DELETE 57 | */ 58 | 59 | test('Delete before user character', async () => { 60 | const input = await initWithDefaultType(); 61 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 4, initialSelectionEnd: 4 }); 62 | expect(input).toHaveValue('+7 (123) 456-78-9'); 63 | }); 64 | 65 | test('Delete before mask character', async () => { 66 | const input = await initWithDefaultType(); 67 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 7, initialSelectionEnd: 7 }); 68 | expect(input).toHaveValue('+7 (912) 345-67-89'); 69 | }); 70 | 71 | /* 72 | * Phone number with track 73 | */ 74 | 75 | const track: Track = ({ inputType, value, data, selectionStart, selectionEnd }) => { 76 | if (inputType === 'insert' && selectionStart <= 1) { 77 | const _data = data.replace(/\D/g, ''); 78 | return /^[78]/.test(_data) ? `7${_data.slice(1)}` : /^[0-69]/.test(_data) ? `7${_data}` : data; 79 | } 80 | 81 | if (inputType !== 'insert' && selectionStart <= 1 && selectionEnd < value.length) { 82 | return selectionEnd > 2 ? '7' : selectionEnd === 2 ? false : data; 83 | } 84 | 85 | return data; 86 | }; 87 | 88 | const initWithPhoneNumberProps = () => { 89 | return init({ mask: '+_ (___) ___-__-__', replacement: { _: /\d/ }, track }); 90 | }; 91 | 92 | test('Insert phone number $1', async () => { 93 | const input = initWithPhoneNumberProps(); 94 | await userEvent.type(input, '9123456789'); 95 | expect(input).toHaveValue('+7 (912) 345-67-89'); 96 | }); 97 | 98 | test('Insert phone number $2', async () => { 99 | const input = initWithPhoneNumberProps(); 100 | await userEvent.type(input, '79123456789'); 101 | expect(input).toHaveValue('+7 (912) 345-67-89'); 102 | }); 103 | 104 | test('Insert phone number $3', async () => { 105 | const input = initWithPhoneNumberProps(); 106 | await userEvent.type(input, '89123456789'); 107 | expect(input).toHaveValue('+7 (912) 345-67-89'); 108 | }); 109 | -------------------------------------------------------------------------------- /packages/number-format/LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Nikolay Goncharuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/number-format/README.md: -------------------------------------------------------------------------------- 1 | # @react-input/number-format 2 | 3 | ✨ Apply locale-specific number, currency, and percentage formatting to input using a provided component or hook bound to the input element. 4 | 5 | ![npm](https://img.shields.io/npm/dt/@react-input/number-format?style=flat-square) 6 | ![npm](https://img.shields.io/npm/v/@react-input/number-format?style=flat-square) 7 | ![npm bundle size](https://img.shields.io/bundlephobia/min/@react-input/number-format?style=flat-square) 8 | 9 | [![Donate to our collective](https://opencollective.com/react-input/donate/button.png)](https://opencollective.com/react-input/donate) 10 | 11 | [![Edit @react-input/number-format](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/p/sandbox/react-input-number-format-r234d9?file=%2Fsrc%2FInput.tsx) 12 | 13 | ## What's new? 14 | 15 | Usage via CDN is available (see «[Usage with CDN](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#usage-with-cdn)»). 16 | 17 | The `input-number-format` event and `onNumberFormat` method are no longer available in newer versions, focusing work on only using React's own events and methods such as `onChange`, since the `input-number-format` event and `onNumberFormat` method cannot be explicitly coordinated with React's events and methods, making such usage and event firing order non-obvious. 18 | 19 | To use the useful data from the `detail` property of the `input-number-format` (`onNumberFormat`) event object, you can also use the utilities described in the «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#utils)» section. 20 | 21 | **Documentation for version `v1` is available [here](https://github.com/GoncharukOrg/react-input/tree/v1/packages/number-format).** 22 | 23 | ## Installation 24 | 25 | ```bash 26 | npm i @react-input/number-format 27 | ``` 28 | 29 | or using **yarn**: 30 | 31 | ```bash 32 | yarn add @react-input/number-format 33 | ``` 34 | 35 | or using **CDN** (for more information, see [UNPKG](https://unpkg.com/)): 36 | 37 | ```html 38 | 39 | ``` 40 | 41 | ## Unique properties 42 | 43 | | Name | Type | Default | Description | 44 | | ----------------------- | :----------------------------------------------------: | :---------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 45 | | `component` | `Component` | | **Not used in the useNumberFormat hook**. Serves to enable the use of custom components, for example, if you want to use your own styled component with the ability to format the value (see «[Integration with custom components](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#integration-with-custom-components)»). | 46 | | `locales` | `string` \| `string[]` | | The locale is specified as a string , or an array of such strings in order of preference. By default, the locale set in the environment (browser) is used. For the general form and interpretation of the locales argument, see [Locale identification and negotiation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locale_identification_and_negotiation). | 47 | | `format` | `"decimal"` \| `"currency"` \| `"percent"` \| `"unit"` | `"decimal"` | The formatting style to use. | 48 | | `currency` | `string` | | The currency to use in currency formatting. Possible values are the ISO 4217 currency codes, such as `"USD"` for the US dollar, `"EUR"` for the euro, or `"CNY"` for the Chinese RMB — see the [Current currency & funds code list](https://www.six-group.com/en/products-services/financial-information/data-standards.html#scrollTo=currency-codes). If the `format` is `"currency"`, the `currency` property must be provided. | 49 | | `currencyDisplay` | `"symbol"` \| `"narrowSymbol"` \| `"code"` \| `"name"` | `"symbol"` | How to display the currency in currency formatting. | 50 | | `unit` | `string` | | The unit to use in `unit` formatting, Possible values are core unit identifiers, defined in [UTS #35, Part 2, Section 6](https://unicode.org/reports/tr35/tr35-general.html#Unit_Elements). Pairs of simple units can be concatenated with `"-per-"` to make a compound unit. If the `format` is `"unit"`, the `unit` property must be provided. | 51 | | `unitDisplay` | `"short"` \| `"long"` \| `"narrow"` | `"short"` | The unit formatting style to use in `unit` formatting. | 52 | | `signDisplay` | `"auto"` \| `"never"` \| `"always"` \| `"exceptZero"` | `"auto"` | When to display the sign for the number. | 53 | | `groupDisplay` | `"auto"` \| `boolean` | `"auto"` | Whether to use grouping separators, such as thousands separators or thousand/lakh/crore separators. | 54 | | `minimumIntegerDigits` | `number` | `1` | The minimum number of integer digits to use. A value with a smaller number of integer digits than this number will be left-padded with zeros (to the specified length) when formatted. | 55 | | `maximumIntegerDigits` | `number` | | The maximum number of integer digits to use. | 56 | | `minimumFractionDigits` | `number` | | The minimum number of fraction digits to use. The default for plain number and percent formatting is `0`. The default for currency formatting is the number of minor unit digits provided by the [ISO 4217 currency code list](https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml) (`2` if the list doesn't provide that information). | 57 | | `maximumFractionDigits` | `number` | | The maximum number of fraction digits to use. The default for plain number formatting is the larger of `minimumFractionDigits` and `3`. The default for currency formatting is the larger of `minimumFractionDigits` and the number of minor unit digits provided by the [ISO 4217 currency code list](https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml) (`2` if the list doesn't provide that information). The default for percent formatting is the larger of `minimumFractionDigits` and `0`. | 58 | 59 | > Since the package is based on the `Intl.NumberFormat` constructor, it is important to consider that the functionality of both the package itself and its properties will depend on your browser versions. You can view support for browser versions [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat). 60 | > 61 | > If you are using TypeScript, the properties of the `InputNumberFormat` component will be typed based on your version of TypeScript, so make sure you are using the latest stable version of TypeScript in your project. 62 | > 63 | > You can also pass other properties available element `input` default or your own components, when integrated across the property `component`. 64 | 65 | ## Usage with React 66 | 67 | The `@react-input/number-format` package provides two options for using formatting. The first is the `InputNumberFormat` component, which is a standard input element with additional logic to handle the input. The second is using the `useNumberFormat` hook, which needs to be linked to the `input` element through the `ref` property. 68 | 69 | One of the key features of the `@react-input/number-format` package is that it can format numbers according to the desired language using the [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#parameters) constructor. 70 | 71 | Let's see how we can easily implement formatting based on the given locale and the minimum number of decimal places using the `InputNumberFormat` component: 72 | 73 | ```tsx 74 | import { InputNumberFormat } from '@react-input/number-format'; 75 | 76 | export default function App() { 77 | return ; 78 | } 79 | ``` 80 | 81 | You can work with the `InputNumberFormat` component in the same way as with the `input` element, with the difference that the `InputNumberFormat` component uses additional logic to value formatting. 82 | 83 | Now the same thing, but using the `useNumberFormat` hook: 84 | 85 | ```tsx 86 | import { useNumberFormat } from '@react-input/number-format'; 87 | 88 | export default function App() { 89 | const inputRef = useNumberFormat({ 90 | locales: 'en', 91 | maximumFractionDigits: 2, 92 | }); 93 | 94 | return ; 95 | } 96 | ``` 97 | 98 | The `useNumberFormat` hook takes the same properties as the `InputNumberFormat` component, except for the `component` properties. Both approaches are equivalent, but the use of the `InputNumberFormat` component provides additional capabilities, which will be discussed in the section «[Integration with custom components](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#integration-with-custom-components)». 99 | 100 | ## Usage with CDN 101 | 102 | To use the library's capabilities, you can also load it via CDN. 103 | 104 | When loaded, you get a global `ReactInput.NumberFormat` class, calling it with the specified formatting parameters will create a new object with two methods, the first one is `register`, which applies the formatting when typing into the specified element, and the second one is `unregister`, which undoes the previous action. The following example illustrates this usage: 105 | 106 | ```js 107 | const numberFormat = new ReactInput.NumberFormat({ 108 | locales: 'en', 109 | maximumFractionDigits: 2, 110 | }); 111 | 112 | const elements = document.getElementsByName('amount'); 113 | 114 | elements.forEach((element) => { 115 | numberFormat.register(element); 116 | }); 117 | 118 | // If necessary, you can turn off formatting while typing. 119 | // elements.forEach((element) => { 120 | // numberFormat.unregister(element); 121 | // }); 122 | ``` 123 | 124 | Note that this way you can register multiple elements to which input formatting will be applied. 125 | 126 | > While you can use a class to format input, using a hook or component in the React environment is preferable due to the optimizations applied, where you don't have to think about when to call `register` and `unregister` for input formatting to work. 127 | 128 | ## Initializing the value 129 | 130 | To support the concept of controlled input, `@react-input/number-format` does not change the value passed in the `value` property of the `input` element, which means that the value in the state exactly matches the value in the input, so set the initialized value to something that can match the formatted value at any point in the input. If you make a mistake, you will see a warning in the console about it. 131 | 132 | With controlled input, when the input value is not formatted, you should use the `format` utility described in the chapter «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#utils)» to substitute the correct value, for example: 133 | 134 | ```tsx 135 | import { useState } from 'react'; 136 | import { useNumberFormat, format } from '@react-input/number-format'; 137 | 138 | const options = { 139 | locales: 'en', 140 | maximumFractionDigits: 2, 141 | }; 142 | 143 | export default function App() { 144 | const inputRef = useNumberFormat(options); 145 | const defaultValue = format(123456789, options); 146 | 147 | const [value, setValue] = useState(defaultValue); // `defaultValue` or '123,456,789' 148 | 149 | return setValue(event.target.value)} />; 150 | } 151 | ``` 152 | 153 | With uncontrolled input, you do not need to worry about the input value being unformatted, as the value will be formatted automatically upon initialization. 154 | 155 | For consistent and correct behavior, the `type` property of the `input` element or the `InputNumberFormat` component must be set to `"text"` (the default). If you use other values, the formatting will not be applied and you will see a warning in the console. 156 | 157 | ## Examples of using 158 | 159 | The base use without specifying a locale returns a formatted string in the default locale and with default options: 160 | 161 | ```tsx 162 | import { InputNumberFormat } from '@react-input/number-format'; 163 | 164 | export default function App() { 165 | // Entering 3500 will print '3,500' to the console if in US English locale 166 | return console.log(event.target.value)} />; 167 | } 168 | ``` 169 | 170 | The following example show some variations of localized number formats. To get the format of the language used in your application's user interface, be sure to specify that language (and possibly some fallback languages) with the `locales` argument: 171 | 172 | ```tsx 173 | import { InputNumberFormat } from '@react-input/number-format'; 174 | 175 | // Entering 123456.789 will print: 176 | 177 | export default function App() { 178 | return ( 179 | <> 180 | {/* India uses thousands/lakh/crore separators */} 181 | console.log(event.target.value)} // "1,23,456.789" 184 | /> 185 | {/* When requesting a language that may not be supported, such as Balinese, include a fallback language, in this case Indonesian */} 186 | console.log(event.target.value)} // "123.456,789" 189 | /> 190 | {/* He nu extension key requests a numbering system, e.g. Chinese decimal */} 191 | console.log(event.target.value)} // "一二三,四五六.七八九" 194 | /> 195 | {/* Arabic in most Arabic speaking countries uses real Arabic digits */} 196 | console.log(event.target.value)} // "١٢٣٤٥٦٫٧٨٩" 199 | /> 200 | {/* German uses comma as decimal separator and period for thousands */} 201 | console.log(event.target.value)} // "123.456,78 €" 206 | /> 207 | {/* Formatting with units */} 208 | console.log(event.target.value)} // "123 456,789 km/h" 213 | /> 214 | 215 | ); 216 | } 217 | ``` 218 | 219 | ## Integration with custom components 220 | 221 | The `InputNumberFormat` component makes it easy to integrate with custom components allowing you to use your own styled components. To do this, you need to pass the custom component to the `forwardRef` method provided by React. `forwardRef` allows you automatically pass a `ref` value to a child element ([more on `forwardRef`](https://reactjs.org/docs/forwarding-refs.html)). 222 | 223 | Then place your own component in the `component` property. The value for the `component` property can be either function components or class components. 224 | 225 | With this approach, the `InputNumberFormat` component acts as a HOC, adding additional logic to the `input` element. 226 | 227 | Here's how to do it: 228 | 229 | ```tsx 230 | import { forwardRef } from 'react'; 231 | 232 | import { InputNumberFormat } from '@react-input/number-format'; 233 | 234 | interface CustomInputProps { 235 | label: string; 236 | } 237 | 238 | // Custom input component 239 | const CustomInput = forwardRef(({ label }, forwardedRef) => { 240 | return ( 241 | <> 242 | 243 | 244 | 245 | ); 246 | }); 247 | 248 | // Component with InputNumberFormat 249 | export default function App() { 250 | return ; 251 | } 252 | ``` 253 | 254 | > The `InputNumberFormat` component will not forward properties available only to the `InputNumberFormat`, so as not to break the logic of your own component. 255 | 256 | ## Integration with Material UI 257 | 258 | If you are using [Material UI](https://mui.com/), you need to create a component that returns a `InputNumberFormat` and pass it as a value to the `inputComponent` property of the Material UI component. 259 | 260 | In this case, the Material UI component expects your component to be wrapped in a `forwardRef`, where you will need to pass the reference directly to the `ref` property of the `InputNumberFormat` component. 261 | 262 | Here's how to do it using the `InputNumberFormat` component: 263 | 264 | ```tsx 265 | import { forwardRef } from 'react'; 266 | 267 | import { InputNumberFormat, type InputNumberFormatProps } from '@react-input/number-format'; 268 | import { TextField } from '@mui/material'; 269 | 270 | // Component with InputNumberFormat 271 | const ForwardedInputNumberFormat = forwardRef((props, forwardedRef) => { 272 | return ; 273 | }); 274 | 275 | // Component with Material UI 276 | export default function App() { 277 | return ( 278 | 283 | ); 284 | } 285 | ``` 286 | 287 | or using the `useNumberFormat` hook: 288 | 289 | ```tsx 290 | import { useNumberFormat } from '@react-input/number-format'; 291 | import { TextField } from '@mui/material'; 292 | 293 | export default function App() { 294 | const inputRef = useNumberFormat({ locales: 'en', maximumFractionDigits: 2 }); 295 | 296 | return ; 297 | } 298 | ``` 299 | 300 | > The examples correspond to Material UI version 5. If you are using a different version, please read the [Material UI documentation](https://mui.com/material-ui/). 301 | 302 | ## Usage with TypeScript 303 | 304 | The `@react-input/number-format` package is written in [TypeScript](https://www.typescriptlang.org/), so you have full type support out of the box. Additionally, you can import the types you need via `@react-input/number-format` or `@react-input/number-format/types`. 305 | 306 | ### Property type support 307 | 308 | Since the `InputNumberFormat` component supports two use cases (as an `input` element and as an HOC for your own component), `InputNumberFormat` takes both use cases into account to support property types. 309 | 310 | By default, the `InputNumberFormat` component is an `input` element and supports all the attributes supported by the `input` element. But if the `component` property was passed, the `InputNumberFormat` will additionally support the properties available to the integrated component. This approach allows you to integrate your own component as conveniently as possible, not forcing you to rewrite its logic, but using a formatting where necessary. 311 | 312 | ```tsx 313 | import { InputNumberFormat, type InputNumberFormatProps, type NumberFormatOptions } from '@react-input/number-format'; 314 | 315 | export default function App() { 316 | // Here, since no `component` property was passed, 317 | // `InputNumberFormat` returns an `input` element and takes the type: 318 | // `NumberFormatOptions & { locales?: Intl.LocalesArgument } & React.InputHTMLAttributes` (the same as `InputNumberFormatProps`) 319 | return ; 320 | } 321 | ``` 322 | 323 | ```tsx 324 | import { InputNumberFormat, type InputNumberFormatProps, type NumberFormatOptions } from '@react-input/number-format'; 325 | 326 | import { CustomInput, type CustomInputProps } from './CustomInput'; 327 | 328 | export default function App() { 329 | // Here, since the `component` property was passed, 330 | // `InputNumberFormat` returns the CustomInput component and takes the type: 331 | // `NumberFormatOptions & { locales?: Intl.LocalesArgument } & CustomInputProps` (the same as `InputNumberFormatProps`) 332 | return ; 333 | } 334 | ``` 335 | 336 | You may run into a situation where you need to pass rest parameters (`...rest`) to the `InputNumberFormat` component. If the rest parameters is of type `any`, the `component` property will not be typed correctly, as well as the properties of the component being integrated. this is typical TypeScript behavior for dynamic type inference. 337 | 338 | To fix this situation and help the `InputNumberFormat` correctly inject your component's properties, you can pass your component's type directly to the `InputNumberFormat` component. 339 | 340 | ```tsx 341 | import { InputNumberFormat } from '@react-input/number-format'; 342 | 343 | import { CustomInput } from './CustomInput'; 344 | 345 | export default function Component(props: any) { 346 | return component={CustomInput} {...props} />; 347 | } 348 | ``` 349 | 350 | ## Testing and development 351 | 352 | To make it easier to work with the library, you will receive corresponding messages in the console when errors occur, which is good during development, but not needed in a production application. To avoid receiving error messages in a production application, make sure that the `NODE_ENV` variable is set to `"production"` when building the application. 353 | 354 | ## Utils 355 | 356 | `@react-input/number-format` provides utilities to make things easier when processing a value. You can use them regardless of using the `InputNumberFormat` component or the `useNumberFormat` hook. 357 | 358 | You can use utilities by importing them from the package or calling them from an instance of the `NumberFormat` class. With the second option, you don't need to pass parameters to the methods, as shown in the examples below, for example when using with a CDN: 359 | 360 | ```ts 361 | const numberFormat = new ReactInput.NumberFormat({ 362 | locales: 'en-IN', 363 | format: 'currency', 364 | currency: 'USD', 365 | }); 366 | 367 | numberFormat.unformat('$1,23,456.78'); // returns: "123456.78" 368 | ``` 369 | 370 | ### `format` 371 | 372 | Formats the value using the specified locales and options. 373 | 374 | Takes two parameters, where the first is a number or string to format, and the second is an object with options you use when formatting. 375 | 376 | The result is exactly the same as the value received from the input. Useful when you need to get a formatted value without raising the input event. 377 | 378 | Since `InputNumberFormat` works exactly like the `input` element, `InputNumberFormat` will not change the value outside of the input event for a controlled input, so you may end up in a situation where the `input` element has a value that does not match the desired format, such as when initializing a value received from a backend (see «[Initializing the value](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#initializing-the-value)» for more details). 379 | 380 | ```ts 381 | format(123456.78, { locales: 'en-IN', format: 'currency', currency: 'USD' }); 382 | // returns: "$1,23,456.78" 383 | ``` 384 | 385 | ### `unformat` 386 | 387 | Unformats the value using the specified locales. 388 | 389 | Takes two parameters, where the first is the value to format, and the second is the locale you are using when formatting. Specifying the locale is required to recognize digits, decimal separator, and minus signs, as they may differ across locales. 390 | 391 | Returns a string as the numeric equivalent of the formatted value. Returning a string is due to the fact that a string stores integer and decimal values ​​regardless of their length, unlike a number which has a limit of 2^5. Using a string, you can convert it to a number at your discretion, as well as separate the integer and decimal parts and use the conversion to `BigInt`. 392 | 393 | ```ts 394 | unformat('$1,23,456.78', 'en-IN'); 395 | // returns: "123456.78" 396 | ``` 397 | 398 | ## Migration to v2 399 | 400 | If you are upgrading from version 1 to version 2, there are a number of important changes you need to take into account. 401 | 402 | ### onNumberFormat 403 | 404 | The `input-number-format` event and `onNumberFormat` method are no longer available in newer versions, focusing work on only using React's own events and methods such as `onChange`, since the `input-number-format` event and `onNumberFormat` method cannot be explicitly coordinated with React's events and methods, making such usage and event firing order non-obvious. 405 | 406 | Thus, you should use `onChange` instead of the `onNumberFormat` method. 407 | 408 | Additionally, if you are referencing data in the `detail` property of the `onNumberFormat` event object, you should use the utilities described in the [`Utils`](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#utils) section instead, for example: 409 | 410 | instead of 411 | 412 | ```tsx 413 | import { InputNumberFormat } from '@react-input/number-format'; 414 | 415 | // ... 416 | 417 | const locales = 'en'; 418 | 419 | return ( 420 | { 423 | const { value, number } = event.detail; 424 | }} 425 | /> 426 | ); 427 | ``` 428 | 429 | use 430 | 431 | ```tsx 432 | import { InputNumberFormat, unformat } from '@react-input/number-format'; 433 | 434 | // ... 435 | 436 | const locales = 'en'; 437 | 438 | return ( 439 | { 442 | const value = event.target.value; 443 | const number = Number(unformat(value, locales)); 444 | }} 445 | /> 446 | ); 447 | ``` 448 | 449 | For more information on using utilities, see [`Utils`](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#utils). 450 | 451 | ## Other packages from `@react-input` 452 | 453 | - [`@react-input/mask`](https://www.npmjs.com/package/@react-input/mask) - apply any mask to the input using a provided component or a hook bound to the input element. 454 | 455 | ## Feedback 456 | 457 | If you find a bug or want to make a suggestion for improving the package, [open the issues on GitHub](https://github.com/GoncharukOrg/react-input/issues) or email [goncharuk.bro@gmail.com](mailto:goncharuk.bro@gmail.com). 458 | 459 | Support the project with a star ⭐ on [GitHub](https://github.com/GoncharukOrg/react-input). 460 | 461 | You can also support the authors by donating 🪙 to [Open Collective](https://opencollective.com/react-input): 462 | 463 | [![Donate to our collective](https://opencollective.com/react-input/donate/button.png)](https://opencollective.com/react-input/donate) 464 | 465 | ## License 466 | 467 | [MIT](https://github.com/GoncharukOrg/react-input/blob/main/packages/number-format/LICENSE) © [Nikolay Goncharuk](https://github.com/GoncharukOrg) 468 | -------------------------------------------------------------------------------- /packages/number-format/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-input/number-format", 3 | "version": "2.0.3", 4 | "license": "MIT", 5 | "author": "Nikolay Goncharuk ", 6 | "description": "React input component for formatted number input with locale-specific.", 7 | "keywords": [ 8 | "react", 9 | "react-component", 10 | "react-hook", 11 | "react-number-format", 12 | "input", 13 | "input-number-format", 14 | "text-field", 15 | "number", 16 | "format", 17 | "number-format", 18 | "currency-format", 19 | "percent-format", 20 | "pattern" 21 | ], 22 | "funding": { 23 | "type": "opencollective", 24 | "url": "https://opencollective.com/react-input" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/GoncharukOrg/react-input.git", 29 | "directory": "packages/number-format" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/GoncharukOrg/react-input/issues" 33 | }, 34 | "homepage": "https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#readme", 35 | "files": [ 36 | "@types", 37 | "cdn", 38 | "module", 39 | "node" 40 | ], 41 | "sideEffects": false, 42 | "type": "module", 43 | "types": "@types/index.d.ts", 44 | "module": "module/index.js", 45 | "main": "node/index.cjs", 46 | "exports": { 47 | ".": { 48 | "types": "./@types/index.d.ts", 49 | "import": "./module/index.js", 50 | "require": "./node/index.cjs" 51 | }, 52 | "./*": { 53 | "types": "./@types/*.d.ts", 54 | "import": "./module/*.js", 55 | "require": "./node/*.cjs" 56 | }, 57 | "./*.js": { 58 | "types": "./@types/*.d.ts", 59 | "import": "./module/*.js", 60 | "require": "./node/*.cjs" 61 | } 62 | }, 63 | "typesVersions": { 64 | "*": { 65 | "@types/index.d.ts": [ 66 | "@types/index.d.ts" 67 | ], 68 | "*": [ 69 | "@types/*" 70 | ] 71 | } 72 | }, 73 | "publishConfig": { 74 | "access": "public" 75 | }, 76 | "scripts": { 77 | "build": "tsx ../../scripts/build.ts", 78 | "release:major": "tsx ../../scripts/release.ts major", 79 | "release:minor": "tsx ../../scripts/release.ts minor", 80 | "release:patch": "tsx ../../scripts/release.ts patch" 81 | }, 82 | "dependencies": { 83 | "@react-input/core": "^2.0.2" 84 | }, 85 | "peerDependencies": { 86 | "@types/react": ">=16.8", 87 | "react": ">=16.8 || ^19.0.0-rc", 88 | "react-dom": ">=16.8 || ^19.0.0-rc" 89 | }, 90 | "peerDependenciesMeta": { 91 | "@types/react": { 92 | "optional": true 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/number-format/src/InputNumberFormat.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 2 | import React, { forwardRef } from 'react'; 3 | 4 | import { useConnectedRef } from '@react-input/core'; 5 | 6 | import useNumberFormat from './useNumberFormat'; 7 | 8 | import type { NumberFormatOptions } from './types'; 9 | import type { InputComponent, InputComponentProps } from '@react-input/core'; 10 | 11 | export type InputNumberFormatProps = NumberFormatOptions & { 12 | locales?: Intl.LocalesArgument; 13 | } & InputComponentProps; 14 | 15 | function ForwardedInputNumberFormat( 16 | { 17 | component: Component, 18 | locales, 19 | format, 20 | currency, 21 | currencyDisplay, 22 | unit, 23 | unitDisplay, 24 | signDisplay, 25 | groupDisplay, 26 | minimumIntegerDigits, 27 | maximumIntegerDigits, 28 | minimumFractionDigits, 29 | maximumFractionDigits, 30 | ...props 31 | }: InputNumberFormatProps, 32 | forwardedRef: React.ForwardedRef, 33 | ) { 34 | const ref = useNumberFormat({ 35 | locales, 36 | format, 37 | currency, 38 | currencyDisplay, 39 | unit, 40 | unitDisplay, 41 | signDisplay, 42 | groupDisplay, 43 | minimumIntegerDigits, 44 | maximumIntegerDigits, 45 | minimumFractionDigits, 46 | maximumFractionDigits, 47 | }); 48 | 49 | const connectedRef = useConnectedRef(ref, forwardedRef); 50 | 51 | if (Component) { 52 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 53 | return ; 54 | } 55 | 56 | return ; 57 | } 58 | 59 | const InputNumberFormat = forwardRef(ForwardedInputNumberFormat) as InputComponent< 60 | NumberFormatOptions & { locales?: Intl.LocalesArgument } 61 | >; 62 | 63 | export default InputNumberFormat; 64 | -------------------------------------------------------------------------------- /packages/number-format/src/NumberFormat.ts: -------------------------------------------------------------------------------- 1 | import { Input, SyntheticChangeError } from '@react-input/core'; 2 | 3 | import * as utils from './utils'; 4 | import exec from './utils/exec'; 5 | import filter from './utils/filter'; 6 | import format from './utils/format'; 7 | import localizeValues from './utils/localizeValues'; 8 | import normalize from './utils/normalize'; 9 | import resolveOptions from './utils/resolveOptions'; 10 | import resolveSelection from './utils/resolveSelection'; 11 | 12 | import type { LocalizedNumberFormatValues, NumberFormatOptions } from './types'; 13 | 14 | function normalizeAddedValue(addedValue: string, localizedValues: LocalizedNumberFormatValues) { 15 | const normalizedLocalizedValues = { minusSign: '-', decimal: '.', digits: '\\d', signBackwards: false }; 16 | 17 | const addedValue$1 = exec(addedValue, localizedValues); 18 | const addedValue$2 = exec(addedValue.replace(',', '.'), normalizedLocalizedValues); 19 | 20 | const $addedValue = addedValue$1 ? addedValue$1 : addedValue$2; 21 | const $localizedValues = addedValue$1 ? localizedValues : normalizedLocalizedValues; 22 | 23 | addedValue = filter($addedValue, $localizedValues); 24 | addedValue = normalize(addedValue, localizedValues); 25 | 26 | return addedValue; 27 | } 28 | 29 | export default class NumberFormat extends Input<{ locales: Intl.LocalesArgument; options: NumberFormatOptions }> { 30 | static { 31 | Object.defineProperty(this.prototype, Symbol.toStringTag, { 32 | writable: false, 33 | enumerable: false, 34 | configurable: true, 35 | value: 'NumberFormat', 36 | }); 37 | } 38 | 39 | format: (value: number | bigint | string) => string; 40 | unformat: (value: string) => string; 41 | 42 | constructor(_options: NumberFormatOptions & { locales?: Intl.LocalesArgument } = {}) { 43 | super({ 44 | /** 45 | * Init 46 | */ 47 | init: ({ initialValue, controlled }) => { 48 | const { locales, ...options } = _options; 49 | 50 | if (!controlled && initialValue.length > 0) { 51 | const localizedValues = localizeValues(locales); 52 | const resolvedOptions = resolveOptions(locales, options); 53 | 54 | let _value = normalizeAddedValue(initialValue, localizedValues); 55 | // Для нормализации значения, ставим минус слева. 56 | // В случае арабской локали он может находиться справа 57 | _value = _value.replace(/(.+)(-)$/, '$2$1'); 58 | 59 | if (_value.length > 0) { 60 | initialValue = format(_value, { locales, options, localizedValues, resolvedOptions }); 61 | } 62 | } 63 | 64 | return { value: initialValue, options: { locales, options } }; 65 | }, 66 | /** 67 | * Tracking 68 | */ 69 | tracking: ({ inputType, previousValue, previousOptions, addedValue, changeStart, changeEnd }) => { 70 | const { locales, ...options } = _options; 71 | 72 | const previousLocalizedValues = localizeValues(previousOptions.locales); 73 | const localizedValues = localizeValues(locales); 74 | const resolvedOptions = resolveOptions(locales, options); 75 | 76 | const r$1 = RegExp(`^[${localizedValues.minusSign}]$`); 77 | const r$2 = RegExp(`^[,${localizedValues.decimal}]$`); 78 | 79 | if (r$1.test(addedValue)) { 80 | addedValue = addedValue.replace(r$1, '-'); 81 | } else if (r$2.test(addedValue)) { 82 | addedValue = addedValue.replace(r$2, '.'); 83 | } else { 84 | addedValue = normalizeAddedValue(addedValue, localizedValues); 85 | } 86 | 87 | if (inputType === 'insert' && !addedValue) { 88 | throw new SyntheticChangeError('The added value does not contain allowed characters.'); 89 | } 90 | 91 | let beforeChangeValue = previousValue.slice(0, changeStart); 92 | beforeChangeValue = exec(beforeChangeValue, previousLocalizedValues); 93 | beforeChangeValue = filter(beforeChangeValue, previousLocalizedValues); 94 | beforeChangeValue = normalize(beforeChangeValue, previousLocalizedValues); 95 | 96 | let afterChangeValue = previousValue.slice(changeEnd); 97 | afterChangeValue = exec(afterChangeValue, previousLocalizedValues); 98 | afterChangeValue = filter(afterChangeValue, previousLocalizedValues); 99 | afterChangeValue = normalize(afterChangeValue, previousLocalizedValues); 100 | 101 | let normalizedValue = beforeChangeValue + addedValue + afterChangeValue; 102 | 103 | // Удаляем лишние знаки десятичного разделителя 104 | normalizedValue = normalizedValue.replace(/[.](?=.*[.])/g, ''); 105 | 106 | // Если `signBackwards === true`, тогда знак минуса определён локалью как "стоящий справа", 107 | // в этом случае удаляем знак минуса стоящий перед остальными символами (`lookahead`), 108 | // в противном случае удаляем знак минуса стоящий после остальных символов (альтернатива `lookbehind`) 109 | if (localizedValues.signBackwards) { 110 | normalizedValue = normalizedValue.replace(/[-](?=.*[-.\d])/g, ''); 111 | } else { 112 | const index = normalizedValue.search(/[-.\d]/); 113 | 114 | normalizedValue = normalizedValue.replace(/[-]/g, (match, offset) => { 115 | return index !== -1 && offset > index ? '' : match; 116 | }); 117 | } 118 | 119 | // Для нормализации значения, ставим минус слева. 120 | // В случае арабской локали он может находиться справа 121 | normalizedValue = normalizedValue.replace(/(.+)(-)$/, '$2$1'); 122 | 123 | // В случае ввода знака минуса нам нужно его удалить если 124 | // оно присутствует, в противном случае добавить, тем самым 125 | // создав автоматическую вставку при любой позиции каретки 126 | { 127 | const isReflectMinusSign = addedValue === '-' && changeStart === changeEnd; 128 | const hasPreviousValueMinusSign = previousValue.includes(previousLocalizedValues.minusSign); 129 | const hasNormalizedValueMinusSign = normalizedValue.includes('-'); 130 | 131 | if (isReflectMinusSign && hasPreviousValueMinusSign && hasNormalizedValueMinusSign) { 132 | normalizedValue = normalizedValue.replace('-', ''); 133 | } 134 | if (isReflectMinusSign && !hasPreviousValueMinusSign && !hasNormalizedValueMinusSign) { 135 | normalizedValue = `-${normalizedValue}`; 136 | } 137 | } 138 | 139 | // Если изменения происходят в области `minimumFractionDigits`, очищаем дробную часть 140 | // для замены значения, чтобы заменить "0" на вводимое значение, 141 | // например, при вводе "1", получим "0.00" -> "0.1" -> "0.10" (не "0.100") 142 | if (/\..*0$/.test(normalizedValue)) { 143 | const p$1 = `([${previousLocalizedValues.digits}])([${previousLocalizedValues.decimal}])([${previousLocalizedValues.digits}]+)`; 144 | const result = RegExp(p$1).exec(previousValue); 145 | 146 | if (result !== null) { 147 | const previousFraction = result[3]; 148 | const previousFractionIndex = Number(result[5]) + result[1].length + result[2].length; 149 | 150 | const previousResolvedOptions = resolveOptions(previousOptions.locales, previousOptions.options); 151 | const previousMinimumFractionDigits = previousResolvedOptions.minimumFractionDigits ?? 0; 152 | 153 | // Если изменения происходят в области `minimumFractionDigits` 154 | const isRange = 155 | changeStart >= previousFractionIndex && 156 | changeEnd < previousFractionIndex + (previousMinimumFractionDigits || 1); 157 | 158 | if (isRange && previousFraction.length <= (previousMinimumFractionDigits || 1)) { 159 | normalizedValue = normalizedValue.replace(/0+$/g, ''); 160 | } 161 | } 162 | } 163 | 164 | const isDelete = inputType === 'deleteBackward' || inputType === 'deleteForward'; 165 | 166 | // В случае когда у нас имеется обязательная минимальная длина и мы удаляем `decimal`, 167 | // нам важно удалить также нули которые перейдут из `fraction` в `integer` при объединении 168 | if (isDelete && previousValue.includes(previousLocalizedValues.decimal) && !normalizedValue.includes('.')) { 169 | const p$1 = `[${previousLocalizedValues.digits[0]}]*[^${previousLocalizedValues.decimal}${previousLocalizedValues.digits}]*$`; 170 | const p$2 = `[^${previousLocalizedValues.digits[0]}]`; 171 | 172 | let countZeros = RegExp(p$1).exec(previousValue)?.[0].replace(RegExp(p$2, 'g'), '').length; 173 | 174 | if (countZeros !== undefined && resolvedOptions.minimumFractionDigits !== undefined) { 175 | if (countZeros > resolvedOptions.minimumFractionDigits) { 176 | countZeros = resolvedOptions.minimumFractionDigits; 177 | } 178 | 179 | normalizedValue = normalizedValue.replace(RegExp(`0{0,${countZeros}}$`), ''); 180 | } 181 | } 182 | 183 | let value = ''; 184 | 185 | // Если `integer` удалён и `fraction` отсутствует или равен нулю, удаляем всё значение 186 | const isEmptyValue = normalizedValue === '' || normalizedValue === '-' || /^-?(\.0*)?$/.test(normalizedValue); 187 | 188 | if (!isDelete || !isEmptyValue) { 189 | value = format(normalizedValue, { 190 | locales, 191 | options, 192 | localizedValues, 193 | resolvedOptions, 194 | }); 195 | } 196 | 197 | const selection = resolveSelection({ 198 | localizedValues, 199 | previousLocalizedValues, 200 | resolvedOptions, 201 | inputType, 202 | value, 203 | previousValue, 204 | addedValue, 205 | changeStart, 206 | changeEnd, 207 | }); 208 | 209 | return { 210 | value, 211 | selectionStart: selection.start, 212 | selectionEnd: selection.end, 213 | options: { locales, options }, 214 | }; 215 | }, 216 | }); 217 | 218 | this.format = (value) => { 219 | return utils.format(value, _options); 220 | }; 221 | 222 | this.unformat = (value) => { 223 | return utils.unformat(value, _options.locales); 224 | }; 225 | } 226 | } 227 | 228 | if (process.env.__OUTPUT__ === 'cdn') { 229 | interface Context { 230 | ReactInput?: { 231 | NumberFormat?: typeof NumberFormat & Partial; 232 | }; 233 | } 234 | 235 | const _global: typeof globalThis & Context = typeof globalThis !== 'undefined' ? globalThis : global || self; 236 | 237 | _global.ReactInput = _global.ReactInput ?? {}; 238 | _global.ReactInput.NumberFormat = NumberFormat; 239 | _global.ReactInput.NumberFormat.format = utils.format; 240 | _global.ReactInput.NumberFormat.unformat = utils.unformat; 241 | } 242 | -------------------------------------------------------------------------------- /packages/number-format/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as InputNumberFormat } from './InputNumberFormat'; 2 | export { default as NumberFormat } from './NumberFormat'; 3 | export { default as useNumberFormat } from './useNumberFormat'; 4 | export * from './utils'; 5 | 6 | export type { InputNumberFormatProps } from './InputNumberFormat'; 7 | export type { NumberFormatOptions } from './types'; 8 | -------------------------------------------------------------------------------- /packages/number-format/src/types.ts: -------------------------------------------------------------------------------- 1 | // ES5 2 | // - `style`?: "decimal" | "currency" | "percent" | "unit"; 3 | // - `currency`?: string; 4 | // - `useGrouping`?: boolean; (changed on `groupDisplay`) 5 | // - `minimumIntegerDigits`?: number; 6 | // - `minimumFractionDigits`?: number; 7 | // - `maximumFractionDigits`?: number; 8 | 9 | // ES2020 10 | // - `signDisplay`?: "auto" | "negative" | "never" | "always" | "exceptZero"; 11 | // - `unit`?: string; 12 | // - `unitDisplay`?: "short" | "long" | "narrow"; 13 | // - `currencyDisplay`?: "symbol" | "narrowSymbol" | "code" | "name"; 14 | 15 | // ES5 **excluded** 16 | // - `localeMatcher`?: string; 17 | // - `currencySign`?: "standard" | "accounting"; 18 | // - `minimumSignificantDigits`?: number; 19 | // - `maximumSignificantDigits`?: number; 20 | 21 | // ES2020 **excluded** 22 | // - `compactDisplay`?: "short" | "long"; 23 | // - `notation`?: "standard" | "scientific" | "engineering" | "compact"; 24 | // - `numberingSystem`?: string; 25 | // - `roundingIncrement`?: number; 26 | // - `roundingMode`?: "ceil" | "floor" | "expand" | "trunc" | "halfCeil" | "halfFloor" | "halfExpand" | "halfTrunc" | "halfEven"; 27 | // - `roundingPriority`?: "auto" | "morePrecision" | "lessPrecision"; 28 | // - `trailingZeroDisplay`?: "auto" | "stripIfInteger"; 29 | 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | type ResolveOptions> = Pick< 32 | T, 33 | | 'currency' 34 | | 'currencyDisplay' 35 | | 'unit' 36 | | 'unitDisplay' 37 | | 'signDisplay' 38 | | 'minimumIntegerDigits' 39 | | 'minimumFractionDigits' 40 | | 'maximumFractionDigits' 41 | >; 42 | 43 | export interface IncludedOptions { 44 | format: Intl.NumberFormatOptions['style']; 45 | groupDisplay: Intl.NumberFormatOptions['useGrouping']; 46 | maximumIntegerDigits: number | undefined; 47 | } 48 | 49 | export type NumberFormatOptions = ResolveOptions & Partial; 50 | 51 | export type ResolvedNumberFormatOptions = ResolveOptions & IncludedOptions; 52 | 53 | export interface LocalizedNumberFormatValues { 54 | signBackwards: boolean; 55 | minusSign: string; 56 | decimal: string; 57 | digits: string; 58 | } 59 | -------------------------------------------------------------------------------- /packages/number-format/src/useNumberFormat.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from 'react'; 2 | 3 | import { createProxy } from '@react-input/core'; 4 | 5 | import NumberFormat from './NumberFormat'; 6 | 7 | import type { NumberFormatOptions } from './types'; 8 | 9 | export default function useNumberFormat({ 10 | locales, 11 | format, 12 | currency, 13 | currencyDisplay, 14 | unit, 15 | unitDisplay, 16 | signDisplay, 17 | groupDisplay, 18 | minimumIntegerDigits, 19 | maximumIntegerDigits, 20 | minimumFractionDigits, 21 | maximumFractionDigits, 22 | }: NumberFormatOptions & { locales?: Intl.LocalesArgument } = {}) { 23 | const $ref = useRef(null); 24 | const $options = useRef({ 25 | locales, 26 | format, 27 | currency, 28 | currencyDisplay, 29 | unit, 30 | unitDisplay, 31 | signDisplay, 32 | groupDisplay, 33 | minimumIntegerDigits, 34 | maximumIntegerDigits, 35 | minimumFractionDigits, 36 | maximumFractionDigits, 37 | }); 38 | 39 | $options.current.locales = locales; 40 | $options.current.format = format; 41 | $options.current.currency = currency; 42 | $options.current.currencyDisplay = currencyDisplay; 43 | $options.current.unit = unit; 44 | $options.current.unitDisplay = unitDisplay; 45 | $options.current.signDisplay = signDisplay; 46 | $options.current.groupDisplay = groupDisplay; 47 | $options.current.minimumIntegerDigits = minimumIntegerDigits; 48 | $options.current.maximumIntegerDigits = maximumIntegerDigits; 49 | $options.current.minimumFractionDigits = minimumFractionDigits; 50 | $options.current.maximumFractionDigits = maximumFractionDigits; 51 | 52 | return useMemo(() => { 53 | return createProxy($ref, new NumberFormat($options.current)); 54 | }, []); 55 | } 56 | -------------------------------------------------------------------------------- /packages/number-format/src/utils.ts: -------------------------------------------------------------------------------- 1 | import _exec from './utils/exec'; 2 | import _filter from './utils/filter'; 3 | import _format from './utils/format'; 4 | import _localizeValues from './utils/localizeValues'; 5 | import _normalize from './utils/normalize'; 6 | import _resolveOptions from './utils/resolveOptions'; 7 | 8 | import type { NumberFormatOptions } from './types'; 9 | 10 | /** 11 | * Formats the value using the specified locales and options (see "[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#format)"). 12 | * 13 | * The result is exactly the same as the value received from the input. 14 | * Useful when you want to get a formatted value without raising an input event. 15 | * 16 | * Since `InputNumberFormat` works exactly like the `input` element, `InputNumberFormat` 17 | * will not change the value outside of an input event, so you may end up in a situation 18 | * where the `input` element has a value that does not match the format, such as when 19 | * initializing a value received from the backend. 20 | * 21 | * `format(123456.78, 'en-IN', { format: "currency", currency: "USD" })` → "$1,23,456.78" 22 | */ 23 | export function format( 24 | value: number | bigint | string, 25 | { locales, ...options }: NumberFormatOptions & { locales?: Intl.LocalesArgument } = {}, 26 | ) { 27 | const localizedValues = _localizeValues(locales); 28 | const resolvedOptions = _resolveOptions(locales, options); 29 | 30 | return _format(value.toString(), { locales, options, localizedValues, resolvedOptions }); 31 | } 32 | 33 | /** 34 | * Unformats the value using the specified locales (see «[Utils](https://github.com/GoncharukOrg/react-input/tree/main/packages/number-format#unformat)»). 35 | * 36 | * Returns a string as the numeric equivalent of the formatted value. Essentially does the opposite of the `format` utility. 37 | * 38 | * `unformat('$1,23,456.78', 'en-IN')` → "123456.78" 39 | */ 40 | export function unformat(value: string, locales?: Intl.LocalesArgument) { 41 | const localizedValues = _localizeValues(locales); 42 | 43 | let _value = value; 44 | 45 | _value = _exec(value, localizedValues); 46 | _value = _filter(_value, localizedValues); 47 | _value = _normalize(_value, localizedValues); 48 | 49 | // Для нормализации значения, ставим минус слева. 50 | // В случае арабской локали он может находиться справа 51 | return _value.replace(/(.+)(-)$/, '$2$1').replace(/\.$/, ''); 52 | } 53 | -------------------------------------------------------------------------------- /packages/number-format/src/utils/exec.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizedNumberFormatValues } from '../types'; 2 | 3 | /** 4 | * Для правильной фильтрации значения нам важно выбрать число чтобы символ 5 | * десятичного разделителя не пересекался, например: "123.456 млн." 6 | * @param value 7 | * @param param 8 | * @returns 9 | */ 10 | export default function exec( 11 | value: string, 12 | { minusSign, decimal, digits, signBackwards }: LocalizedNumberFormatValues, 13 | ) { 14 | const integerPattern = `[${digits}]+([^${decimal}${digits}][${digits}]+)*`; 15 | const fractionPattern = `[${decimal}][${digits}]`; 16 | 17 | const p$1 = `${integerPattern}${fractionPattern}*`; 18 | const p$2 = `${fractionPattern}+`; 19 | const p$3 = integerPattern; 20 | const p$4 = `[${decimal}]`; 21 | 22 | const _value = RegExp(`(${p$1}|${p$2}|${p$3}|${p$4})`).exec(value)?.[0] ?? ''; 23 | 24 | if (signBackwards && RegExp(`[${digits}]?.*${minusSign}`).test(value)) { 25 | return _value + minusSign; 26 | } 27 | 28 | if (RegExp(`${minusSign}.*[${digits}]?`).test(value)) { 29 | return minusSign + _value; 30 | } 31 | 32 | return _value; 33 | } 34 | -------------------------------------------------------------------------------- /packages/number-format/src/utils/filter.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizedNumberFormatValues } from '../types'; 2 | 3 | /** 4 | * Оставляем только символы цифр, разделитель дробной части и знак "минус" 5 | */ 6 | export default function filter(value: string, { minusSign, decimal, digits }: LocalizedNumberFormatValues) { 7 | return value.replace(RegExp(`[^\\${minusSign}${decimal}${digits}]`, 'g'), ''); 8 | } 9 | -------------------------------------------------------------------------------- /packages/number-format/src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizedNumberFormatValues, NumberFormatOptions, ResolvedNumberFormatOptions } from '../types'; 2 | 3 | interface Options { 4 | locales: Intl.LocalesArgument; 5 | options: NumberFormatOptions; 6 | localizedValues: LocalizedNumberFormatValues; 7 | resolvedOptions: ResolvedNumberFormatOptions; 8 | } 9 | 10 | /** 11 | * 12 | * @param value 13 | * @param options 14 | * @returns 15 | */ 16 | export default function format(value: string, { locales, options, localizedValues, resolvedOptions }: Options) { 17 | const { maximumIntegerDigits, minimumFractionDigits = 0, maximumFractionDigits } = resolvedOptions; 18 | 19 | const normalizedOptions: NumberFormatOptions & Intl.NumberFormatOptions = { ...options }; 20 | 21 | normalizedOptions.style = normalizedOptions.format; 22 | normalizedOptions.useGrouping = normalizedOptions.groupDisplay; 23 | normalizedOptions.minimumFractionDigits = 0; 24 | normalizedOptions.maximumFractionDigits = 0; 25 | 26 | delete normalizedOptions.format; 27 | delete normalizedOptions.groupDisplay; 28 | delete normalizedOptions.maximumIntegerDigits; 29 | 30 | // При `fraction` равном `undefined` - разделитель не был введён 31 | let [integer, fraction = ''] = value.split('.'); 32 | 33 | // Поскольку состояние ввода хранит последний введенный символ, 34 | // при форматировании может произойти округление, поэтому нам важно 35 | // заранее обрезать символ не соответствующий максимальному количеству символов 36 | 37 | // - `replace` - Учитываем `minimumIntegerDigits`. 38 | // Так, при `addedValue` "2": "000 001" -> "000 0012" -> "12" -> "000 012" 39 | integer = integer.replace(/^(-)?0+/, '$1'); 40 | integer = RegExp(`-?\\d{0,${maximumIntegerDigits ?? ''}}`).exec(integer)?.[0] ?? ''; 41 | 42 | // `BigInt` не принимает "-" и при отрицательном нуле не учитывает "-" 43 | const bigInteger = /^-0?$/.test(integer) ? -0 : BigInt(integer); 44 | let nextValue = ''; 45 | 46 | // При `percent` происходит умножение на 100, поэтому нам важно обработать его отдельно под видом `decimal` 47 | if (resolvedOptions.format === 'percent') { 48 | const p$1 = `[${localizedValues.digits}]+([^${localizedValues.digits}][${localizedValues.digits}]+)*`; 49 | const r$1 = RegExp(p$1); 50 | 51 | const decimalValue = new Intl.NumberFormat(locales, { ...normalizedOptions, style: 'decimal' }).format(bigInteger); 52 | const percentValue = new Intl.NumberFormat(locales, normalizedOptions).format(bigInteger); 53 | 54 | nextValue = percentValue.replace(r$1, r$1.exec(decimalValue)?.[0] ?? ''); 55 | } else { 56 | nextValue = new Intl.NumberFormat(locales, normalizedOptions).format(bigInteger); 57 | } 58 | 59 | // В значении может встречаться юникод, нам важно заменить 60 | // такие символы для соответствия стандартному значению 61 | nextValue = nextValue.replace(/\s/g, ' '); 62 | 63 | if (fraction.length < minimumFractionDigits) { 64 | fraction += '0'.repeat(minimumFractionDigits - fraction.length); 65 | } 66 | 67 | if ( 68 | (maximumFractionDigits === undefined || maximumFractionDigits > 0) && 69 | (value.includes('.') || fraction.length > 0) 70 | ) { 71 | nextValue = nextValue.replace( 72 | RegExp(`([${localizedValues.digits}])([^${localizedValues.digits}]*)$`), 73 | `$1${localizedValues.decimal}$2`, 74 | ); 75 | 76 | if (fraction.length > 0) { 77 | fraction = fraction.slice(0, maximumFractionDigits); 78 | const localizedFraction = fraction.replace(/\d/g, (digit) => localizedValues.digits[Number(digit)]); 79 | 80 | nextValue = nextValue.replace( 81 | RegExp(`([${localizedValues.decimal}])([^${localizedValues.digits}]*)$`), 82 | `$1${localizedFraction}$2`, 83 | ); 84 | } 85 | } 86 | 87 | let sign: string | undefined; 88 | 89 | if (nextValue.includes('+')) { 90 | sign = '+'; 91 | } else if (nextValue.includes(localizedValues.minusSign)) { 92 | sign = localizedValues.minusSign; 93 | } 94 | 95 | // Арабская локаль содержит юникод, что приводит к неожидаемому 96 | // сдвигу курсора при смещении через клавиатуру, для предотвращения 97 | // устанавливаем знак в конец числа с удалением нежелательного юникода 98 | if (sign !== undefined && localizedValues.signBackwards) { 99 | nextValue = nextValue.replace(RegExp(`[‎؜\\${sign}]`, 'g'), ''); 100 | 101 | const lastDigitIndex = nextValue.search(RegExp(`[${localizedValues.digits}](?!.*[${localizedValues.digits}])`)); 102 | 103 | if (lastDigitIndex !== -1) { 104 | nextValue = nextValue.slice(0, lastDigitIndex + 1) + sign + nextValue.slice(lastDigitIndex + 1); 105 | 106 | // Если не поставить юникод, поведение курсора будет нарушено 107 | if (!nextValue.startsWith('‏')) { 108 | nextValue = `‏${nextValue}`; 109 | } 110 | } 111 | } 112 | 113 | return nextValue; 114 | } 115 | -------------------------------------------------------------------------------- /packages/number-format/src/utils/localizeValues.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizedNumberFormatValues } from '../types'; 2 | 3 | /** 4 | * Возвращает применяемые значения по заданной локали 5 | * @param locales 6 | * @returns 7 | */ 8 | export default function localizeValues(locales: Intl.LocalesArgument): LocalizedNumberFormatValues { 9 | const value = new Intl.NumberFormat(locales, { 10 | useGrouping: false, 11 | signDisplay: 'always', 12 | minimumIntegerDigits: 10, 13 | minimumFractionDigits: 1, 14 | maximumFractionDigits: 1, 15 | }).format(-1234567890.1); 16 | 17 | // При, например, арабской локали, минус устанавливается 18 | // справа от чисел, поэтому нам важно определить положение 19 | // минуса. Если минус расположен справа, то на первой 20 | // позиции будет юникод `U+061C` (char code 1564) 21 | const signBackwards = value.startsWith('‎') || value.startsWith('؜'); 22 | const minusSign = signBackwards ? value[1] : value[0]; 23 | const decimal = value[value.length - 2]; 24 | 25 | // Получаем все цифры в заданной локали (возможны варианты 26 | // с китайской десятичной системой или арабскими цифрами) 27 | let digits = value.slice(signBackwards ? 2 : 1, -2); 28 | digits = digits[9] + digits.slice(0, -1); 29 | 30 | return { signBackwards, minusSign, decimal, digits }; 31 | } 32 | -------------------------------------------------------------------------------- /packages/number-format/src/utils/normalize.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizedNumberFormatValues } from '../types'; 2 | 3 | export default function normalize(value: string, { minusSign, decimal, digits }: LocalizedNumberFormatValues) { 4 | return ( 5 | value 6 | // Нормализуем знак минуса 7 | .replace(RegExp(minusSign, 'g'), '-') 8 | // Нормализуем десятичный разделитель 9 | .replace(RegExp(`[${decimal}]`, 'g'), '.') 10 | // Нормализуем цифры 11 | .replace(RegExp(`[${digits}]`, 'g'), (localeDigit) => { 12 | const digit = digits.indexOf(localeDigit); 13 | return digit !== -1 ? digit.toString() : localeDigit; 14 | }) 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/number-format/src/utils/resolveOptions.ts: -------------------------------------------------------------------------------- 1 | import type { NumberFormatOptions, ResolvedNumberFormatOptions } from '../types'; 2 | 3 | interface TempResolved extends Partial { 4 | localeMatcher?: string; 5 | numberingSystem?: string; 6 | roundingIncrement?: 1 | 2 | 5 | 10 | 20 | 25 | 50 | 100 | 200 | 250 | 500 | 1000 | 2000 | 2500 | 5000 | undefined; 7 | roundingMode?: 8 | | 'ceil' 9 | | 'floor' 10 | | 'expand' 11 | | 'trunc' 12 | | 'halfCeil' 13 | | 'halfFloor' 14 | | 'halfExpand' 15 | | 'halfTrunc' 16 | | 'halfEven'; 17 | roundingPriority?: 'auto' | 'morePrecision' | 'lessPrecision'; 18 | trailingZeroDisplay?: 'auto' | 'stripIfInteger'; 19 | } 20 | 21 | export default function resolveOptions( 22 | locales: Intl.LocalesArgument, 23 | { format, groupDisplay, maximumIntegerDigits, ...options }: NumberFormatOptions, 24 | ) { 25 | maximumIntegerDigits = maximumIntegerDigits !== undefined ? Number(maximumIntegerDigits) : undefined; 26 | 27 | if (maximumIntegerDigits !== undefined && Number.isNaN(maximumIntegerDigits)) { 28 | throw new RangeError('maximumIntegerDigits value is out of range.'); 29 | } 30 | 31 | const resolvedOptions = new Intl.NumberFormat(locales, { 32 | ...options, 33 | style: format === 'percent' ? 'decimal' : format, 34 | useGrouping: groupDisplay, 35 | }).resolvedOptions() as unknown as ResolvedNumberFormatOptions; 36 | 37 | const resolvedFormat = (resolvedOptions as unknown as Intl.ResolvedNumberFormatOptions).style; 38 | const resolvedGroupDisplay = (resolvedOptions as unknown as Intl.ResolvedNumberFormatOptions).useGrouping; 39 | 40 | resolvedOptions.format = format === 'percent' ? 'percent' : resolvedFormat; 41 | resolvedOptions.groupDisplay = resolvedGroupDisplay; 42 | 43 | const isMaxGreaterMin = 44 | maximumIntegerDigits !== undefined && maximumIntegerDigits < resolvedOptions.minimumIntegerDigits; 45 | 46 | resolvedOptions.maximumIntegerDigits = isMaxGreaterMin ? resolvedOptions.minimumIntegerDigits : maximumIntegerDigits; 47 | 48 | // Удаляем из `resolvedOptions` неиспользуемые свойства 49 | const tempResolvedOptions = resolvedOptions as unknown as TempResolved; 50 | 51 | delete tempResolvedOptions.style; 52 | delete tempResolvedOptions.currencySign; 53 | delete tempResolvedOptions.useGrouping; 54 | delete tempResolvedOptions.minimumSignificantDigits; 55 | delete tempResolvedOptions.maximumSignificantDigits; 56 | delete tempResolvedOptions.compactDisplay; 57 | delete tempResolvedOptions.notation; 58 | delete tempResolvedOptions.numberingSystem; 59 | delete tempResolvedOptions.localeMatcher; 60 | delete tempResolvedOptions.roundingIncrement; 61 | delete tempResolvedOptions.roundingMode; 62 | delete tempResolvedOptions.roundingPriority; 63 | delete tempResolvedOptions.trailingZeroDisplay; 64 | 65 | return resolvedOptions; 66 | } 67 | -------------------------------------------------------------------------------- /packages/number-format/src/utils/resolveSelection.ts: -------------------------------------------------------------------------------- 1 | import type { LocalizedNumberFormatValues, ResolvedNumberFormatOptions } from '../types'; 2 | import type { InputType } from '@react-input/core'; 3 | 4 | interface ResolveSelectionParam { 5 | localizedValues: LocalizedNumberFormatValues; 6 | previousLocalizedValues: LocalizedNumberFormatValues; 7 | resolvedOptions: ResolvedNumberFormatOptions; 8 | inputType: InputType; 9 | value: string; 10 | addedValue: string; 11 | previousValue: string; 12 | changeStart: number; 13 | changeEnd: number; 14 | } 15 | 16 | interface ResolveSelectionReturn { 17 | start: number; 18 | end: number; 19 | } 20 | 21 | /** 22 | * Определяет позицию каретки для последующей установки 23 | * @param param 24 | * @returns 25 | */ 26 | export default function resolveSelection({ 27 | localizedValues, 28 | previousLocalizedValues, 29 | resolvedOptions, 30 | inputType, 31 | value, 32 | previousValue, 33 | addedValue, 34 | changeStart, 35 | changeEnd, 36 | }: ResolveSelectionParam): ResolveSelectionReturn { 37 | const hasPreviousDecimal = previousValue.includes(localizedValues.decimal); 38 | 39 | // Если был введен `decimal` возвращаем позицию после символа `decimal` 40 | if (hasPreviousDecimal && addedValue === '.') { 41 | const decimalIndex = value.indexOf(localizedValues.decimal); 42 | 43 | if (decimalIndex !== -1) { 44 | const position = decimalIndex + 1; 45 | return { start: position, end: position }; 46 | } 47 | } 48 | 49 | const hasPreviousMinusSign = previousValue.includes(localizedValues.minusSign); 50 | 51 | // Если был введен `minusSign` 52 | if (hasPreviousMinusSign && addedValue === '-') { 53 | const minusSignIndex = value.indexOf(localizedValues.minusSign); 54 | 55 | if (minusSignIndex !== -1) { 56 | const position = minusSignIndex + (localizedValues.signBackwards ? 0 : 1); 57 | return { start: position, end: position }; 58 | } 59 | } 60 | 61 | // При стирании значения в `integer`, при условии что `integer` равен нулю, 62 | // необходимо выделить все нули для последующего удаления `integer`. Такое 63 | // поведение оправдано в случае если `minimumIntegerDigits` больше чем 1 64 | if (inputType === 'deleteBackward' || inputType === 'deleteForward') { 65 | const [beforePreviousDecimal] = previousValue.split(previousLocalizedValues.decimal); 66 | 67 | if ( 68 | changeEnd <= beforePreviousDecimal.length && 69 | !RegExp(`[${previousLocalizedValues.digits.slice(1)}]`).test(beforePreviousDecimal) 70 | ) { 71 | const firstPreviousIntegerDigitIndex = beforePreviousDecimal.indexOf(previousLocalizedValues.digits[0]); 72 | const lastPreviousIntegerDigitIndex = beforePreviousDecimal.lastIndexOf(previousLocalizedValues.digits[0]); 73 | 74 | if (firstPreviousIntegerDigitIndex !== -1 && lastPreviousIntegerDigitIndex !== -1) { 75 | const _lastPreviousIntegerDigitIndex = lastPreviousIntegerDigitIndex + 1; 76 | 77 | // Нам не нужно повторно сохранять выделение 78 | const hasNotSelection = 79 | changeStart !== firstPreviousIntegerDigitIndex || changeEnd !== _lastPreviousIntegerDigitIndex; 80 | 81 | const hasIntegerDigitsRange = 82 | changeEnd > firstPreviousIntegerDigitIndex && changeEnd <= _lastPreviousIntegerDigitIndex; 83 | 84 | if (hasNotSelection && hasIntegerDigitsRange) { 85 | return { start: firstPreviousIntegerDigitIndex, end: _lastPreviousIntegerDigitIndex }; 86 | } 87 | } 88 | } 89 | } 90 | 91 | // Поскольку длина значения способна меняться, за счёт добавления/удаления 92 | // символов разрядности, гарантированным способом получить точную позицию 93 | // каретки, в рамках изменения значения при добавлении/удалении значения, 94 | // будет подсчёт количества цифр до выделенной области изменения (до `changeStart`). 95 | 96 | const maximumIntegerDigits = 97 | resolvedOptions.maximumIntegerDigits !== undefined ? Number(resolvedOptions.maximumIntegerDigits) : undefined; 98 | 99 | let selection = value.length; 100 | // "Устойчиваое" число - цифры и десятичный разделитель без учитёта 101 | // нулей от начала значения до первой цифры не равной нулю в `integer` 102 | let countStableDigits = 0; 103 | 104 | // Находим символы "устойчивого" числа до `changeStart` 105 | for (let i = 0, start = false; i < changeStart; i++) { 106 | const isDigit = previousLocalizedValues.digits.includes(previousValue[i]); 107 | const isDecimal = previousValue[i] === previousLocalizedValues.decimal; 108 | 109 | if (!start && (isDecimal || (isDigit && previousValue[i] !== previousLocalizedValues.digits[0]))) { 110 | start = true; 111 | } 112 | 113 | if (start && (isDecimal || isDigit)) { 114 | countStableDigits += 1; 115 | } 116 | } 117 | 118 | // Важно учесть добавленные символы, в противном случае позиция каретки не будет сдвигаться 119 | if (inputType === 'insert') { 120 | const previousValueBeforeSelectionStartRange = previousValue.slice(0, changeStart); 121 | const previousDecimalIndex = previousValue.indexOf(previousLocalizedValues.decimal); 122 | 123 | // eslint-disable-next-line prefer-const 124 | let [previousInteger, previousFraction = ''] = previousValueBeforeSelectionStartRange 125 | .replace(RegExp(`[^${previousLocalizedValues.decimal}${previousLocalizedValues.digits}]`, 'g'), '') 126 | .replace(RegExp(`^${previousLocalizedValues.digits[0]}+`, 'g'), '') 127 | .split(previousLocalizedValues.decimal); 128 | 129 | // Поскольку десятичный разделитель не может находиться 130 | // перед имеющимся разделителем, нам важно удалить его 131 | const p$0 = previousDecimalIndex !== -1 && changeEnd <= previousDecimalIndex ? '\\.' : '\\.(?=.*\\.)'; 132 | const absAdded = addedValue.replace(RegExp(`-|${p$0}`, 'g'), ''); 133 | 134 | const hasAddedDecimal = absAdded.includes('.'); 135 | let [addedInteger, addedFraction = ''] = absAdded.split('.'); 136 | 137 | if (previousDecimalIndex !== -1 && changeStart > previousDecimalIndex) { 138 | if (hasAddedDecimal) { 139 | // Нам важно не учитывать `decimal` в предыдущем значении поскольку 140 | // в текущем значении при данном условии он будет отсутствовать 141 | countStableDigits -= 1; 142 | 143 | const joinedPreviousInteger = previousInteger + previousFraction; 144 | 145 | if (maximumIntegerDigits !== undefined && joinedPreviousInteger.length > maximumIntegerDigits) { 146 | countStableDigits = maximumIntegerDigits; 147 | previousInteger = joinedPreviousInteger.slice(0, maximumIntegerDigits); 148 | } 149 | } else { 150 | addedFraction = addedInteger; 151 | addedInteger = ''; 152 | } 153 | } 154 | 155 | const p$1 = `[${previousLocalizedValues.decimal}${previousLocalizedValues.digits.slice(1)}]`; 156 | 157 | if (!RegExp(p$1).test(previousValueBeforeSelectionStartRange)) { 158 | addedInteger = addedInteger.replace(/^0+/g, ''); 159 | } 160 | 161 | const endSlice = maximumIntegerDigits !== undefined ? maximumIntegerDigits - previousInteger.length : undefined; 162 | const normalizedAdded = addedInteger.slice(0, endSlice) + (hasAddedDecimal ? '.' : '') + addedFraction; 163 | 164 | countStableDigits += normalizedAdded.replace( 165 | RegExp(`[^\\.${localizedValues.decimal}\\d${localizedValues.digits}]+`, 'g'), 166 | '', 167 | ).length; 168 | } 169 | 170 | // Вычисляем первоначальную позицию каретки по индексу отформатированного 171 | // значения путём подсчёта количества цифр "устойчивого" числа, где: 172 | // `start` - начало "устойчивого" числа 173 | // `countDigits` - количество найденных символов после начала "устойчивого" числа 174 | // Порядок инструкций имеет значение! 175 | for (let i = 0, start = false, countDigits = 0; i < value.length; i++) { 176 | const isDigit = localizedValues.digits.includes(value[i]); 177 | const isDecimal = value[i] === localizedValues.decimal; 178 | 179 | if (!start && (isDecimal || (isDigit && value[i] !== localizedValues.digits[0]))) { 180 | start = true; 181 | } 182 | 183 | if (start && countDigits >= countStableDigits) { 184 | selection = i; 185 | break; 186 | } 187 | 188 | if (start && (isDecimal || isDigit)) { 189 | countDigits += 1; 190 | } 191 | } 192 | 193 | // Сдвигаем каретку к ближайшей цифре 194 | if (inputType === 'deleteForward') { 195 | const p$1 = `\\${localizedValues.minusSign}`; 196 | const p$2 = `^.{${selection}}[^${localizedValues.decimal}${localizedValues.digits}]*[${p$1}${localizedValues.decimal}${localizedValues.digits}]`; 197 | const nextDigitIndex = RegExp(p$2).exec(value)?.[0].length; 198 | 199 | if (nextDigitIndex !== undefined) { 200 | selection = nextDigitIndex - 1; 201 | } 202 | } else { 203 | // При `deleteBackward` нам важно поставить каретку после знака минуса, если такой существует 204 | const p$1 = inputType === 'deleteBackward' ? `\\${localizedValues.minusSign}` : ''; 205 | const p$2 = `[${p$1}${localizedValues.decimal}${localizedValues.digits}][^${localizedValues.decimal}${localizedValues.digits}]*.{${value.length - selection}}$`; 206 | const previousDigitIndex = RegExp(p$2).exec(value)?.index; 207 | 208 | if (previousDigitIndex !== undefined) { 209 | selection = previousDigitIndex + 1; 210 | } 211 | } 212 | 213 | // Страхуем границы позиции каретки 214 | { 215 | const p$1 = `[\\${localizedValues.minusSign}${localizedValues.decimal}${localizedValues.digits.slice(1)}]`; 216 | const p$2 = `[\\${localizedValues.minusSign}${localizedValues.decimal}${localizedValues.digits}][^${localizedValues.decimal}${localizedValues.digits}]*$`; 217 | 218 | const firstIndex = value.search(RegExp(p$1)); 219 | const lastIndex = value.search(RegExp(p$2)); 220 | 221 | if (firstIndex !== -1 && selection < firstIndex) { 222 | selection = firstIndex; 223 | } else if (lastIndex !== -1 && selection > lastIndex + 1) { 224 | selection = lastIndex + 1; 225 | } 226 | } 227 | 228 | return { start: selection, end: selection }; 229 | } 230 | -------------------------------------------------------------------------------- /packages/number-format/stories/Component.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { InputNumberFormat } from '../src'; 4 | 5 | import type { NumberFormatOptions } from '../src'; 6 | import type { Meta, StoryObj } from '@storybook/react'; 7 | 8 | function Component(props: NumberFormatOptions) { 9 | return ( 10 |
11 |

{JSON.stringify(props)}

12 | 13 |
14 | ); 15 | } 16 | 17 | const meta = { 18 | title: 'number-format/InputNumberFormat', 19 | component: InputNumberFormat, 20 | tags: ['autodocs'], 21 | } satisfies Meta; 22 | 23 | export default meta; 24 | 25 | type Story = StoryObj; 26 | 27 | export const Story1: Story = { 28 | render: Component, 29 | args: {}, 30 | }; 31 | 32 | export const Story2: Story = { 33 | render: Component, 34 | args: { 35 | locales: 'de-DE', 36 | format: 'currency', 37 | currency: 'EUR', 38 | }, 39 | }; 40 | 41 | export const Story3: Story = { 42 | render: Component, 43 | args: { 44 | locales: 'ja-JP', 45 | format: 'currency', 46 | currency: 'JPY', 47 | }, 48 | }; 49 | 50 | export const Story4: Story = { 51 | render: Component, 52 | args: { 53 | locales: 'en-IN', 54 | maximumIntegerDigits: 3, 55 | }, 56 | }; 57 | 58 | export const Story5: Story = { 59 | render: Component, 60 | args: { 61 | locales: 'pt-PT', 62 | format: 'unit', 63 | unit: 'kilometer-per-hour', 64 | }, 65 | }; 66 | 67 | export const Story6: Story = { 68 | render: Component, 69 | args: { 70 | locales: 'en-GB', 71 | format: 'unit', 72 | unit: 'liter', 73 | unitDisplay: 'long', 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /packages/number-format/stories/Hook.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { InputNumberFormat, useNumberFormat } from '../src'; 4 | 5 | import type { NumberFormatOptions } from '../src'; 6 | import type { Meta, StoryObj } from '@storybook/react'; 7 | 8 | function Component(props: NumberFormatOptions) { 9 | const ref = useNumberFormat(props); 10 | 11 | return ( 12 |
13 |

{JSON.stringify(props)}

14 | 15 |
16 | ); 17 | } 18 | 19 | const meta = { 20 | title: 'number-format/useNumberFormat', 21 | component: InputNumberFormat, 22 | tags: ['autodocs'], 23 | } satisfies Meta; 24 | 25 | export default meta; 26 | 27 | type Story = StoryObj; 28 | 29 | export const Story1: Story = { 30 | render: Component, 31 | args: {}, 32 | }; 33 | 34 | export const Story2: Story = { 35 | render: Component, 36 | args: { 37 | locales: 'ru-RU', 38 | maximumIntegerDigits: 6, 39 | }, 40 | }; 41 | 42 | export const Story3: Story = { 43 | render: Component, 44 | args: { 45 | locales: 'ru-RU', 46 | format: 'currency', 47 | currency: 'RUB', 48 | }, 49 | }; 50 | 51 | export const Story4: Story = { 52 | render: Component, 53 | args: { 54 | locales: 'ja-JP', 55 | format: 'currency', 56 | currency: 'RUB', 57 | }, 58 | }; 59 | 60 | export const Story5: Story = { 61 | render: Component, 62 | args: { 63 | locales: 'ar-EG', 64 | }, 65 | }; 66 | 67 | export const Story6: Story = { 68 | render: Component, 69 | args: { 70 | locales: 'zh-Hans-CN-u-nu-hanidec', 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /packages/number-format/tests/number-format.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from '@testing-library/react'; 4 | import userEvent from '@testing-library/user-event'; 5 | 6 | import InputNumberFormat from '@react-input/number-format/InputNumberFormat'; 7 | 8 | import type { InputNumberFormatProps } from '@react-input/number-format/InputNumberFormat'; 9 | 10 | import '@testing-library/jest-dom'; 11 | 12 | const init = (props: InputNumberFormatProps = {}) => { 13 | render(); 14 | return screen.getByTestId('input-number-format'); 15 | }; 16 | 17 | const initWithDefaultType = async (props: InputNumberFormatProps = {}) => { 18 | const input = init(props); 19 | await userEvent.type(input, '1234'); 20 | return input; 21 | }; 22 | 23 | /** 24 | * INSERT 25 | */ 26 | 27 | // default 28 | 29 | test('Insert', async () => { 30 | const input = await initWithDefaultType(); 31 | expect(input).toHaveValue('1 234'); 32 | }); 33 | 34 | test('Insert without selection range', async () => { 35 | const input = await initWithDefaultType(); 36 | await userEvent.type(input, '9', { initialSelectionStart: 3, initialSelectionEnd: 3 }); 37 | expect(input).toHaveValue('12 934'); 38 | }); 39 | 40 | test('Insert less than selection range', async () => { 41 | const input = await initWithDefaultType(); 42 | await userEvent.type(input, '9', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 43 | expect(input).toHaveValue('194'); 44 | }); 45 | 46 | test('Insert more than selection range', async () => { 47 | const input = await initWithDefaultType(); 48 | await userEvent.type(input, '6789', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 49 | expect(input).toHaveValue('167 894'); 50 | }); 51 | 52 | // minimumIntegerDigits: 4 53 | 54 | test('Insert without selection range (3-3)', async () => { 55 | const input = await initWithDefaultType({ minimumIntegerDigits: 4 }); 56 | await userEvent.type(input, '9', { initialSelectionStart: 3, initialSelectionEnd: 3 }); 57 | expect(input).toHaveValue('12 934'); 58 | }); 59 | 60 | test('Insert less than selection range (2-4)', async () => { 61 | const input = await initWithDefaultType({ minimumIntegerDigits: 4 }); 62 | await userEvent.type(input, '9', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 63 | expect(input).toHaveValue('0 194'); 64 | }); 65 | 66 | test('Insert more than selection range (2-4)', async () => { 67 | const input = await initWithDefaultType({ minimumIntegerDigits: 4 }); 68 | await userEvent.type(input, '6789', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 69 | expect(input).toHaveValue('167 894'); 70 | }); 71 | 72 | // minimumIntegerDigits: 6 73 | 74 | test('Insert without selection range (5-5)', async () => { 75 | const input = await initWithDefaultType({ minimumIntegerDigits: 6 }); 76 | await userEvent.type(input, '9', { initialSelectionStart: 5, initialSelectionEnd: 5 }); 77 | expect(input).toHaveValue('012 934'); 78 | }); 79 | 80 | test('Insert less than selection range (4-6)', async () => { 81 | const input = await initWithDefaultType({ minimumIntegerDigits: 6 }); 82 | await userEvent.type(input, '9', { initialSelectionStart: 4, initialSelectionEnd: 6 }); 83 | expect(input).toHaveValue('000 194'); 84 | }); 85 | 86 | test('Insert more than selection range (4-6)', async () => { 87 | const input = await initWithDefaultType({ minimumIntegerDigits: 6 }); 88 | await userEvent.type(input, '6789', { initialSelectionStart: 4, initialSelectionEnd: 6 }); 89 | expect(input).toHaveValue('167 894'); 90 | }); 91 | 92 | /** 93 | * BACKSPACE 94 | */ 95 | 96 | test('Backspace without selection range', async () => { 97 | const input = await initWithDefaultType(); 98 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 4, initialSelectionEnd: 4 }); 99 | expect(input).toHaveValue('124'); 100 | }); 101 | 102 | test('Backspace with selection range', async () => { 103 | const input = await initWithDefaultType(); 104 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 105 | expect(input).toHaveValue('14'); 106 | }); 107 | 108 | // minimumIntegerDigits: 4 109 | 110 | test('Backspace without selection range (4-4)', async () => { 111 | const input = await initWithDefaultType({ minimumIntegerDigits: 4 }); 112 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 4, initialSelectionEnd: 4 }); 113 | expect(input).toHaveValue('0 124'); 114 | }); 115 | 116 | test('Backspace with selection range (2-4)', async () => { 117 | const input = await initWithDefaultType({ minimumIntegerDigits: 4 }); 118 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 119 | expect(input).toHaveValue('0 014'); 120 | }); 121 | 122 | // minimumIntegerDigits: 6 123 | 124 | test('Backspace without selection range (6, 6)', async () => { 125 | const input = await initWithDefaultType({ minimumIntegerDigits: 6 }); 126 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 6, initialSelectionEnd: 6 }); 127 | expect(input).toHaveValue('000 124'); 128 | }); 129 | 130 | test('Backspace with selection range (4-6)', async () => { 131 | const input = await initWithDefaultType({ minimumIntegerDigits: 6 }); 132 | await userEvent.type(input, '{Backspace}', { initialSelectionStart: 4, initialSelectionEnd: 6 }); 133 | expect(input).toHaveValue('000 014'); 134 | }); 135 | 136 | /** 137 | * DELETE 138 | */ 139 | 140 | // default 141 | 142 | test('Delete without selection range', async () => { 143 | const input = await initWithDefaultType(); 144 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 3, initialSelectionEnd: 3 }); 145 | expect(input).toHaveValue('124'); 146 | }); 147 | 148 | test('Delete with selection range', async () => { 149 | const input = await initWithDefaultType(); 150 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 151 | expect(input).toHaveValue('14'); 152 | }); 153 | 154 | // minimumIntegerDigits: 4 155 | 156 | test('Delete without selection range (3-3)', async () => { 157 | const input = await initWithDefaultType({ minimumIntegerDigits: 4 }); 158 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 3, initialSelectionEnd: 3 }); 159 | expect(input).toHaveValue('0 124'); 160 | }); 161 | 162 | test('Delete with selection range (2-4)', async () => { 163 | const input = await initWithDefaultType({ minimumIntegerDigits: 4 }); 164 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 2, initialSelectionEnd: 4 }); 165 | expect(input).toHaveValue('0 014'); 166 | }); 167 | 168 | // minimumIntegerDigits: 6 169 | 170 | test('Delete without selection range (5-5)', async () => { 171 | const input = await initWithDefaultType({ minimumIntegerDigits: 6 }); 172 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 5, initialSelectionEnd: 5 }); 173 | expect(input).toHaveValue('000 124'); 174 | }); 175 | 176 | test('Delete with selection range (4-6)', async () => { 177 | const input = await initWithDefaultType({ minimumIntegerDigits: 6 }); 178 | await userEvent.type(input, '{Delete}', { initialSelectionStart: 4, initialSelectionEnd: 6 }); 179 | expect(input).toHaveValue('000 014'); 180 | }); 181 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import nodeResolve from '@rollup/plugin-node-resolve'; 4 | import replace from '@rollup/plugin-replace'; 5 | import terser from '@rollup/plugin-terser'; 6 | 7 | import readdir from './utils/readdir.js'; 8 | 9 | const { npm_package_name } = process.env; 10 | 11 | if (!npm_package_name) { 12 | throw new Error(''); 13 | } 14 | 15 | const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.es6', '.es', '.mjs']; 16 | const ENTRIES = { 17 | '@react-input/core': { 18 | react: { 19 | input: ['./src'], 20 | }, 21 | }, 22 | '@react-input/mask': { 23 | cdn: { 24 | input: 'src/Mask.ts', 25 | output: { 26 | name: 'Mask', 27 | }, 28 | }, 29 | react: { 30 | input: ['src/index.ts', 'src/InputMask.tsx', 'src/Mask.ts', 'src/useMask.ts', 'src/utils.ts'], 31 | }, 32 | }, 33 | '@react-input/number-format': { 34 | cdn: { 35 | input: 'src/NumberFormat.ts', 36 | output: { 37 | name: 'NumberFormat', 38 | }, 39 | }, 40 | react: { 41 | input: [ 42 | 'src/index.ts', 43 | 'src/InputNumberFormat.tsx', 44 | 'src/NumberFormat.ts', 45 | 'src/useNumberFormat.ts', 46 | 'src/utils.ts', 47 | ], 48 | }, 49 | }, 50 | }; 51 | 52 | const reduceFromDir = (/** @type {import("fs").PathLike} */ dir) => { 53 | return readdir(dir) 54 | .filter((path) => !/\.stories\.[^/]+$|\.test\.[^/]+$|types.ts$|\.d\.ts$/gm.test(path)) 55 | .reduce((prev, path) => { 56 | return { ...prev, [path.replace(/^\.\/src\/|\.[^/]+$/g, '')]: path }; 57 | }, {}); 58 | }; 59 | 60 | const reduceEntries = (/** @type {string[]} */ entries) => { 61 | return entries.reduce((prev, path) => { 62 | if (/\.[^/]+$/.test(path)) { 63 | return { ...prev, [path.replace(/^src\/|\.[^/]+$/g, '')]: path }; 64 | } 65 | 66 | return { ...prev, ...reduceFromDir(path) }; 67 | }, {}); 68 | }; 69 | 70 | const plugins = (/** @type {'cdn'|'react'} */ output) => [ 71 | replace({ 72 | preventAssignment: true, 73 | values: { 74 | 'process.env.__OUTPUT__': JSON.stringify(output), 75 | ...(output === 'cdn' ? { 'process.env.NODE_ENV': JSON.stringify('production') } : {}), 76 | }, 77 | }), 78 | nodeResolve({ 79 | extensions: EXTENSIONS, 80 | }), 81 | commonjs(), 82 | babel({ 83 | root: '../..', 84 | babelHelpers: 'bundled', 85 | extensions: EXTENSIONS, 86 | }), 87 | terser(), 88 | ]; 89 | 90 | export default () => { 91 | // @ts-expect-error 92 | const entries = ENTRIES[npm_package_name]; 93 | const config = []; 94 | 95 | if (entries.cdn) { 96 | config.push({ 97 | input: entries.cdn.input, 98 | output: { 99 | format: 'umd', 100 | file: 'cdn/index.js', 101 | name: `ReactInput.${entries.cdn.output.name}`, 102 | }, 103 | plugins: plugins('cdn'), 104 | }); 105 | } 106 | 107 | if (entries.react) { 108 | config.push({ 109 | input: reduceEntries(entries.react.input), 110 | output: [ 111 | { 112 | format: 'es', 113 | dir: 'module', 114 | entryFileNames: '[name].js', 115 | chunkFileNames: 'helpers-[hash].js', 116 | hoistTransitiveImports: false, 117 | }, 118 | { 119 | format: 'cjs', 120 | dir: 'node', 121 | entryFileNames: '[name].cjs', 122 | chunkFileNames: 'helpers-[hash].cjs', 123 | hoistTransitiveImports: false, 124 | exports: 'named', 125 | }, 126 | ], 127 | external: [/^react(\/.*)?$/, /^react-dom(\/.*)?$/, /^@react-input\/core(\/.*)?$/], 128 | plugins: plugins('react'), 129 | }); 130 | } 131 | 132 | return config; 133 | }; 134 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | 4 | import readdir from '../utils/readdir'; 5 | import style from '../utils/style'; 6 | 7 | const { npm_package_name } = process.env; 8 | 9 | if (!npm_package_name) { 10 | throw new Error(''); 11 | } 12 | 13 | /** 14 | * Write entries 15 | */ 16 | 17 | if (npm_package_name === '@react-input/core') { 18 | const resolvedPaths = readdir('./src').filter((path) => { 19 | return !/index.[^/]+$|\.stories\.[^/]+$|\.test\.[^/]+$|types\.ts$|\.d\.ts$/gm.test(path); 20 | }); 21 | 22 | const imports = resolvedPaths.map((path) => { 23 | const normalizedPath = path.replace(/^\.\/src\/|\.[^/]+$/g, ''); 24 | const moduleName = normalizedPath.replace(/^(.*\/)?/g, ''); 25 | 26 | return `export { default as ${moduleName} } from './${normalizedPath}';\n`; 27 | }); 28 | 29 | fs.writeFileSync('./src/index.ts', `${imports.join('')}\nexport type * from './types';\n`, { encoding: 'utf-8' }); 30 | } 31 | 32 | /** 33 | * Remove output directories 34 | */ 35 | 36 | for (const dir of ['@types', 'cdn', 'module', 'node']) { 37 | fs.rmSync(`./${dir}`, { recursive: true, force: true }); 38 | } 39 | 40 | /** 41 | * Rollup build 42 | */ 43 | 44 | execSync('rollup --config ../../rollup.config.js'); 45 | 46 | /** 47 | * Declare types 48 | */ 49 | 50 | execSync( 51 | `tsc src/index.ts ${npm_package_name === '@react-input/core' ? '--removeComments' : ''} --declaration --emitDeclarationOnly --esModuleInterop --jsx react --rootDir src --outDir @types`, 52 | ); 53 | 54 | /** 55 | * Clear types 56 | */ 57 | 58 | { 59 | const nodePaths = readdir('./node'); 60 | const typesPaths = readdir('./@types'); 61 | 62 | typesPaths.forEach((path) => { 63 | const normalizedPath = path.replace(/^\.\/@types/, './node').replace(/d\.ts$/, 'cjs'); 64 | 65 | if (!nodePaths.includes(normalizedPath) && !path.endsWith('/types.d.ts')) { 66 | const dirPath = path.replace(/\/[^/]*$/gm, ''); 67 | 68 | fs.rmSync(path); 69 | 70 | if (fs.readdirSync(dirPath).length === 0) { 71 | fs.rmdirSync(dirPath); 72 | } 73 | } 74 | }); 75 | } 76 | 77 | /** 78 | * Add directives 79 | */ 80 | 81 | { 82 | const map = { 83 | '@react-input/mask': 'InputMask', 84 | '@react-input/number-format': 'InputNumberFormat', 85 | }; 86 | 87 | if (npm_package_name === '@react-input/mask' || npm_package_name === '@react-input/number-format') { 88 | for (const type of ['node', 'module']) { 89 | const format = type === 'module' ? 'js' : 'cjs'; 90 | const path = `./${type}/${map[npm_package_name]}.${format}`; 91 | 92 | let src = fs.readFileSync(path, 'utf-8'); 93 | 94 | if (type === 'module') { 95 | src = src.replace('', '"use client";'); 96 | } 97 | 98 | if (type === 'node') { 99 | src = src.replace('"use strict";', '"use strict";"use client";'); 100 | } 101 | 102 | fs.writeFileSync(path, src); 103 | } 104 | } 105 | } 106 | 107 | // /** 108 | // * Create package.json 109 | // */ 110 | 111 | // { 112 | // const map = { 113 | // '@react-input/mask': 'InputMask', 114 | // '@react-input/number-format': 'InputNumberFormat', 115 | // }; 116 | 117 | // if (npm_package_name === '@react-input/mask' || npm_package_name === '@react-input/number-format') { 118 | // for (const type of ['node', 'module']) { 119 | // fs.writeFileSync( 120 | // `./${type}/${map[npm_package_name]}/package.json`, 121 | // JSON.stringify( 122 | // { 123 | // sideEffects: false, 124 | // type: type === 'module' ? 'module' : undefined, 125 | // types: `../../@types/${map[npm_package_name]}/index.d.ts`, 126 | // module: type === 'module' ? './index.js' : `../../module/${map[npm_package_name]}/index.js`, 127 | // main: type === 'node' ? './index.cjs' : `../../node/${map[npm_package_name]}/index.cjs`, 128 | // }, 129 | // null, 130 | // 2, 131 | // ), 132 | // ); 133 | // } 134 | // } 135 | // } 136 | 137 | /** 138 | * Console 139 | */ 140 | 141 | { 142 | const packageName = `${process.env.npm_package_name}@${process.env.npm_package_version}`; 143 | 144 | console.log( 145 | `\n${style.fg.yellow}The package ${style.fg.blue}${packageName} ${style.fg.yellow}was successfully built!\n`, 146 | style.reset, 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /scripts/release.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import fs from 'fs'; 3 | import readline from 'readline'; 4 | 5 | import corePackage from '../packages/core/package.json'; 6 | import style from '../utils/style'; 7 | 8 | const { npm_package_json, npm_package_name, npm_package_version } = process.env; 9 | 10 | if (!npm_package_json || !npm_package_name || !npm_package_version) { 11 | throw new Error(''); 12 | } 13 | 14 | const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); 15 | 16 | rl.question(`Do you want to update and publish the ${npm_package_name} package? `, (answer) => { 17 | if (answer.toLowerCase() !== 'y') { 18 | rl.close(); 19 | process.exit(); 20 | } 21 | 22 | if (npm_package_name !== '@react-input/core') { 23 | fs.writeFileSync( 24 | npm_package_json, 25 | fs 26 | .readFileSync(npm_package_json, 'utf-8') 27 | .replace(/"@react-input\/core": "\^\d+\.\d+\.\d+"/, `"@react-input/core": "^${corePackage.version}"`), 28 | ); 29 | } 30 | execSync('npm run build'); 31 | const output = execSync(`npm version ${process.argv[2]}`, { encoding: 'utf-8' }); 32 | 33 | const packageName = npm_package_name.replace(/^.+\/(.+)/, '$1'); 34 | const newPackageVersion = /\d+\.\d+\.\d+/.exec(output)?.[0]; 35 | 36 | if (!newPackageVersion) { 37 | throw new Error(''); 38 | } 39 | 40 | const commit = `${packageName}/v${newPackageVersion}`; 41 | 42 | execSync('npm publish'); 43 | execSync(`git add ../../ && git commit -m "${commit}" && git push && git tag ${commit} && git push origin --tags`); 44 | 45 | console.log( 46 | `${style.fg.yellow}The version of the package ${style.fg.blue}${npm_package_name} ${style.fg.yellow}has been updated!`, 47 | style.reset, 48 | `\n${npm_package_version} → ${newPackageVersion}\n`, 49 | ); 50 | 51 | rl.close(); 52 | }); 53 | -------------------------------------------------------------------------------- /scripts/update-deps.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | import pckg from '../package.json'; 4 | 5 | const deps = Object.entries(pckg.devDependencies).map(([key]) => `${key}@latest`); 6 | const command = `npm i -D ${deps.join(' ')}`; 7 | 8 | console.log(command); 9 | execSync(command); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "jsx": "react", 6 | 7 | "module": "ESNext", 8 | "moduleResolution": "Node", 9 | "resolveJsonModule": true, 10 | 11 | "allowJs": true, 12 | "checkJs": true, 13 | 14 | "noEmit": true, 15 | "stripInternal": true, 16 | 17 | "isolatedModules": true, 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "forceConsistentCasingInFileNames": true, 21 | 22 | "strict": true, 23 | "noImplicitAny": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | 27 | "skipLibCheck": true 28 | }, 29 | "include": [".storybook/**/*", "packages", "scripts", "utils"], 30 | "exclude": ["**/node_modules", "**/@types", "**/cdn", "**/module", "**/node"] 31 | } 32 | -------------------------------------------------------------------------------- /utils/readdir.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | /** 4 | * @param {fs.PathLike} dir 5 | * @param {string[]} paths 6 | */ 7 | export default function readdir(dir, paths = []) { 8 | fs.readdirSync(dir).forEach((relativePath) => { 9 | const path = `${dir}/${relativePath}`; 10 | 11 | if (fs.statSync(path).isDirectory()) { 12 | readdir(path, paths); 13 | } else { 14 | paths.push(path); 15 | } 16 | }); 17 | 18 | return paths; 19 | } 20 | -------------------------------------------------------------------------------- /utils/style.js: -------------------------------------------------------------------------------- 1 | export default { 2 | reset: '\x1b[0m', 3 | bright: '\x1b[1m', 4 | dim: '\x1b[2m', 5 | underscore: '\x1b[4m', 6 | blink: '\x1b[5m', 7 | reverse: '\x1b[7m', 8 | hidden: '\x1b[8m', 9 | fg: { 10 | black: '\x1b[30m', 11 | red: '\x1b[31m', 12 | green: '\x1b[32m', 13 | yellow: '\x1b[33m', 14 | blue: '\x1b[34m', 15 | magenta: '\x1b[35m', 16 | cyan: '\x1b[36m', 17 | white: '\x1b[37m', 18 | crimson: '\x1b[38m', 19 | }, 20 | bg: { 21 | black: '\x1b[40m', 22 | red: '\x1b[41m', 23 | green: '\x1b[42m', 24 | yellow: '\x1b[43m', 25 | blue: '\x1b[44m', 26 | magenta: '\x1b[45m', 27 | cyan: '\x1b[46m', 28 | white: '\x1b[47m', 29 | crimson: '\x1b[48m', 30 | }, 31 | }; 32 | --------------------------------------------------------------------------------