├── .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 | - 4 4 | - 6 5 | script: npm test 6 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 4.0.0 / 2017-07-04 2 | 3 | * Potential breaking change as the `peerDependencies` range has been changed from `"0.14.x || 15.x"` to `"^0.14.9 || ^15.3.0"`. 4 | 5 | * 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]] 6 | 7 | * Update nwb to 0.17.x: 8 | * `module` config replaces `jsnext:main` in `package.json` to specify the location of the ES6 modules build. 9 | * `prop-types` is bundled with the UMD development build and stripped from the production build, along with usage of `propTypes`. 10 | 11 | ## 3.3.4 / 2016-12-15 12 | 13 | * Silence React 15.4 invalid property warnings [[#80](https://github.com/insin/react-maskedinput/pull/80)] [[nathanstitt][nathanstitt]] 14 | 15 | ## 3.3.2 / 2016-12-01 16 | 17 | * Fix for both Android and MS Edge input entering 18 | 19 | ## 3.2.0 / 2016-05-24 20 | 21 | * Allow dynamic pattern updating [[martyphee][martyphee]] 22 | 23 | ## 3.1.3 / 2016-05-02 24 | 25 | * Don’t call `onChange` function if undefined. 26 | * Update nwb to 0.9.x 27 | 28 | ## 3.1.2 / 2016-04-11 29 | 30 | * Support for React 15.x.x 31 | 32 | ## 3.1.1 / 2016-03-09 33 | 34 | * Convert tooling to use [nwb](https://github.com/insin/nwb/) [[bpugh]][[bpugh]] 35 | * Publish `dist` files 36 | 37 | ## 3.1.0 / 2016-02-11 38 | 39 | * Added support for `value` behaving as a controlled component. 40 | 41 | ## 3.0.0 / 2015-10-23 42 | 43 | **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. 44 | 45 | **Breaking change:** React >= 0.14 is now required. 46 | 47 | React 0.14 compatibility. [[jquense][jquense]] 48 | 49 | Updated to [inputmask-core@2.1.1](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#211--2015-09-11) 50 | 51 | Updates based on [inputmask-core@2.1.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#210--2015-07-15): 52 | 53 | * Added `placeholderChar` prop to configure the placeholder character. 54 | * 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). 55 | 56 | UMD build is now available via npm in `dist/`. [[muffinresearch][muffinresearch]] 57 | 58 | ## 2.0.0 / 2015-04-07 59 | 60 | **Breaking change:** [inputmask-core@2.0.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#200--2015-04-03) is now required. 61 | 62 | Added undo/redo when Ctrl/Command + Z/Y are used. 63 | 64 | ## 1.1.0 / 2015-03-26 65 | 66 | Updated to [inputmask-core@1.2.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#120--2015-03-26) 67 | 68 | A `formatCharacters` prop can now be passed to configure input mask format characters. 69 | 70 | ## 1.0.0 / 2015-03-25 71 | 72 | Initial release features: 73 | 74 | * Based on [inputmask-core@1.1.0](https://github.com/insin/inputmask-core/blob/master/CHANGES.md#110--2015-03-25) 75 | * Basic editing works: 76 | * Typing, backspacing, pasting, cutting and deleting 77 | * Invalid content will be ignored if typed or pasted 78 | * Static parts of the mask can't be modfied 79 | * Editing operations can handle text selections 80 | * Tested in latest versions of Firefox, Chrome, Opera and IE 81 | * Placeholder generation and display when the mask has no user input 82 | 83 | [jquense]: https://github.com/jquense 84 | [krvital]: https://github.com/krvital 85 | [muffinresearch]: https://github.com/muffinresearch 86 | [martyphee]: https://github.com/martyphee 87 | [nathanstitt]: https://github.com/nathanstitt 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites 2 | 3 | [Node.js](http://nodejs.org/) must be installed. 4 | 5 | ## Installation 6 | 7 | * Running `npm install` in the components'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 | `MaskedInput` can be used on the server, or bundled for the client using an npm-compatible packaging system such as [Browserify](http://browserify.org/) or [webpack](http://webpack.github.io/). 14 | 15 | ``` 16 | npm install react-maskedinput --save 17 | ``` 18 | 19 | ### Browser bundle 20 | 21 | The browser bundle exposes a global `MaskedInput` variable and expects to find a global `React` (>= 0.14.0) variable to work with. 22 | 23 | * [react-maskedinput.js](https://unpkg.com/react-maskedinput/umd/react-maskedinput.js) (development version) 24 | * [react-maskedinput.min.js](https://unpkg.com/react-maskedinput/umd/react-maskedinput.min.js) (compressed production version) 25 | 26 | ## Usage 27 | 28 | Give `MaskedInput` a [`mask`](#mask-string) and an `onChange` callback: 29 | 30 | ```javascript 31 | var React = require('react') 32 | var MaskedInput = require('react-maskedinput') 33 | 34 | var CreditCardDetails = React.createClass({ 35 | state: { 36 | card: '', 37 | expiry: '', 38 | ccv: '' 39 | }, 40 | 41 | _onChange(e) { 42 | var stateChange = {} 43 | stateChange[e.target.name] = e.target.value 44 | this.setState(stateChange) 45 | }, 46 | 47 | render() { 48 | return
49 | 53 | 57 | 61 |
62 | } 63 | }) 64 | ``` 65 | 66 | Create some wrapper components if you have a masking configuration which will be reused: 67 | 68 | ```javascript 69 | var CustomInput = React.createClass({ 70 | render() { 71 | return 83 | } 84 | }) 85 | ``` 86 | 87 | ## Props 88 | 89 | ### `mask` : `string` 90 | 91 | The masking pattern to be applied to the ``. 92 | 93 | See the [inputmask-core docs](https://github.com/insin/inputmask-core#pattern) for supported formatting characters. 94 | 95 | ### `onChange` : `(event: SyntheticEvent) => any` 96 | 97 | A callback which will be called any time the mask's value changes. 98 | 99 | This will be passed a `SyntheticEvent` with the input accessible via `event.target` as usual. 100 | 101 | **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. 102 | 103 | ### `formatCharacters`: `Object` 104 | 105 | Customised format character definitions for use in the pattern. 106 | 107 | See the [inputmask-core docs](https://github.com/insin/inputmask-core#formatcharacters) for details of the structure of this object. 108 | 109 | ### `placeholderChar`: `string` 110 | 111 | Customised placeholder character used to fill in editable parts of the pattern. 112 | 113 | See the [inputmask-core docs](https://github.com/insin/inputmask-core#placeholderchar--string) for details. 114 | 115 | ### `value` : `string` 116 | 117 | A default value for the mask. 118 | 119 | ### `placeholder` : `string` 120 | 121 | A default `placeholder` will be generated from the mask's pattern, but you can pass a `placeholder` prop to provide your own. 122 | 123 | ### `size` : `number | string` 124 | 125 | By default, the rendered ``'s `size` will be the length of the pattern, but you can pass a `size` prop to override this. 126 | 127 | ### Other props 128 | 129 | Any other props passed in will be passed as props to the rendered ``, except for the following, which are managed by the component: 130 | 131 | * `maxLength` - will always be equal to the pattern's `.length` 132 | * `onKeyDown`, `onKeyPress` & `onPaste` - will each trigger a call to `onChange` when necessary 133 | 134 | ## MIT Licensed 135 | -------------------------------------------------------------------------------- /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 | const stateChange = {} 31 | stateChange[e.target.name] = e.target.value 32 | this.setState(stateChange) 33 | } 34 | 35 | _changePattern = (e) => { 36 | this.setState({pattern: e.target.value}) 37 | } 38 | 39 | _onCardChange = (e) => { 40 | if (/^3[47]/.test(e.target.value)) { 41 | this.setState({cardPattern: '1111 111111 11111'}) 42 | } 43 | else { 44 | this.setState({cardPattern: '1111 1111 1111 1111'}) 45 | } 46 | } 47 | 48 | render() { 49 | return
50 |

51 | <MaskedInput/> 52 |

53 |

A React component which creates a masked <input/>

54 |
55 | 56 | 57 |
58 |

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

59 |
60 | 61 | 62 |
63 |

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

64 |
65 | 66 | 67 |
68 |
69 | 70 | 71 |
72 |
73 | 74 | 75 |
76 |

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

77 |
78 | 79 | 80 |
81 |

Leading static characters:

82 |
83 | 84 | 85 |
86 |

Changing patterns:

87 |
88 | 89 | 90 |
91 |
92 | 93 | 96 |
97 |

Dynamically changing the pattern as the user types:

98 |
99 | 100 | 101 |
102 |

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

103 |
104 | 105 | 106 |
107 |
108 |
{JSON.stringify(this.state, null, 2)}
109 |
110 | 111 |
112 | } 113 | } 114 | 115 | const CustomInput = (props) => 116 | 129 | 130 | render(, document.getElementById('demo')) 131 | -------------------------------------------------------------------------------- /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 | // Prevent React 15.x triggering inclusion of the Node.js process shim in the 18 | // demo build. 19 | config.webpack = { 20 | extra: { 21 | node: { 22 | process: false 23 | } 24 | } 25 | } 26 | } 27 | 28 | return config 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zeit/react-maskedinput", 3 | "version": "4.0.1", 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.17.x", 31 | "react": "15.x", 32 | "react-dom": "15.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 | var KEYCODE_Z = 90 6 | var 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 | var start, end, rangeEl, clone 18 | 19 | if (el.selectionStart !== undefined) { 20 | start = el.selectionStart 21 | end = el.selectionEnd 22 | } 23 | else { 24 | try { 25 | el.focus() 26 | rangeEl = el.createTextRange() 27 | clone = rangeEl.duplicate() 28 | 29 | rangeEl.moveToBookmark(document.selection.createRange().getBookmark()) 30 | clone.setEndPoint('EndToStart', rangeEl) 31 | 32 | start = clone.text.length 33 | end = start + rangeEl.text.length 34 | } 35 | catch (e) { /* not focused or not visible */ } 36 | } 37 | 38 | return { start, end } 39 | } 40 | 41 | function setSelection(el, selection) { 42 | var rangeEl 43 | 44 | try { 45 | if (el.selectionStart !== undefined) { 46 | el.focus() 47 | el.setSelectionRange(selection.start, selection.end) 48 | } 49 | else { 50 | el.focus() 51 | rangeEl = el.createTextRange() 52 | rangeEl.collapse(true) 53 | rangeEl.moveStart('character', selection.start) 54 | rangeEl.moveEnd('character', selection.end - selection.start) 55 | rangeEl.select() 56 | } 57 | } 58 | catch (e) { /* not focused or not visible */ } 59 | } 60 | 61 | class MaskedInput extends React.Component { 62 | constructor(props) { 63 | super(props) 64 | 65 | this._onChange = this._onChange.bind(this) 66 | this._onKeyDown = this._onKeyDown.bind(this) 67 | this._onPaste = this._onPaste.bind(this) 68 | this._onKeyPress = this._onKeyPress.bind(this) 69 | this._updateInputSelection = this._updateInputSelection.bind(this) 70 | } 71 | 72 | componentWillMount() { 73 | var options = { 74 | pattern: this.props.mask, 75 | value: this.props.value, 76 | formatCharacters: this.props.formatCharacters 77 | } 78 | if (this.props.placeholderChar) { 79 | options.placeholderChar = this.props.placeholderChar 80 | } 81 | this.mask = new InputMask(options) 82 | } 83 | 84 | 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 | componentWillUpdate(nextProps, nextState) { 106 | if (nextProps.mask !== this.props.mask) { 107 | this._updatePattern(nextProps) 108 | } 109 | } 110 | 111 | componentDidUpdate(prevProps) { 112 | if (prevProps.mask !== this.props.mask && this.mask.selection.start) { 113 | this._updateInputSelection() 114 | } 115 | } 116 | 117 | _updatePattern(props) { 118 | this.mask.setPattern(props.mask, { 119 | value: this.mask.getRawValue(), 120 | selection: getSelection(this.input) 121 | }) 122 | } 123 | 124 | _updateMaskSelection() { 125 | this.mask.selection = getSelection(this.input) 126 | } 127 | 128 | _updateInputSelection() { 129 | setSelection(this.input, this.mask.selection) 130 | } 131 | 132 | _onChange(e) { 133 | // console.log('onChange', JSON.stringify(getSelection(this.input)), e.target.value) 134 | 135 | var maskValue = this.mask.getValue() 136 | var incomingValue = e.target.value 137 | if (incomingValue !== maskValue) { // only modify mask if form contents actually changed 138 | this._updateMaskSelection() 139 | this.mask.setValue(incomingValue) // write the whole updated value into the mask 140 | e.target.value = this._getDisplayValue() // update the form with pattern applied to the value 141 | this._updateInputSelection() 142 | } 143 | 144 | if (this.props.onChange) { 145 | this.props.onChange(e) 146 | } 147 | } 148 | 149 | _onKeyDown(e) { 150 | // console.log('onKeyDown', JSON.stringify(getSelection(this.input)), e.key, e.target.value) 151 | 152 | if (isUndo(e)) { 153 | e.preventDefault() 154 | if (this.mask.undo()) { 155 | e.target.value = this._getDisplayValue() 156 | this._updateInputSelection() 157 | if (this.props.onChange) { 158 | this.props.onChange(e) 159 | } 160 | } 161 | return 162 | } 163 | else if (isRedo(e)) { 164 | e.preventDefault() 165 | if (this.mask.redo()) { 166 | e.target.value = this._getDisplayValue() 167 | this._updateInputSelection() 168 | if (this.props.onChange) { 169 | this.props.onChange(e) 170 | } 171 | } 172 | return 173 | } 174 | 175 | if (e.key === 'Backspace') { 176 | e.preventDefault() 177 | this._updateMaskSelection() 178 | if (this.mask.backspace()) { 179 | var value = this._getDisplayValue() 180 | e.target.value = value 181 | if (value) { 182 | this._updateInputSelection() 183 | } 184 | if (this.props.onChange) { 185 | this.props.onChange(e) 186 | } 187 | } 188 | } 189 | } 190 | 191 | _onKeyPress(e) { 192 | // console.log('onKeyPress', JSON.stringify(getSelection(this.input)), e.key, e.target.value) 193 | 194 | // Ignore modified key presses 195 | // Ignore enter key to allow form submission 196 | if (e.metaKey || e.altKey || e.ctrlKey || e.key === 'Enter') { return } 197 | 198 | e.preventDefault() 199 | this._updateMaskSelection() 200 | if (this.mask.input((e.key || e.data))) { 201 | e.target.value = this.mask.getValue() 202 | this._updateInputSelection() 203 | if (this.props.onChange) { 204 | this.props.onChange(e) 205 | } 206 | } 207 | } 208 | 209 | _onPaste(e) { 210 | // console.log('onPaste', JSON.stringify(getSelection(this.input)), e.clipboardData.getData('Text'), e.target.value) 211 | 212 | e.preventDefault() 213 | this._updateMaskSelection() 214 | // getData value needed for IE also works in FF & Chrome 215 | if (this.mask.paste(e.clipboardData.getData('Text'))) { 216 | e.target.value = this.mask.getValue() 217 | // Timeout needed for IE 218 | setTimeout(this._updateInputSelection.bind(this), 0) 219 | if (this.props.onChange) { 220 | this.props.onChange(e) 221 | } 222 | } 223 | } 224 | 225 | _getDisplayValue() { 226 | var value = this.mask.getValue() 227 | return value === this.mask.emptyValue ? '' : value 228 | } 229 | 230 | _keyPressPropName() { 231 | if (typeof navigator !== 'undefined') { 232 | return navigator.userAgent.match(/Android/i) 233 | ? 'onBeforeInput' 234 | : 'onKeyPress' 235 | } 236 | return 'onKeyPress' 237 | } 238 | 239 | _getEventHandlers() { 240 | return { 241 | onChange: this._onChange, 242 | onKeyDown: this._onKeyDown, 243 | onPaste: this._onPaste, 244 | [this._keyPressPropName()]: this._onKeyPress 245 | } 246 | } 247 | 248 | focus() { 249 | this.input.focus() 250 | } 251 | 252 | blur() { 253 | this.input.blur() 254 | } 255 | 256 | render() { 257 | var ref = r => { this.input = r } 258 | var maxLength = this.mask.pattern.length 259 | var value = this._getDisplayValue() 260 | var eventHandlers = this._getEventHandlers() 261 | var { size = maxLength, placeholder = this.mask.emptyValue } = this.props 262 | 263 | var { placeholderChar, formatCharacters, ...cleanedProps } = this.props // eslint-disable-line 264 | var inputProps = { ...cleanedProps, ...eventHandlers, ref, maxLength, value, size, placeholder } 265 | return 266 | } 267 | } 268 | 269 | MaskedInput.propTypes = { 270 | mask: PropTypes.string.isRequired, 271 | 272 | formatCharacters: PropTypes.object, 273 | placeholderChar: PropTypes.string 274 | } 275 | 276 | MaskedInput.defaultProps = { 277 | value: '' 278 | } 279 | 280 | export default MaskedInput 281 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------