├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── docs.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── MIGRATING.md ├── README.md ├── commitlint.config.js ├── examples ├── .babelrc ├── .eslintrc ├── README.md ├── devServer.js ├── example.gif ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App │ │ ├── App.jsx │ │ ├── Demo.jsx │ │ ├── PageNotFound.jsx │ │ ├── ResultCode.jsx │ │ ├── index.js │ │ └── prism.js │ ├── Changelog │ │ └── Changelog.jsx │ ├── CreateNumberMask │ │ ├── CreateNumberMask.jsx │ │ └── CreateNumberMask.md │ ├── CreateTextMask │ │ ├── CreateTextMask.jsx │ │ └── CreateTextMask.md │ ├── GettingStarted │ │ ├── GettingStarted.jsx │ │ └── GettingStarted.md │ ├── Migrating │ │ └── Migrating.jsx │ ├── MoreExamples │ │ ├── MoreExamples.jsx │ │ └── MoreExamples.md │ └── index.jsx ├── webpack.config.dev.js └── webpack.config.prod.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── createNumberMask.test.js │ ├── createTextMask.test.js │ └── utils.test.js ├── createNumberMask.js ├── createTextMask.js ├── defaultMaskDefinitions.js ├── index.js ├── typings.d.ts └── utils.js └── webpack.config.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "es2015"] 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.config*.js 2 | node_modules 3 | dist 4 | **/__tests__/** -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"], 3 | "rules": { 4 | "max-len": [ 5 | "error", 6 | { 7 | "code": 80, 8 | "tabWidth": 2 9 | } 10 | ], 11 | "arrow-parens": ["error", "as-needed"], 12 | "consistent-return": "off", 13 | "no-useless-escape": "off", 14 | "default-case": "off" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This document contains guidelines for contributing to `redux-form-input-masks`. 4 | 5 | Thanks for taking your time and considering contributing to this project :tada:. 6 | 7 | Feel free to open a pull request and propose changes to this document. 8 | 9 | ## Contents 10 | 11 | [Code of conduct](#code-of-conduct) 12 | 13 | [How can I contribute?](#how-can-i-contribute) 14 | 15 | * [Reporting bugs](#reporting-bugs) 16 | * [Suggesting enhancements](#suggesting-enhancements) 17 | * [Your first code contribution](#your-first-code-contribution) 18 | * [Pull requests](#pull-requests) 19 | 20 | ## Code of conduct 21 | 22 | This project and everyone participating in it is governed by this [Code of conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code and report unacceptable behavior to [renato.bohler@gmail.com](mailto:renato.bohler@gmail.com). 23 | 24 | ## How can I contribute? 25 | 26 | ### Reporting bugs 27 | 28 | If you've found a bug on `redux-form-input-masks`, follow these steps: 29 | 30 | * check if anyone have already reported it [on our opened issues](https://github.com/renato-bohler/redux-form-input-masks/issues). If you find an already existing issue reporting a similar bug, you should comment on this issue instead of creating a new one; 31 | * if there's no similar issues opened, please do [open a new issue](https://github.com/renato-bohler/redux-form-input-masks/issues/new), using a clear and descriptive title and filling the [issue template](ISSUE_TEMPLATE.md) with as many details as possible. 32 | 33 | ### Suggesting enhancements 34 | 35 | Any ideas for new masks or new options for the existing ones are welcome! If you have a suggestion, you can open an issue, repeating the steps described at the [Reporting bugs](#reporting-bugs) section. 36 | 37 | ### Your first code contribution 38 | 39 | There's some things that you really should know before contributing with your awesome coding skills. 40 | 41 | * **starting:** to start, clone the project into your machine and run `npm install`. You can locally run the [`redux-form-input-masks` documentations & examples](https://renato-bohler.github.io/redux-form-input-masks/) to test your changes by running `cd examples && npm install & npm start`; 42 | * **commitlint:** in order to use [`semantic-release`](https://github.com/semantic-release/semantic-release), we follow the [`conventional-changelog`](https://github.com/conventional-changelog-archived-repos/conventional-changelog-angular/blob/master/convention.md) commit messages conventions. You need to make sure that all of your commit messages are complying with these conventions, **otherwise your commit will fail**. But don't worry! To simplify this process, we have configured [`commitizen`'s cli](https://github.com/commitizen/cz-cli) onto the project: to commit your stashed changes, run `npm run commit`. If you have `commitizen` installed globally, you can run `git cz`; 43 | * **eslint and prettier**: if you don't have ESLint and Prettier extensions installed for your editor of choice, you definitely should before contributing. They will help you improve your coding in many ways; 44 | * **tests:** make sure your changes haven't broken any tests by running `npm test`. If you open a pull request with broken tests, GitHub will block the pull request from being merged; 45 | * **code coverage**: please do you best to keep the code coverage as high as possible (100% is the goal); 46 | * **good first issue**: if you're looking for a good first issue, there's a label for that. You can use [this link](https://github.com/renato-bohler/redux-form-input-masks/labels/good%20first%20issue) to check all issues marked with this label; 47 | * **documentation**: you have fixed an issue, you ran the tests and everything is fine. Nice, but hold on! Always remember to keep the documentation updated, as this is **very** important. You can update the docs by changing the `examples` folder. If your code makes it to the `master` branch, GitHub Actions will take care of building and deploying the updated documentation to GitHub Pages on the repository. 48 | 49 | ### Pull requests 50 | 51 | When you open a pull request, it will trigger the [Test workfflow on GitHub Actions](https://github.com/renato-bohler/redux-form-input-masks/actions/workflows/test.yml), which will in turn trigger [codecov](http://codecov.io/) and other scripts. Codecov will post a comment with the test coverage report for your changes (absolute and diff values). 52 | 53 | Currently, we have no pull request template, but please be as descriptive as you can and be patient while waiting for review. 54 | 55 | And hey! You can always open a pull request adding a template if you will. 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What are you reporting? 2 | 3 | 4 | 5 | * [ ] Bug 6 | * [ ] Feature request 7 | * [ ] Code refactor 8 | * [ ] Continuous Integration (CI) improvement 9 | * [ ] Changes in documentation (docs) 10 | * [ ] Other (describe) 11 | 12 | ### What is the current behavior? 13 | 14 | 15 | 16 | ### What is the expected behavior? 17 | 18 | 19 | 20 | ### Sandbox Link 21 | 22 | 27 | 28 | ### What's your environment? 29 | 30 | 38 | 39 | ### Other information 40 | 41 | 45 | -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: Sets up Node.js, npm and caches `node_modules` 3 | 4 | inputs: 5 | install-dependencies: 6 | description: When 'true', dependencies will be installed 7 | default: 'true' 8 | 9 | runs: 10 | using: composite 11 | steps: 12 | - name: Setup Node.js 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | cache: 'npm' 17 | - name: Install dependencies 18 | if: ${{ inputs.install-dependencies == 'true' }} 19 | run: npm install 20 | shell: sh 21 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | concurrency: 9 | group: 'docs' 10 | 11 | permissions: 12 | contents: read 13 | pages: write 14 | 15 | jobs: 16 | build_docs: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: ./.github/actions/setup 21 | - name: Build docs 22 | run: npm run build:docs 23 | - name: Upload GitHub Pages artifact 24 | uses: actions/upload-pages-artifact@v1 25 | with: 26 | path: docs 27 | 28 | deploy_docs: 29 | runs-on: ubuntu-latest 30 | needs: build_docs 31 | env: 32 | name: github-pages 33 | steps: 34 | - name: Deploy to GitHub Pages 35 | id: deployment 36 | uses: actions/deploy-pages@v2 37 | 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | concurrency: 8 | group: 'release' 9 | 10 | permissions: 11 | contents: write 12 | issues: write 13 | pull-requests: write 14 | id-token: write 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: ./.github/actions/setup 22 | - name: Test 23 | run: npm run test 24 | - uses: codecov/codecov-action@v3 25 | with: 26 | token: ${{ secrets.CODECOV_TOKEN }} 27 | 28 | publish_package: 29 | runs-on: ubuntu-latest 30 | needs: test 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: ./.github/actions/setup 34 | - name: Publish package 35 | run: | 36 | npm run build 37 | npx semantic-release 38 | env: 39 | GIT_EMAIL: ${{ vars.GIT_EMAIL }} 40 | GIT_USERNAME: ${{ vars.GIT_USERNAME }} 41 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | 44 | 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: ./.github/actions/setup 18 | - name: Test 19 | run: npm run test 20 | - uses: codecov/codecov-action@v3 21 | 22 | build_package: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: ./.github/actions/setup 27 | - name: Build package 28 | run: npm run build 29 | 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | 3 | # Logs 4 | logs 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Optional npm cache directory 10 | .npm 11 | 12 | # Dependency directories 13 | node_modules/ 14 | jspm_packages/ 15 | bower_components/ 16 | 17 | # Yarn Integrity file 18 | .yarn-integrity 19 | 20 | # Optional eslint cache 21 | .eslintcache 22 | 23 | # dotenv environment variables file(s) 24 | .env 25 | .env.* 26 | 27 | # Test generated 28 | coverage/ 29 | 30 | # Build generated 31 | dist/ 32 | docs/ 33 | 34 | # Serverless generated files 35 | .serverless/ 36 | 37 | ### SublimeText ### 38 | # cache files for sublime text 39 | *.tmlanguage.cache 40 | *.tmPreferences.cache 41 | *.stTheme.cache 42 | 43 | # workspace files are user-specific 44 | *.sublime-workspace 45 | 46 | # project files should be checked into the repository, unless a significant 47 | # proportion of contributors will probably not be using SublimeText 48 | # *.sublime-project 49 | 50 | ### VisualStudioCode ### 51 | .vscode/* 52 | !.vscode/settings.json 53 | !.vscode/tasks.json 54 | !.vscode/launch.json 55 | !.vscode/extensions.json 56 | 57 | ### Vim ### 58 | *.sw[a-p] 59 | 60 | ### WebStorm/IntelliJ ### 61 | /.idea 62 | modules.xml 63 | *.ipr 64 | 65 | ### System Files ### 66 | .DS_Store 67 | 68 | # Windows thumbnail cache files 69 | Thumbs.db 70 | ehthumbs.db 71 | ehthumbs_vista.db 72 | 73 | # Folder config file 74 | Desktop.ini 75 | 76 | # Recycle Bin used on file shares 77 | $RECYCLE.BIN/ 78 | 79 | # Thumbnails 80 | ._* 81 | 82 | # Files that might appear in the root of a volume 83 | .DocumentRevisions-V100 84 | .fseventsd 85 | .Spotlight-V100 86 | .TemporaryItems 87 | .Trashes 88 | .VolumeIcon.icns 89 | .com.apple.timemachine.donotpresent 90 | 91 | # Misc 92 | .DS_Store 93 | .env.local 94 | .env.development.local 95 | .env.test.local 96 | .env.production.local 97 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | .eslint* 3 | .prettierrc 4 | .babelrc 5 | webpack.config.prod.js 6 | commitlint.config.js 7 | package-lock.json 8 | yarn.lock 9 | coverage 10 | .github 11 | examples 12 | src -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [2.0.2](https://github.com/renato-bohler/redux-form-input-masks/compare/v2.0.1...v2.0.2) (2022-02-17) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * **types:** incompatibility from React-Form and Project ([894045a](https://github.com/renato-bohler/redux-form-input-masks/commit/894045a)) 8 | 9 | 10 | 11 | ## [2.0.1](https://github.com/renato-bohler/redux-form-input-masks/compare/v2.0.0...v2.0.1) (2019-02-28) 12 | 13 | ### Development 14 | 15 | * adds TypeScript definitions ([6062a4b3](https://github.com/renato-bohler/redux-form-input-masks/commit/6062a4b3)) 16 | 17 | 18 | 19 | # [2.0.0](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.3.0...v2.0.0) (2019-02-25) 20 | 21 | ### Bug Fixes 22 | 23 | * **createNumberMask:** changes the default empty value to null ([d2f83f7](https://github.com/renato-bohler/redux-form-input-masks/commit/d2f83f7)), closes [#37](https://github.com/renato-bohler/redux-form-input-masks/issues/37) [#37](https://github.com/renato-bohler/redux-form-input-masks/issues/37) 24 | 25 | ### BREAKING CHANGES 26 | 27 | * **createNumberMask:** default empty value is now null instead of empty string 28 | 29 | 30 | 31 | # [1.3.0](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.2.0...v1.3.0) (2018-11-06) 32 | 33 | ### Features 34 | 35 | * **createTextMask:** adds allowEmpty option ([07e713a](https://github.com/renato-bohler/redux-form-input-masks/commit/07e713a)), closes [#53](https://github.com/renato-bohler/redux-form-input-masks/issues/53) 36 | 37 | 38 | 39 | # [1.2.0](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.1.5...v1.2.0) (2018-07-23) 40 | 41 | ### Bug Fixes 42 | 43 | * **createNumberMask:** adds validation for the multiplier option ([dcca4a1](https://github.com/renato-bohler/redux-form-input-masks/commit/dcca4a1)) 44 | 45 | ### Features 46 | 47 | * **createNumberMask:** adds multiplier option ([8e4ea2b](https://github.com/renato-bohler/redux-form-input-masks/commit/8e4ea2b)), closes [#46](https://github.com/renato-bohler/redux-form-input-masks/issues/46) 48 | 49 | 50 | 51 | ## [1.1.5](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.1.4...v1.1.5) (2018-06-26) 52 | 53 | ### Bug Fixes 54 | 55 | * **CreateNumberMask.js:** allow full select delete ([8aa92c9](https://github.com/renato-bohler/redux-form-input-masks/commit/8aa92c9)), closes [#43](https://github.com/renato-bohler/redux-form-input-masks/issues/43) 56 | 57 | 58 | 59 | ## [1.1.4](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.1.3...v1.1.4) (2018-05-24) 60 | 61 | ### Bug Fixes 62 | 63 | * **createTextMask:** fixes onCompletePattern and onChange calls for non stripped masks ([b9569dd](https://github.com/renato-bohler/redux-form-input-masks/commit/b9569dd)), closes [#39](https://github.com/renato-bohler/redux-form-input-masks/issues/39) 64 | 65 | 66 | 67 | ## [1.1.3](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.1.2...v1.1.3) (2018-05-12) 68 | 69 | ### Bug Fixes 70 | 71 | * **allowEmpty:** return empty string also when value is empty string ([894deeb](https://github.com/renato-bohler/redux-form-input-masks/commit/894deeb)) 72 | 73 | 74 | 75 | ## [1.1.2](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.1.1...v1.1.2) (2018-05-08) 76 | 77 | ### Bug Fixes 78 | 79 | * **createTextMask:** fixes onCompletePattern and onChange not being called sometimes ([5081b55](https://github.com/renato-bohler/redux-form-input-masks/commit/5081b55)), closes [#28](https://github.com/renato-bohler/redux-form-input-masks/issues/28) [#25](https://github.com/renato-bohler/redux-form-input-masks/issues/25) 80 | 81 | 82 | 83 | ## [1.1.1](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.1.0...v1.1.1) (2018-05-05) 84 | 85 | ### Bug Fixes 86 | 87 | * **createTextMask:** fixes backspace not controlling caret position correctly on some cases ([4efad32](https://github.com/renato-bohler/redux-form-input-masks/commit/4efad32)), closes [#27](https://github.com/renato-bohler/redux-form-input-masks/issues/27) 88 | 89 | 90 | 91 | # [1.1.0](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.0.1...v1.1.0) (2018-05-03) 92 | 93 | ### Features 94 | 95 | * **CreateNumberMask:** added option allowEmpty ([17cf160](https://github.com/renato-bohler/redux-form-input-masks/commit/17cf160)), closes [#29](https://github.com/renato-bohler/redux-form-input-masks/issues/29) 96 | 97 | 98 | 99 | ## [1.0.1](https://github.com/renato-bohler/redux-form-input-masks/compare/v1.0.0...v1.0.1) (2018-03-14) 100 | 101 | ### Bug Fixes 102 | 103 | * removes babel-polyfill from the bundle ([3c99cd7](https://github.com/renato-bohler/redux-form-input-masks/commit/3c99cd7)), closes [#22](https://github.com/renato-bohler/redux-form-input-masks/issues/22) 104 | 105 | 106 | 107 | # [1.0.0](https://github.com/renato-bohler/redux-form-input-masks/compare/v0.4.1...v1.0.0) (2018-03-12) 108 | 109 | ### Chores 110 | 111 | * first official release :tada: ([ddc0082](https://github.com/renato-bohler/redux-form-input-masks/commit/ddc0082)) 112 | 113 | ### BREAKING CHANGES 114 | 115 | * First official release (v1.0.0) :tada: 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Renato Böhler 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 | -------------------------------------------------------------------------------- /MIGRATING.md: -------------------------------------------------------------------------------- 1 | # Migration guide 2 | 3 | ## From 1.x.x to 2.x.x 4 | 5 | A breaking change had to happen on `redux-form-input-masks@2.0.0` in order to fix both [#37](https://github.com/renato-bohler/redux-form-input-masks/issues/37) and [#64](https://github.com/renato-bohler/redux-form-input-masks/issues/64). The reason is explained on [this comment](https://github.com/renato-bohler/redux-form-input-masks/issues/37#issuecomment-398935472). 6 | 7 | Basically, for `createNumberMask`, if the `allowEmpty` message is set to `true`, the empty value at version `2.x.x` will be `null` instead of `undefined` (or empty string). If you never used `createNumberMask`'s `allowEmpty` option, you should be fine to migrate, otherwise you'll need to test if there's anything broken. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-form-input-masks 2 | 3 |

4 | 5 | github actions build status 6 | 7 | 8 | percentage of code coverage by tests 9 | 10 | 11 | latest release 12 | 13 | 14 | code style: prettier 15 | 16 | 17 | commitizen friendly 18 | 19 | 20 | semantic release 21 | 22 | 23 | license MIT 24 | 25 |

26 | 27 | ## [Documentation and examples](https://renato-bohler.github.io/redux-form-input-masks) 28 | 29 | ## [Migration guide](https://renato-bohler.github.io/redux-form-input-masks/#/migration-guide) 30 | 31 | ## Getting started 32 | 33 | `redux-form-input-masks` is a library that works with [`redux-form`](https://github.com/erikras/redux-form) to easily add masking to `Field`s. 34 | 35 |
36 | Example GIF 37 |
38 | 39 | ## Motivation 40 | 41 | Redux is awesome and so are input masks: they help standardizing inputs and improves the UX of the application. `redux-form` has support for input formatting, parseing and normalizing, but it can get pretty tricky to implement a mask with these functions. `redux-form-input-masks` offer simple APIs to create these masks so you don't need to worry about it! 42 | 43 | Also, the value of the `Field`s in any application should be agnostic of how the `Field`s themselves are presented to the user. For example, if there's a currency field in a form, it makes more sense to store the value as a number. Storing `Field` values in a way that makes more sense for the application makes it easier to integrate form data with backend services and to create validations for it. With `redux-form-input-masks` you can also choose how the value of a formatted `Field` will be stored in the application's store. 44 | 45 | ## Under the hood 46 | 47 | `redux-form-input-masks` returns objects implementing `redux-form`'s [Value Lifecycle Hooks](https://redux-form.com/7.2.3/docs/valuelifecycle.md/) and also some [Global Event Handlers](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers) to manage the caret position. 48 | 49 | ## Installation 50 | 51 | ``` 52 | npm install --save redux-form-input-masks 53 | ``` 54 | 55 | or 56 | 57 | ``` 58 | yarn add redux-form-input-masks 59 | ``` 60 | 61 | ## Features 62 | 63 | * **simple to setup:** works with `redux-form` out of the box, you just need to install `redux-form-input-masks` and you're good to go; 64 | * **simple to use:** import a mask creator and apply it... and that's it. There's no need to change the component you're already using; 65 | * **flexible:** lets you choose how you want the input mask to behave; 66 | * **dependency compatible**: `redux-form-input-masks` works with basically all combinations of versions of `react`, `react-dom`, `react-redux`, `redux` and `redux-form`; 67 | * **browser compatible**: works on all major browsers (Chrome, Firefox, Safari, Edge, Opera, Opera Mini and Internet Explorer >= 10); 68 | * **lightweight:** not a single dependency is added to `redux-form-input-masks`; 69 | * compatible with component libraries like `material-ui` and `redux-form-material-ui`'s wrappers, for both v0-stable and v1-beta versions. 70 | 71 | ## Available masks 72 | 73 | | Name | Description | API Reference | Demo | 74 | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------- | 75 | | Number Mask | Ideal for currency, percentage or any other numeric input. Supports prefix, suffix, locale number formatting and even more options. You can also choose wether the value is stored as `number` or `string`. | [createNumberMask](https://renato-bohler.github.io/redux-form-input-masks/#/number-mask) | [codesandbox.io](https://codesandbox.io/s/k0op1kwywr) | 76 | | Text Mask | Flexible string mask. Lets you specify the pattern, inputtable characters and much more. | [createTextMask](https://renato-bohler.github.io/redux-form-input-masks/#/text-mask) | [codesandbox.io](https://codesandbox.io/s/9o5vyqxn84) | 77 | 78 | ## Usage 79 | 80 | It's super simple to apply a mask using this library. You just need to import your mask creator from `redux-form-input-masks`, specify the parameters and pass it to the `Field` using [spread attributes](https://reactjs.org/docs/jsx-in-depth.html#spread-attributes). Yep, it's that easy. 81 | 82 | You can find several examples including demos on our [documentation](https://renato-bohler.github.io/redux-form-input-masks). 83 | 84 | Here's a simple snippet that uses `createNumberMask` and `createTextMask` and applies them to `Field`s: 85 | 86 | ```jsx 87 | import { createNumberMask, createTextMask } from 'redux-form-input-masks'; 88 | 89 | (...) 90 | 91 | const currencyMask = createNumberMask({ 92 | prefix: 'US$ ', 93 | suffix: ' per item', 94 | decimalPlaces: 2, 95 | locale: 'en-US', 96 | }) 97 | 98 | const phoneMask = createTextMask({ 99 | pattern: '(999) 999-9999', 100 | }); 101 | 102 | (...) 103 | 104 | 110 | 111 | 117 | ``` 118 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'scope-case': [0, 'always'], 5 | 'header-max-length': [0, 'always', 100], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /examples/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015"] 3 | } -------------------------------------------------------------------------------- /examples/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "rules": { 4 | "jsx-a11y/href-no-hash": 0, 5 | "import/no-webpack-loader-syntax": 0, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Check the code 4 | 5 | If you want to check some examples code, enter the `src` folder. 6 | 7 | ## Run examples 8 | 9 | You can run this locally to test the masks by doing 10 | 11 | ``` 12 | npm install 13 | npm start 14 | ``` 15 | -------------------------------------------------------------------------------- /examples/devServer.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const webpack = require('webpack'); 4 | const config = require('./webpack.config.dev'); 5 | 6 | const app = express(); 7 | const compiler = webpack(config); 8 | 9 | app.use( 10 | require('webpack-dev-middleware')(compiler, { 11 | noInfo: true, 12 | publicPath: config.output.publicPath, 13 | }), 14 | ); 15 | 16 | app.use(require('webpack-hot-middleware')(compiler)); 17 | 18 | app.get('*', function(req, res) { 19 | res.sendFile(path.join(__dirname, 'index.html')); 20 | }); 21 | 22 | app.listen(3030, '0.0.0.0', function(err) { 23 | if (err) { 24 | console.log(err); 25 | return; 26 | } 27 | 28 | console.log('Listening at http://localhost:3030'); 29 | }); 30 | -------------------------------------------------------------------------------- /examples/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renato-bohler/redux-form-input-masks/f7d63eaaaa697b05ed4de7cf6ff5fbafd89d134f/examples/example.gif -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | redux-form-input-masks 8 | 9 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | 19 | 808 | 809 | 810 | 811 |
812 |
813 |
814 |
815 |
816 | 817 | 827 | 828 | 829 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "clean": "rimraf dist", 5 | "build:webpack": "cross-env NODE_ENV=production webpack --config webpack.config.prod.js", 6 | "build": "npm run clean && npm run build:webpack", 7 | "lint": "eslint src", 8 | "start": "node devServer.js", 9 | "prepublishOnly": "npm run lint && npm run build" 10 | }, 11 | "dependencies": { 12 | "@material-ui/core": "^1.1.0", 13 | "babel-polyfill": "^6.16.0", 14 | "html-loader": "^0.5.1", 15 | "json-loader": "^0.5.7", 16 | "markdown-loader": "^2.0.0", 17 | "react": "^16.0.0", 18 | "react-dom": "^16.0.0", 19 | "react-github-button": "^0.1.11", 20 | "react-redux": "^5.0.3", 21 | "react-router-dom": "^4.2.2", 22 | "redux": "^3.6.0", 23 | "redux-form": "^7.2.3", 24 | "redux-form-material-ui": "^5.0.0-beta.3", 25 | "redux-form-website-template": "0.0.103", 26 | "semantic-ui-react": "^0.78.2" 27 | }, 28 | "devDependencies": { 29 | "babel-core": "^6.18.2", 30 | "babel-eslint": "^7.2.3", 31 | "babel-loader": "^7.1.2", 32 | "babel-preset-es2015": "^6.18.0", 33 | "babel-preset-react": "^6.16.0", 34 | "cross-env": "^5.1.1", 35 | "eslint": "^4.1.1", 36 | "eslint-config-rackt": "1.1.1", 37 | "eslint-config-react-app": "^2.1.0", 38 | "eslint-loader": "^1.6.1", 39 | "eslint-plugin-babel": "^4.0.0", 40 | "eslint-plugin-flowtype": "^2.34.1", 41 | "eslint-plugin-import": "^2.6.0", 42 | "eslint-plugin-jsx-a11y": "^5.1.1", 43 | "eslint-plugin-react": "^7.1.0", 44 | "eventsource-polyfill": "0.9.6", 45 | "express": "^4.21.1", 46 | "extract-text-webpack-plugin": "^3.0.2", 47 | "file-loader": "^2.0.0", 48 | "redbox-react": "^1.3.3", 49 | "rimraf": "^2.5.4", 50 | "uglifyjs-webpack-plugin": "^1.2.3", 51 | "webpack": "^3.9.1", 52 | "webpack-dev-middleware": "^5.3.4", 53 | "webpack-hot-middleware": "^2.13.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /examples/src/App/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import GitHubButton from 'react-github-button'; 4 | import { version } from '../../../package.json'; 5 | import Prism from './prism'; 6 | 7 | export default class App extends React.Component { 8 | componentDidMount() { 9 | // We need this in order to work with routers 10 | Prism.highlightAll(); 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 |
17 |
18 | redux-form-input-masks 19 | @{version} 20 |
21 |
22 | Input masking with redux-form made easy 23 |
24 |
25 | 31 |
32 |
33 | Getting started 34 | {' | '} 35 | Number mask 36 | {' | '} 37 | Text mask 38 | {' | '} 39 | More examples 40 | {' | '} 41 | Migration guide 42 | {' | '} 43 | Changelog 44 |
45 |
46 |
47 |
{this.props.children}
48 |
49 | Created by{' '} 50 | Renato Böhler 51 |
52 |
53 |
54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/src/App/Demo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default props => ( 4 |

5 | Demo 6 | {props.codesandbox && ( 7 | 8 | Edit on CodeSandbox 12 | 13 | )} 14 |

15 | ); 16 | -------------------------------------------------------------------------------- /examples/src/App/PageNotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './App'; 3 | 4 | export default () => ( 5 | 6 |
7 |
404
8 |
Page not found
9 |
10 |
11 | ); 12 | -------------------------------------------------------------------------------- /examples/src/App/ResultCode.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default props =>

Result code

; 4 | -------------------------------------------------------------------------------- /examples/src/App/index.js: -------------------------------------------------------------------------------- 1 | import App from './App'; 2 | import Demo from './Demo'; 3 | import ResultCode from './ResultCode'; 4 | 5 | export { App, Demo, ResultCode }; 6 | -------------------------------------------------------------------------------- /examples/src/App/prism.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* http://prismjs.com/download.html?themes=prism&languages=markup+css+clike+javascript+json+jsx */ 3 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(){var e=/\blang(?:uage)?-(\w+)\b/i,t=0,n=_self.Prism={util:{encode:function(e){return e instanceof a?new a(e.type,n.util.encode(e.content),e.alias):"Array"===n.util.type(e)?e.map(n.util.encode):e.replace(/&/g,"&").replace(/e.length)break e;if(!(m instanceof a)){u.lastIndex=0;var v=u.exec(m),y=1;if(!v&&h&&p!=r.length-1){var b=r[p+1].matchedStr||r[p+1],k=m+b;if(p=m.length)continue;var _=v.index+v[0].length,P=m.length+b.length;y=3,P>=_&&(y=2,k=k.slice(0,P)),m=k}if(v){g&&(f=v[1].length);var w=v.index+f,v=v[0].slice(f),_=w+v.length,S=m.slice(0,w),O=m.slice(_),j=[p,y];S&&j.push(S);var A=new a(i,c?n.tokenize(v,c):v,d,v);j.push(A),O&&j.push(O),Array.prototype.splice.apply(r,j)}}}}}return r},hooks:{all:{},add:function(e,t){var a=n.hooks.all;a[e]=a[e]||[],a[e].push(t)},run:function(e,t){var a=n.hooks.all[e];if(a&&a.length)for(var r,l=0;r=a[l++];)r(t)}}},a=n.Token=function(e,t,n,a){this.type=e,this.content=t,this.alias=n,this.matchedStr=a||null};if(a.stringify=function(e,t,r){if("string"==typeof e)return e;if("Array"===n.util.type(e))return e.map(function(n){return a.stringify(n,t,e)}).join("");var l={type:e.type,content:a.stringify(e.content,t,r),tag:"span",classes:["token",e.type],attributes:{},language:t,parent:r};if("comment"==l.type&&(l.attributes.spellcheck="true"),e.alias){var i="Array"===n.util.type(e.alias)?e.alias:[e.alias];Array.prototype.push.apply(l.classes,i)}n.hooks.run("wrap",l);var o="";for(var s in l.attributes)o+=(o?" ":"")+s+'="'+(l.attributes[s]||"")+'"';return"<"+l.tag+' class="'+l.classes.join(" ")+'" '+o+">"+l.content+""},!_self.document)return _self.addEventListener?(_self.addEventListener("message",function(e){var t=JSON.parse(e.data),a=t.language,r=t.code,l=t.immediateClose;_self.postMessage(n.highlight(r,n.languages[a],a)),l&&_self.close()},!1),_self.Prism):_self.Prism;var r=document.currentScript||[].slice.call(document.getElementsByTagName("script")).pop();return r&&(n.filename=r.src,document.addEventListener&&!r.hasAttribute("data-manual")&&document.addEventListener("DOMContentLoaded",n.highlightAll)),_self.Prism}();"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 4 | Prism.languages.markup={comment://,prolog:/<\?[\w\W]+?\?>/,doctype://,cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=.$<]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\\1|\\?(?!\1)[\w\W])*\1|[^\s'">=]+))?)*\s*\/?>/i,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=(?:('|")[\w\W]*?(\1)|[^\s>]+)/i,inside:{punctuation:/[=>"']/}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/&#?[\da-z]{1,8};/i},Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Prism.languages.xml=Prism.languages.markup,Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup; 5 | Prism.languages.css={comment:/\/\*[\w\W]*?\*\//,atrule:{pattern:/@[\w-]+?.*?(;|(?=\s*\{))/i,inside:{rule:/@[\w-]+/}},url:/url\((?:(["'])(\\(?:\r\n|[\w\W])|(?!\1)[^\\\r\n])*\1|.*?)\)/i,selector:/[^\{\}\s][^\{\};]*?(?=\s*\{)/,string:/("|')(\\(?:\r\n|[\w\W])|(?!\1)[^\\\r\n])*\1/,property:/(\b|\B)[\w-]+(?=\s*:)/i,important:/\B!important\b/i,"function":/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:]/},Prism.languages.css.atrule.inside.rest=Prism.util.clone(Prism.languages.css),Prism.languages.markup&&(Prism.languages.insertBefore("markup","tag",{style:{pattern:/()[\w\W]*?(?=<\/style>)/i,lookbehind:!0,inside:Prism.languages.css,alias:"language-css"}}),Prism.languages.insertBefore("inside","attr-value",{"style-attr":{pattern:/\s*style=("|').*?\1/i,inside:{"attr-name":{pattern:/^\s*style/i,inside:Prism.languages.markup.tag.inside},punctuation:/^\s*=\s*['"]|['"]\s*$/,"attr-value":{pattern:/.+/i,inside:Prism.languages.css}},alias:"language-css"}},Prism.languages.markup.tag)); 6 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\w\W]*?\*\//,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0}],string:{pattern:/(["'])(\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/((?:\b(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/i,lookbehind:!0,inside:{punctuation:/(\.|\\)/}},keyword:/\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,"boolean":/\b(true|false)\b/,"function":/[a-z0-9_]+(?=\()/i,number:/\b-?(?:0x[\da-f]+|\d*\.?\d+(?:e[+-]?\d+)?)\b/i,operator:/--?|\+\+?|!=?=?|<=?|>=?|==?=?|&&?|\|\|?|\?|\*|\/|~|\^|%/,punctuation:/[{}[\];(),.:]/}; 7 | Prism.languages.javascript=Prism.languages.extend("clike",{keyword:/\b(as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/,number:/\b-?(0x[\dA-Fa-f]+|0b[01]+|0o[0-7]+|\d*\.?\d+([Ee][+-]?\d+)?|NaN|Infinity)\b/,"function":/[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*(?=\()/i}),Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/(^|[^\/])\/(?!\/)(\[.+?]|\\.|[^\/\\\r\n])+\/[gimyu]{0,5}(?=\s*($|[\r\n,.;})]))/,lookbehind:!0,greedy:!0}}),Prism.languages.insertBefore("javascript","class-name",{"template-string":{pattern:/`(?:\\\\|\\?[^\\])*?`/,inside:{interpolation:{pattern:/\$\{[^}]+\}/,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}}}),Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{script:{pattern:/()[\w\W]*?(?=<\/script>)/i,lookbehind:!0,inside:Prism.languages.javascript,alias:"language-javascript"}}),Prism.languages.js=Prism.languages.javascript; 8 | Prism.languages.json={property:/".*?"(?=\s*:)/gi,string:/"(?!:)(\\?[^"])*?"(?!:)/g,number:/\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?)\b/g,punctuation:/[{}[\]);,]/g,operator:/:/g,"boolean":/\b(true|false)\b/gi,"null":/\bnull\b/gi},Prism.languages.jsonp=Prism.languages.json; 9 | !function(a){var e=a.util.clone(a.languages.javascript);a.languages.jsx=a.languages.extend("markup",e),a.languages.jsx.tag.pattern=/<\/?[\w\.:-]+\s*(?:\s+[\w\.:-]+(?:=(?:("|')(\\?[\w\W])*?\1|[^\s'">=]+|(\{[\w\W]*?\})))?\s*)*\/?>/i,a.languages.jsx.tag.inside["attr-value"].pattern=/=[^\{](?:('|")[\w\W]*?(\1)|[^\s>]+)/i;var s=a.util.clone(a.languages.jsx);delete s.punctuation,s=a.languages.insertBefore("jsx","operator",{punctuation:/=(?={)|[{}[\];(),.:]/},{jsx:s}),a.languages.insertBefore("inside","attr-value",{script:{pattern:/=(\{(?:\{[^}]*\}|[^}])+\})/i,inside:s,alias:"language-javascript"}},a.languages.jsx.tag)}(Prism); 10 | /* eslint-enable */ -------------------------------------------------------------------------------- /examples/src/Changelog/Changelog.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Markdown } from 'redux-form-website-template'; 3 | import { App } from '../App'; 4 | import changelog from '../../../CHANGELOG.md'; 5 | 6 | const Changelog = () => ( 7 | 8 | 16 |

Changelog

17 | 18 |
19 | ); 20 | 21 | export default Changelog; 22 | -------------------------------------------------------------------------------- /examples/src/CreateNumberMask/CreateNumberMask.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Field, reduxForm, formValueSelector } from 'redux-form'; 4 | import { createNumberMask } from '../../../src/index'; 5 | import { Code, Markdown, Values } from 'redux-form-website-template'; 6 | import { App, Demo, ResultCode } from '../App'; 7 | import documentation from './CreateNumberMask.md'; 8 | /** material-ui@1 */ 9 | import { TextField } from 'redux-form-material-ui'; 10 | import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'; 11 | import { orange } from '@material-ui/core/colors'; 12 | 13 | const muiTheme = createMuiTheme({ 14 | palette: { 15 | primary: orange, 16 | }, 17 | }); 18 | 19 | const selector = formValueSelector('numberMask'); 20 | 21 | const basic = createNumberMask({ 22 | prefix: 'US$ ', 23 | suffix: ' per item', 24 | decimalPlaces: 2, 25 | locale: 'en-US', 26 | }); 27 | 28 | const converted = createNumberMask({ 29 | prefix: 'US$ ', 30 | decimalPlaces: 2, 31 | stringValue: true, 32 | }); 33 | 34 | const frLocale = createNumberMask({ 35 | prefix: '€ ', 36 | decimalPlaces: 2, 37 | locale: 'fr', 38 | }); 39 | 40 | const percentage = createNumberMask({ 41 | suffix: '%', 42 | decimalPlaces: 2, 43 | multiplier: 1 / 100, 44 | }); 45 | 46 | const negative = createNumberMask({ 47 | decimalPlaces: 3, 48 | allowNegative: true, 49 | }); 50 | 51 | const validation = value => (value > 10 ? 'Maximum value is 10' : ''); 52 | 53 | let CreateNumberMask = props => { 54 | // createNumberMask on try/catch to build custom mask 55 | let safeNumberMask; 56 | let customizedCode; 57 | let error; 58 | try { 59 | safeNumberMask = createNumberMask({ 60 | prefix: props.prefix, 61 | suffix: props.suffix, 62 | decimalPlaces: props.decimalPlaces, 63 | multiplier: props.multiplier, 64 | stringValue: props.stringValue, 65 | allowEmpty: props.allowEmpty, 66 | allowNegative: props.allowNegative, 67 | showPlusSign: props.showPlusSign, 68 | spaceAfterSign: props.spaceAfterSign, 69 | locale: props.locale, 70 | }); 71 | 72 | customizedCode = 73 | "import { createNumberMask } from 'redux-form-input-masks';\n\n" + 74 | 'const myCustomNumberMask = createNumberMask({\n' + 75 | (props.prefix !== '' ? ` prefix: '${props.prefix}',\n` : '') + 76 | (props.suffix !== '' ? ` suffix: '${props.suffix}',\n` : '') + 77 | (props.decimalPlaces !== '0' 78 | ? ` decimalPlaces: ${props.decimalPlaces},\n` 79 | : '') + 80 | (props.multiplier !== 1 ? ` multiplier: ${props.multiplier},\n` : '') + 81 | (props.stringValue !== false 82 | ? ` stringValue: ${props.stringValue},\n` 83 | : '') + 84 | (props.allowEmpty !== false 85 | ? ` allowEmpty: ${props.allowEmpty},\n` 86 | : '') + 87 | (props.allowNegative !== false 88 | ? ` allowNegative: ${props.allowNegative},\n` 89 | : '') + 90 | (props.showPlusSign !== false 91 | ? ` showPlusSign: ${props.showPlusSign},\n` 92 | : '') + 93 | (props.spaceAfterSign !== false 94 | ? ` spaceAfterSign: ${props.spaceAfterSign},\n` 95 | : '') + 96 | (props.locale !== undefined ? ` locale: '${props.locale}',\n` : '') + 97 | ' // onChange: value => console.log(value),\n' + 98 | '});'; 99 | } catch (e) { 100 | customizedCode = '// Fix the errors above to generate the code'; 101 | error = e.message; 102 | } 103 | return ( 104 | 105 | 116 | 117 | 118 | 119 |
120 |
121 |

Basic

122 | 123 |
124 |
125 |

Value converted to string

126 | 127 |
128 |
129 |

French number formatting

130 | 131 |
132 |
133 |

Percentage (1/100 multiplier)

134 | 140 |
141 |
142 |

Allow negative

143 | 144 |
145 |

Validation

146 |
147 |

Maximum value is 10

148 | 149 | 156 | 157 |
158 |
159 | 160 |
161 |

Build your own

162 | {error && ( 163 |
164 | Error: 165 | {error} 166 |
167 | )} 168 |
169 |

Customized

170 | 176 |
177 |
178 |
179 | 185 |
186 |
187 |
188 | 194 |
195 |
196 |
197 | 205 |
206 |
207 |
208 | 215 |
216 |
217 |
218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 |
226 |
227 |
228 | 232 |
233 |
234 |
235 | 239 |
240 |
241 |
242 | 246 |
247 |
248 |
249 | 253 |
254 |
255 |
256 | 260 |
261 | 262 | 263 | 264 | 265 | ); 266 | }; 267 | 268 | const mapStateToProps = state => 269 | selector( 270 | state, 271 | 'prefix', 272 | 'suffix', 273 | 'decimalPlaces', 274 | 'multiplier', 275 | 'stringValue', 276 | 'allowEmpty', 277 | 'allowNegative', 278 | 'showPlusSign', 279 | 'spaceAfterSign', 280 | 'locale', 281 | ); 282 | 283 | CreateNumberMask = connect(mapStateToProps)(CreateNumberMask); 284 | 285 | export default reduxForm({ 286 | form: 'numberMask', 287 | initialValues: { 288 | negative: -1.234, 289 | decimalPlaces: 2, 290 | multiplier: 1, 291 | prefix: '', 292 | suffix: '', 293 | stringValue: false, 294 | allowEmpty: false, 295 | allowNegative: false, 296 | showPlusSign: false, 297 | spaceAfterSign: false, 298 | }, 299 | })(CreateNumberMask); 300 | -------------------------------------------------------------------------------- /examples/src/CreateNumberMask/CreateNumberMask.md: -------------------------------------------------------------------------------- 1 | # Number Mask 2 | 3 | This mask is ideal for currency, percentage or any other number format you may come across. It is possible to add a prefix, suffix, choose the amount of decimal places, choose if it should allow negative values, the locale to format the number and the data type to store the value (`number` or `string`). 4 | 5 | It is also possible to use this mask to use on more elaborated cases. Check an euro to bitcoin conversion example at [`more examples`](#/more). 6 | 7 | **Note:** we recommend using `type="tel"` on the formatted field so that on mobile the keypad shows up instead of the regular keyboard. 8 | 9 | ## Config options 10 | 11 | `createNumberMask` accepts an `options` object with the keys described in this section. 12 | 13 | ```jsx 14 | createNumberMask({ 15 | prefix: '', 16 | suffix: '', 17 | decimalPlaces: 0, 18 | multiplier: 1, 19 | allowEmpty: false, 20 | allowNegative: false, 21 | showPlusSign: false, 22 | spaceAfterSign: false, 23 | stringValue: false, 24 | locale, // defaults to browser's locale when undefined 25 | onChange: updatedValue => {}, 26 | }); 27 | ``` 28 | 29 | | Key | Type | Required | Default | Description | 30 | | -------------- | ---------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | 31 | | prefix | `string` | no | `''` | The input's prefix. | 32 | | suffix | `string` | no | `''` | The input's suffix. | 33 | | decimalPlaces | `number` | no | `0` | The amount of numbers following the decimal point. **Maximum value is 10.** | 34 | | multiplier | `number` | no | `1` | The multiplier to be used before storing the value. Useful for percentage formatting using `multiplier: 1/100`. | 35 | | allowEmpty | `boolean` | no | `false` | If true, the empty value will be stored as undefined and formated as empty string. | 36 | | allowNegative | `boolean` | no | `false` | If true, the value will be negated when the user types `-`. | 37 | | showPlusSign | `boolean` | no | `false` | If true, a plus sign (`+`) will be put before the prefix when the value is positive. | 38 | | spaceAfterSign | `boolean` | no | `false` | If true, a space will be put after the sign if the sign is visible. | 39 | | stringValue | `boolean` | no | `false` | If true, the value on the store will be converted to string. | 40 | | locale | `string` | no | `undefined` | The locale to format the number in the input. `undefined` will take the browser's locale. Examples: `en-US`, `fr`, `de`, `pt-BR`, `jp`. | 41 | | onChange | `function` | no | `undefined` | You can pass a function which receives the updated value to do your stuff. Example: `updatedValue => console.log(updatedValue)` | 42 | 43 | ## Usage 44 | 45 | You just need to import `createNumberMask` from `redux-form-input-masks`, specify the parameters and pass it to the `Field` using [spread attributes](https://reactjs.org/docs/jsx-in-depth.html#spread-attributes), just like that: 46 | 47 | ```jsx 48 | import { Field } from 'redux-form'; 49 | import { createNumberMask } from 'redux-form-input-masks'; 50 | 51 | const currencyMask = createNumberMask({ 52 | prefix: 'US$ ', 53 | suffix: ' per item', 54 | decimalPlaces: 2, 55 | locale: 'en-US', 56 | }); 57 | 58 | const inputUSDPerItem = () => ( 59 | 60 | ); 61 | ``` 62 | 63 | You could also call the function direcly inside the `Field`, if you need dynamically change the mask. 64 | 65 | ```jsx 66 | 84 | ``` 85 | 86 | ## Validation 87 | 88 | It is easy to create `redux-form`'s validations to any `Field` formatted with `createNumberMask`. Validation functions can be as simple as 89 | 90 | ```jsx 91 | const validation = value => value > 10 ? 'Maximum value is 10' : ''; 92 | 93 | (...) 94 | 95 | 102 | ``` 103 | -------------------------------------------------------------------------------- /examples/src/CreateTextMask/CreateTextMask.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Field, reduxForm, formValueSelector, change } from 'redux-form'; 4 | import { createTextMask } from '../../../src/index'; 5 | import { Code, Markdown, Values } from 'redux-form-website-template'; 6 | import { App, Demo, ResultCode } from '../App'; 7 | import documentation from './CreateTextMask.md'; 8 | 9 | const selector = formValueSelector('textMask'); 10 | 11 | const guidedStripped = createTextMask({ 12 | pattern: '(999) 999-9999', 13 | allowEmpty: true, 14 | }); 15 | 16 | const notGuided = createTextMask({ 17 | pattern: '(999) 999-9999', 18 | guide: false, 19 | }); 20 | 21 | const notGuidedAllowEmpty = createTextMask({ 22 | pattern: '(999) 999-9999', 23 | guide: false, 24 | allowEmpty: true, 25 | }); 26 | 27 | const notStripped = createTextMask({ 28 | pattern: '(999) 999-9999', 29 | stripMask: false, 30 | }); 31 | 32 | const norGuidedOrStripped = createTextMask({ 33 | pattern: '(999) 999-9999', 34 | guide: false, 35 | stripMask: false, 36 | }); 37 | 38 | let CreateTextMask = props => { 39 | // createTextMask on try/catch to build custom mask 40 | let safeTextMask; 41 | let customizedCode; 42 | let error; 43 | try { 44 | safeTextMask = createTextMask({ 45 | pattern: props.pattern, 46 | placeholder: props.placeholder, 47 | guide: props.guide, 48 | stripMask: props.stripMask, 49 | allowEmpty: props.allowEmpty, 50 | }); 51 | 52 | customizedCode = 53 | "import { createTextMask } from 'redux-form-input-masks';\n\n" + 54 | 'const myCustomTextMask = createTextMask({\n' + 55 | ` pattern: '${props.pattern}',\n` + 56 | (props.placeholder !== '_' 57 | ? ` placeholder: '${props.placeholder}',\n` 58 | : '') + 59 | (props.guide !== true ? ` guide: ${props.guide},\n` : '') + 60 | (props.stripMask !== true ? ` stripMask: ${props.stripMask},\n` : '') + 61 | (props.allowEmpty !== false 62 | ? ` allowEmpty: ${props.allowEmpty},\n` 63 | : '') + 64 | ' // maskDefinitions: myCustomMaskDefinitions,\n' + 65 | ' // onChange: value => console.log(value),\n' + 66 | ' // onCompletePattern: value => console.log(value),\n' + 67 | '});'; 68 | } catch (e) { 69 | customizedCode = '// Fix the errors above to generate the code'; 70 | error = e.message; 71 | } 72 | 73 | return ( 74 | 75 | 86 | 87 | 88 | 89 |
90 |
91 |

92 | Phone number (guided and stripped) 93 |

94 | 100 |
101 |
102 |

103 | Phone number (not guided) 104 |

105 | 106 |
107 |
108 |

109 | Phone number (not guided, allow empty) 110 |

111 | 117 |
118 |
119 |

120 | Phone number (not stripped) 121 |

122 | 128 |
129 |
130 |

131 | Phone number (nor guided or stripped) 132 |

133 | 139 |
140 |
141 |

142 | Transform to random (AAA-999) 143 |

144 | { 154 | const possibleChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 155 | const randomIndex = Math.floor( 156 | Math.random() * possibleChars.length, 157 | ); 158 | return possibleChars.charAt(randomIndex); 159 | }, 160 | }, 161 | 9: { 162 | regExp: /[0-9]/, 163 | transform: () => Math.floor(Math.random() * 9), 164 | }, 165 | }, 166 | })} 167 | /> 168 |
169 |
170 | 171 |
172 |

Build your own

173 | {error && ( 174 |
175 | Error: 176 | {error} 177 |
178 | )} 179 |
180 |

Customized

181 | 187 |
188 |
189 |

190 | 191 | using defaultMaskDefinitions (see above) 192 | 193 |

194 | props.clearCustomValue()} 200 | /> 201 |
202 |
203 |
204 | props.clearCustomValue()} 210 | /> 211 |
212 |
213 |
214 | 223 |
224 |
225 |
226 | 235 |
236 |
237 |
238 | 247 |
248 | 249 | 250 | 251 | 252 | ); 253 | }; 254 | 255 | const mapStateToProps = state => 256 | selector(state, 'pattern', 'placeholder', 'guide', 'stripMask', 'allowEmpty'); 257 | 258 | const mapDispatchToProps = dispatch => ({ 259 | clearCustomValue: () => dispatch(change('textMask', 'customized', '')), 260 | }); 261 | 262 | CreateTextMask = connect(mapStateToProps, mapDispatchToProps)(CreateTextMask); 263 | 264 | export default reduxForm({ 265 | form: 'textMask', 266 | initialValues: { 267 | pattern: '(999) 999-9999', 268 | placeholder: '_', 269 | guide: true, 270 | stripMask: true, 271 | allowEmpty: false, 272 | }, 273 | })(CreateTextMask); 274 | -------------------------------------------------------------------------------- /examples/src/CreateTextMask/CreateTextMask.md: -------------------------------------------------------------------------------- 1 | # Text Mask 2 | 3 | The text mask is designed to be easily used in any kind of string formatted inputs, like telephone numbers, zip codes, credit card numbers and so on. You can build your own text mask with ease: the only required parameter is the `pattern`. It is also possible to customize the `placeholder` and specify `maskDefinitions`, if the `guide` should show or not and if the value stored should be stripped or not. 4 | 5 | It is also possible to specify an `onChange` function (to be called every time the value changes) and `onCompletePattern` (to be called when the pattern is completely filled by the user). There's an example of 16 digits credit card validation at [`more examples`](#/more). 6 | 7 | **Note:** we recommend using `type="tel"` on only numeric fields, so that on mobile the keypad shows up instead of the regular keyboard. 8 | 9 | ## Config options 10 | 11 | `createTextMask` accepts an `options` object with the keys described in this section. 12 | 13 | ```jsx 14 | createTextMask({ 15 | pattern, // required 16 | placeholder: '_', 17 | maskDefinitions: defaultMaskDefinitions, // see below 18 | guide: true, 19 | stripMask: true, 20 | allowEmpty: false, 21 | onChange: value => {}, 22 | onCompletePattern: value => {}, 23 | }); 24 | ``` 25 | 26 | | Key | Type | Required | Default | Description | 27 | | ----------------- | ---------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------- | 28 | | pattern | `string` | yes | | The input's pattern. Example: `(999) 999-9999`, where the character `9` is specified in the `maskDefinitions` | 29 | | placeholder | `string` | no | `'_'` | The placeholder to fill the guide. It should be a single character | 30 | | maskDefinitions | see below | no | see below | An object with the inputtable characters for the `pattern`. Check the section below for more info | 31 | | guide | `boolean` | no | `true` | If true, the non inputted part of the mask will be shown and the inputtable characters of the `pattern` will be replaced by the `placeholder` | 32 | | stripMask | `boolean` | no | `true` | If true, the value on the store will not contain any characters that the user didn't input | 33 | | allowEmpty | `boolean` | no | `false` | If true, when the stored value is empty, the mask will not be shown. If guide is set to true, the guide will be shown | 34 | | onChange | `function` | no | `undefined` | You can pass a function which receives the updated value upon change. Example: `updatedValue => console.log(updatedValue)` | 35 | | onCompletePattern | `function` | no | `undefined` | You can pass a function which receives the updated value upon completing the `pattern`. Example: `updatedValue => validate(updatedValue)` | 36 | 37 | ## Mask definitions 38 | 39 | Mask definitions is simply an `object` which contains keys for any character that should be inputtable on the `pattern`. Every character has to specify an `regExp` key, containing a regular expression to determine wether a input should be allowed or not for this mask definition. You can go even further and define a `transform` for it, which is a hook that will modify the inputted character. 40 | 41 | So, for example, if you are willing to build a custom mask, your code could look like this: 42 | 43 | ```jsx 44 | const myCustomMaskDefinitions = { 45 | 9: { 46 | regExp: /[0-9]/, 47 | }, 48 | A: { 49 | regExp: /[A-Za-z]/, 50 | transform: char => char.toUpperCase(), 51 | }, 52 | }; 53 | 54 | const myTextMask = createTextMask({ 55 | pattern: '999-AAA', 56 | maskDefinitions: myCustomMaskDefinitions, 57 | }); 58 | ``` 59 | 60 | This means that `myTextMask` would have the guide `___-___` and would accept: 61 | 62 | * any numeric character (`/[0-9]/`) for the first three positions; 63 | * any upper or lowercase letter from A to Z (`/[A-Za-z]/`), transforming it to uppercase for the three last positions. 64 | 65 | Luckily, the `maskDefinitions` option is not required for `createTextMask` as it has a default value covering common use cases: 66 | 67 | ```jsx 68 | const defaultMaskDefinitions = { 69 | // Accepts both uppercase and lowercase and transform to uppercase 70 | A: { 71 | regExp: /[A-Za-z]/, 72 | transform: char => char.toUpperCase(), 73 | }, 74 | // Accepts both uppercase and lowercase and transform to lowercase 75 | a: { 76 | regExp: /[A-Za-z]/, 77 | transform: char => char.toLowerCase(), 78 | }, 79 | // Accepts only uppercase 80 | U: { 81 | regExp: /[A-Z]/, 82 | }, 83 | // Accepts only lowercase 84 | l: { 85 | regExp: /[a-z]/, 86 | }, 87 | // Numbers 88 | 9: { 89 | regExp: /[0-9]/, 90 | }, 91 | }; 92 | ``` 93 | 94 | ## Usage 95 | 96 | You just need to import `createTextMask` from `redux-form-input-masks`, specify the parameters and pass it to the `Field` using [spread attributes](https://reactjs.org/docs/jsx-in-depth.html#spread-attributes), just like that: 97 | 98 | ```jsx 99 | import { Field } from 'redux-form'; 100 | import { createTextMask } from 'redux-form-input-masks'; 101 | 102 | const phoneMask = createTextMask({ 103 | pattern: '(999) 999-9999', 104 | }); 105 | 106 | const inputPhone = () => ( 107 | 108 | ); 109 | ``` 110 | -------------------------------------------------------------------------------- /examples/src/GettingStarted/GettingStarted.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Field, reduxForm, change } from 'redux-form'; 4 | import { createNumberMask, createTextMask } from '../../../src/index'; 5 | import { Markdown } from 'redux-form-website-template'; 6 | import { App, Demo } from '../App'; 7 | import documentation from './GettingStarted.md'; 8 | 9 | const currencyMask = createNumberMask({ 10 | prefix: 'US$ ', 11 | suffix: ' per item', 12 | decimalPlaces: 2, 13 | locale: 'en-US', 14 | }); 15 | 16 | const phoneMask = createTextMask({ 17 | pattern: '(999) 999-9999', 18 | }); 19 | 20 | let GettingStarted = props => { 21 | return ( 22 | 23 | 28 | 29 | 30 |
31 |
32 |

Amount

33 | 34 |
35 |
36 |

Phone number

37 | 38 |
39 |
40 |
41 | ); 42 | }; 43 | 44 | const mapStateToProps = undefined; 45 | 46 | const mapDispatchToProps = dispatch => ({ 47 | change: (form, field, value) => dispatch(change(form, field, value)), 48 | }); 49 | 50 | GettingStarted = connect(mapStateToProps, mapDispatchToProps)(GettingStarted); 51 | 52 | export default reduxForm({ 53 | form: 'gettingStarted', 54 | })(GettingStarted); 55 | -------------------------------------------------------------------------------- /examples/src/GettingStarted/GettingStarted.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | This page contains the documentation and some working examples of `redux-form-input-masks`, which is a library that works with [`redux-form`](https://github.com/erikras/redux-form) to easily add masking to `Field`s. 4 | 5 | ## Motivation 6 | 7 | Redux is awesome and so are input masks: they help standardizing inputs and improves the UX of the application. `redux-form` has support for input formatting, parseing and normalizing, but it can get pretty tricky to implement a mask with these functions. `redux-form-input-masks` offer simple APIs to create these masks so you don't need to worry about it! 8 | 9 | Also, the value of the `Field`s in any application should be agnostic of how the `Field`s themselves are presented to the user. For example, if there's a currency field in a form, it makes more sense to store the value as a number. Storing `Field` values in a way that makes more sense for the application makes it easier to integrate form data with backend services and to create validations for it. With `redux-form-input-masks` you can also choose how the value of a formatted `Field` will be stored in the application's store. 10 | 11 | ## Under the hood 12 | 13 | `redux-form-input-masks` returns objects implementing `redux-form`'s [Value Lifecycle Hooks](https://redux-form.com/7.2.3/docs/valuelifecycle.md/) and also some [Global Event Handlers](https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers) to manage the caret position. 14 | 15 | ## Installation 16 | 17 | ``` 18 | npm install --save redux-form-input-masks 19 | ``` 20 | 21 | or 22 | 23 | ``` 24 | yarn add redux-form-input-masks 25 | ``` 26 | 27 | ## Features 28 | 29 | * **simple to setup:** works with `redux-form` out of the box, you just need to install `redux-form-input-masks` and you're good to go; 30 | * **simple to use:** import a mask creator and apply it... and that's it. There's no need to change the component you're already using; 31 | * **flexible:** lets you choose how you want the input mask to behave; 32 | * **dependency compatible**: `redux-form-input-masks` works with basically all combinations of versions of `react`, `react-dom`, `react-redux`, `redux` and `redux-form`; 33 | * **browser compatible**: works on all major browsers (Chrome, Firefox, Safari, Edge, Opera, Opera Mini and Internet Explorer >= 10); 34 | * **lightweight:** not a single dependency is added to `redux-form-input-masks`; 35 | * compatible with component libraries like `material-ui` and `redux-form-material-ui`'s wrappers, for both v0-stable and v1-beta versions. 36 | 37 | ## Available masks 38 | 39 | | Name | Description | API Reference | Demo | 40 | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------- | 41 | | Number Mask | Ideal for currency, percentage or any other numeric input. Supports prefix, suffix, locale number formatting and even more options. You can also choose wether the value is stored as `number` or `string`. | [createNumberMask](https://renato-bohler.github.io/redux-form-input-masks/#/number-mask) | [codesandbox.io](https://codesandbox.io/s/k0op1kwywr) | 42 | | Text Mask | Flexible string mask. Lets you specify the pattern, inputtable characters and much more. | [createTextMask](https://renato-bohler.github.io/redux-form-input-masks/#/text-mask) | [codesandbox.io](https://codesandbox.io/s/9o5vyqxn84) | 43 | 44 | ## Usage 45 | 46 | It's super simple to apply a mask using this library. You just need to import your mask creator from `redux-form-input-masks`, specify the parameters and pass it to the `Field` using [spread attributes](https://reactjs.org/docs/jsx-in-depth.html#spread-attributes). Yep, it's that easy. 47 | 48 | Here's a simple snippet that uses `createNumberMask` and `createTextMask` and applies them to `Field`s: 49 | 50 | ```jsx 51 | import { createNumberMask, createTextMask } from 'redux-form-input-masks'; 52 | 53 | (...) 54 | 55 | const currencyMask = createNumberMask({ 56 | prefix: 'US$ ', 57 | suffix: ' per item', 58 | decimalPlaces: 2, 59 | locale: 'en-US', 60 | }) 61 | 62 | const phoneMask = createTextMask({ 63 | pattern: '(999) 999-9999', 64 | }); 65 | 66 | (...) 67 | 68 | 74 | 75 | 81 | ``` 82 | -------------------------------------------------------------------------------- /examples/src/Migrating/Migrating.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Markdown } from 'redux-form-website-template'; 3 | import { App } from '../App'; 4 | import migrating from '../../../MIGRATING.md'; 5 | 6 | const Migrating = () => ( 7 | 8 | 16 | 17 | 18 | ); 19 | 20 | export default Migrating; 21 | -------------------------------------------------------------------------------- /examples/src/MoreExamples/MoreExamples.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Field, reduxForm, change } from 'redux-form'; 4 | import { createNumberMask, createTextMask } from '../../../src/index'; 5 | import { Markdown, Values } from 'redux-form-website-template'; 6 | import { App, Demo } from '../App'; 7 | import documentation from './MoreExamples.md'; 8 | /** material-ui@1 */ 9 | import { TextField } from 'redux-form-material-ui'; 10 | import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'; 11 | import { orange } from '@material-ui/core/colors'; 12 | /** semantic-ui-react */ 13 | import { Input } from 'semantic-ui-react'; 14 | 15 | const muiTheme = createMuiTheme({ 16 | palette: { 17 | primary: orange, 18 | }, 19 | }); 20 | 21 | const basic = createNumberMask({ 22 | prefix: 'US$ ', 23 | suffix: ' per item', 24 | decimalPlaces: 2, 25 | locale: 'en-US', 26 | }); 27 | 28 | // Luhn algorithm. Adapted from https://stackoverflow.com/a/23222600 29 | const validateCardNumber = number => { 30 | const regex = new RegExp('^[0-9]{16}$'); 31 | if (!regex.test(number)) return false; 32 | 33 | let sum = 0; 34 | for (let i = 0; i < number.length; i += 1) { 35 | var intVal = parseInt(number.substr(i, 1), 10); 36 | if (i % 2 === 0) { 37 | intVal *= 2; 38 | if (intVal > 9) { 39 | intVal = 1 + intVal % 10; 40 | } 41 | } 42 | sum += intVal; 43 | } 44 | return sum % 10 === 0; 45 | }; 46 | 47 | const creditCard = createTextMask({ 48 | pattern: '9999 - 9999 - 9999 - 9999', 49 | placeholder: '_', 50 | onCompletePattern: value => { 51 | if (validateCardNumber(value)) { 52 | window.alert('This credit card number is valid!'); 53 | } else { 54 | window.alert("This credit card number isn't valid!"); 55 | } 56 | }, 57 | }); 58 | 59 | const conversionRate = 6800; 60 | 61 | let MoreExamples = props => { 62 | const btcChange = btc => { 63 | props.change('EUR', btc * conversionRate); 64 | }; 65 | 66 | const eurChange = eur => { 67 | props.change('BTC', eur / conversionRate); 68 | }; 69 | 70 | const btcMask = createNumberMask({ 71 | prefix: 'BTC ', 72 | decimalPlaces: 5, 73 | locale: 'en-US', 74 | onChange: btcChange, 75 | }); 76 | 77 | const eurMask = createNumberMask({ 78 | suffix: ' €', 79 | decimalPlaces: 2, 80 | locale: 'de', 81 | onChange: eurChange, 82 | }); 83 | 84 | return ( 85 | 86 | 91 | 92 | 93 | 94 |
95 |

Integration with component libraries

96 |
97 |

material-ui@1

98 | 99 | 105 | 106 |
107 |
108 |

semantic-ui-react

109 | 117 |
118 |

Conversion EUR {'<=>'} BTC

119 |
120 | 121 | 122 |
123 |

Credit card (16 digits) with validation

124 |
125 |

126 | 127 | this is valid: 3530 1113 3330 0000 128 | 129 |

130 | 136 |
137 |
138 | 139 |
140 | ); 141 | }; 142 | 143 | const mapStateToProps = undefined; 144 | 145 | const mapDispatchToProps = dispatch => ({ 146 | change: (field, value) => dispatch(change('moreExamples', field, value)), 147 | }); 148 | 149 | MoreExamples = connect(mapStateToProps, mapDispatchToProps)(MoreExamples); 150 | 151 | export default reduxForm({ 152 | form: 'moreExamples', 153 | })(MoreExamples); 154 | -------------------------------------------------------------------------------- /examples/src/MoreExamples/MoreExamples.md: -------------------------------------------------------------------------------- 1 | # More examples 2 | 3 | ## Integration with component libraries 4 | 5 | Our input masks are also easily integrated with component libraries. 6 | 7 | * [`material-ui@0`](https://v0.material-ui.com/#/) (v0.x-stable) with [`redux-form-material-ui`](http://erikras.github.io/redux-form-material-ui/) wrappers 8 | * [`material-ui@1`](https://material-ui.com/) (v1.x-stable) with [`redux-form-material-ui@5.0.0-beta.3`](https://github.com/erikras/redux-form-material-ui/tree/5.0) wrappers 9 | * [`semantic-ui-react`](https://react.semantic-ui.com) 10 | 11 | ## Conversion EUR <=> BTC 12 | 13 | The following is a use case for [`createNumberMask`](#/number-mask). It consists of two inputs that convert bitcoins to euros and vice versa. Check the demo below the code. Please note that this conversion does not reflect real conversion rates. 14 | 15 | ```jsx 16 | import React from 'react'; 17 | import { connect } from 'react-redux'; 18 | import { Field, reduxForm, change } from 'redux-form'; 19 | import { createNumberMask } from 'redux-form-input-masks'; 20 | 21 | const conversionRate = 6800; 22 | 23 | let GettingStarted = props => { 24 | const btcChange = btc => { 25 | props.change('gettingStarted', 'EUR', btc * conversionRate); 26 | }; 27 | 28 | const eurChange = eur => { 29 | props.change('gettingStarted', 'BTC', eur / conversionRate); 30 | }; 31 | 32 | const btcMask = createNumberMask({ 33 | prefix: 'BTC ', 34 | decimalPlaces: 5, 35 | locale: 'en-US', 36 | onChange: btcChange, 37 | }); 38 | 39 | const eurMask = createNumberMask({ 40 | suffix: ' €', 41 | decimalPlaces: 2, 42 | locale: 'de', 43 | onChange: eurChange, 44 | }); 45 | 46 | return ( 47 |
48 |
49 | 50 | 51 |
52 |
53 | ); 54 | }; 55 | 56 | const mapStateToProps = undefined; 57 | 58 | const mapDispatchToProps = dispatch => ({ 59 | change: (form, field, value) => dispatch(change(form, field, value)), 60 | }); 61 | 62 | GettingStarted = connect(mapStateToProps, mapDispatchToProps)(GettingStarted); 63 | 64 | export default reduxForm({ 65 | form: 'gettingStarted', 66 | })(GettingStarted); 67 | ``` 68 | 69 | ## Credit card (16 digits) with validation 70 | 71 | You can use [`createTextMask`](#/text-mask)'s `onCompletePattern` to execute validations upon pattern completion. This is an example of 16 digits credit card validation using Luhn algorithm adapted from [here](https://stackoverflow.com/a/23222600). 72 | 73 | ```jsx 74 | import { createTextMask } from 'redux-form-input-masks'; 75 | 76 | (...) 77 | 78 | const validateCardNumber = number => {(...)} 79 | 80 | const creditCard = createTextMask({ 81 | pattern: '9999 - 9999 - 9999 - 9999', 82 | placeholder: '_', 83 | onCompletePattern: value => { 84 | if (validateCardNumber(value)) { 85 | window.alert('This credit card number is valid!'); 86 | } else { 87 | window.alert("This credit card number isn't valid!"); 88 | } 89 | }, 90 | }); 91 | 92 | (...) 93 | 94 | 100 | ``` 101 | -------------------------------------------------------------------------------- /examples/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { createStore, combineReducers } from 'redux'; 5 | import { reducer as reduxFormReducer } from 'redux-form'; 6 | import { HashRouter, Route, Switch } from 'react-router-dom'; 7 | 8 | const dest = document.getElementById('content'); 9 | const reducer = combineReducers({ 10 | form: reduxFormReducer, 11 | }); 12 | const store = (window.devToolsExtension 13 | ? window.devToolsExtension()(createStore) 14 | : createStore)(reducer); 15 | 16 | let render = () => { 17 | const PageNotFound = require('./App/PageNotFound.jsx').default; 18 | const GettingStarted = require('./GettingStarted/GettingStarted.jsx').default; 19 | const CreateNumberMask = require('./CreateNumberMask/CreateNumberMask.jsx') 20 | .default; 21 | const CreateTextMask = require('./CreateTextMask/CreateTextMask.jsx').default; 22 | const MoreExamples = require('./MoreExamples/MoreExamples.jsx').default; 23 | const Migrating = require('./Migrating/Migrating.jsx').default; 24 | const Changelog = require('./Changelog/Changelog.jsx').default; 25 | 26 | ReactDOM.render( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | , 40 | dest, 41 | ); 42 | }; 43 | 44 | if (module.hot) { 45 | const renderApp = render; 46 | 47 | render = () => { 48 | renderApp(); 49 | }; 50 | 51 | const rerender = () => { 52 | setTimeout(render); 53 | }; 54 | 55 | module.hot.accept('./App/App.jsx', rerender); 56 | module.hot.accept('./App/PageNotFound.jsx', rerender); 57 | module.hot.accept('./GettingStarted/GettingStarted.jsx', rerender); 58 | module.hot.accept('./GettingStarted/GettingStarted.md', rerender); 59 | module.hot.accept('./CreateNumberMask/CreateNumberMask.jsx', rerender); 60 | module.hot.accept('./CreateNumberMask/CreateNumberMask.md', rerender); 61 | module.hot.accept('./CreateTextMask/CreateTextMask.jsx', rerender); 62 | module.hot.accept('./CreateTextMask/CreateTextMask.md', rerender); 63 | module.hot.accept('./MoreExamples/MoreExamples.jsx', rerender); 64 | module.hot.accept('./MoreExamples/MoreExamples.md', rerender); 65 | module.hot.accept('./Migrating/Migrating.jsx', rerender); 66 | module.hot.accept('./Changelog/Changelog.jsx', rerender); 67 | } 68 | 69 | render(); 70 | -------------------------------------------------------------------------------- /examples/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'eval', 6 | entry: [ 7 | 'babel-polyfill', 8 | 'eventsource-polyfill', // necessary for hot reloading with IE 9 | 'webpack-hot-middleware/client', 10 | './src/index.jsx', 11 | ], 12 | output: { 13 | path: path.join(__dirname, 'dist'), 14 | filename: 'bundle.js', 15 | publicPath: '/dist/', 16 | }, 17 | plugins: [new webpack.HotModuleReplacementPlugin()], 18 | resolve: { 19 | modules: ['src', 'node_modules'], 20 | extensions: ['.json', '.js', '.jsx'], 21 | }, 22 | module: { 23 | loaders: [ 24 | { 25 | test: /\.jsx?/, 26 | loaders: ['babel-loader', 'eslint-loader'], 27 | include: [path.join(__dirname, 'src'), path.join(__dirname, '../src')], 28 | }, 29 | { 30 | test: /\.json$/, 31 | loader: 'json-loader', 32 | }, 33 | { 34 | test: /\.md/, 35 | loaders: ['html-loader', 'markdown-loader'], 36 | }, 37 | { 38 | test: /\.(png|jpg|gif)$/, 39 | loader: 'file-loader', 40 | }, 41 | ], 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /examples/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 4 | 5 | module.exports = { 6 | devtool: 'source-map', 7 | entry: ['babel-polyfill', './src/index.jsx'], 8 | output: { 9 | path: path.join(__dirname, 'dist'), 10 | filename: 'bundle.js', 11 | publicPath: '/dist/', 12 | }, 13 | plugins: [ 14 | new webpack.DefinePlugin({ 15 | 'process.env': { 16 | NODE_ENV: JSON.stringify('production'), 17 | }, 18 | }), 19 | new UglifyJsPlugin(), 20 | ], 21 | resolve: { 22 | modules: ['src', 'node_modules'], 23 | extensions: ['.json', '.js', '.jsx'], 24 | }, 25 | module: { 26 | loaders: [ 27 | { 28 | test: /\.js$/, 29 | loader: 'babel-loader', 30 | include: [path.join(__dirname, 'src'), path.join(__dirname, '../src')], 31 | }, 32 | { 33 | test: /\.jsx$/, 34 | loader: 'babel-loader', 35 | include: path.join(__dirname, 'src'), 36 | query: { 37 | presets: ['react', 'es2015'], 38 | }, 39 | }, 40 | { 41 | test: /\.json$/, 42 | loader: 'json-loader', 43 | }, 44 | { 45 | test: /\.md/, 46 | loaders: ['html-loader', 'markdown-loader'], 47 | }, 48 | { 49 | test: /\.(png|jpg|gif)$/, 50 | loader: 'file-loader', 51 | }, 52 | ], 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-form-input-masks", 3 | "version": "2.0.2", 4 | "description": "Input masking with redux-form made easy", 5 | "main": "./dist/bundle.js", 6 | "typings": "./dist/typings.d.ts", 7 | "scripts": { 8 | "build:clean": "rimraf dist", 9 | "build:webpack": "cross-env NODE_ENV=production webpack --config webpack.config.prod.js", 10 | "build:docs": "cd examples && npm i && npm run build && cpy index.html ../docs && cpy dist/* ../docs/dist", 11 | "build": "npm run build:clean && npm run build:webpack", 12 | "test": "jest", 13 | "lint:eslint": "eslint --ext .js,.jsx src examples", 14 | "lint:prettier": "lint-staged", 15 | "commitmsg": "commitlint -e $GIT_PARAMS", 16 | "precommit": "npm-run-all lint:*" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/renato-bohler/redux-form-input-masks.git" 21 | }, 22 | "keywords": [ 23 | "redux", 24 | "form", 25 | "redux-form", 26 | "input", 27 | "masking", 28 | "format", 29 | "formatting", 30 | "field", 31 | "react" 32 | ], 33 | "author": "Renato Böhler (https://github.com/renato-bohler)", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/renato-bohler/redux-form-input-masks/issues" 37 | }, 38 | "homepage": "https://renato-bohler.github.io/redux-form-input-masks", 39 | "devDependencies": { 40 | "@commitlint/cli": "^16.2.1", 41 | "@commitlint/config-conventional": "^16.2.1", 42 | "@semantic-release/changelog": "^2.0.0", 43 | "@semantic-release/git": "^4.0.0", 44 | "babel-core": "^6.26.0", 45 | "babel-eslint": "^7.2.3", 46 | "babel-jest": "^22.2.2", 47 | "babel-loader": "^7.1.3", 48 | "babel-preset-env": "^1.6.1", 49 | "babel-preset-es2015": "^6.24.1", 50 | "commitizen": "^4.3.0", 51 | "copy-webpack-plugin": "^4.6.0", 52 | "cpy-cli": "^1.0.1", 53 | "cross-env": "^5.1.3", 54 | "cz-conventional-changelog": "^2.1.0", 55 | "eslint": "^4.18.0", 56 | "eslint-config-airbnb-base": "^12.1.0", 57 | "eslint-config-prettier": "^2.9.0", 58 | "eslint-config-react-app": "^2.1.0", 59 | "eslint-plugin-flowtype": "^2.44.0", 60 | "eslint-plugin-import": "^2.7.0", 61 | "eslint-plugin-jsx-a11y": "^5.1.1", 62 | "eslint-plugin-react": "^7.6.1", 63 | "husky": "^0.14.3", 64 | "intl": "^1.2.5", 65 | "jest": "^22.3.0", 66 | "lint-staged": "^6.1.1", 67 | "npm-run-all": "^4.1.2", 68 | "prettier": "^1.10.2", 69 | "rimraf": "^2.6.2", 70 | "semantic-release": "^15.0.0", 71 | "webpack": "^3.11.0" 72 | }, 73 | "jest": { 74 | "verbose": true, 75 | "collectCoverage": true, 76 | "transform": { 77 | "^.+\\.js$": "babel-jest" 78 | }, 79 | "globals": { 80 | "NODE_ENV": "test" 81 | }, 82 | "moduleFileExtensions": [ 83 | "js" 84 | ], 85 | "moduleDirectories": [ 86 | "node_modules" 87 | ] 88 | }, 89 | "lint-staged": { 90 | "*.{js,jsx,css,md}": [ 91 | "prettier --write", 92 | "git add" 93 | ] 94 | }, 95 | "config": { 96 | "commitizen": { 97 | "path": "node_modules/cz-conventional-changelog" 98 | } 99 | }, 100 | "release": { 101 | "verifyConditions": [ 102 | "@semantic-release/changelog", 103 | "@semantic-release/npm", 104 | "@semantic-release/git", 105 | "@semantic-release/github" 106 | ], 107 | "prepare": [ 108 | "@semantic-release/changelog", 109 | "@semantic-release/npm", 110 | { 111 | "path": "@semantic-release/git", 112 | "assets": [ 113 | "package.json", 114 | "CHANGELOG.md" 115 | ], 116 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 117 | } 118 | ], 119 | "publish": [ 120 | "@semantic-release/npm", 121 | "@semantic-release/github" 122 | ] 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/__tests__/createNumberMask.test.js: -------------------------------------------------------------------------------- 1 | import { createNumberMask } from '../index'; 2 | 3 | describe('Number mask', () => { 4 | it('should add the prefix to formatted input', () => { 5 | const prefix = 'prefix 1@,.'; 6 | const number = 90; 7 | 8 | const mask = createNumberMask({ prefix }); 9 | expect(mask.format(number)).toBe(`${prefix}${number.toLocaleString()}`); 10 | }); 11 | 12 | it('should add the suffix to formatted input', () => { 13 | const suffix = '1@,. suffix'; 14 | const number = 90; 15 | 16 | const mask = createNumberMask({ suffix }); 17 | expect(mask.format(number)).toBe(`${number.toLocaleString()}${suffix}`); 18 | }); 19 | 20 | it('should have the correct amount of decimal places', () => { 21 | const prefix = 'p'; 22 | const suffix = 's'; 23 | const decimalPlaces = 5; 24 | const number = 1234.56789; 25 | 26 | const mask = createNumberMask({ prefix, suffix, decimalPlaces }); 27 | expect(mask.format(number)).toBe( 28 | `${prefix}${number.toLocaleString(undefined, { 29 | minimumFractionDigits: decimalPlaces, 30 | maximumFractionDigits: decimalPlaces, 31 | })}${suffix}`, 32 | ); 33 | }); 34 | 35 | it('should consider the multiplier when storing the value', () => { 36 | // In this case the displayed value will be displayedNumber * multiplier 37 | const decimalPlaces = 2; 38 | const displayedNumber = '33.33'; 39 | const multiplier = 1 / 100; 40 | 41 | const mask = createNumberMask({ decimalPlaces, multiplier }); 42 | expect(mask.normalize(displayedNumber)).toBe(displayedNumber * multiplier); 43 | }); 44 | 45 | it('should consider the multiplier when formatting the value', () => { 46 | // In this case the displayed value will be storedNumber / multiplier 47 | const decimalPlaces = 2; 48 | const storedNumber = 0.3333; 49 | const multiplier = 1 / 100; 50 | 51 | const mask = createNumberMask({ decimalPlaces, multiplier }); 52 | expect(mask.format(storedNumber)).toBe( 53 | `${(storedNumber / multiplier).toLocaleString(undefined, { 54 | minimumFractionDigits: decimalPlaces, 55 | maximumFractionDigits: decimalPlaces, 56 | })}`, 57 | ); 58 | }); 59 | 60 | it('should be able to format the number with a plus sign', () => { 61 | const prefix = 'p'; 62 | const showPlusSign = true; 63 | 64 | const mask = createNumberMask({ prefix, showPlusSign }); 65 | expect(mask.format(1000)).toBe('+p1,000'); 66 | }); 67 | 68 | it('should be able to format the number with a space after the sign', () => { 69 | const prefix = 'p'; 70 | const allowNegative = true; 71 | const showPlusSign = true; 72 | const spaceAfterSign = true; 73 | 74 | const mask = createNumberMask({ 75 | prefix, 76 | allowNegative, 77 | showPlusSign, 78 | spaceAfterSign, 79 | }); 80 | 81 | expect(mask.format(1000)).toBe('+ p1,000'); 82 | expect(mask.format(-1000)).toBe('- p1,000'); 83 | }); 84 | 85 | it('should be formatting the number according to the locale', () => { 86 | // The default node build includes only en-US locale. 87 | const locale = 'en-US'; 88 | const decimalPlaces = 1; 89 | const number = 1000; 90 | 91 | const mask = createNumberMask({ decimalPlaces, locale }); 92 | expect(mask.format(number)).toBe('1,000.0'); 93 | }); 94 | 95 | it('should be formatting correctly when the value is stored as a string', () => { 96 | const string = '1234.567'; 97 | const decimalPlaces = 3; 98 | const locale = 'en-US'; 99 | const stringValue = true; 100 | 101 | const mask = createNumberMask({ decimalPlaces, locale, stringValue }); 102 | expect(mask.format(string)).toBe('1,234.567'); 103 | }); 104 | 105 | it('should be formatting correctly when the value is negative', () => { 106 | const prefix = ' -- '; 107 | const suffix = '-'; 108 | const stringValue = true; 109 | const allowNegative = true; 110 | 111 | const number = -1234; 112 | const absoluteNumber = 1234; 113 | const string = '-1234'; 114 | 115 | const negativeNumberMask = createNumberMask({ 116 | prefix, 117 | suffix, 118 | allowNegative, 119 | }); 120 | const positiveNumberMask = createNumberMask({ prefix, suffix }); 121 | const negativeStringMask = createNumberMask({ 122 | prefix, 123 | suffix, 124 | stringValue, 125 | allowNegative, 126 | }); 127 | const positiveStringMask = createNumberMask({ 128 | prefix, 129 | suffix, 130 | stringValue, 131 | }); 132 | 133 | expect(negativeNumberMask.format(number)).toBe( 134 | `-${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 135 | ); 136 | expect(positiveNumberMask.format(number)).toBe( 137 | `${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 138 | ); 139 | expect(negativeStringMask.format(string)).toBe( 140 | `-${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 141 | ); 142 | expect(positiveStringMask.format(string)).toBe( 143 | `${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 144 | ); 145 | }); 146 | 147 | it('should be formatting as zero when the value on the store is undefined and allowEmpty is false', () => { 148 | const prefix = 'p'; 149 | const suffix = 's'; 150 | const mask = createNumberMask({ prefix, suffix, allowEmpty: false }); 151 | 152 | expect(mask.format()).toBe( 153 | `${prefix}${Number(0).toLocaleString()}${suffix}`, 154 | ); 155 | }); 156 | 157 | it('should be formatting as empty string when the value on the store is undefined and allowEmpty is true', () => { 158 | const mask = createNumberMask({ 159 | prefix: 'p', 160 | suffix: 's', 161 | allowEmpty: true, 162 | }); 163 | 164 | expect(mask.format()).toBe(''); 165 | }); 166 | 167 | it('should be formatting as empty string when the value on the store is empty string and allowEmpty is true', () => { 168 | const mask = createNumberMask({ 169 | prefix: 'p', 170 | suffix: 's', 171 | allowEmpty: true, 172 | }); 173 | 174 | expect(mask.format('')).toBe(''); 175 | }); 176 | 177 | it('should update the stored value correctly', () => { 178 | const prefix = 'prefix 1@,.'; 179 | const suffix = '1@,. suffix'; 180 | const decimalPlaces = '4'; 181 | const stringValue = true; 182 | 183 | const prefixMask = createNumberMask({ prefix }); 184 | const suffixMask = createNumberMask({ suffix }); 185 | const decimalPlacesMask = createNumberMask({ decimalPlaces }); 186 | const stringValueMask = createNumberMask({ stringValue }); 187 | const allMask = createNumberMask({ 188 | prefix, 189 | suffix, 190 | decimalPlaces, 191 | stringValue, 192 | }); 193 | 194 | expect(prefixMask.normalize(`${prefix}1,2345`)).toBe(12345); 195 | expect(prefixMask.normalize(`${prefix}1,2340`)).toBe(12340); 196 | 197 | expect(suffixMask.normalize(`1,2345${suffix}`)).toBe(12345); 198 | expect(suffixMask.normalize(`1,2340${suffix}`)).toBe(12340); 199 | 200 | expect(decimalPlacesMask.normalize(`1,234.56789`)).toBe(12345.6789); 201 | expect(decimalPlacesMask.normalize(`1,234.56780`)).toBe(12345.678); 202 | 203 | expect(stringValueMask.normalize('1,2345')).toBe('12345'); 204 | expect(stringValueMask.normalize('1,2340')).toBe('12340'); 205 | 206 | expect(allMask.normalize(`+${prefix}1,234.56789${suffix}`)).toBe( 207 | '12345.6789', 208 | ); 209 | expect(allMask.normalize(`+ ${prefix}1,234.56789${suffix}`)).toBe( 210 | '12345.6789', 211 | ); 212 | expect(allMask.normalize(`${prefix}1,234.56789${suffix}`)).toBe( 213 | '12345.6789', 214 | ); 215 | expect(allMask.normalize(`${prefix}1,234.56780${suffix}`)).toBe( 216 | '12345.678', 217 | ); 218 | }); 219 | 220 | it('should ignore any non-alphanumeric characters inputted', () => { 221 | const prefix = 'p'; 222 | const suffix = 's'; 223 | const decimalPlaces = 1; 224 | 225 | const mask = createNumberMask({ prefix, suffix, decimalPlaces }); 226 | 227 | expect(mask.normalize(`${prefix}1,234a${suffix}`)).toBe(123.4); 228 | expect(mask.normalize(`${prefix}a1,!2?3.4/${suffix}`)).toBe(123.4); 229 | }); 230 | 231 | it('should return null for empty input when allowEmpty is true', () => { 232 | const mask = createNumberMask({ allowEmpty: true, decimalPlaces: 2 }); 233 | 234 | expect(mask.normalize('')).toBe(null); 235 | expect(mask.normalize('0.0', 0)).toBe(null); 236 | expect(mask.normalize('0.00', 0)).toBe(0); 237 | }); 238 | 239 | it('should return the inputted value when allowEmpty is true', () => { 240 | const mask = createNumberMask({ allowEmpty: true, decimalPlaces: 2 }); 241 | 242 | expect(mask.normalize('0', null)).toBe(0); 243 | expect(mask.normalize('1', null)).toBe(0.01); 244 | }); 245 | 246 | it('should return the inputted value as string when both allowEmpty and stringValue is true', () => { 247 | const mask = createNumberMask({ 248 | allowEmpty: true, 249 | stringValue: true, 250 | decimalPlaces: 2, 251 | }); 252 | 253 | expect(mask.normalize('0', null)).toBe('0'); 254 | expect(mask.normalize('1', null)).toBe('0.01'); 255 | }); 256 | 257 | it('should call onChange if it is passed as an option', () => { 258 | const onChange = jest.fn(); 259 | const mask = createNumberMask({ onChange }); 260 | 261 | const updatedValue = mask.normalize('123,456,789'); 262 | 263 | expect(onChange).toBeCalledWith(updatedValue); 264 | }); 265 | 266 | it('should fix the caret position before the suffix', () => { 267 | // Needed because we use setTimeout on our manageCaretPosition function 268 | jest.useFakeTimers(); 269 | 270 | const prefix = 'prefix 1@,.'; 271 | const suffix = '1@,. suffix'; 272 | const value = '1,234.56789'; 273 | const decimalPlaces = '5'; 274 | 275 | // Mocked events 276 | const event = { 277 | persist: jest.fn(), 278 | target: { 279 | value: `${prefix}${value}${suffix}`, 280 | setSelectionRange: jest.fn(), 281 | }, 282 | }; 283 | 284 | // prefix 1@,.1,234.56789| 285 | // Caret should be here! ^ in the 22th position 286 | const correctCaretPosition = 22; 287 | 288 | const mask = createNumberMask({ prefix, suffix, decimalPlaces }); 289 | 290 | // Simulate events 291 | mask.onChange(event); 292 | mask.onFocus(event); 293 | 294 | jest.runAllTimers(); 295 | 296 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 297 | correctCaretPosition, 298 | correctCaretPosition, 299 | ); 300 | 301 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(2); 302 | expect(event.persist).toHaveBeenCalledTimes(2); 303 | 304 | // These are used just to cover the else statements 305 | mask.onChange({}); 306 | mask.onChange({ 307 | target: { 308 | value: `${prefix}${value}${suffix}`, 309 | setSelectionRange: () => {}, 310 | }, 311 | }); 312 | }); 313 | 314 | it('should force the input prop "autocomplete" to "off"', () => { 315 | const mask = createNumberMask(); 316 | 317 | expect(mask.autoComplete).toBe('off'); 318 | }); 319 | 320 | it('should handle negative values configurations properly', () => { 321 | const prefix = ' -- '; 322 | const suffix = '-'; 323 | const stringValue = true; 324 | const allowNegative = true; 325 | 326 | const number = -1234; 327 | const absoluteNumber = 1234; 328 | const string = '-1234'; 329 | const absoluteString = '1234'; 330 | 331 | const simpleNegativeNumberMask = createNumberMask({ 332 | allowNegative, 333 | }); 334 | const negativeNumberMask = createNumberMask({ 335 | prefix, 336 | suffix, 337 | allowNegative, 338 | }); 339 | const positiveNumberMask = createNumberMask({ prefix, suffix }); 340 | const negativeStringMask = createNumberMask({ 341 | prefix, 342 | suffix, 343 | stringValue, 344 | allowNegative, 345 | }); 346 | const positiveStringMask = createNumberMask({ 347 | prefix, 348 | suffix, 349 | stringValue, 350 | }); 351 | 352 | expect( 353 | simpleNegativeNumberMask.normalize(`-${absoluteNumber.toLocaleString()}`), 354 | ).toBe(number); 355 | expect( 356 | negativeNumberMask.normalize( 357 | `-${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 358 | ), 359 | ).toBe(number); 360 | expect( 361 | negativeNumberMask.normalize( 362 | `- ${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 363 | ), 364 | ).toBe(number); 365 | expect( 366 | negativeNumberMask.normalize( 367 | `${prefix}${absoluteNumber.toLocaleString()}-${suffix}`, 368 | ), 369 | ).toBe(number); 370 | 371 | expect( 372 | positiveNumberMask.normalize( 373 | `-${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 374 | ), 375 | ).toBe(absoluteNumber); 376 | 377 | expect( 378 | negativeStringMask.normalize( 379 | `-${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 380 | ), 381 | ).toBe(string); 382 | 383 | expect( 384 | positiveStringMask.normalize( 385 | `-${prefix}${absoluteNumber.toLocaleString()}${suffix}`, 386 | ), 387 | ).toBe(absoluteString); 388 | }); 389 | 390 | it('should throw an error if decimalPlaces is greater than 10', () => { 391 | expect(() => createNumberMask({ decimalPlaces: 11 })).toThrowError( 392 | "The maximum value for createNumberMask's option `decimalPlaces` is 10.", 393 | ); 394 | }); 395 | 396 | it('should throw an error if multiplier is not type number', () => { 397 | expect(() => createNumberMask({ multiplier: '1' })).toThrowError( 398 | "The createNumberMask's option `multilpier` should be of type number.", 399 | ); 400 | }); 401 | 402 | it('should throw an error if multiplier is equal to zero', () => { 403 | expect(() => createNumberMask({ multiplier: 0 })).toThrowError( 404 | "The createNumberMask's option `multilpier` cannot be zero.", 405 | ); 406 | }); 407 | }); 408 | -------------------------------------------------------------------------------- /src/__tests__/createTextMask.test.js: -------------------------------------------------------------------------------- 1 | import { createTextMask } from '../index'; 2 | import defaultMaskDefinitions from '../defaultMaskDefinitions'; 3 | 4 | const complexPattern = '---AAA.aaa---Ul-9--'; 5 | 6 | describe('Text mask', () => { 7 | it('should be able to format non guided and non stripped masks', () => { 8 | const mask = createTextMask({ 9 | pattern: complexPattern, 10 | guide: false, 11 | stripMask: false, 12 | }); 13 | 14 | expect(mask.format('')).toBe('---'); 15 | expect(mask.format('---A')).toBe('---A'); 16 | expect(mask.format('---ABC.xyz---Ul-1--')).toBe('---ABC.xyz---Ul-1--'); 17 | }); 18 | 19 | it('should be able to format non guided and stripped masks', () => { 20 | const mask = createTextMask({ 21 | pattern: complexPattern, 22 | guide: false, 23 | // stripMask: true is default 24 | }); 25 | 26 | expect(mask.format('')).toBe('---'); 27 | expect(mask.format('A')).toBe('---A'); 28 | expect(mask.format('ABCxyzUl1')).toBe('---ABC.xyz---Ul-1--'); 29 | }); 30 | 31 | it('should be able to format guided and non stripped masks', () => { 32 | const mask = createTextMask({ 33 | pattern: complexPattern, 34 | // guide: true is default 35 | stripMask: false, 36 | }); 37 | 38 | expect(mask.format('')).toBe('---___.___---__-_--'); 39 | expect(mask.format('---A__.___---__-_--')).toBe('---A__.___---__-_--'); 40 | expect(mask.format('---ABC.xyz---Ul-1--')).toBe('---ABC.xyz---Ul-1--'); 41 | }); 42 | 43 | it('should be able to format guided and stripped masks', () => { 44 | const mask = createTextMask({ 45 | pattern: complexPattern, 46 | // guide: true is default 47 | // stripMask: true is default 48 | }); 49 | 50 | expect(mask.format('')).toBe('---___.___---__-_--'); 51 | expect(mask.format('A')).toBe('---A__.___---__-_--'); 52 | expect(mask.format('ABCxyzUl1')).toBe('---ABC.xyz---Ul-1--'); 53 | }); 54 | 55 | it('should be able to format non guided, stripped and allow empty masks', () => { 56 | const mask = createTextMask({ 57 | pattern: complexPattern, 58 | guide: false, 59 | // stripMask: true is default 60 | allowEmpty: true, 61 | }); 62 | 63 | expect(mask.format('')).toBe(''); 64 | expect(mask.format('A')).toBe('---A'); 65 | expect(mask.format('ABCxyzUl1')).toBe('---ABC.xyz---Ul-1--'); 66 | }); 67 | 68 | it('should be able to format non guided, non stripped and allow empty masks', () => { 69 | const mask = createTextMask({ 70 | pattern: complexPattern, 71 | guide: false, 72 | stripMask: false, 73 | allowEmpty: true, 74 | }); 75 | 76 | expect(mask.format('')).toBe(''); 77 | expect(mask.format('---A')).toBe('---A'); 78 | expect(mask.format('---ABC.xyz---Ul-1--')).toBe('---ABC.xyz---Ul-1--'); 79 | }); 80 | 81 | it('guided should precede allow empty', () => { 82 | const mask = createTextMask({ 83 | pattern: complexPattern, 84 | guide: true, 85 | allowEmpty: true, 86 | }); 87 | 88 | expect(mask.format('')).toBe('---___.___---__-_--'); 89 | expect(mask.format('A')).toBe('---A__.___---__-_--'); 90 | expect(mask.format('ABCxyzUl1')).toBe('---ABC.xyz---Ul-1--'); 91 | }); 92 | 93 | it('should be able to handle input for non guided and non stripped masks', () => { 94 | const mask = createTextMask({ 95 | pattern: complexPattern, 96 | guide: false, 97 | stripMask: false, 98 | }); 99 | 100 | // Insertions 101 | expect(mask.normalize('', '')).toBe('---'); 102 | expect(mask.normalize('---A', '---')).toBe('---A'); 103 | expect(mask.normalize('---ABC', '---AB')).toBe('---ABC.'); 104 | expect(mask.normalize('---ABCx.', '---ABC.')).toBe('---ABC.x'); 105 | expect(mask.normalize('---ABC.xyz', '---ABC.xy')).toBe('---ABC.xyz---'); 106 | expect(mask.normalize('---ABC.xyz---Ul-1', '---ABC.xyz---Ul-')).toBe( 107 | '---ABC.xyz---Ul-1--', 108 | ); 109 | 110 | // Remotions 111 | expect(mask.normalize('---ABC.xyz---Ul---', '---ABC.xyz---Ul-1--')).toBe( 112 | '---ABC.xyz---Ul-', 113 | ); 114 | expect(mask.normalize('---ABC.xz---Ul-1--', '---ABC.xyz---Ul-1--')).toBe( 115 | '---ABC.xzu---', 116 | ); 117 | }); 118 | 119 | it('should be able to handle input for non guided and stripped masks', () => { 120 | const mask = createTextMask({ 121 | pattern: complexPattern, 122 | guide: false, 123 | // stripMask: true is default 124 | }); 125 | 126 | // Insertions 127 | expect(mask.normalize('', '')).toBe(''); 128 | expect(mask.normalize('---A', '---')).toBe('A'); 129 | expect(mask.normalize('---ABC', 'AB')).toBe('ABC'); 130 | expect(mask.normalize('---ABCx.', 'ABC')).toBe('ABCx'); 131 | expect(mask.normalize('---ABC.xyz', 'ABCxy')).toBe('ABCxyz'); 132 | expect(mask.normalize('---ABC.xyz---Ul-1', 'ABCxyzUl')).toBe('ABCxyzUl1'); 133 | 134 | // Remotions 135 | expect(mask.normalize('---ABC.xyz---Ul---', 'ABCxyzUl1')).toBe('ABCxyzUl'); 136 | expect(mask.normalize('---ABC.xz---Ul-1--', 'ABCxyzUl1')).toBe('ABCxzu'); 137 | }); 138 | 139 | it('should be able to handle input for guided and non stripped masks', () => { 140 | const mask = createTextMask({ 141 | pattern: complexPattern, 142 | // guide: true is default 143 | stripMask: false, 144 | }); 145 | 146 | // Insertions 147 | expect(mask.normalize('', '')).toBe('---___.___---__-_--'); 148 | expect(mask.normalize('---A___.___---__-_--', '---___.___---__-_--')).toBe( 149 | '---A__.___---__-_--', 150 | ); 151 | expect(mask.normalize('---ABC_.___---__-_--', '---AB_.___---__-_--')).toBe( 152 | '---ABC.___---__-_--', 153 | ); 154 | expect(mask.normalize('---ABCx.___---__-_--', '---ABC.___---__-_--')).toBe( 155 | '---ABC.x__---__-_--', 156 | ); 157 | expect(mask.normalize('---ABC.x___---__-_--', '---ABC.___---__-_--')).toBe( 158 | '---ABC.x__---__-_--', 159 | ); 160 | expect(mask.normalize('---ABC.xyz_---__-_--', '---ABC.xy_---__-_--')).toBe( 161 | '---ABC.xyz---__-_--', 162 | ); 163 | expect(mask.normalize('---ABC.xy_z---__-_--', '---ABC.xy_---__-_--')).toBe( 164 | '---ABC.xyz---__-_--', 165 | ); 166 | expect(mask.normalize('---ABC.xyz---Ul-1--', '---ABC.xyz---Ul-1--')).toBe( 167 | '---ABC.xyz---Ul-1--', 168 | ); 169 | 170 | // Remotions 171 | expect(mask.normalize('---ABC.xyz---Ul---', '---ABC.xyz---Ul-1--')).toBe( 172 | '---ABC.xyz---Ul-_--', 173 | ); 174 | expect(mask.normalize('---ABC.xz---Ul-1--', '---ABC.xyz---Ul-1--')).toBe( 175 | '---ABC.xzu---__-_--', 176 | ); 177 | }); 178 | 179 | it('should be able to handle input for guided and stripped masks', () => { 180 | const mask = createTextMask({ 181 | pattern: complexPattern, 182 | // guide: true is default 183 | // stripMask: true is default 184 | }); 185 | 186 | // Insertions 187 | expect(mask.normalize('', '')).toBe(''); 188 | expect(mask.normalize('---A___.___---__-_--', '---___.___---__-_--')).toBe( 189 | 'A', 190 | ); 191 | expect(mask.normalize('---ABC_.___---__-_--', '---AB_.___---__-_--')).toBe( 192 | 'ABC', 193 | ); 194 | expect(mask.normalize('---ABCx.___---__-_--', '---ABC.___---__-_--')).toBe( 195 | 'ABCx', 196 | ); 197 | expect(mask.normalize('---ABC.x___---__-_--', '---ABC.___---__-_--')).toBe( 198 | 'ABCx', 199 | ); 200 | expect(mask.normalize('---ABC.xyz_---__-_--', '---ABC.xy_---__-_--')).toBe( 201 | 'ABCxyz', 202 | ); 203 | expect(mask.normalize('---ABC.xy_z---__-_--', '---ABC.xy_---__-_--')).toBe( 204 | 'ABCxyz', 205 | ); 206 | expect(mask.normalize('---ABC.xyz---Ul-1--', '---ABC.xyz---Ul-1--')).toBe( 207 | 'ABCxyzUl1', 208 | ); 209 | 210 | // Remotions 211 | expect(mask.normalize('---ABC.xyz---Ul---', '---ABC.xyz---Ul-1--')).toBe( 212 | 'ABCxyzUl', 213 | ); 214 | expect(mask.normalize('---ABC.xz---Ul-1--', '---ABC.xyz---Ul-1--')).toBe( 215 | 'ABCxzu', 216 | ); 217 | }); 218 | 219 | it('should be able to handle input for non guided, non stripped and allow empty masks', () => { 220 | const mask = createTextMask({ 221 | pattern: complexPattern, 222 | guide: false, 223 | stripMask: false, 224 | allowEmpty: true, 225 | }); 226 | 227 | // Insertions 228 | expect(mask.normalize('', '')).toBe(''); 229 | expect(mask.normalize('---A', '---')).toBe('---A'); 230 | expect(mask.normalize('---ABC', '---AB')).toBe('---ABC.'); 231 | expect(mask.normalize('---ABCx.', '---ABC.')).toBe('---ABC.x'); 232 | expect(mask.normalize('---ABC.xyz', '---ABC.xy')).toBe('---ABC.xyz---'); 233 | expect(mask.normalize('---ABC.xyz---Ul-1', '---ABC.xyz---Ul-')).toBe( 234 | '---ABC.xyz---Ul-1--', 235 | ); 236 | 237 | // Remotions 238 | expect(mask.normalize('---ABC.xyz---Ul---', '---ABC.xyz---Ul-1--')).toBe( 239 | '---ABC.xyz---Ul-', 240 | ); 241 | expect(mask.normalize('---ABC.xz---Ul-1--', '---ABC.xyz---Ul-1--')).toBe( 242 | '---ABC.xzu---', 243 | ); 244 | expect(mask.normalize('---', '---A')).toBe(''); 245 | }); 246 | 247 | it('should not update the stored value if the input is invalid', () => { 248 | const mask = createTextMask({ 249 | pattern: complexPattern, 250 | }); 251 | 252 | const notStrippedMask = createTextMask({ 253 | pattern: complexPattern, 254 | stripMask: false, 255 | }); 256 | 257 | // Invalid insertions 258 | expect(mask.normalize('---ABC.___---__-_--', 'ABC')).toBe('ABC'); 259 | expect(mask.normalize('---ABC,x__---__-_--', 'ABC')).toBe('ABC'); 260 | expect(mask.normalize('---ABC.___---__-1--', 'ABC')).toBe('ABC'); 261 | expect( 262 | notStrippedMask.normalize('---___.___---__-_--', '---9__.___---__-_--'), 263 | ).toBe('---___.___---__-_--'); 264 | 265 | // Mask remotions 266 | expect(mask.normalize('--ABC.1__---__-_--', 'ABC')).toBe('ABC'); 267 | expect(mask.normalize('---ABC1__---__-_--', 'ABC')).toBe('ABC'); 268 | }); 269 | 270 | it('should call onChange when there is a change on the value', () => { 271 | const onChange = jest.fn(); 272 | 273 | const mask = createTextMask({ 274 | pattern: complexPattern, 275 | onChange, 276 | }); 277 | 278 | const updatedValue = mask.normalize('---A___.___---__-_--', ''); 279 | 280 | expect(onChange).toBeCalledWith(updatedValue); 281 | }); 282 | 283 | it('should not call onChange when there is not a change on the value', () => { 284 | const onChange = jest.fn(); 285 | 286 | const mask = createTextMask({ 287 | pattern: complexPattern, 288 | onChange, 289 | }); 290 | 291 | const notStrippedMask = createTextMask({ 292 | pattern: complexPattern, 293 | stripMask: false, 294 | onChange, 295 | }); 296 | 297 | mask.normalize('---ABC.xyz---Ul-1_--', 'ABCxyzUl1'); 298 | mask.normalize('---A__.___---__-_--', 'A'); 299 | mask.normalize('---___.___---__-_--', undefined); 300 | 301 | notStrippedMask.normalize('---ABC.xyz---Ul-1_--', '---ABC.xyz---Ul-1--'); 302 | notStrippedMask.normalize('---A___.___---__-_--', '---A__.___---__-_--'); 303 | notStrippedMask.normalize('---___.___---__-_--', '---___.___---__-_--'); 304 | 305 | expect(onChange).not.toBeCalled(); 306 | }); 307 | 308 | it('should call onCompletePattern when the mask is filled', () => { 309 | // Needed because we use setTimeout on onCompletePattern 310 | jest.useFakeTimers(); 311 | 312 | const onCompletePattern = jest.fn(); 313 | 314 | const mask = createTextMask({ 315 | pattern: complexPattern, 316 | onCompletePattern, 317 | }); 318 | 319 | const updatedValue = mask.normalize('---ABC.xyz---Ul-1_--', 'ABCxyzUl'); 320 | 321 | jest.runAllTimers(); 322 | 323 | expect(onCompletePattern).toBeCalledWith(updatedValue); 324 | }); 325 | 326 | it('should not call onCompletePattern when there is not a change on the value', () => { 327 | // Needed because we use setTimeout on onCompletePattern 328 | jest.useFakeTimers(); 329 | 330 | const onCompletePattern = jest.fn(); 331 | 332 | const mask = createTextMask({ 333 | pattern: complexPattern, 334 | onCompletePattern, 335 | }); 336 | 337 | const notStrippedMask = createTextMask({ 338 | pattern: complexPattern, 339 | stripMask: false, 340 | onCompletePattern, 341 | }); 342 | 343 | mask.normalize('---ABC.xyz---Ul-1_--', 'ABCxyzUl1'); 344 | 345 | notStrippedMask.normalize('---ABC.xyz---Ul-1_--', '---ABC.xyz---Ul-1--'); 346 | 347 | jest.runAllTimers(); 348 | 349 | expect(onCompletePattern).not.toBeCalled(); 350 | }); 351 | 352 | it('should apply transform to the inputted characters', () => { 353 | // Only the first three digits should be incremented 354 | const mask = createTextMask({ 355 | pattern: '000-999', 356 | maskDefinitions: { 357 | 9: { 358 | regExp: /[0-9]/, 359 | }, 360 | 0: { 361 | regExp: /[0-9]/, 362 | transform: char => (Number(char) === 9 ? 0 : Number(char) + 1), 363 | }, 364 | }, 365 | }); 366 | 367 | // User inputs 7 and it is transformed to 8 368 | expect(mask.normalize('7___-___', '___-___')).toBe('8'); 369 | // User inputs 8 and it is transformed to 9 370 | expect(mask.normalize('88__-___', '8__-___')).toBe('89'); 371 | // User inputs 9 and it is transformed to 0 372 | expect(mask.normalize('899_-___', '89_-___')).toBe('890'); 373 | // After the dash, transform shouldn't be applied 374 | expect(mask.normalize('890-1___', '890-___')).toBe('8901'); 375 | expect(mask.normalize('890-12__', '890-1__')).toBe('89012'); 376 | expect(mask.normalize('890-123_', '890-12_')).toBe('890123'); 377 | }); 378 | 379 | it('should be able to handle custom placeholders', () => { 380 | const mask = createTextMask({ 381 | pattern: 'AA (999) 999-9999', 382 | placeholder: '?', 383 | stripMask: false, 384 | }); 385 | 386 | expect(mask.format('')).toBe('?? (???) ???-????'); 387 | expect(mask.normalize('', '')).toBe('?? (???) ???-????'); 388 | }); 389 | 390 | describe('Validations', () => { 391 | it('should validate if the pattern was informed', () => { 392 | expect(() => createTextMask({})).toThrowError( 393 | 'The key `pattern` is required for createTextMask. You probably forgot to add it to your options.', 394 | ); 395 | }); 396 | 397 | it('should validate if the pattern is valid', () => { 398 | expect(() => createTextMask({ pattern: '---' })).toThrowError( 399 | 'The pattern `---` passed for createTextMask is not valid.', 400 | ); 401 | }); 402 | 403 | it("should validate the placeholder's length", () => { 404 | expect(() => 405 | createTextMask({ pattern: complexPattern, placeholder: '' }), 406 | ).toThrowError( 407 | 'The key `placeholder` should have a single character as a value.', 408 | ); 409 | 410 | expect(() => 411 | createTextMask({ pattern: complexPattern, placeholder: '--' }), 412 | ).toThrowError( 413 | 'The key `placeholder` should have a single character as a value.', 414 | ); 415 | }); 416 | 417 | it('should validate if the placeholder matches any mask definition', () => { 418 | expect(() => 419 | createTextMask({ pattern: 'AAA', placeholder: 'B' }), 420 | ).toThrowError( 421 | `The placeholder \`B\` matches the mask definition` + 422 | `\`A\`. The mask created using \`createTextMask\`` + 423 | 'is therefore invalid.', 424 | ); 425 | 426 | expect(() => 427 | createTextMask({ pattern: 'AAA', placeholder: 'b' }), 428 | ).toThrowError( 429 | `The placeholder \`b\` matches the mask definition` + 430 | `\`A\`. The mask created using \`createTextMask\`` + 431 | 'is therefore invalid.', 432 | ); 433 | 434 | expect(() => 435 | createTextMask({ pattern: '999', placeholder: '8' }), 436 | ).toThrowError( 437 | `The placeholder \`8\` matches the mask definition` + 438 | `\`9\`. The mask created using \`createTextMask\`` + 439 | 'is therefore invalid.', 440 | ); 441 | }); 442 | }); 443 | 444 | describe('Event handlers', () => { 445 | it('should handle the caret position upon insertion', () => { 446 | // Needed because we use setTimeout on our manageCaretPosition function 447 | jest.useFakeTimers(); 448 | 449 | const valueBefore = '---ABC.xyz_---__-_--'; 450 | const valueAfter = '---ABC.xyz---__-_--'; 451 | const correctCaretPosition = 13; 452 | 453 | // Mocked events 454 | const event = { 455 | type: 'change', 456 | persist: jest.fn(), 457 | target: { 458 | value: valueBefore, 459 | setSelectionRange: jest.fn(), 460 | }, 461 | }; 462 | 463 | const mask = createTextMask({ pattern: complexPattern }); 464 | 465 | // Simulate change event 466 | mask.onChange(event); 467 | 468 | // Simulate value updating 469 | event.target.value = valueAfter; 470 | 471 | jest.runAllTimers(); 472 | 473 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 474 | correctCaretPosition, 475 | correctCaretPosition, 476 | ); 477 | 478 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 479 | expect(event.persist).toHaveBeenCalledTimes(1); 480 | }); 481 | 482 | it('should handle the caret position upon remotion', () => { 483 | // Needed because we use setTimeout on our manageCaretPosition function 484 | jest.useFakeTimers(); 485 | 486 | const valueBefore = '---ABC.xyz---U_-_--'; 487 | const valueAfter = '---ABC.xyz---__-_--'; 488 | const correctCaretPosition = 13; 489 | 490 | // Mocked events 491 | const event = { 492 | type: 'change', 493 | persist: jest.fn(), 494 | target: { 495 | value: valueBefore, 496 | setSelectionRange: jest.fn(), 497 | }, 498 | }; 499 | 500 | const mask = createTextMask({ pattern: complexPattern }); 501 | 502 | // Simulate change event 503 | mask.onChange(event); 504 | 505 | // Simulate value updating 506 | event.target.value = valueAfter; 507 | 508 | jest.runAllTimers(); 509 | 510 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 511 | correctCaretPosition, 512 | correctCaretPosition, 513 | ); 514 | 515 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 516 | expect(event.persist).toHaveBeenCalledTimes(1); 517 | }); 518 | 519 | it('should handle the caret position upon mask remotion', () => { 520 | /** Example (pipe represents the caret position): 521 | * ---ABC.xyz---|__-_-- 522 | * [user presses backspace] 523 | * ---ABC.xyz|---__-_-- 524 | */ 525 | 526 | // Needed because we use setTimeout on our manageCaretPosition function 527 | jest.useFakeTimers(); 528 | 529 | const valueBefore = '---ABC.xyz--__-_--'; 530 | const caretPositionBefore = 12; 531 | const valueAfter = '---ABC.xyz---__-_--'; 532 | const correctCaretPosition = 10; 533 | 534 | // Mocked events 535 | const event = { 536 | type: 'change', 537 | persist: jest.fn(), 538 | target: { 539 | value: valueBefore, 540 | selectionStart: caretPositionBefore, 541 | selectionEnd: caretPositionBefore, 542 | setSelectionRange: jest.fn(), 543 | }, 544 | }; 545 | 546 | const mask = createTextMask({ pattern: complexPattern }); 547 | 548 | // Simulate change event 549 | mask.onChange(event); 550 | 551 | // Simulate value and caret position updating 552 | event.target.value = valueAfter; 553 | 554 | jest.runAllTimers(); 555 | 556 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 557 | correctCaretPosition, 558 | correctCaretPosition, 559 | ); 560 | 561 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 562 | expect(event.persist).toHaveBeenCalledTimes(1); 563 | }); 564 | 565 | it('should handle the caret position upon focus', () => { 566 | // Needed because we use setTimeout on our manageCaretPosition function 567 | jest.useFakeTimers(); 568 | 569 | const value = '---ABC.xyz---Ul-_--'; 570 | const correctCaretPosition = 16; 571 | 572 | // Mocked events 573 | const event = { 574 | type: 'focus', 575 | persist: jest.fn(), 576 | target: { 577 | value, 578 | setSelectionRange: jest.fn(), 579 | }, 580 | }; 581 | 582 | const mask = createTextMask({ pattern: complexPattern }); 583 | 584 | // Simulate focus event 585 | mask.onFocus(event); 586 | 587 | jest.runAllTimers(); 588 | 589 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 590 | correctCaretPosition, 591 | correctCaretPosition, 592 | ); 593 | 594 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 595 | expect(event.persist).toHaveBeenCalledTimes(1); 596 | }); 597 | 598 | it('should handle the caret position upon valid click', () => { 599 | // Needed because we use setTimeout on our manageCaretPosition function 600 | jest.useFakeTimers(); 601 | 602 | const value = '---ABC.xyz---Ul-_--'; 603 | const clickPosition = 5; 604 | 605 | // Mocked events 606 | const event = { 607 | type: 'click', 608 | persist: jest.fn(), 609 | preventDefault: jest.fn(), 610 | target: { 611 | value, 612 | selectionStart: clickPosition, 613 | selectionEnd: clickPosition, 614 | }, 615 | }; 616 | 617 | const mask = createTextMask({ pattern: complexPattern }); 618 | 619 | // Simulate click event 620 | mask.onClick(event); 621 | 622 | jest.runAllTimers(); 623 | 624 | expect(event.preventDefault).toHaveBeenCalledTimes(1); 625 | expect(event.persist).toHaveBeenCalledTimes(1); 626 | }); 627 | 628 | it('should handle the caret position upon invalid click', () => { 629 | // Needed because we use setTimeout on our manageCaretPosition function 630 | jest.useFakeTimers(); 631 | 632 | const value = '---ABC.xyz---Ul-_--'; 633 | const clickPosition = 1; 634 | const correctCaretPosition = 16; 635 | 636 | // Mocked events 637 | const event = { 638 | type: 'click', 639 | persist: jest.fn(), 640 | target: { 641 | value, 642 | selectionStart: clickPosition, 643 | selectionEnd: clickPosition, 644 | setSelectionRange: jest.fn(), 645 | }, 646 | }; 647 | 648 | const mask = createTextMask({ pattern: complexPattern }); 649 | 650 | // Simulate click event 651 | mask.onClick(event); 652 | 653 | jest.runAllTimers(); 654 | 655 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 656 | correctCaretPosition, 657 | correctCaretPosition, 658 | ); 659 | 660 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 661 | expect(event.persist).toHaveBeenCalledTimes(1); 662 | }); 663 | 664 | it('should not handle the caret position upon selection', () => { 665 | // Needed because we use setTimeout on our manageCaretPosition function 666 | jest.useFakeTimers(); 667 | 668 | const value = '---ABC.xyz---Ul-_--'; 669 | const selectionStart = 0; 670 | const selectionEnd = 19; 671 | 672 | // Mocked events 673 | const event = { 674 | type: 'click', 675 | preventDefault: jest.fn(), 676 | persist: jest.fn(), 677 | target: { 678 | value, 679 | selectionStart, 680 | selectionEnd, 681 | setSelectionRange: jest.fn(), 682 | }, 683 | }; 684 | 685 | const mask = createTextMask({ pattern: complexPattern }); 686 | 687 | // Simulate click (selection) event 688 | mask.onClick(event); 689 | 690 | jest.runAllTimers(); 691 | 692 | expect(event.target.setSelectionRange).not.toBeCalled(); 693 | expect(event.preventDefault).not.toBeCalled(); 694 | 695 | expect(event.persist).toHaveBeenCalledTimes(1); 696 | }); 697 | 698 | it('should handle the caret position upon arrow left keypress', () => { 699 | // Needed because we use setTimeout on our manageCaretPosition function 700 | jest.useFakeTimers(); 701 | 702 | const value = '---ABC.xyz---Ul-1--'; 703 | const caretPositionBefore = 13; 704 | const caretPositionAfter = 12; 705 | const correctCaretPosition = 10; 706 | 707 | // Mocked events 708 | const event = { 709 | type: 'keydown', 710 | key: 'ArrowLeft', 711 | persist: jest.fn(), 712 | target: { 713 | value, 714 | selectionStart: caretPositionBefore, 715 | selectionEnd: caretPositionBefore, 716 | setSelectionRange: jest.fn(), 717 | }, 718 | }; 719 | 720 | const mask = createTextMask({ pattern: complexPattern }); 721 | 722 | // Simulate keydown event 723 | mask.onKeyDown(event); 724 | 725 | // Simulate caret position updating 726 | event.target.selectionStart = caretPositionAfter; 727 | event.target.selectionEnd = caretPositionAfter; 728 | 729 | jest.runAllTimers(); 730 | 731 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 732 | correctCaretPosition, 733 | correctCaretPosition, 734 | ); 735 | 736 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 737 | expect(event.persist).toHaveBeenCalledTimes(1); 738 | }); 739 | 740 | it('should handle the caret position upon arrow right keypress', () => { 741 | // Needed because we use setTimeout on our manageCaretPosition function 742 | jest.useFakeTimers(); 743 | 744 | const value = '---ABC.xyz---Ul-1--'; 745 | const caretPositionBefore = 10; 746 | const caretPositionAfter = 11; 747 | const correctCaretPosition = 13; 748 | 749 | // Mocked events 750 | const event = { 751 | type: 'keydown', 752 | key: 'ArrowRight', 753 | persist: jest.fn(), 754 | target: { 755 | value, 756 | selectionStart: caretPositionBefore, 757 | selectionEnd: caretPositionBefore, 758 | setSelectionRange: jest.fn(), 759 | }, 760 | }; 761 | 762 | const mask = createTextMask({ pattern: complexPattern }); 763 | 764 | // Simulate keydown event 765 | mask.onKeyDown(event); 766 | 767 | // Simulate caret position updating 768 | event.target.selectionStart = caretPositionAfter; 769 | event.target.selectionEnd = caretPositionAfter; 770 | 771 | jest.runAllTimers(); 772 | 773 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 774 | correctCaretPosition, 775 | correctCaretPosition, 776 | ); 777 | 778 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 779 | expect(event.persist).toHaveBeenCalledTimes(1); 780 | }); 781 | 782 | it('should handle the caret position upon arrow left keypress at the first inputtable position', () => { 783 | // Needed because we use setTimeout on our manageCaretPosition function 784 | jest.useFakeTimers(); 785 | 786 | const value = '---ABC.xyz---Ul-1--'; 787 | const caretPositionBefore = 3; 788 | const caretPositionAfter = 2; 789 | const correctCaretPosition = 3; 790 | 791 | // Mocked events 792 | const event = { 793 | type: 'keydown', 794 | key: 'ArrowLeft', 795 | persist: jest.fn(), 796 | target: { 797 | value, 798 | selectionStart: caretPositionBefore, 799 | selectionEnd: caretPositionBefore, 800 | setSelectionRange: jest.fn(), 801 | }, 802 | }; 803 | 804 | const mask = createTextMask({ pattern: complexPattern }); 805 | 806 | // Simulate keydown event 807 | mask.onKeyDown(event); 808 | 809 | // Simulate caret position updating 810 | event.target.selectionStart = caretPositionAfter; 811 | event.target.selectionEnd = caretPositionAfter; 812 | 813 | jest.runAllTimers(); 814 | 815 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 816 | correctCaretPosition, 817 | correctCaretPosition, 818 | ); 819 | 820 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 821 | expect(event.persist).toHaveBeenCalledTimes(1); 822 | }); 823 | 824 | it('should not control any keypress other than ArrowLeft and ArrowRight', () => { 825 | // Needed because we use setTimeout on our manageCaretPosition function 826 | jest.useFakeTimers(); 827 | 828 | const value = '---ABC.xyz---Ul-1--'; 829 | 830 | // Mocked events 831 | const event = { 832 | type: 'keydown', 833 | key: 'any', 834 | target: { 835 | value, 836 | setSelectionRange: jest.fn(), 837 | }, 838 | }; 839 | 840 | const mask = createTextMask({ pattern: complexPattern }); 841 | 842 | // Simulate keydown event 843 | mask.onKeyDown(event); 844 | 845 | expect(event.target.setSelectionRange).not.toBeCalled(); 846 | }); 847 | 848 | it('should handle the caret position upon arrow right keypress at the last inputtable position', () => { 849 | // Needed because we use setTimeout on our manageCaretPosition function 850 | jest.useFakeTimers(); 851 | 852 | const value = '---ABC.xyz---Ul-1--'; 853 | const caretPositionBefore = 17; 854 | const caretPositionAfter = 18; 855 | const correctCaretPosition = 17; 856 | 857 | // Mocked events 858 | const event = { 859 | type: 'keydown', 860 | key: 'ArrowRight', 861 | persist: jest.fn(), 862 | target: { 863 | value, 864 | selectionStart: caretPositionBefore, 865 | selectionEnd: caretPositionBefore, 866 | setSelectionRange: jest.fn(), 867 | }, 868 | }; 869 | 870 | const mask = createTextMask({ pattern: complexPattern }); 871 | 872 | // Simulate keydown event 873 | mask.onKeyDown(event); 874 | 875 | // Simulate caret position updating 876 | event.target.selectionStart = caretPositionAfter; 877 | event.target.selectionEnd = caretPositionAfter; 878 | 879 | jest.runAllTimers(); 880 | 881 | expect(event.target.setSelectionRange).toHaveBeenLastCalledWith( 882 | correctCaretPosition, 883 | correctCaretPosition, 884 | ); 885 | 886 | expect(event.target.setSelectionRange).toHaveBeenCalledTimes(1); 887 | expect(event.persist).toHaveBeenCalledTimes(1); 888 | }); 889 | 890 | it('should do nothing if the event does not have a target', () => { 891 | const mask = createTextMask({ pattern: complexPattern }); 892 | 893 | // These are used just to cover the else statements 894 | mask.onChange({}); 895 | }); 896 | }); 897 | }); 898 | -------------------------------------------------------------------------------- /src/createNumberMask.js: -------------------------------------------------------------------------------- 1 | import { escapeRegExp, countOcurrences, numberToLocaleString } from './utils'; 2 | 3 | export default options => { 4 | const { 5 | prefix = '', 6 | suffix = '', 7 | decimalPlaces = 0, 8 | multiplier = 1, 9 | stringValue = false, 10 | allowEmpty = false, 11 | allowNegative = false, 12 | showPlusSign = false, 13 | spaceAfterSign = false, 14 | locale, 15 | onChange, 16 | } = 17 | options || {}; 18 | 19 | if (decimalPlaces > 10) { 20 | throw new Error( 21 | "The maximum value for createNumberMask's option `decimalPlaces` is 10.", 22 | ); 23 | } 24 | 25 | if (typeof multiplier !== 'number') { 26 | throw new Error( 27 | "The createNumberMask's option `multilpier` should be of type number.", 28 | ); 29 | } 30 | 31 | if (multiplier === 0) { 32 | throw new Error( 33 | "The createNumberMask's option `multilpier` cannot be zero.", 34 | ); 35 | } 36 | 37 | const format = storeValue => { 38 | let number = storeValue; 39 | 40 | if (number === null || number === undefined || number === '') { 41 | if (allowEmpty) { 42 | return ''; 43 | } 44 | number = 0; 45 | } else if (typeof number !== 'number') { 46 | number = Number(number); 47 | } 48 | 49 | // Checks for the sign 50 | let sign = showPlusSign ? '+' : ''; 51 | if (number < 0) { 52 | number *= -1; 53 | if (allowNegative) { 54 | sign = '-'; 55 | } 56 | } 57 | if (sign && spaceAfterSign) { 58 | sign = `${sign} `; 59 | } 60 | 61 | // Apply the multiplier 62 | number *= 1 / multiplier; 63 | 64 | // Reformat the number 65 | number = numberToLocaleString(number, locale, decimalPlaces); 66 | 67 | return `${sign}${prefix}${number}${suffix}`; 68 | }; 69 | 70 | const normalize = (updatedValue, previousValue) => { 71 | const escapedPrefix = escapeRegExp(prefix); 72 | const escapedSuffix = escapeRegExp(suffix); 73 | 74 | const prefixRegex = new RegExp(`^[-|+]? ?${escapedPrefix}`); 75 | const suffixRegex = new RegExp(`${escapedSuffix}$`); 76 | 77 | // Checks if we need to negate the value 78 | let negateMultiplier = 1; 79 | if (allowNegative) { 80 | const minusRegexp = /-/g; 81 | const power = 82 | countOcurrences(updatedValue, minusRegexp) - 83 | countOcurrences(prefix, minusRegexp) - 84 | countOcurrences(suffix, minusRegexp); 85 | negateMultiplier = (-1) ** power % 2; 86 | } 87 | 88 | // Extracting the digits out of updatedValue 89 | let digits = updatedValue; 90 | // Removes the prefix 91 | if (prefix) { 92 | digits = digits.replace(prefixRegex, ''); 93 | } 94 | // Removes the suffix 95 | if (suffix) { 96 | digits = digits.replace(suffixRegex, ''); 97 | } 98 | // Removes non-digits 99 | digits = digits.replace(/\D/g, ''); 100 | 101 | if (allowEmpty) { 102 | // Input value has no digits 103 | const emptyInput = digits === ''; 104 | // Input value contains zeroes only 105 | const zeroInput = digits.replace(/0+/g, '') === ''; 106 | // One character was removed for sure 107 | const characterIsRemoved = digits.length <= decimalPlaces; 108 | // The value entered before is empty 109 | const previousValueIsEmpty = previousValue === undefined; 110 | 111 | if ( 112 | emptyInput || 113 | (!previousValueIsEmpty && characterIsRemoved && zeroInput) 114 | ) { 115 | if (digits === '0') { 116 | return stringValue ? '0' : 0; 117 | } 118 | return null; 119 | } 120 | } 121 | 122 | // Get the number out of digits 123 | let number = Number(digits) / 10 ** decimalPlaces * negateMultiplier; 124 | 125 | // Apply the multiplier 126 | number = Number((number * multiplier).toPrecision(10)); 127 | 128 | if (stringValue) { 129 | number = number.toString(); 130 | } 131 | 132 | const hasValueChanged = number !== previousValue; 133 | 134 | if (onChange && hasValueChanged) { 135 | onChange(number); 136 | } 137 | 138 | return number; 139 | }; 140 | 141 | const manageCaretPosition = event => { 142 | const { target } = event; 143 | 144 | if (target) { 145 | if (event.persist) { 146 | event.persist(); 147 | } 148 | 149 | // This timeout is needed to get updated values 150 | setTimeout(() => { 151 | const caretPos = target.value.length - suffix.length; 152 | event.target.setSelectionRange(caretPos, caretPos); 153 | }); 154 | } 155 | }; 156 | 157 | return { 158 | format: storeValue => format(storeValue), 159 | normalize: (updatedValue, previousValue) => 160 | normalize(updatedValue, previousValue), 161 | onChange: event => manageCaretPosition(event), 162 | onFocus: event => manageCaretPosition(event), 163 | autoComplete: 'off', 164 | }; 165 | }; 166 | -------------------------------------------------------------------------------- /src/createTextMask.js: -------------------------------------------------------------------------------- 1 | import { 2 | applyMask, 3 | applyTransform, 4 | firstUnfilledPosition, 5 | inputReformat, 6 | isPatternComplete, 7 | maskStrip, 8 | charMatchTest, 9 | validCaretPositions, 10 | } from './utils'; 11 | import defaultMaskDefinitions from './defaultMaskDefinitions'; 12 | 13 | export default options => { 14 | const { 15 | pattern, 16 | placeholder = '_', 17 | maskDefinitions = defaultMaskDefinitions, 18 | guide = true, 19 | stripMask = true, 20 | allowEmpty = false, 21 | onChange, 22 | onCompletePattern, 23 | } = options; 24 | 25 | if (!pattern) { 26 | throw new Error( 27 | 'The key `pattern` is required for createTextMask.' + 28 | ' You probably forgot to add it to your options.', 29 | ); 30 | } 31 | 32 | if (!placeholder || placeholder.length !== 1) { 33 | throw new Error( 34 | 'The key `placeholder` should have a single character as a value.', 35 | ); 36 | } 37 | 38 | const validPositions = validCaretPositions(pattern, maskDefinitions); 39 | 40 | // If there's no valid position for this pattern, throw an error 41 | if (validPositions.length === 0) { 42 | throw new Error( 43 | `The pattern \`${pattern}\` passed for createTextMask is not valid.`, 44 | ); 45 | } 46 | 47 | const placeholderMatch = charMatchTest(placeholder, maskDefinitions); 48 | if (placeholderMatch) { 49 | throw new Error( 50 | `The placeholder \`${placeholder}\` matches the mask definition` + 51 | `\`${placeholderMatch}\`. The mask created using \`createTextMask\`` + 52 | 'is therefore invalid.', 53 | ); 54 | } 55 | 56 | const strippedPattern = maskStrip( 57 | pattern, 58 | pattern, 59 | placeholder, 60 | maskDefinitions, 61 | ); 62 | 63 | const format = (storeValue, calledFromNormalize = false) => { 64 | if (!storeValue) { 65 | return applyMask( 66 | '', 67 | pattern, 68 | placeholder, 69 | guide, 70 | allowEmpty, 71 | maskDefinitions, 72 | ); 73 | } 74 | 75 | if (!stripMask && !calledFromNormalize) { 76 | // If we aren't stripping the mask, the value should be already formatted 77 | return storeValue; 78 | } 79 | 80 | // Format the mask according to pattern and maskDefinitions 81 | return applyMask( 82 | storeValue, 83 | pattern, 84 | placeholder, 85 | guide, 86 | allowEmpty, 87 | maskDefinitions, 88 | ); 89 | }; 90 | 91 | const normalize = (updatedValue, previousValue) => { 92 | const inputHandledValue = inputReformat( 93 | updatedValue, 94 | pattern, 95 | placeholder, 96 | guide, 97 | allowEmpty, 98 | maskDefinitions, 99 | ); 100 | 101 | // We need to strip the mask before working with it 102 | const strippedValue = maskStrip( 103 | inputHandledValue, 104 | pattern, 105 | placeholder, 106 | maskDefinitions, 107 | ); 108 | 109 | // Apply the `transform` function on the inputted character 110 | const transformedValue = applyTransform( 111 | strippedValue, 112 | stripMask 113 | ? previousValue 114 | : maskStrip(previousValue, pattern, placeholder, maskDefinitions), 115 | strippedPattern, 116 | maskDefinitions, 117 | ); 118 | const formattedValue = format(transformedValue, true); 119 | const newValue = stripMask ? transformedValue : formattedValue; 120 | const hasValueChanged = 121 | newValue !== previousValue && 122 | (newValue !== '' || previousValue !== undefined); 123 | 124 | // We call `onChange` if it was set and if the value actually changed 125 | if (onChange && hasValueChanged) { 126 | onChange(newValue); 127 | } 128 | 129 | // We call `onCompletePattern` if it was set and the pattern is complete 130 | if ( 131 | onCompletePattern && 132 | isPatternComplete(formattedValue, pattern, maskDefinitions) && 133 | hasValueChanged 134 | ) { 135 | /* setTimeout is used to avoid the function being called before rendering 136 | the last input from the user */ 137 | setTimeout(() => onCompletePattern(newValue), 10); 138 | } 139 | 140 | // We need to reformat the string before storing 141 | return newValue; 142 | }; 143 | 144 | const goToFirstUnfilledPosition = target => { 145 | const caretPos = firstUnfilledPosition( 146 | target.value, 147 | pattern, 148 | placeholder, 149 | maskDefinitions, 150 | ); 151 | 152 | target.setSelectionRange(caretPos, caretPos); 153 | }; 154 | 155 | const goToNearestValidPosition = (target, position, direction) => { 156 | /* `validPositions` is ordered from least to greatest, so we find the first 157 | valid positon after `position` */ 158 | let nearestIndexToTheRight; 159 | for (let index = 0; index <= validPositions.length; index += 1) { 160 | const element = validPositions[index]; 161 | if (element > position) { 162 | nearestIndexToTheRight = index; 163 | break; 164 | } 165 | } 166 | 167 | let caretPos; 168 | if (direction === 'left') { 169 | /* The nearest valid position to the left will be the element that comes 170 | before it. */ 171 | caretPos = validPositions[nearestIndexToTheRight - 1]; 172 | } else { 173 | caretPos = validPositions[nearestIndexToTheRight]; 174 | } 175 | 176 | /* If there are no valid position to the informed direction we fallback to 177 | the first valid position (left) or to the last valid position (right) */ 178 | if (caretPos === undefined) { 179 | const fallbackIndex = 180 | direction === 'left' ? 0 : validPositions.length - 1; 181 | caretPos = validPositions[fallbackIndex]; 182 | } 183 | target.setSelectionRange(caretPos, caretPos); 184 | }; 185 | 186 | const manageCaretPosition = event => { 187 | if (event.target) { 188 | if (event.persist) { 189 | event.persist(); 190 | } 191 | 192 | // We get these values before updating 193 | const previousSelection = event.target.selectionStart; 194 | const previousValue = event.target.value; 195 | 196 | // This timeout is needed to get updated values 197 | setTimeout(() => { 198 | const { target, type, key } = event; 199 | const { value, selectionStart, selectionEnd } = event.target; 200 | 201 | switch (type) { 202 | case 'change': 203 | /* Upon change, we need to determine if the user has pressed 204 | backspace to move the caret accordingly */ 205 | if ( 206 | value.length === previousValue.length + 1 && 207 | value.charAt(previousSelection) === 208 | pattern.charAt(previousSelection) 209 | ) { 210 | // Backspace was pressed at a pattern char 211 | goToNearestValidPosition(target, previousSelection, 'left'); 212 | break; 213 | } 214 | goToFirstUnfilledPosition(target); 215 | break; 216 | case 'focus': 217 | // Upon focus, we move to the first unfilled position 218 | goToFirstUnfilledPosition(target); 219 | break; 220 | case 'click': 221 | /* Upon click, we first check if the caret is on a valid position. 222 | If it isn't, we move it to the first unfilled position */ 223 | if (selectionStart === selectionEnd) { 224 | if (validPositions.indexOf(selectionStart) >= 0) { 225 | event.preventDefault(); 226 | } else { 227 | goToFirstUnfilledPosition(target); 228 | } 229 | } 230 | break; 231 | case 'keydown': 232 | /* Upon left or right arrow, we need to move the caret to 233 | the next valid position to the right direction */ 234 | if (key === 'ArrowLeft') { 235 | goToNearestValidPosition(target, selectionStart, 'left'); 236 | } else if (key === 'ArrowRight') { 237 | goToNearestValidPosition(target, previousSelection, 'right'); 238 | } 239 | break; 240 | } 241 | }); 242 | } 243 | }; 244 | 245 | return { 246 | format: storeValue => format(storeValue), 247 | normalize: (updatedValue, previousValue) => 248 | normalize(updatedValue, previousValue), 249 | onKeyDown: event => manageCaretPosition(event), 250 | onChange: event => manageCaretPosition(event), 251 | onFocus: event => manageCaretPosition(event), 252 | onClick: event => manageCaretPosition(event), 253 | autoComplete: 'off', 254 | }; 255 | }; 256 | -------------------------------------------------------------------------------- /src/defaultMaskDefinitions.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Accepts both uppercase and lowercase and transform to uppercase 3 | A: { 4 | regExp: /[A-Za-z]/, 5 | transform: char => char.toUpperCase(), 6 | }, 7 | // Accepts both uppercase and lowercase and transform to lowercase 8 | a: { 9 | regExp: /[A-Za-z]/, 10 | transform: char => char.toLowerCase(), 11 | }, 12 | // Accepts only uppercase 13 | U: { 14 | regExp: /[A-Z]/, 15 | }, 16 | // Accepts only lowercase 17 | l: { 18 | regExp: /[a-z]/, 19 | }, 20 | // Numbers 21 | 9: { 22 | regExp: /[0-9]/, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createNumberMask from './createNumberMask'; 2 | import createTextMask from './createTextMask'; 3 | 4 | export { createNumberMask, createTextMask }; 5 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FocusEvent } from "react"; 2 | import { EventHandler, EventWithDataHandler } from "redux-form"; 3 | 4 | declare namespace reduxFormInputMasks { 5 | interface numberMaskOptions { 6 | prefix?: string; 7 | suffix?: string; 8 | decimalPlaces?: number; 9 | multiplier?: number; 10 | stringValue?: boolean; 11 | allowEmpty?: boolean; 12 | allowNegative?: boolean; 13 | showPlusSign?: boolean; 14 | spaceAfterSign?: boolean; 15 | locale?: string; 16 | onChange?: (value: number | string) => void; 17 | } 18 | 19 | interface maskDefinition { 20 | regExp: RegExp; 21 | transform?: Function; 22 | } 23 | 24 | interface maskDefinitions { 25 | [key: string]: maskDefinition; 26 | [key: number]: maskDefinition; 27 | } 28 | 29 | interface textMaskOptions { 30 | pattern: string; 31 | placeholder?: string; 32 | maskDefinitions?: maskDefinitions; 33 | guide?: boolean; 34 | stripMask?: boolean; 35 | allowEmpty?: boolean; 36 | onChange?: (value: string) => void; 37 | onCompletePattern?: (value: string) => void; 38 | } 39 | 40 | interface numberMaskReturn { 41 | format: (storeValue: number | string) => string; 42 | normalize: (updatedValue: string, previousValue: number | string) => number | string; 43 | onChange: EventWithDataHandler>; 44 | onFocus: EventHandler>; 45 | autoComplete: string, 46 | } 47 | 48 | interface textMaskReturn { 49 | format: (storeValue: string) => string; 50 | normalize: (updatedValue: string, previousValue: string) => string; 51 | onKeyDown: (event: Event) => void, 52 | onChange: EventWithDataHandler>; 53 | onFocus: EventHandler>; 54 | onClick: (event: Event) => void, 55 | autoComplete: string, 56 | } 57 | 58 | function createNumberMask(options?: numberMaskOptions): numberMaskReturn; 59 | function createTextMask(options?: textMaskOptions): textMaskReturn; 60 | } 61 | 62 | export = reduxFormInputMasks; 63 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // General use 2 | /** 3 | * This function should escape every special RegExp characters 4 | */ 5 | const escapeRegExp = string => 6 | string.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); 7 | 8 | // createNumberMask.js 9 | /** 10 | * This function should return the amount of occurrences of a given RegExp on a 11 | * given string 12 | */ 13 | const countOcurrences = (string, regexp) => (string.match(regexp) || []).length; 14 | 15 | /** 16 | * This function is used to format the number based on a given locale and the 17 | * amount of decimal places 18 | */ 19 | const numberToLocaleString = (number, locale, decimalPlaces) => 20 | number.toLocaleString(locale, { 21 | minimumFractionDigits: decimalPlaces, 22 | maximumFractionDigits: decimalPlaces, 23 | }); 24 | 25 | // createStringMask.js 26 | /** 27 | * This function should simply return the mask definition for a given pattern 28 | * char 29 | */ 30 | const getMaskDefinition = (char, maskDefinitions) => maskDefinitions[char]; 31 | 32 | /** 33 | * This function should take any masked value and remove all the non-pattern 34 | * characters that it contains 35 | */ 36 | const maskStrip = (formattedValue, pattern, placeholder, maskDefinitions) => { 37 | let stripped = ''; 38 | 39 | let value = !formattedValue ? '' : formattedValue; 40 | 41 | // For every char in value... 42 | for (let index = 0; index < value.length; index += 1) { 43 | const valueChar = value.charAt(index); 44 | const patternChar = pattern.charAt(index); 45 | const maskDefinition = getMaskDefinition(patternChar, maskDefinitions); 46 | 47 | if (maskDefinition) { 48 | if (maskDefinition.regExp.test(valueChar)) { 49 | stripped = stripped.concat(valueChar); 50 | } else if (valueChar === placeholder) { 51 | // Ignore everything after the first placeholder 52 | value = ''; 53 | } 54 | } 55 | } 56 | return stripped; 57 | }; 58 | 59 | /** 60 | * This function should take any stripped value and format it according to the 61 | * given pattern. 62 | */ 63 | const applyMask = ( 64 | strippedValue, 65 | pattern, 66 | placeholder, 67 | guide, 68 | allowEmpty, 69 | maskDefinitions, 70 | ) => { 71 | let applied = ''; 72 | 73 | let value = !strippedValue ? '' : strippedValue; 74 | 75 | /* If the value is empty, allowEmpty is set and guide is not, the formatted 76 | value should be an empty string */ 77 | if (value.length === 0 && allowEmpty && !guide) { 78 | return ''; 79 | } 80 | 81 | // There are two indexes we need to control: value and pattern 82 | let valueIndex = 0; 83 | 84 | // For every char in the pattern... 85 | for (let patternIndex = 0; patternIndex < pattern.length; patternIndex += 1) { 86 | // Take the current value char 87 | const valueChar = value.charAt(valueIndex); 88 | // Take the current pattern char 89 | const patternChar = pattern.charAt(patternIndex); 90 | // Take the mask definition for the current pattern char 91 | const maskDefinition = getMaskDefinition(patternChar, maskDefinitions); 92 | 93 | if (maskDefinition) { 94 | // If the current pattern char have a mask definition 95 | if (valueChar) { 96 | // If the current value char is defined 97 | if (maskDefinition.regExp.test(valueChar)) { 98 | // If the current value char is valid, we concatenate it 99 | applied = applied.concat(valueChar); 100 | valueIndex += 1; 101 | } else if (guide) { 102 | /* If the current value char isn't valid and the mask is guided, we 103 | concatenate a placeholder */ 104 | applied = applied.concat(placeholder); 105 | value = ''; 106 | } else { 107 | /* If the current value isn't valid and the mask isn't guided, we are 108 | finished */ 109 | return applied; 110 | } 111 | } else if (guide) { 112 | /* If the current pattern char doesn't have a mask definition and the 113 | mask is guided, we concatenate a placeholder */ 114 | applied = applied.concat(placeholder); 115 | } else { 116 | /* If the current pattern char doesn't have a mask definition and the 117 | mask isn't guided, we are done */ 118 | return applied; 119 | } 120 | } else { 121 | /* If the current pattern char doesn't have a mask definition, we 122 | concatenate it */ 123 | applied = applied.concat(patternChar); 124 | } 125 | } 126 | return applied; 127 | }; 128 | 129 | /** 130 | * This function should handle the user input, reformatting it according to the 131 | * pattern 132 | */ 133 | const inputReformat = ( 134 | inputString, 135 | pattern, 136 | placeholder, 137 | guide, 138 | allowEmpty, 139 | maskDefinitions, 140 | ) => { 141 | let string = !inputString ? '' : inputString; 142 | 143 | // Removes all chars that doesn't have mask definition on the pattern 144 | for (let index = 0; index < pattern.length; index += 1) { 145 | const patternChar = pattern.charAt(index); 146 | const maskDefinition = getMaskDefinition(patternChar, maskDefinitions); 147 | 148 | if (!maskDefinition) { 149 | const escapedPatternChar = escapeRegExp(patternChar); 150 | string = string.replace(new RegExp(escapedPatternChar), ''); 151 | } 152 | } 153 | 154 | // Removes placeholders 155 | const placeholderRegExp = escapeRegExp(placeholder); 156 | string = string.replace(placeholderRegExp, ''); 157 | 158 | return applyMask( 159 | string, 160 | pattern, 161 | placeholder, 162 | guide, 163 | allowEmpty, 164 | maskDefinitions, 165 | ); 166 | }; 167 | 168 | /** 169 | * This function should return wether a given value completely fills the given 170 | * pattern 171 | */ 172 | const isPatternComplete = (formattedValue, pattern, maskDefinitions) => { 173 | // Trivial cases 174 | if (!formattedValue || formattedValue.length === 0) { 175 | return false; 176 | } 177 | if (formattedValue.length !== pattern.length) { 178 | return false; 179 | } 180 | 181 | // For every char in the formatted value 182 | for (let index = 0; index < formattedValue.length; index += 1) { 183 | // Take the current value char 184 | const valueChar = formattedValue.charAt(index); 185 | // Take the current pattern char 186 | const patternChar = pattern.charAt(index); 187 | // Take the mask definition for the current pattern char 188 | const maskDefinition = getMaskDefinition(patternChar, maskDefinitions); 189 | 190 | if (maskDefinition) { 191 | // If the current pattern char have a mask definition 192 | if (!maskDefinition.regExp.test(valueChar)) { 193 | // If the current value char isn't according to the mask definition 194 | return false; 195 | } 196 | } else if (valueChar !== patternChar) { 197 | /* If the current pattern char doesn't have a mask definition and the 198 | current value char is not equal to the current pattern char */ 199 | return false; 200 | } 201 | } 202 | 203 | /* If we passed the above loop without returning false, then formattedValue 204 | completely fills the given pattern */ 205 | return true; 206 | }; 207 | 208 | /** 209 | * This function should return an array with all the positions on which the 210 | * caret can be, ordered from least to greatest 211 | */ 212 | const validCaretPositions = (pattern, maskDefinitions) => { 213 | const validPositions = []; 214 | 215 | // Trivial case 216 | if (!pattern || typeof pattern !== 'string' || pattern.length === 0) { 217 | return validPositions; 218 | } 219 | 220 | // Caret position 0 is valid iff the first char is inputtable 221 | if (getMaskDefinition(pattern.charAt(0), maskDefinitions)) { 222 | validPositions.push(0); 223 | } 224 | 225 | // The middle caret positions are valid iff any adjacent char is inputtable 226 | for (let index = 1; index < pattern.length; index += 1) { 227 | // Take the char before the current caret position 228 | const charBefore = pattern.charAt(index - 1); 229 | // Take the char after the current caret position 230 | const charAfter = pattern.charAt(index); 231 | 232 | if ( 233 | getMaskDefinition(charBefore, maskDefinitions) || 234 | getMaskDefinition(charAfter, maskDefinitions) 235 | ) { 236 | validPositions.push(index); 237 | } 238 | } 239 | 240 | // Last caret position is valid iff the last char is inputtable 241 | if (getMaskDefinition(pattern.charAt(pattern.length - 1), maskDefinitions)) { 242 | validPositions.push(pattern.length); 243 | } 244 | 245 | return validPositions; 246 | }; 247 | 248 | /** 249 | * This function should return the index of the first unfilled position for any 250 | * value 251 | */ 252 | const firstUnfilledPosition = ( 253 | value, 254 | pattern, 255 | placeholder, 256 | maskDefinitions, 257 | ) => { 258 | // Trivial case 259 | if (value === '') { 260 | return 0; 261 | } 262 | 263 | /* The first unfilled positon can be determined by finding the first char that 264 | is a placeholder and isn't a pattern char */ 265 | for (let index = 0; index < value.length; index += 1) { 266 | const valueChar = value.charAt(index); 267 | const patternChar = pattern.charAt(index); 268 | 269 | if (valueChar === placeholder && valueChar !== patternChar) { 270 | return index; 271 | } 272 | } 273 | 274 | // In case everyone is filled, return the last inputtable position 275 | if (value.length === pattern.length) { 276 | // We need to check if the pattern doesn't end with pattern chars like 277 | for (let index = pattern.length - 1; index >= 0; index -= 1) { 278 | const patternChar = pattern.charAt(index); 279 | 280 | if (getMaskDefinition(patternChar, maskDefinitions)) { 281 | /* The last char in the pattern to have a mask definition will be the 282 | last inputtable position */ 283 | return index + 1; 284 | } 285 | } 286 | } 287 | // In case everyone is filled and there's no pattern chars at the end 288 | return value.length; 289 | }; 290 | 291 | /** 292 | * This function should apply the transform function on any new character, based 293 | * on the current value and the previous one 294 | */ 295 | const applyTransform = ( 296 | strippedValue, 297 | strippedPreviousValue, 298 | strippedPattern, 299 | maskDefinitions, 300 | ) => { 301 | const value = !strippedValue ? '' : strippedValue; 302 | const previousValue = !strippedPreviousValue ? '' : strippedPreviousValue; 303 | 304 | /* We run the current value string, comparing it to the previous value to 305 | determine what characters have been added and we use pattern and 306 | maskDefinitions to get the correct transform function to apply */ 307 | let transformed = ''; 308 | for (let index = 0; index < value.length; index += 1) { 309 | const valueChar = value.charAt(index); 310 | const previousValueChar = previousValue.charAt(index); 311 | 312 | if (valueChar !== previousValueChar) { 313 | const patternChar = strippedPattern.charAt(index); 314 | const maskDefinition = getMaskDefinition(patternChar, maskDefinitions); 315 | if (maskDefinition && maskDefinition.transform) { 316 | // If it has changed and it have transform defined, apply it 317 | transformed = transformed.concat(maskDefinition.transform(valueChar)); 318 | } else { 319 | // No transform function is defined 320 | transformed = transformed.concat(valueChar); 321 | } 322 | } else { 323 | // Transform was already applied, do not apply again 324 | transformed = transformed.concat(valueChar); 325 | } 326 | } 327 | 328 | return transformed; 329 | }; 330 | 331 | /** 332 | * This function is used to validate that the given char doesn't match with any 333 | * of the maskDefinitions. It should return the mask definition key that matches 334 | * with the character if there are any. 335 | */ 336 | const charMatchTest = (character, maskDefinitions) => { 337 | const keys = Object.keys(maskDefinitions); 338 | 339 | for (let index = 0; index < keys.length; index += 1) { 340 | const key = keys[index]; 341 | const maskDefinition = maskDefinitions[key]; 342 | 343 | if (maskDefinition.regExp.test(character)) { 344 | return key; 345 | } 346 | } 347 | }; 348 | 349 | export { 350 | applyMask, 351 | applyTransform, 352 | countOcurrences, 353 | escapeRegExp, 354 | firstUnfilledPosition, 355 | getMaskDefinition, 356 | inputReformat, 357 | isPatternComplete, 358 | maskStrip, 359 | numberToLocaleString, 360 | charMatchTest, 361 | validCaretPositions, 362 | }; 363 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 4 | const CopyPlugin = require('copy-webpack-plugin'); 5 | 6 | module.exports = { 7 | devtool: 'source-map', 8 | entry: ['./src/index.js'], 9 | output: { 10 | path: path.join(__dirname, 'dist'), 11 | filename: 'bundle.js', 12 | publicPath: '/dist/', 13 | library: 'library', 14 | libraryTarget: 'umd', 15 | }, 16 | plugins: [ 17 | new CopyPlugin([{ from: './src/typings.d.ts', to: '.' }]), 18 | new webpack.DefinePlugin({ 19 | 'process.env': { 20 | NODE_ENV: JSON.stringify('production'), 21 | }, 22 | }), 23 | new UglifyJsPlugin(), 24 | ], 25 | resolve: { 26 | modules: ['src', 'node_modules'], 27 | extensions: ['.json', '.js', '.jsx'], 28 | }, 29 | module: { 30 | loaders: [ 31 | { 32 | test: /\.js$/, 33 | loader: 'babel-loader', 34 | include: path.join(__dirname, 'src'), 35 | }, 36 | { 37 | test: /\.jsx$/, 38 | loader: 'babel-loader', 39 | include: path.join(__dirname, 'src'), 40 | query: { 41 | presets: ['es2015'], 42 | }, 43 | }, 44 | { 45 | test: /\.json$/, 46 | loader: 'json-loader', 47 | }, 48 | { 49 | test: /\.md/, 50 | loaders: ['html-loader', 'markdown-loader'], 51 | }, 52 | ], 53 | }, 54 | }; 55 | --------------------------------------------------------------------------------