├── .eslintrc ├── .gitignore ├── .npmrc ├── .travis.yml ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo └── src │ ├── index.js │ └── style.css ├── nwb.config.js ├── package.json ├── src └── index.js └── tests └── index-test.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "standard-react"], 3 | "rules": { 4 | "brace-style": [2, "stroustrup", {"allowSingleLine": true}], 5 | "eqeqeq": [2, "smart"], 6 | "jsx-quotes": [2, "prefer-double"], 7 | "react/prop-types": 0, 8 | "react/self-closing-comp": 0, 9 | "react/wrap-multilines": 0, 10 | "space-before-function-paren": 0 11 | }, 12 | "parser": "babel-eslint" 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6 4 | - 8 5 | - 10 6 | - 12 7 | - 14 8 | script: npm test 9 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 4.0.2 / 2020-05-18 2 | 3 | * Renamed lifecycle methods to add the UNSAFE_ prefix in order to remove warnings in developer mode 4 | 5 | ## 4.0.1 / 2018-01-26 🇦🇺 6 | 7 | * Fix auto-fill scenarios by using data from `onChange` events [[#112](https://github.com/insin/react-maskedinput/pull/112)] 8 | 9 | * Fix wrong scope in `onPaste` event [[#108](https://github.com/insin/react-maskedinput/pull/108)] 10 | 11 | * Include React 16 in `peerDependencies` [[#115](https://github.com/insin/react-maskedinput/pull/115)] 12 | 13 | * Update nwb to 0.21.x to fix UMD build, which was exporting an object with a `default` property 14 | 15 | ## 4.0.0 / 2017-07-04 16 | 17 | * Potential breaking change as the `peerDependencies` range has been changed from `"0.14.x || 15.x"` to `"^0.14.9 || ^15.3.0"`. 18 | 19 | * Use `React.Component` instead of `React.createClass` and the `prop-types` package instead of `React.PropTypes` to silence deprecation warnings [[#94](https://github.com/insin/react-maskedinput/pull/94)] [[krvital][krvital]] 20 | 21 | * Update nwb to 0.17.x: 22 | * `module` config replaces `jsnext:main` in `package.json` to specify the location of the ES6 modules build. 23 | * `prop-types` is bundled with the UMD development build and stripped from the production build, along with usage of `propTypes`. 24 | 25 | ## 3.3.4 / 2016-12-15 26 | 27 | * Silence React 15.4 invalid property warnings [[#80](https://github.com/insin/react-maskedinput/pull/80)] [[nathanstitt][nathanstitt]] 28 | 29 | ## 3.3.2 / 2016-12-01 30 | 31 | * Fix for both Android and MS Edge input entering 32 | 33 | ## 3.2.0 / 2016-05-24 34 | 35 | * Allow dynamic pattern updating [[martyphee][martyphee]] 36 | 37 | ## 3.1.3 / 2016-05-02 38 | 39 | * Don’t call `onChange` function if undefined. 40 | * Update nwb to 0.9.x 41 | 42 | ## 3.1.2 / 2016-04-11 43 | 44 | * Support for React 15.x.x 45 | 46 | ## 3.1.1 / 2016-03-09 47 | 48 | * Convert tooling to use [nwb](https://github.com/insin/nwb/) [[bpugh]][[bpugh]] 49 | * Publish `dist` files 50 | 51 | ## 3.1.0 / 2016-02-11 52 | 53 | * Added support for `value` behaving as a controlled component. 54 | 55 | ## 3.0.0 / 2015-10-23 56 | 57 | **Breaking change:** Now uses a `mask` prop to define the input mask instead of `pattern`, to avoid preventing use of the the HTML5 `pattern` attribute in conjunction with the input mask. 58 | 59 | **Breaking change:** React >= 0.14 is now required. 60 | 61 | React 0.14 compatibility. [[jquense][jquense]] 62 | 63 | Updated to [inputmask-core@2.1.1](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#211--2015-09-11) 64 | 65 | Updates based on [inputmask-core@2.1.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#210--2015-07-15): 66 | 67 | * Added `placeholderChar` prop to configure the placeholder character. 68 | * The mask's pattern is now changed if the `pattern` prop changes - the user's input so far is replayed with the new pattern (with mixed results - TBD). 69 | 70 | UMD build is now available via npm in `dist/`. [[muffinresearch][muffinresearch]] 71 | 72 | ## 2.0.0 / 2015-04-07 73 | 74 | **Breaking change:** [inputmask-core@2.0.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#200--2015-04-03) is now required. 75 | 76 | Added undo/redo when Ctrl/Command + Z/Y are used. 77 | 78 | ## 1.1.0 / 2015-03-26 79 | 80 | Updated to [inputmask-core@1.2.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#120--2015-03-26) 81 | 82 | A `formatCharacters` prop can now be passed to configure input mask format characters. 83 | 84 | ## 1.0.0 / 2015-03-25 85 | 86 | Initial release features: 87 | 88 | * Based on [inputmask-core@1.1.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#110--2015-03-25) 89 | * Basic editing works: 90 | * Typing, backspacing, pasting, cutting and deleting 91 | * Invalid content will be ignored if typed or pasted 92 | * Static parts of the mask can't be modfied 93 | * Editing operations can handle text selections 94 | * Tested in latest versions of Firefox, Chrome, Opera and IE 95 | * Placeholder generation and display when the mask has no user input 96 | 97 | [jquense]: https://github.com/jquense 98 | [krvital]: https://github.com/krvital 99 | [muffinresearch]: https://github.com/muffinresearch 100 | [martyphee]: https://github.com/martyphee 101 | [nathanstitt]: https://github.com/nathanstitt 102 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) must be installed. 4 | 5 | ## Installation 6 | 7 | * Running `npm install` in the component's root directory will install everything you need for development. 8 | 9 | ## Demo Development Server 10 | 11 | * `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. 12 | 13 | ## Running Tests 14 | 15 | * `npm test` will run the tests once. 16 | * `npm run test:watch` will run the tests on every change. 17 | 18 | ## Building 19 | 20 | * `npm run build` will build the component for publishing to npm and also bundle the demo app. 21 | 22 | * `npm run clean` will delete built resources. 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Jonny Buchanan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `MaskedInput` 2 | 3 | A [React](http://facebook.github.io/react/) component for `` masking, built on top of [inputmask-core](https://github.com/insin/inputmask-core). 4 | 5 | ![This project has never been used by its author, other than while making it.](https://img.shields.io/badge/author--usage-never-red.png "This project has never been used by its author, other than while making it") 6 | 7 | ## [Live Demo](http://insin.github.io/react-maskedinput/) 8 | 9 | ## Install 10 | 11 | ### npm 12 | 13 | ``` 14 | npm install react-maskedinput --save 15 | ``` 16 | 17 | ### Browser bundle 18 | 19 | The browser bundle exposes a global `MaskedInput` variable and expects to find a global `React` variable to work with. 20 | 21 | * [react-maskedinput.js](https://unpkg.com/react-maskedinput/umd/react-maskedinput.js) (development version) 22 | * [react-maskedinput.min.js](https://unpkg.com/react-maskedinput/umd/react-maskedinput.min.js) (compressed production version) 23 | 24 | ## Usage 25 | 26 | Give `MaskedInput` a [`mask`](#mask-string) and an `onChange` callback: 27 | 28 | ```javascript 29 | import React from 'react' 30 | import MaskedInput from 'react-maskedinput' 31 | 32 | class CreditCardDetails extends React.Component { 33 | state = { 34 | card: '', 35 | expiry: '', 36 | ccv: '' 37 | } 38 | 39 | _onChange = (e) => { 40 | this.setState({[e.target.name]: e.target.value}) 41 | } 42 | 43 | render() { 44 | return
45 | 49 | 53 | 57 |
58 | } 59 | } 60 | ``` 61 | 62 | Create some wrapper components if you have a masking configuration which will be reused: 63 | 64 | ```javascript 65 | function CustomInput(props) { 66 | return 78 | } 79 | ``` 80 | 81 | ## Props 82 | 83 | ### `mask` : `string` 84 | 85 | The masking pattern to be applied to the ``. 86 | 87 | See the [inputmask-core docs](https://github.com/insin/inputmask-core#pattern) for supported formatting characters. 88 | 89 | ### `onChange` : `(event: SyntheticEvent) => any` 90 | 91 | A callback which will be called any time the mask's value changes. 92 | 93 | This will be passed a `SyntheticEvent` with the input accessible via `event.target` as usual. 94 | 95 | **Note:** this component currently calls `onChange` directly, it does not generate an `onChange` event which will bubble up like a regular `` component, so you *must* pass an `onChange` if you want to get a value back out. 96 | 97 | ### `formatCharacters`: `Object` 98 | 99 | Customised format character definitions for use in the pattern. 100 | 101 | See the [inputmask-core docs](https://github.com/insin/inputmask-core#formatcharacters) for details of the structure of this object. 102 | 103 | ### `placeholderChar`: `string` 104 | 105 | Customised placeholder character used to fill in editable parts of the pattern. 106 | 107 | See the [inputmask-core docs](https://github.com/insin/inputmask-core#placeholderchar--string) for details. 108 | 109 | ### `value` : `string` 110 | 111 | A default value for the mask. 112 | 113 | ### `placeholder` : `string` 114 | 115 | A default `placeholder` will be generated from the mask's pattern, but you can pass a `placeholder` prop to provide your own. 116 | 117 | ### `size` : `number | string` 118 | 119 | By default, the rendered ``'s `size` will be the length of the pattern, but you can pass a `size` prop to override this. 120 | 121 | ### Other props 122 | 123 | Any other props passed in will be passed as props to the rendered ``, except for the following, which are managed by the component: 124 | 125 | * `maxLength` - will always be equal to the pattern's `.length` 126 | * `onKeyDown`, `onKeyPress` & `onPaste` - will each trigger a call to `onChange` when necessary 127 | 128 | ## MIT Licensed 129 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import './style.css' 2 | 3 | import React from 'react' 4 | import {render} from 'react-dom' 5 | 6 | import MaskedInput from '../../src' 7 | 8 | const PATTERNS = [ 9 | '1111 1111', 10 | '111 111', 11 | '11 11', 12 | '1 1', 13 | ] 14 | 15 | class App extends React.Component { 16 | state = { 17 | card: '', 18 | expiry: '', 19 | ccv: '', 20 | plate: '', 21 | escaped: '', 22 | leading: '', 23 | custom: '', 24 | changing: '', 25 | pattern: '1111 1111', 26 | cardPattern: '1111 1111 1111 1111', 27 | } 28 | 29 | _onChange = (e) => { 30 | this.setState({[e.target.name]: e.target.value}) 31 | } 32 | 33 | _changePattern = (e) => { 34 | this.setState({pattern: e.target.value}) 35 | } 36 | 37 | _onCardChange = (e) => { 38 | if (/^3[47]/.test(e.target.value)) { 39 | this.setState({cardPattern: '1111 111111 11111'}) 40 | } 41 | else { 42 | this.setState({cardPattern: '1111 1111 1111 1111'}) 43 | } 44 | } 45 | 46 | render() { 47 | return
48 |

49 | <MaskedInput/> 50 |

51 |

A React component which creates a masked <input/>

52 |
53 | 54 | 55 |
56 |

You can even externally update the card state like a standard input element:

57 |
58 | 59 | 60 |
61 |

Placeholders are automatically generated but can be overridden with your own:

62 |
63 | 64 | 65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 |
74 |

Mask placeholder characters can be escaped with a leading \ to use them as static contents:

75 |
76 | 77 | 78 |
79 |

Leading static characters:

80 |
81 | 82 | 83 |
84 |

Changing patterns:

85 |
86 | 87 | 88 |
89 |
90 | 91 | 94 |
95 |

Dynamically changing the pattern as the user types:

96 |
97 | 98 | 99 |
100 |

Custom format character (W=[a-zA-Z0-9_], transformed to uppercase) and placeholder character (en space):

101 |
102 | 103 | 104 |
105 |
106 |
{JSON.stringify(this.state, null, 2)}
107 |
108 | 109 |
110 | } 111 | } 112 | 113 | let CustomInput = (props) => 114 | /\w/.test(char), 123 | transform: (char) => char.toUpperCase() 124 | } 125 | }} 126 | /> 127 | 128 | render(, document.getElementById('demo')) 129 | -------------------------------------------------------------------------------- /demo/src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | box-sizing: border-box; 3 | width: 550px; 4 | margin: 1em auto; 5 | padding: 0 1em; 6 | font-family: sans-serif; 7 | } 8 | code { 9 | font-size: 1.3em; 10 | } 11 | h1 { 12 | font-size: 3em; 13 | text-align: center; 14 | margin-top: 0; 15 | } 16 | p.lead { 17 | font-weight: bold; 18 | text-align: center; 19 | } 20 | hr { 21 | margin-top: 20px; 22 | margin-bottom: 20px; 23 | border: 0; 24 | border-top: 1px solid #222; 25 | } 26 | .form-field { 27 | margin-bottom: .5em; 28 | } 29 | label { 30 | display: inline-block; 31 | width: 7em; 32 | text-align: right; 33 | margin-right: .75em; 34 | } 35 | input { 36 | border: none; 37 | font-size: 1.5em; 38 | } 39 | footer { 40 | text-align: center; 41 | } 42 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(build) { 2 | var config = { 3 | type: 'react-component', 4 | npm: { 5 | umd: { 6 | externals: { 7 | 'react': 'React' 8 | }, 9 | global: 'MaskedInput' 10 | } 11 | } 12 | } 13 | 14 | if (/^build/.test(build.command)) { 15 | // Don't include default polyfills in the demo build 16 | config.polyfill = false 17 | } 18 | 19 | return config 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-maskedinput", 3 | "version": "4.0.2", 4 | "description": "Masked React component", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "es", 9 | "lib", 10 | "umd" 11 | ], 12 | "scripts": { 13 | "build": "nwb build-react-component", 14 | "clean": "nwb clean-module && nwb clean-demo", 15 | "lint": "eslint src tests", 16 | "start": "nwb serve-react-demo", 17 | "test": "nwb test-react", 18 | "posttest": "npm run lint", 19 | "test:watch": "nwb test-react --server" 20 | }, 21 | "dependencies": { 22 | "inputmask-core": "^2.1.1", 23 | "prop-types": "^15.5.7" 24 | }, 25 | "peerDependencies": { 26 | "react": "^0.14.9 || ^15.3.0 || ^16.0.0" 27 | }, 28 | "devDependencies": { 29 | "eslint-config-jonnybuchanan": "5.0.x", 30 | "nwb": "0.21.x", 31 | "react": "16.x", 32 | "react-dom": "16.x" 33 | }, 34 | "author": "Jonny Buchanan ", 35 | "homepage": "https://github.com/insin/react-maskedinput", 36 | "license": "MIT", 37 | "repository": { 38 | "type": "git", 39 | "url": "http://github.com/insin/react-maskedinput.git" 40 | }, 41 | "keywords": [ 42 | "react", 43 | "masked", 44 | "input", 45 | "react-component" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputMask from 'inputmask-core' 4 | 5 | let KEYCODE_Z = 90 6 | let KEYCODE_Y = 89 7 | 8 | function isUndo(e) { 9 | return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Y : KEYCODE_Z) 10 | } 11 | 12 | function isRedo(e) { 13 | return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Z : KEYCODE_Y) 14 | } 15 | 16 | function getSelection (el) { 17 | let start, end 18 | if (el.selectionStart !== undefined) { 19 | start = el.selectionStart 20 | end = el.selectionEnd 21 | } 22 | else { 23 | try { 24 | el.focus() 25 | let rangeEl = el.createTextRange() 26 | let clone = rangeEl.duplicate() 27 | 28 | rangeEl.moveToBookmark(document.selection.createRange().getBookmark()) 29 | clone.setEndPoint('EndToStart', rangeEl) 30 | 31 | start = clone.text.length 32 | end = start + rangeEl.text.length 33 | } 34 | catch (e) { /* not focused or not visible */ } 35 | } 36 | 37 | return { start, end } 38 | } 39 | 40 | function setSelection(el, selection) { 41 | try { 42 | if (el.selectionStart !== undefined) { 43 | el.focus() 44 | el.setSelectionRange(selection.start, selection.end) 45 | } 46 | else { 47 | el.focus() 48 | let rangeEl = el.createTextRange() 49 | rangeEl.collapse(true) 50 | rangeEl.moveStart('character', selection.start) 51 | rangeEl.moveEnd('character', selection.end - selection.start) 52 | rangeEl.select() 53 | } 54 | } 55 | catch (e) { /* not focused or not visible */ } 56 | } 57 | 58 | class MaskedInput extends React.Component { 59 | static propTypes = { 60 | mask: PropTypes.string.isRequired, 61 | 62 | formatCharacters: PropTypes.object, 63 | placeholderChar: PropTypes.string 64 | } 65 | 66 | static defaultProps = { 67 | value: '' 68 | } 69 | 70 | /* eslint-disable camelcase */ 71 | UNSAFE_componentWillMount() { 72 | let options = { 73 | pattern: this.props.mask, 74 | value: this.props.value, 75 | formatCharacters: this.props.formatCharacters 76 | } 77 | if (this.props.placeholderChar) { 78 | options.placeholderChar = this.props.placeholderChar 79 | } 80 | this.mask = new InputMask(options) 81 | } 82 | 83 | /* eslint-disable camelcase */ 84 | UNSAFE_componentWillReceiveProps(nextProps) { 85 | if (this.props.mask !== nextProps.mask && this.props.value !== nextProps.mask) { 86 | // if we get a new value and a new mask at the same time 87 | // check if the mask.value is still the initial value 88 | // - if so use the nextProps value 89 | // - otherwise the `this.mask` has a value for us (most likely from paste action) 90 | if (this.mask.getValue() === this.mask.emptyValue) { 91 | this.mask.setPattern(nextProps.mask, {value: nextProps.value}) 92 | } 93 | else { 94 | this.mask.setPattern(nextProps.mask, {value: this.mask.getRawValue()}) 95 | } 96 | } 97 | else if (this.props.mask !== nextProps.mask) { 98 | this.mask.setPattern(nextProps.mask, {value: this.mask.getRawValue()}) 99 | } 100 | else if (this.props.value !== nextProps.value) { 101 | this.mask.setValue(nextProps.value) 102 | } 103 | } 104 | 105 | /* eslint-disable camelcase */ 106 | UNSAFE_componentWillUpdate(nextProps, nextState) { 107 | if (nextProps.mask !== this.props.mask) { 108 | this._updatePattern(nextProps) 109 | } 110 | } 111 | 112 | componentDidUpdate(prevProps) { 113 | if (prevProps.mask !== this.props.mask && this.mask.selection.start) { 114 | this._updateInputSelection() 115 | } 116 | } 117 | 118 | _updatePattern(props) { 119 | this.mask.setPattern(props.mask, { 120 | value: this.mask.getRawValue(), 121 | selection: getSelection(this.input) 122 | }) 123 | } 124 | 125 | _updateMaskSelection() { 126 | this.mask.selection = getSelection(this.input) 127 | } 128 | 129 | _updateInputSelection() { 130 | setSelection(this.input, this.mask.selection) 131 | } 132 | 133 | _onChange = (e) => { 134 | // console.log('onChange', JSON.stringify(getSelection(this.input)), e.target.value) 135 | 136 | let maskValue = this.mask.getValue() 137 | let incomingValue = e.target.value 138 | if (incomingValue !== maskValue) { // only modify mask if form contents actually changed 139 | this._updateMaskSelection() 140 | this.mask.setValue(incomingValue) // write the whole updated value into the mask 141 | e.target.value = this._getDisplayValue() // update the form with pattern applied to the value 142 | this._updateInputSelection() 143 | } 144 | 145 | if (this.props.onChange) { 146 | this.props.onChange(e) 147 | } 148 | } 149 | 150 | _onKeyDown = (e) => { 151 | // console.log('onKeyDown', JSON.stringify(getSelection(this.input)), e.key, e.target.value) 152 | 153 | if (isUndo(e)) { 154 | e.preventDefault() 155 | if (this.mask.undo()) { 156 | e.target.value = this._getDisplayValue() 157 | this._updateInputSelection() 158 | if (this.props.onChange) { 159 | this.props.onChange(e) 160 | } 161 | } 162 | return 163 | } 164 | else if (isRedo(e)) { 165 | e.preventDefault() 166 | if (this.mask.redo()) { 167 | e.target.value = this._getDisplayValue() 168 | this._updateInputSelection() 169 | if (this.props.onChange) { 170 | this.props.onChange(e) 171 | } 172 | } 173 | return 174 | } 175 | 176 | if (e.key === 'Backspace') { 177 | e.preventDefault() 178 | this._updateMaskSelection() 179 | if (this.mask.backspace()) { 180 | let value = this._getDisplayValue() 181 | e.target.value = value 182 | if (value) { 183 | this._updateInputSelection() 184 | } 185 | if (this.props.onChange) { 186 | this.props.onChange(e) 187 | } 188 | } 189 | } 190 | } 191 | 192 | _onKeyPress = (e) => { 193 | // console.log('onKeyPress', JSON.stringify(getSelection(this.input)), e.key, e.target.value) 194 | 195 | // Ignore modified key presses 196 | // Ignore enter key to allow form submission 197 | if (e.metaKey || e.altKey || e.ctrlKey || e.key === 'Enter') { return } 198 | 199 | e.preventDefault() 200 | this._updateMaskSelection() 201 | if (this.mask.input((e.key || e.data))) { 202 | e.target.value = this.mask.getValue() 203 | this._updateInputSelection() 204 | if (this.props.onChange) { 205 | this.props.onChange(e) 206 | } 207 | } 208 | } 209 | 210 | _onPaste = (e) => { 211 | // console.log('onPaste', JSON.stringify(getSelection(this.input)), e.clipboardData.getData('Text'), e.target.value) 212 | 213 | e.preventDefault() 214 | this._updateMaskSelection() 215 | // getData value needed for IE also works in FF & Chrome 216 | if (this.mask.paste(e.clipboardData.getData('Text'))) { 217 | e.target.value = this.mask.getValue() 218 | // Timeout needed for IE 219 | setTimeout(() => this._updateInputSelection(), 0) 220 | if (this.props.onChange) { 221 | this.props.onChange(e) 222 | } 223 | } 224 | } 225 | 226 | _getDisplayValue() { 227 | let value = this.mask.getValue() 228 | return value === this.mask.emptyValue ? '' : value 229 | } 230 | 231 | _keyPressPropName() { 232 | if (typeof navigator !== 'undefined') { 233 | return navigator.userAgent.match(/Android/i) 234 | ? 'onBeforeInput' 235 | : 'onKeyPress' 236 | } 237 | return 'onKeyPress' 238 | } 239 | 240 | _getEventHandlers() { 241 | return { 242 | onChange: this._onChange, 243 | onKeyDown: this._onKeyDown, 244 | onPaste: this._onPaste, 245 | [this._keyPressPropName()]: this._onKeyPress 246 | } 247 | } 248 | 249 | focus() { 250 | this.input.focus() 251 | } 252 | 253 | blur() { 254 | this.input.blur() 255 | } 256 | 257 | render() { 258 | let ref = r => { this.input = r } 259 | let maxLength = this.mask.pattern.length 260 | let value = this._getDisplayValue() 261 | let eventHandlers = this._getEventHandlers() 262 | let { size = maxLength, placeholder = this.mask.emptyValue } = this.props 263 | 264 | let { placeholderChar, formatCharacters, ...cleanedProps } = this.props // eslint-disable-line no-unused-vars 265 | let inputProps = { ...cleanedProps, ...eventHandlers, ref, maxLength, value, size, placeholder } 266 | return 267 | } 268 | } 269 | 270 | export default MaskedInput 271 | -------------------------------------------------------------------------------- /tests/index-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import expect from 'expect' 5 | import MaskedInput from 'src' 6 | 7 | const setup = () => { 8 | const element = document.createElement('div') 9 | document.body.appendChild(element) 10 | return element 11 | } 12 | 13 | const cleanup = (element) => { 14 | ReactDOM.unmountComponentAtNode(element) 15 | document.body.removeChild(element) 16 | } 17 | 18 | describe('MaskedInput', () => { 19 | it('should render (smokescreen test)', () => { 20 | expect.spyOn(console, 'error') 21 | expect().toExist() 22 | expect(console.error.calls[0].arguments[0]).toMatch( 23 | new RegExp('Warning: Failed prop type:') 24 | ) 25 | expect(console.error.calls[0].arguments[0]).toMatch( 26 | new RegExp('`mask`') 27 | ) 28 | expect(console.error.calls[0].arguments[0]).toMatch( 29 | new RegExp('required', 'i') 30 | ) 31 | console.error.restore() 32 | }) 33 | 34 | it('should handle a masking workflow', () => { 35 | const el = setup() 36 | let ref = null 37 | ReactDOM.render( 38 | { 40 | if (r) ref = r 41 | }} 42 | mask="11/11" 43 | />, 44 | el 45 | ) 46 | const input = ReactDOM.findDOMNode(ref) 47 | 48 | // initial state 49 | expect(input.value).toBe('') 50 | expect(input.placeholder).toBe('__/__') 51 | expect(input.size).toBe(5) 52 | 53 | cleanup(el) 54 | }) 55 | 56 | it('should handle updating mask', () => { 57 | const el = setup() 58 | let ref = null 59 | let defaultMask = '1111 1111 1111 1111' 60 | let amexMask = '1111 111111 11111' 61 | 62 | function render(props) { 63 | ReactDOM.render( 64 | { 66 | if (r) ref = r 67 | }} 68 | {...props} 69 | />, 70 | el 71 | ) 72 | } 73 | 74 | render({mask: defaultMask}) 75 | let input = ReactDOM.findDOMNode(ref) 76 | 77 | // initial state 78 | expect(input.value).toBe('') 79 | expect(input.placeholder).toBe('____ ____ ____ ____') 80 | expect(input.size).toBe(19) 81 | expect(input.selectionStart).toBe(0) 82 | 83 | render({mask: amexMask}) 84 | input = ReactDOM.findDOMNode(ref) 85 | 86 | // initial state 87 | expect(input.value).toBe('') 88 | expect(input.placeholder).toBe('____ ______ _____') 89 | expect(input.size).toBe(17) 90 | expect(input.selectionStart).toBe(0) 91 | 92 | cleanup(el) 93 | }) 94 | 95 | it('should handle updating value', () => { 96 | const el = setup() 97 | let ref = null 98 | let defaultMask = '1111 1111 1111 1111' 99 | 100 | function render(props) { 101 | ReactDOM.render( 102 | { ref = r }} 104 | {...props} 105 | />, 106 | el 107 | ) 108 | } 109 | 110 | render({mask: defaultMask, value: ''}) 111 | let input = ReactDOM.findDOMNode(ref) 112 | 113 | // initial state 114 | expect(input.value).toBe('') 115 | expect(input.placeholder).toBe('____ ____ ____ ____') 116 | expect(input.size).toBe(19) 117 | expect(input.selectionStart).toBe(0) 118 | 119 | // update value 120 | render({mask: defaultMask, value: '4111111111111111'}) 121 | input = ReactDOM.findDOMNode(ref) 122 | 123 | // initial state 124 | expect(input.value).toBe('4111 1111 1111 1111') 125 | expect(input.size).toBe(19) 126 | expect(input.selectionStart).toBe(19) 127 | 128 | cleanup(el) 129 | }) 130 | 131 | it('should handle updating mask and value', () => { 132 | const el = setup() 133 | let ref = null 134 | let defaultMask = '1111 1111 1111 1111' 135 | let amexMask = '1111 111111 11111' 136 | let value = '' 137 | let mask = defaultMask 138 | 139 | function render(props) { 140 | ReactDOM.render( 141 | { ref = r }} 143 | {...props} 144 | />, 145 | el 146 | ) 147 | } 148 | 149 | render({mask, value}) 150 | let input = ReactDOM.findDOMNode(ref) 151 | 152 | // initial state 153 | expect(input.value).toBe('') 154 | expect(input.placeholder).toBe('____ ____ ____ ____') 155 | expect(input.size).toBe(19) 156 | expect(input.selectionStart).toBe(0) 157 | 158 | // update mask and value 159 | render({mask: amexMask, value: '378282246310005'}) 160 | input = ReactDOM.findDOMNode(ref) 161 | 162 | // initial state 163 | expect(input.value).toBe('3782 822463 10005') 164 | expect(input.size).toBe(17) 165 | expect(input.selectionStart).toBe(17) 166 | 167 | cleanup(el) 168 | }) 169 | 170 | it('should remove leftover placeholder characters when switching to smaller mask', () => { 171 | const el = setup() 172 | let ref = null 173 | let defaultMask = '1111 1111 1111 1111' 174 | let amexMask = '1111 111111 11111' 175 | let mask = defaultMask 176 | let value = null 177 | 178 | function render(props) { 179 | ReactDOM.render( 180 | { 182 | if (r) ref = r 183 | }} 184 | mask={mask} 185 | value={value} 186 | />, 187 | el 188 | ) 189 | } 190 | 191 | render() 192 | let input = ReactDOM.findDOMNode(ref) 193 | 194 | // initial state 195 | expect(input.value).toBe('') 196 | expect(input.placeholder).toBe('____ ____ ____ ____') 197 | expect(input.size).toBe(19) 198 | expect(input.selectionStart).toBe(0) 199 | 200 | mask = amexMask 201 | value = '1234 123456 12345' 202 | render() 203 | input = ReactDOM.findDOMNode(ref) 204 | 205 | // initial state 206 | expect(input.value).toBe('1234 123456 12345') 207 | expect(input.size).toBe(17) 208 | 209 | cleanup(el) 210 | }) 211 | 212 | it('cleans props from input', () => { 213 | const el = setup() 214 | let ref = null 215 | let defaultMask = '1111 1111 1111 1111' 216 | function render(props) { 217 | ReactDOM.render( 218 | { ref = r }} {...props} />, 219 | el 220 | ) 221 | } 222 | expect.spyOn(console, 'error') 223 | render({ 224 | mask: defaultMask, 225 | value: '', 226 | placeholderChar: 'X', 227 | formatCharacters: {A: null} 228 | }) 229 | expect(console.error).toNotHaveBeenCalled() 230 | console.error.restore() 231 | let input = ReactDOM.findDOMNode(ref) 232 | expect(input.getAttribute('placeholderChar')).toNotExist() 233 | expect(input.getAttribute('formatCharacters')).toNotExist() 234 | cleanup(el) 235 | }) 236 | 237 | it('should handle updating multiple values', () => { 238 | const el = setup() 239 | let ref = null 240 | let defaultMask = '1111 1111 1111 1111' 241 | const mastercard = '5555555555554444' 242 | const visa = '4111111111111111' 243 | 244 | function render(props) { 245 | ReactDOM.render( 246 | { ref = r }} 248 | {...props} 249 | />, 250 | el 251 | ) 252 | } 253 | 254 | render({mask: defaultMask, value: ''}) 255 | let input = ReactDOM.findDOMNode(ref) 256 | 257 | // initial state 258 | expect(input.value).toBe('') 259 | expect(input.placeholder).toBe('____ ____ ____ ____') 260 | expect(input.size).toBe(19) 261 | expect(input.selectionStart).toBe(0) 262 | 263 | // update mask and value 264 | render({mask: defaultMask, value: visa}) 265 | input = ReactDOM.findDOMNode(ref) 266 | 267 | // initial state 268 | expect(input.value).toBe('4111 1111 1111 1111') 269 | expect(input.size).toBe(19) 270 | expect(input.selectionStart).toBe(19) 271 | 272 | // update mask and value 273 | render({mask: defaultMask, value: mastercard}) 274 | input = ReactDOM.findDOMNode(ref) 275 | 276 | // initial state 277 | expect(input.value).toBe('5555 5555 5555 4444') 278 | expect(input.size).toBe(19) 279 | expect(input.selectionStart).toBe(19) 280 | 281 | cleanup(el) 282 | }) 283 | }) 284 | --------------------------------------------------------------------------------