diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..acede28 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,13 @@ +{ + "extends": ["standard", "standard-react"], + "rules": { + "brace-style": [2, "stroustrup", {"allowSingleLine": true}], + "eqeqeq": [2, "smart"], + "jsx-quotes": [2, "prefer-double"], + "react/prop-types": 0, + "react/self-closing-comp": 0, + "react/wrap-multilines": 0, + "space-before-function-paren": 0 + }, + "parser": "babel-eslint" +} diff --git a/.gitignore b/.gitignore index 46f1072..4994ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ -lib/ -node_modules/ \ No newline at end of file +/coverage +/demo/dist +/es6 +/lib +/node_modules +/umd +npm-debug.log diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 3ccf497..0000000 --- a/.jshintrc +++ /dev/null @@ -1,24 +0,0 @@ -{ - "browser": true, - "node": true, - - "curly": true, - "devel": true, - "globals": { - }, - "noempty": true, - "newcap": false, - "undef": true, - "unused": "vars", - - "asi": true, - "boss": true, - "eqnull": true, - "expr": true, - "funcscope": true, - "globalstrict": true, - "laxbreak": true, - "laxcomma": true, - "loopfunc": true, - "sub": true -} \ No newline at end of file diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 564d394..0000000 --- a/.npmignore +++ /dev/null @@ -1,7 +0,0 @@ -demo -src -test -.gitignore -.jshintrc -.travis.yml -gulpfile.js diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5c036ab --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: node_js +node_js: + - "4" + - "5" +script: npm test diff --git a/CHANGES.md b/CHANGES.md index 80d52b9..a327b7e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,26 @@ -## 3.1.0 / 2015-10-23 +## 3.3.2 / 2016-12-01 + +* Fix for both Android and MS Edge input entering + +## 3.2.0 / 2016-05-24 + +* Allow dynamic pattern updating [[martyphee][martyphee]] + +## 3.1.3 / 2016-05-02 + +* Don’t call `onChange` function if undefined. +* Update nwb to 0.9.x + +## 3.1.2 / 2016-04-11 + +* Support for React 15.x.x + +## 3.1.1 / 2016-03-09 + +* Convert tooling to use [nwb](https://github.com/insin/nwb/) [[bpugh]][[bpugh]] +* Publish `dist` files + +## 3.1.0 / 2016-02-11 * Added support for `value` behaving as a controlled component. @@ -46,3 +68,4 @@ Initial release features: [jquense]: https://github.com/jquense [muffinresearch]: https://github.com/muffinresearch +[martyphee]: https://github.com/martyphee diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bc86b50 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +## Prerequisites + +[Node.js](http://nodejs.org/) must be installed. + +## Installation + +* Running `npm install` in the components's root directory will install everything you need for development. + +## Demo Development Server + +* `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading. + +## Running Tests + +* `npm test` will run the tests once. +* `npm run test:watch` will run the tests on every change. + +## Building + +* `npm run build` will build the component for publishing to npm and also bundle the demo app. + +* `npm run clean` will delete built resources. diff --git a/README.md b/README.md index e0d1898..cbfb565 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ npm install react-maskedinput --save The browser bundle exposes a global `MaskedInput` variable and expects to find a global `React` (>= 0.14.0) variable to work with. -You can find it in the [/dist directory](https://github.com/insin/react-maskedinput/tree/master/dist). +* [react-maskedinput.js](https://unpkg.com/react-maskedinput/umd/react-maskedinput.js) (development version) +* [react-maskedinput.min.js](https://unpkg.com/react-maskedinput/umd/react-maskedinput.min.js) (compressed production version) ## Usage diff --git a/demo/index.html b/demo/src/index.js similarity index 69% rename from demo/index.html rename to demo/src/index.js index e1c166d..9419c27 100644 --- a/demo/index.html +++ b/demo/src/index.js @@ -1,67 +1,18 @@ - - - - react-maskedinput Demo - - - - - - - -
- - +render(, document.getElementById('demo')) diff --git a/demo/src/style.css b/demo/src/style.css new file mode 100644 index 0000000..0c54f0c --- /dev/null +++ b/demo/src/style.css @@ -0,0 +1,41 @@ +body { + box-sizing: border-box; + width: 550px; + margin: 1em auto; + padding: 0 1em; + font-family: sans-serif; +} +code { + font-size: 1.3em; +} +h1 { + font-size: 3em; + text-align: center; + margin-top: 0; +} +p.lead { + font-weight: bold; + text-align: center; +} +hr { + margin-top: 20px; + margin-bottom: 20px; + border: 0; + border-top: 1px solid #222; +} +.form-field { + margin-bottom: .5em; +} +label { + display: inline-block; + width: 7em; + text-align: right; + margin-right: .75em; +} +input { + border: none; + font-size: 1.5em; +} +footer { + text-align: center; +} diff --git a/dist/react-maskedinput.js b/dist/react-maskedinput.js deleted file mode 100644 index a7ad6bf..0000000 --- a/dist/react-maskedinput.js +++ /dev/null @@ -1,1337 +0,0 @@ -/*! - * react-maskedinput 3.1.0 (dev build at Thu, 11 Feb 2016 15:36:55 GMT) - https://github.com/insin/react-maskedinput - * MIT Licensed - */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.MaskedInput = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o} value - * @return {Array} - */ -Pattern.prototype.formatValue = function format(value) { - var valueBuffer = new Array(this.length) - var valueIndex = 0 - - for (var i = 0, l = this.length; i < l ; i++) { - if (this.isEditableIndex(i)) { - valueBuffer[i] = (value.length > valueIndex && this.isValidAtIndex(value[valueIndex], i) - ? this.transform(value[valueIndex], i) - : this.placeholderChar) - valueIndex++ - } - else { - valueBuffer[i] = this.pattern[i] - // Also allow the value to contain static values from the pattern by - // advancing its index. - if (value.length > valueIndex && value[valueIndex] === this.pattern[i]) { - valueIndex++ - } - } - } - - return valueBuffer -} - -/** - * @param {number} index - * @return {boolean} - */ -Pattern.prototype.isEditableIndex = function isEditableIndex(index) { - return !!this._editableIndices[index] -} - -/** - * @param {string} char - * @param {number} index - * @return {boolean} - */ -Pattern.prototype.isValidAtIndex = function isValidAtIndex(char, index) { - return this.formatCharacters[this.pattern[index]].validate(char) -} - -Pattern.prototype.transform = function transform(char, index) { - var format = this.formatCharacters[this.pattern[index]] - return typeof format.transform == 'function' ? format.transform(char) : char -} - -function InputMask(options) { - if (!(this instanceof InputMask)) { return new InputMask(options) } - - options = extend({ - formatCharacters: null, - pattern: null, - placeholderChar: DEFAULT_PLACEHOLDER_CHAR, - selection: {start: 0, end: 0}, - value: '' - }, options) - - if (options.pattern == null) { - throw new Error('InputMask: you must provide a pattern.') - } - - if (options.placeholderChar.length !== 1) { - throw new Error('InputMask: placeholderChar should be a single character.') - } - - this.placeholderChar = options.placeholderChar - this.formatCharacters = mergeFormatCharacters(options.formatCharacters) - this.setPattern(options.pattern, { - value: options.value, - selection: options.selection - }) -} - -// Editing - -/** - * Applies a single character of input based on the current selection. - * @param {string} char - * @return {boolean} true if a change has been made to value or selection as a - * result of the input, false otherwise. - */ -InputMask.prototype.input = function input(char) { - // Ignore additional input if the cursor's at the end of the pattern - if (this.selection.start === this.selection.end && - this.selection.start === this.pattern.length) { - return false - } - - var selectionBefore = copy(this.selection) - var valueBefore = this.getValue() - - var inputIndex = this.selection.start - - // If the cursor or selection is prior to the first editable character, make - // sure any input given is applied to it. - if (inputIndex < this.pattern.firstEditableIndex) { - inputIndex = this.pattern.firstEditableIndex - } - - // Bail out or add the character to input - if (this.pattern.isEditableIndex(inputIndex)) { - if (!this.pattern.isValidAtIndex(char, inputIndex)) { - return false - } - this.value[inputIndex] = this.pattern.transform(char, inputIndex) - } - - // If multiple characters were selected, blank the remainder out based on the - // pattern. - var end = this.selection.end - 1 - while (end > inputIndex) { - if (this.pattern.isEditableIndex(end)) { - this.value[end] = this.placeholderChar - } - end-- - } - - // Advance the cursor to the next character - this.selection.start = this.selection.end = inputIndex + 1 - - // Skip over any subsequent static characters - while (this.pattern.length > this.selection.start && - !this.pattern.isEditableIndex(this.selection.start)) { - this.selection.start++ - this.selection.end++ - } - - // History - if (this._historyIndex != null) { - // Took more input after undoing, so blow any subsequent history away - console.log('splice(', this._historyIndex, this._history.length - this._historyIndex, ')') - this._history.splice(this._historyIndex, this._history.length - this._historyIndex) - this._historyIndex = null - } - if (this._lastOp !== 'input' || - selectionBefore.start !== selectionBefore.end || - this._lastSelection !== null && selectionBefore.start !== this._lastSelection.start) { - this._history.push({value: valueBefore, selection: selectionBefore, lastOp: this._lastOp}) - } - this._lastOp = 'input' - this._lastSelection = copy(this.selection) - - return true -} - -/** - * Attempts to delete from the value based on the current cursor position or - * selection. - * @return {boolean} true if the value or selection changed as the result of - * backspacing, false otherwise. - */ -InputMask.prototype.backspace = function backspace() { - // If the cursor is at the start there's nothing to do - if (this.selection.start === 0 && this.selection.end === 0) { - return false - } - - var selectionBefore = copy(this.selection) - var valueBefore = this.getValue() - - // No range selected - work on the character preceding the cursor - if (this.selection.start === this.selection.end) { - if (this.pattern.isEditableIndex(this.selection.start - 1)) { - this.value[this.selection.start - 1] = this.placeholderChar - } - this.selection.start-- - this.selection.end-- - } - // Range selected - delete characters and leave the cursor at the start of the selection - else { - var end = this.selection.end - 1 - while (end >= this.selection.start) { - if (this.pattern.isEditableIndex(end)) { - this.value[end] = this.placeholderChar - } - end-- - } - this.selection.end = this.selection.start - } - - // History - if (this._historyIndex != null) { - // Took more input after undoing, so blow any subsequent history away - this._history.splice(this._historyIndex, this._history.length - this._historyIndex) - } - if (this._lastOp !== 'backspace' || - selectionBefore.start !== selectionBefore.end || - this._lastSelection !== null && selectionBefore.start !== this._lastSelection.start) { - this._history.push({value: valueBefore, selection: selectionBefore, lastOp: this._lastOp}) - } - this._lastOp = 'backspace' - this._lastSelection = copy(this.selection) - - return true -} - -/** - * Attempts to paste a string of input at the current cursor position or over - * the top of the current selection. - * Invalid content at any position will cause the paste to be rejected, and it - * may contain static parts of the mask's pattern. - * @param {string} input - * @return {boolean} true if the paste was successful, false otherwise. - */ -InputMask.prototype.paste = function paste(input) { - // This is necessary because we're just calling input() with each character - // and rolling back if any were invalid, rather than checking up-front. - var initialState = { - value: this.value.slice(), - selection: copy(this.selection), - _lastOp: this._lastOp, - _history: this._history.slice(), - _historyIndex: this._historyIndex, - _lastSelection: copy(this._lastSelection) - } - - // If there are static characters at the start of the pattern and the cursor - // or selection is within them, the static characters must match for a valid - // paste. - if (this.selection.start < this.pattern.firstEditableIndex) { - for (var i = 0, l = this.pattern.firstEditableIndex - this.selection.start; i < l; i++) { - if (input.charAt(i) !== this.pattern.pattern[i]) { - return false - } - } - - // Continue as if the selection and input started from the editable part of - // the pattern. - input = input.substring(this.pattern.firstEditableIndex - this.selection.start) - this.selection.start = this.pattern.firstEditableIndex - } - - for (i = 0, l = input.length; - i < l && this.selection.start <= this.pattern.lastEditableIndex; - i++) { - var valid = this.input(input.charAt(i)) - // Allow static parts of the pattern to appear in pasted input - they will - // already have been stepped over by input(), so verify that the value - // deemed invalid by input() was the expected static character. - if (!valid) { - if (this.selection.start > 0) { - // XXX This only allows for one static character to be skipped - var patternIndex = this.selection.start - 1 - if (!this.pattern.isEditableIndex(patternIndex) && - input.charAt(i) === this.pattern.pattern[patternIndex]) { - continue - } - } - extend(this, initialState) - return false - } - } - - return true -} - -// History - -InputMask.prototype.undo = function undo() { - // If there is no history, or nothing more on the history stack, we can't undo - if (this._history.length === 0 || this._historyIndex === 0) { - return false - } - - var historyItem - if (this._historyIndex == null) { - // Not currently undoing, set up the initial history index - this._historyIndex = this._history.length - 1 - historyItem = this._history[this._historyIndex] - // Add a new history entry if anything has changed since the last one, so we - // can redo back to the initial state we started undoing from. - var value = this.getValue() - if (historyItem.value !== value || - historyItem.selection.start !== this.selection.start || - historyItem.selection.end !== this.selection.end) { - this._history.push({value: value, selection: copy(this.selection), lastOp: this._lastOp, startUndo: true}) - } - } - else { - historyItem = this._history[--this._historyIndex] - } - - this.value = historyItem.value.split('') - this.selection = historyItem.selection - this._lastOp = historyItem.lastOp - return true -} - -InputMask.prototype.redo = function redo() { - if (this._history.length === 0 || this._historyIndex == null) { - return false - } - var historyItem = this._history[++this._historyIndex] - // If this is the last history item, we're done redoing - if (this._historyIndex === this._history.length - 1) { - this._historyIndex = null - // If the last history item was only added to start undoing, remove it - if (historyItem.startUndo) { - this._history.pop() - } - } - this.value = historyItem.value.split('') - this.selection = historyItem.selection - this._lastOp = historyItem.lastOp - return true -} - -// Getters & setters - -InputMask.prototype.setPattern = function setPattern(pattern, options) { - options = extend({ - selection: {start: 0, end: 0}, - value: '' - }, options) - this.pattern = new Pattern(pattern, this.formatCharacters, this.placeholderChar) - this.setValue(options.value) - this.emptyValue = this.pattern.formatValue([]).join('') - this.selection = options.selection - this._resetHistory() -} - -InputMask.prototype.setSelection = function setSelection(selection) { - this.selection = copy(selection) - if (this.selection.start === this.selection.end) { - if (this.selection.start < this.pattern.firstEditableIndex) { - this.selection.start = this.selection.end = this.pattern.firstEditableIndex - return true - } - if (this.selection.end > this.pattern.lastEditableIndex + 1) { - this.selection.start = this.selection.end = this.pattern.lastEditableIndex + 1 - return true - } - } - return false -} - -InputMask.prototype.setValue = function setValue(value) { - if (value == null) { - value = '' - } - this.value = this.pattern.formatValue(value.split('')) -} - -InputMask.prototype.getValue = function getValue() { - return this.value.join('') -} - -InputMask.prototype.getRawValue = function getRawValue() { - var rawValue = [] - for (var i = 0; i < this.value.length; i++) { - if (this.pattern._editableIndices[i] === true) { - rawValue.push(this.value[i]) - } - } - return rawValue.join('') -} - -InputMask.prototype._resetHistory = function _resetHistory() { - this._history = [] - this._historyIndex = null - this._lastOp = null - this._lastSelection = copy(this.selection) -} - -InputMask.Pattern = Pattern - -module.exports = InputMask - -},{}],9:[function(require,module,exports){ -/** - * Copyright 2013-2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule ReactDOMSelection - */ - -'use strict'; - -var ExecutionEnvironment = require('fbjs/lib/ExecutionEnvironment'); - -var getNodeForCharacterOffset = require('./getNodeForCharacterOffset'); -var getTextContentAccessor = require('./getTextContentAccessor'); - -/** - * While `isCollapsed` is available on the Selection object and `collapsed` - * is available on the Range object, IE11 sometimes gets them wrong. - * If the anchor/focus nodes and offsets are the same, the range is collapsed. - */ -function isCollapsed(anchorNode, anchorOffset, focusNode, focusOffset) { - return anchorNode === focusNode && anchorOffset === focusOffset; -} - -/** - * Get the appropriate anchor and focus node/offset pairs for IE. - * - * The catch here is that IE's selection API doesn't provide information - * about whether the selection is forward or backward, so we have to - * behave as though it's always forward. - * - * IE text differs from modern selection in that it behaves as though - * block elements end with a new line. This means character offsets will - * differ between the two APIs. - * - * @param {DOMElement} node - * @return {object} - */ -function getIEOffsets(node) { - var selection = document.selection; - var selectedRange = selection.createRange(); - var selectedLength = selectedRange.text.length; - - // Duplicate selection so we can move range without breaking user selection. - var fromStart = selectedRange.duplicate(); - fromStart.moveToElementText(node); - fromStart.setEndPoint('EndToStart', selectedRange); - - var startOffset = fromStart.text.length; - var endOffset = startOffset + selectedLength; - - return { - start: startOffset, - end: endOffset - }; -} - -/** - * @param {DOMElement} node - * @return {?object} - */ -function getModernOffsets(node) { - var selection = window.getSelection && window.getSelection(); - - if (!selection || selection.rangeCount === 0) { - return null; - } - - var anchorNode = selection.anchorNode; - var anchorOffset = selection.anchorOffset; - var focusNode = selection.focusNode; - var focusOffset = selection.focusOffset; - - var currentRange = selection.getRangeAt(0); - - // In Firefox, range.startContainer and range.endContainer can be "anonymous - // divs", e.g. the up/down buttons on an . Anonymous - // divs do not seem to expose properties, triggering a "Permission denied - // error" if any of its properties are accessed. The only seemingly possible - // way to avoid erroring is to access a property that typically works for - // non-anonymous divs and catch any error that may otherwise arise. See - // https://bugzilla.mozilla.org/show_bug.cgi?id=208427 - try { - /* eslint-disable no-unused-expressions */ - currentRange.startContainer.nodeType; - currentRange.endContainer.nodeType; - /* eslint-enable no-unused-expressions */ - } catch (e) { - return null; - } - - // If the node and offset values are the same, the selection is collapsed. - // `Selection.isCollapsed` is available natively, but IE sometimes gets - // this value wrong. - var isSelectionCollapsed = isCollapsed(selection.anchorNode, selection.anchorOffset, selection.focusNode, selection.focusOffset); - - var rangeLength = isSelectionCollapsed ? 0 : currentRange.toString().length; - - var tempRange = currentRange.cloneRange(); - tempRange.selectNodeContents(node); - tempRange.setEnd(currentRange.startContainer, currentRange.startOffset); - - var isTempRangeCollapsed = isCollapsed(tempRange.startContainer, tempRange.startOffset, tempRange.endContainer, tempRange.endOffset); - - var start = isTempRangeCollapsed ? 0 : tempRange.toString().length; - var end = start + rangeLength; - - // Detect whether the selection is backward. - var detectionRange = document.createRange(); - detectionRange.setStart(anchorNode, anchorOffset); - detectionRange.setEnd(focusNode, focusOffset); - var isBackward = detectionRange.collapsed; - - return { - start: isBackward ? end : start, - end: isBackward ? start : end - }; -} - -/** - * @param {DOMElement|DOMTextNode} node - * @param {object} offsets - */ -function setIEOffsets(node, offsets) { - var range = document.selection.createRange().duplicate(); - var start, end; - - if (typeof offsets.end === 'undefined') { - start = offsets.start; - end = start; - } else if (offsets.start > offsets.end) { - start = offsets.end; - end = offsets.start; - } else { - start = offsets.start; - end = offsets.end; - } - - range.moveToElementText(node); - range.moveStart('character', start); - range.setEndPoint('EndToStart', range); - range.moveEnd('character', end - start); - range.select(); -} - -/** - * In modern non-IE browsers, we can support both forward and backward - * selections. - * - * Note: IE10+ supports the Selection object, but it does not support - * the `extend` method, which means that even in modern IE, it's not possible - * to programatically create a backward selection. Thus, for all IE - * versions, we use the old IE API to create our selections. - * - * @param {DOMElement|DOMTextNode} node - * @param {object} offsets - */ -function setModernOffsets(node, offsets) { - if (!window.getSelection) { - return; - } - - var selection = window.getSelection(); - var length = node[getTextContentAccessor()].length; - var start = Math.min(offsets.start, length); - var end = typeof offsets.end === 'undefined' ? start : Math.min(offsets.end, length); - - // IE 11 uses modern selection, but doesn't support the extend method. - // Flip backward selections, so we can set with a single range. - if (!selection.extend && start > end) { - var temp = end; - end = start; - start = temp; - } - - var startMarker = getNodeForCharacterOffset(node, start); - var endMarker = getNodeForCharacterOffset(node, end); - - if (startMarker && endMarker) { - var range = document.createRange(); - range.setStart(startMarker.node, startMarker.offset); - selection.removeAllRanges(); - - if (start > end) { - selection.addRange(range); - selection.extend(endMarker.node, endMarker.offset); - } else { - range.setEnd(endMarker.node, endMarker.offset); - selection.addRange(range); - } - } -} - -var useIEOffsets = ExecutionEnvironment.canUseDOM && 'selection' in document && !('getSelection' in window); - -var ReactDOMSelection = { - /** - * @param {DOMElement} node - */ - getOffsets: useIEOffsets ? getIEOffsets : getModernOffsets, - - /** - * @param {DOMElement|DOMTextNode} node - * @param {object} offsets - */ - setOffsets: useIEOffsets ? setIEOffsets : setModernOffsets -}; - -module.exports = ReactDOMSelection; -},{"./getNodeForCharacterOffset":11,"./getTextContentAccessor":12,"fbjs/lib/ExecutionEnvironment":2}],10:[function(require,module,exports){ -/** - * Copyright 2013-2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule ReactInputSelection - */ - -'use strict'; - -var ReactDOMSelection = require('./ReactDOMSelection'); - -var containsNode = require('fbjs/lib/containsNode'); -var focusNode = require('fbjs/lib/focusNode'); -var getActiveElement = require('fbjs/lib/getActiveElement'); - -function isInDocument(node) { - return containsNode(document.documentElement, node); -} - -/** - * @ReactInputSelection: React input selection module. Based on Selection.js, - * but modified to be suitable for react and has a couple of bug fixes (doesn't - * assume buttons have range selections allowed). - * Input selection module for React. - */ -var ReactInputSelection = { - - hasSelectionCapabilities: function (elem) { - var nodeName = elem && elem.nodeName && elem.nodeName.toLowerCase(); - return nodeName && (nodeName === 'input' && elem.type === 'text' || nodeName === 'textarea' || elem.contentEditable === 'true'); - }, - - getSelectionInformation: function () { - var focusedElem = getActiveElement(); - return { - focusedElem: focusedElem, - selectionRange: ReactInputSelection.hasSelectionCapabilities(focusedElem) ? ReactInputSelection.getSelection(focusedElem) : null - }; - }, - - /** - * @restoreSelection: If any selection information was potentially lost, - * restore it. This is useful when performing operations that could remove dom - * nodes and place them back in, resulting in focus being lost. - */ - restoreSelection: function (priorSelectionInformation) { - var curFocusedElem = getActiveElement(); - var priorFocusedElem = priorSelectionInformation.focusedElem; - var priorSelectionRange = priorSelectionInformation.selectionRange; - if (curFocusedElem !== priorFocusedElem && isInDocument(priorFocusedElem)) { - if (ReactInputSelection.hasSelectionCapabilities(priorFocusedElem)) { - ReactInputSelection.setSelection(priorFocusedElem, priorSelectionRange); - } - focusNode(priorFocusedElem); - } - }, - - /** - * @getSelection: Gets the selection bounds of a focused textarea, input or - * contentEditable node. - * -@input: Look up selection bounds of this input - * -@return {start: selectionStart, end: selectionEnd} - */ - getSelection: function (input) { - var selection; - - if ('selectionStart' in input) { - // Modern browser with input or textarea. - selection = { - start: input.selectionStart, - end: input.selectionEnd - }; - } else if (document.selection && (input.nodeName && input.nodeName.toLowerCase() === 'input')) { - // IE8 input. - var range = document.selection.createRange(); - // There can only be one selection per document in IE, so it must - // be in our element. - if (range.parentElement() === input) { - selection = { - start: -range.moveStart('character', -input.value.length), - end: -range.moveEnd('character', -input.value.length) - }; - } - } else { - // Content editable or old IE textarea. - selection = ReactDOMSelection.getOffsets(input); - } - - return selection || { start: 0, end: 0 }; - }, - - /** - * @setSelection: Sets the selection bounds of a textarea or input and focuses - * the input. - * -@input Set selection bounds of this input or textarea - * -@offsets Object of same form that is returned from get* - */ - setSelection: function (input, offsets) { - var start = offsets.start; - var end = offsets.end; - if (typeof end === 'undefined') { - end = start; - } - - if ('selectionStart' in input) { - input.selectionStart = start; - input.selectionEnd = Math.min(end, input.value.length); - } else if (document.selection && (input.nodeName && input.nodeName.toLowerCase() === 'input')) { - var range = input.createTextRange(); - range.collapse(true); - range.moveStart('character', start); - range.moveEnd('character', end - start); - range.select(); - } else { - ReactDOMSelection.setOffsets(input, offsets); - } - } -}; - -module.exports = ReactInputSelection; -},{"./ReactDOMSelection":9,"fbjs/lib/containsNode":3,"fbjs/lib/focusNode":4,"fbjs/lib/getActiveElement":5}],11:[function(require,module,exports){ -/** - * Copyright 2013-2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule getNodeForCharacterOffset - */ - -'use strict'; - -/** - * Given any node return the first leaf node without children. - * - * @param {DOMElement|DOMTextNode} node - * @return {DOMElement|DOMTextNode} - */ -function getLeafNode(node) { - while (node && node.firstChild) { - node = node.firstChild; - } - return node; -} - -/** - * Get the next sibling within a container. This will walk up the - * DOM if a node's siblings have been exhausted. - * - * @param {DOMElement|DOMTextNode} node - * @return {?DOMElement|DOMTextNode} - */ -function getSiblingNode(node) { - while (node) { - if (node.nextSibling) { - return node.nextSibling; - } - node = node.parentNode; - } -} - -/** - * Get object describing the nodes which contain characters at offset. - * - * @param {DOMElement|DOMTextNode} root - * @param {number} offset - * @return {?object} - */ -function getNodeForCharacterOffset(root, offset) { - var node = getLeafNode(root); - var nodeStart = 0; - var nodeEnd = 0; - - while (node) { - if (node.nodeType === 3) { - nodeEnd = nodeStart + node.textContent.length; - - if (nodeStart <= offset && nodeEnd >= offset) { - return { - node: node, - offset: offset - nodeStart - }; - } - - nodeStart = nodeEnd; - } - - node = getLeafNode(getSiblingNode(node)); - } -} - -module.exports = getNodeForCharacterOffset; -},{}],12:[function(require,module,exports){ -/** - * Copyright 2013-2015, Facebook, Inc. - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @providesModule getTextContentAccessor - */ - -'use strict'; - -var ExecutionEnvironment = require('fbjs/lib/ExecutionEnvironment'); - -var contentKey = null; - -/** - * Gets the key used to access text content on a DOM node. - * - * @return {?string} Key used to access text content. - * @internal - */ -function getTextContentAccessor() { - if (!contentKey && ExecutionEnvironment.canUseDOM) { - // Prefer textContent to innerText because many browsers support both but - // SVG elements don't support innerText even when
does. - contentKey = 'textContent' in document.documentElement ? 'textContent' : 'innerText'; - } - return contentKey; -} - -module.exports = getTextContentAccessor; -},{"fbjs/lib/ExecutionEnvironment":2}]},{},[1])(1) -}); \ No newline at end of file diff --git a/dist/react-maskedinput.min.js b/dist/react-maskedinput.min.js deleted file mode 100644 index e2e7c12..0000000 --- a/dist/react-maskedinput.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/*! - * react-maskedinput 3.1.0 - https://github.com/insin/react-maskedinput - * MIT Licensed - */ -!function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var e;e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,e.MaskedInput=t()}}(function(){return function t(e,n,i){function s(a,o){if(!n[a]){if(!e[a]){var l="function"==typeof require&&require;if(!o&&l)return l(a,!0);if(r)return r(a,!0);var h=new Error("Cannot find module '"+a+"'");throw h.code="MODULE_NOT_FOUND",h}var c=n[a]={exports:{}};e[a][0].call(c.exports,function(t){var n=e[a][1][t];return s(n?n:t)},c,c.exports,t,e,n,i)}return n[a].exports}for(var r="function"==typeof require&&require,a=0;ai;i++)t[n[i]]=e[n[i]];return t}function s(t){return i({},t)}function r(t){var e=s(p);if(t)for(var n=Object.keys(t),i=0,r=n.length;r>i;i++){var a=n[i];null==t[a]?delete e[a]:e[a]=t[a]}return e}function a(t,e,n){return this instanceof a?(this.placeholderChar=n||d,this.formatCharacters=e||p,this.source=t,this.pattern=[],this.length=0,this.firstEditableIndex=null,this.lastEditableIndex=null,this._editableIndices={},void this._parse()):new a(t,e,n)}function o(t){if(!(this instanceof o))return new o(t);if(t=i({formatCharacters:null,pattern:null,placeholderChar:d,selection:{start:0,end:0},value:""},t),null==t.pattern)throw new Error("InputMask: you must provide a pattern.");if(1!==t.placeholderChar.length)throw new Error("InputMask: placeholderChar should be a single character.");this.placeholderChar=t.placeholderChar,this.formatCharacters=r(t.formatCharacters),this.setPattern(t.pattern,{value:t.value,selection:t.selection})}var l="\\",h=/^\d$/,c=/^[A-Za-z]$/,u=/^[\dA-Za-z]$/,d="_",p={"*":{validate:function(t){return u.test(t)}},1:{validate:function(t){return h.test(t)}},a:{validate:function(t){return c.test(t)}},A:{validate:function(t){return c.test(t)},transform:function(t){return t.toUpperCase()}},"#":{validate:function(t){return u.test(t)},transform:function(t){return t.toUpperCase()}}};a.prototype._parse=function(){for(var t=this.source.split(""),e=0,n=[],i=0,s=t.length;s>i;i++){var r=t[i];if(r===l){if(i===s-1)throw new Error("InputMask: pattern ends with a raw "+l);r=t[++i]}else r in this.formatCharacters&&(null===this.firstEditableIndex&&(this.firstEditableIndex=e),this.lastEditableIndex=e,this._editableIndices[e]=!0);n.push(r),e++}if(null===this.firstEditableIndex)throw new Error('InputMask: pattern "'+this.source+'" does not contain any editable characters.');this.pattern=n,this.length=n.length},a.prototype.formatValue=function(t){for(var e=new Array(this.length),n=0,i=0,s=this.length;s>i;i++)this.isEditableIndex(i)?(e[i]=t.length>n&&this.isValidAtIndex(t[n],i)?this.transform(t[n],i):this.placeholderChar,n++):(e[i]=this.pattern[i],t.length>n&&t[n]===this.pattern[i]&&n++);return e},a.prototype.isEditableIndex=function(t){return!!this._editableIndices[t]},a.prototype.isValidAtIndex=function(t,e){return this.formatCharacters[this.pattern[e]].validate(t)},a.prototype.transform=function(t,e){var n=this.formatCharacters[this.pattern[e]];return"function"==typeof n.transform?n.transform(t):t},o.prototype.input=function(t){if(this.selection.start===this.selection.end&&this.selection.start===this.pattern.length)return!1;var e=s(this.selection),n=this.getValue(),i=this.selection.start;if(ii;)this.pattern.isEditableIndex(r)&&(this.value[r]=this.placeholderChar),r--;for(this.selection.start=this.selection.end=i+1;this.pattern.length>this.selection.start&&!this.pattern.isEditableIndex(this.selection.start);)this.selection.start++,this.selection.end++;return null!=this._historyIndex&&(console.log("splice(",this._historyIndex,this._history.length-this._historyIndex,")"),this._history.splice(this._historyIndex,this._history.length-this._historyIndex),this._historyIndex=null),("input"!==this._lastOp||e.start!==e.end||null!==this._lastSelection&&e.start!==this._lastSelection.start)&&this._history.push({value:n,selection:e,lastOp:this._lastOp}),this._lastOp="input",this._lastSelection=s(this.selection),!0},o.prototype.backspace=function(){if(0===this.selection.start&&0===this.selection.end)return!1;var t=s(this.selection),e=this.getValue();if(this.selection.start===this.selection.end)this.pattern.isEditableIndex(this.selection.start-1)&&(this.value[this.selection.start-1]=this.placeholderChar),this.selection.start--,this.selection.end--;else{for(var n=this.selection.end-1;n>=this.selection.start;)this.pattern.isEditableIndex(n)&&(this.value[n]=this.placeholderChar),n--;this.selection.end=this.selection.start}return null!=this._historyIndex&&this._history.splice(this._historyIndex,this._history.length-this._historyIndex),("backspace"!==this._lastOp||t.start!==t.end||null!==this._lastSelection&&t.start!==this._lastSelection.start)&&this._history.push({value:e,selection:t,lastOp:this._lastOp}),this._lastOp="backspace",this._lastSelection=s(this.selection),!0},o.prototype.paste=function(t){var e={value:this.value.slice(),selection:s(this.selection),_lastOp:this._lastOp,_history:this._history.slice(),_historyIndex:this._historyIndex,_lastSelection:s(this._lastSelection)};if(this.selection.startn;n++)if(t.charAt(n)!==this.pattern.pattern[n])return!1;t=t.substring(this.pattern.firstEditableIndex-this.selection.start),this.selection.start=this.pattern.firstEditableIndex}for(n=0,r=t.length;r>n&&this.selection.start<=this.pattern.lastEditableIndex;n++){var a=this.input(t.charAt(n));if(!a){if(this.selection.start>0){var o=this.selection.start-1;if(!this.pattern.isEditableIndex(o)&&t.charAt(n)===this.pattern.pattern[o])continue}return i(this,e),!1}}return!0},o.prototype.undo=function(){if(0===this._history.length||0===this._historyIndex)return!1;var t;if(null==this._historyIndex){this._historyIndex=this._history.length-1,t=this._history[this._historyIndex];var e=this.getValue();(t.value!==e||t.selection.start!==this.selection.start||t.selection.end!==this.selection.end)&&this._history.push({value:e,selection:s(this.selection),lastOp:this._lastOp,startUndo:!0})}else t=this._history[--this._historyIndex];return this.value=t.value.split(""),this.selection=t.selection,this._lastOp=t.lastOp,!0},o.prototype.redo=function(){if(0===this._history.length||null==this._historyIndex)return!1;var t=this._history[++this._historyIndex];return this._historyIndex===this._history.length-1&&(this._historyIndex=null,t.startUndo&&this._history.pop()),this.value=t.value.split(""),this.selection=t.selection,this._lastOp=t.lastOp,!0},o.prototype.setPattern=function(t,e){e=i({selection:{start:0,end:0},value:""},e),this.pattern=new a(t,this.formatCharacters,this.placeholderChar),this.setValue(e.value),this.emptyValue=this.pattern.formatValue([]).join(""),this.selection=e.selection,this._resetHistory()},o.prototype.setSelection=function(t){if(this.selection=s(t),this.selection.start===this.selection.end){if(this.selection.startthis.pattern.lastEditableIndex+1)return this.selection.start=this.selection.end=this.pattern.lastEditableIndex+1,!0}return!1},o.prototype.setValue=function(t){null==t&&(t=""),this.value=this.pattern.formatValue(t.split(""))},o.prototype.getValue=function(){return this.value.join("")},o.prototype.getRawValue=function(){for(var t=[],e=0;ee.end?(n=e.end,i=e.start):(n=e.start,i=e.end),s.moveToElementText(t),s.moveStart("character",n),s.setEndPoint("EndToStart",s),s.moveEnd("character",i-n),s.select()}function o(t,e){if(window.getSelection){var n=window.getSelection(),i=t[c()].length,s=Math.min(e.start,i),r="undefined"==typeof e.end?s:Math.min(e.end,i);if(!n.extend&&s>r){var a=r;r=s,s=a}var o=h(t,s),l=h(t,r);if(o&&l){var u=document.createRange();u.setStart(o.node,o.offset),n.removeAllRanges(),s>r?(n.addRange(u),n.extend(l.node,l.offset)):(u.setEnd(l.node,l.offset),n.addRange(u))}}}var l=t("fbjs/lib/ExecutionEnvironment"),h=t("./getNodeForCharacterOffset"),c=t("./getTextContentAccessor"),u=l.canUseDOM&&"selection"in document&&!("getSelection"in window),d={getOffsets:u?s:r,setOffsets:u?a:o};e.exports=d},{"./getNodeForCharacterOffset":11,"./getTextContentAccessor":12,"fbjs/lib/ExecutionEnvironment":2}],10:[function(t,e,n){"use strict";function i(t){return r(document.documentElement,t)}var s=t("./ReactDOMSelection"),r=t("fbjs/lib/containsNode"),a=t("fbjs/lib/focusNode"),o=t("fbjs/lib/getActiveElement"),l={hasSelectionCapabilities:function(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return e&&("input"===e&&"text"===t.type||"textarea"===e||"true"===t.contentEditable)},getSelectionInformation:function(){var t=o();return{focusedElem:t,selectionRange:l.hasSelectionCapabilities(t)?l.getSelection(t):null}},restoreSelection:function(t){var e=o(),n=t.focusedElem,s=t.selectionRange;e!==n&&i(n)&&(l.hasSelectionCapabilities(n)&&l.setSelection(n,s),a(n))},getSelection:function(t){var e;if("selectionStart"in t)e={start:t.selectionStart,end:t.selectionEnd};else if(document.selection&&t.nodeName&&"input"===t.nodeName.toLowerCase()){var n=document.selection.createRange();n.parentElement()===t&&(e={start:-n.moveStart("character",-t.value.length),end:-n.moveEnd("character",-t.value.length)})}else e=s.getOffsets(t);return e||{start:0,end:0}},setSelection:function(t,e){var n=e.start,i=e.end;if("undefined"==typeof i&&(i=n),"selectionStart"in t)t.selectionStart=n,t.selectionEnd=Math.min(i,t.value.length);else if(document.selection&&t.nodeName&&"input"===t.nodeName.toLowerCase()){var r=t.createTextRange();r.collapse(!0),r.moveStart("character",n),r.moveEnd("character",i-n),r.select()}else s.setOffsets(t,e)}};e.exports=l},{"./ReactDOMSelection":9,"fbjs/lib/containsNode":3,"fbjs/lib/focusNode":4,"fbjs/lib/getActiveElement":5}],11:[function(t,e,n){"use strict";function i(t){for(;t&&t.firstChild;)t=t.firstChild;return t}function s(t){for(;t;){if(t.nextSibling)return t.nextSibling;t=t.parentNode}}function r(t,e){for(var n=i(t),r=0,a=0;n;){if(3===n.nodeType){if(a=r+n.textContent.length,e>=r&&a>=e)return{node:n,offset:e-r};r=a}n=i(s(n))}}e.exports=r},{}],12:[function(t,e,n){"use strict";function i(){return!r&&s.canUseDOM&&(r="textContent"in document.documentElement?"textContent":"innerText"),r}var s=t("fbjs/lib/ExecutionEnvironment"),r=null;e.exports=i},{"fbjs/lib/ExecutionEnvironment":2}]},{},[1])(1)}); \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index 3215fbb..0000000 --- a/gulpfile.js +++ /dev/null @@ -1,70 +0,0 @@ -var browserify = require('browserify') -var del = require('del') -var gulp = require('gulp') -var source = require('vinyl-source-stream') - -var header = require('gulp-header') -var jshint = require('gulp-jshint') -var rename = require('gulp-rename') -var plumber = require('gulp-plumber') -var react = require('gulp-react') -var streamify = require('gulp-streamify') -var uglify = require('gulp-uglify') -var gutil = require('gulp-util') - -var pkg = require('./package.json') -var devBuild = gutil.env.release ? '' : ' (dev build at ' + (new Date()).toUTCString() + ')' -var distHeader = '/*!\n\ - * <%= pkg.name %> <%= pkg.version %><%= devBuild %> - <%= pkg.homepage %>\n\ - * <%= pkg.license %> Licensed\n\ - */\n' - -var jsSrcPaths = './src/**/*.js*' -var jsLibPaths = './lib/**/*.js' - -gulp.task('clean-lib', function(cb) { - del(jsLibPaths, cb) -}) - -gulp.task('transpile-js', ['clean-lib'], function() { - return gulp.src(jsSrcPaths) - .pipe(plumber()) - .pipe(react({harmony: true})) - .pipe(gulp.dest('./lib')) -}) - -gulp.task('lint-js', ['transpile-js'], function() { - return gulp.src(jsLibPaths) - .pipe(jshint('./.jshintrc')) - .pipe(jshint.reporter('jshint-stylish')) -}) - -gulp.task('bundle-js', ['lint-js'], function() { - var b = browserify(pkg.main, { - debug: !!gutil.env.debug - , standalone: pkg.standalone - , detectGlobals: false - }) - b.transform('browserify-shim') - - var stream = b.bundle() - .pipe(source(pkg.name + '.js')) - .pipe(streamify(header(distHeader, {pkg: pkg, devBuild: devBuild}))) - .pipe(gulp.dest('./dist')) - - if (gutil.env.production) { - stream = stream - .pipe(rename(pkg.name + '.min.js')) - .pipe(streamify(uglify())) - .pipe(streamify(header(distHeader, {pkg: pkg, devBuild: devBuild}))) - .pipe(gulp.dest('./dist')) - } - - return stream -}) - -gulp.task('watch', function() { - gulp.watch(jsSrcPaths, ['bundle-js']) -}) - -gulp.task('default', ['bundle-js', 'watch']) \ No newline at end of file diff --git a/nwb.config.js b/nwb.config.js new file mode 100644 index 0000000..43398c0 --- /dev/null +++ b/nwb.config.js @@ -0,0 +1,11 @@ +module.exports = { + type: 'react-component', + build: { + externals: { + 'react': 'React' + }, + global: 'MaskedInput', + jsNext: true, + umd: true + } +} diff --git a/package.json b/package.json index 0483f02..10ec80a 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "react-maskedinput", "description": "Masked React component", - "version": "3.1.0", + "version": "3.3.4", "main": "./lib/index.js", + "jsnext:main": "es6/index.js", "standalone": "MaskedInput", "homepage": "https://github.com/insin/react-maskedinput", "license": "MIT", @@ -13,38 +14,31 @@ "input", "react-component" ], + "files": [ + "es6", + "lib", + "umd" + ], + "scripts": { + "build": "nwb build", + "clean": "nwb clean", + "lint": "eslint src tests", + "start": "nwb serve", + "test": "nwb test", + "posttest": "npm run lint", + "test:watch": "nwb test --server" + }, "dependencies": { "inputmask-core": "^2.1.1" }, "peerDependencies": { - "react": ">=0.14.0" + "react": "0.14.x || 15.x.x" }, "devDependencies": { - "browserify": "^10.1.3", - "browserify-shim": "^3.8.6", - "del": "^1.1.1", - "gulp": "^3.8.11", - "gulp-header": "^1.2.2", - "gulp-jshint": "^1.10.0", - "gulp-plumber": "^1.0.0", - "gulp-react": "^3.0.1", - "gulp-rename": "^1.2.2", - "gulp-streamify": "0.0.5", - "gulp-uglify": "^1.2.0", - "gulp-util": "^3.0.4", - "jshint-stylish": "^1.0.2", - "react": ">=0.13.0", - "tape": "^4.0.0", - "vinyl-source-stream": "^1.1.0" - }, - "scripts": { - "debug": "gulp --debug", - "dist": "gulp bundle-js --production --release && gulp bundle-js --development --release", - "test": "gulp transpile-js && tape test/*.js", - "watch": "gulp" - }, - "browserify-shim": { - "react": "global:React" + "eslint-config-jonnybuchanan": "2.0.3", + "nwb": "0.9.x", + "react": "15.x.x", + "react-dom": "15.x.x" }, "repository": { "type": "git", diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e34ff42 --- /dev/null +++ b/src/index.js @@ -0,0 +1,286 @@ +var React = require('react') +var InputMask = require('inputmask-core') + +var KEYCODE_Z = 90 +var KEYCODE_Y = 89 + +function isUndo(e) { + return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Y : KEYCODE_Z) +} + +function isRedo(e) { + return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Z : KEYCODE_Y) +} + +function getSelection (el) { + var start, end, rangeEl, clone + + if (el.selectionStart !== undefined) { + start = el.selectionStart + end = el.selectionEnd + } + else { + try { + el.focus() + rangeEl = el.createTextRange() + clone = rangeEl.duplicate() + + rangeEl.moveToBookmark(document.selection.createRange().getBookmark()) + clone.setEndPoint('EndToStart', rangeEl) + + start = clone.text.length + end = start + rangeEl.text.length + } + catch (e) { /* not focused or not visible */ } + } + + return { start, end } +} + +function setSelection(el, selection) { + var rangeEl + + try { + if (el.selectionStart !== undefined) { + el.focus() + el.setSelectionRange(selection.start, selection.end) + } + else { + el.focus() + rangeEl = el.createTextRange() + rangeEl.collapse(true) + rangeEl.moveStart('character', selection.start) + rangeEl.moveEnd('character', selection.end - selection.start) + rangeEl.select() + } + } + catch (e) { /* not focused or not visible */ } +} + +var MaskedInput = React.createClass({ + propTypes: { + mask: React.PropTypes.string.isRequired, + + formatCharacters: React.PropTypes.object, + placeholderChar: React.PropTypes.string + }, + + getDefaultProps() { + return { + value: '' + } + }, + + componentWillMount() { + var options = { + pattern: this.props.mask, + value: this.props.value, + formatCharacters: this.props.formatCharacters + } + if (this.props.placeholderChar) { + options.placeholderChar = this.props.placeholderChar + } + this.mask = new InputMask(options) + }, + + componentWillReceiveProps(nextProps) { + if (this.props.mask !== nextProps.mask && this.props.value !== nextProps.mask) { + // if we get a new value and a new mask at the same time + // check if the mask.value is still the initial value + // - if so use the nextProps value + // - otherwise the `this.mask` has a value for us (most likely from paste action) + if (this.mask.getValue() === this.mask.emptyValue) { + this.mask.setPattern(nextProps.mask, {value: nextProps.value}) + } + else { + this.mask.setPattern(nextProps.mask, {value: this.mask.getRawValue()}) + } + } + else if (this.props.mask !== nextProps.mask) { + this.mask.setPattern(nextProps.mask, {value: this.mask.getRawValue()}) + } + else if (this.props.value !== nextProps.value) { + this.mask.setValue(nextProps.value) + } + }, + + componentWillUpdate(nextProps, nextState) { + if (nextProps.mask !== this.props.mask) { + this._updatePattern(nextProps) + } + }, + + componentDidUpdate(prevProps) { + if (prevProps.mask !== this.props.mask && this.mask.selection.start) { + this._updateInputSelection() + } + }, + + _updatePattern: function(props) { + this.mask.setPattern(props.mask, { + value: this.mask.getRawValue(), + selection: getSelection(this.input) + }) + }, + + _updateMaskSelection() { + this.mask.selection = getSelection(this.input) + }, + + _updateInputSelection() { + setSelection(this.input, this.mask.selection) + }, + + _onChange(e) { + // console.log('onChange', JSON.stringify(getSelection(this.input)), e.target.value) + + var maskValue = this.mask.getValue() + if (e.target.value !== maskValue) { + // Cut or delete operations will have shortened the value + if (e.target.value.length < maskValue.length) { + var sizeDiff = maskValue.length - e.target.value.length + this._updateMaskSelection() + this.mask.selection.end = this.mask.selection.start + sizeDiff + this.mask.backspace() + } + this.mask.setValue(e.target.value); + var value = this._getDisplayValue() + e.target.value = value + if (value) { + this._updateInputSelection() + } + } + if (this.props.onChange) { + this.props.onChange(e) + } + }, + + _onKeyDown(e) { + // console.log('onKeyDown', JSON.stringify(getSelection(this.input)), e.key, e.target.value) + + if (isUndo(e)) { + e.preventDefault() + if (this.mask.undo()) { + e.target.value = this._getDisplayValue() + this._updateInputSelection() + if (this.props.onChange) { + this.props.onChange(e) + } + } + return + } + else if (isRedo(e)) { + e.preventDefault() + if (this.mask.redo()) { + e.target.value = this._getDisplayValue() + this._updateInputSelection() + if (this.props.onChange) { + this.props.onChange(e) + } + } + return + } + + if (e.key === 'Backspace') { + e.preventDefault() + this._updateMaskSelection() + if (this.mask.backspace()) { + var value = this._getDisplayValue() + e.target.value = value + if (value) { + this._updateInputSelection() + } + if (this.props.onChange) { + this.props.onChange(e) + } + } + } + }, + + _onKeyPress(e) { + // console.log('onKeyPress', JSON.stringify(getSelection(this.input)), e.key, e.target.value) + + // Ignore modified key presses + // Ignore enter key to allow form submission + if (e.metaKey || e.altKey || e.ctrlKey || e.key === 'Enter') { return } + + e.preventDefault() + this._updateMaskSelection() + if (this.mask.input((e.key || e.data))) { + e.target.value = this.mask.getValue() + this._updateInputSelection() + if (this.props.onChange) { + this.props.onChange(e) + } + } + }, + + _onPaste(e) { + // console.log('onPaste', JSON.stringify(getSelection(this.input)), e.clipboardData.getData('Text'), e.target.value) + + e.preventDefault() + this._updateMaskSelection() + // getData value needed for IE also works in FF & Chrome + if (this.mask.paste(e.clipboardData.getData('Text'))) { + e.target.value = this.mask.getValue() + // Timeout needed for IE + setTimeout(this._updateInputSelection, 0) + if (this.props.onChange) { + this.props.onChange(e) + } + } + else { + this.mask.setValue(e.clipboardData.getData('Text')); + var value = this._getDisplayValue() + e.target.value = value + if (value) { + this._updateInputSelection() + } + } + }, + + _getDisplayValue() { + var value = this.mask.getValue() + return value === this.mask.emptyValue ? '' : value + }, + + _keyPressPropName() { + if (typeof navigator !== 'undefined') { + return navigator.userAgent.match(/Android/i) + ? 'onBeforeInput' + : 'onKeyPress' + } + return 'onKeyPress' + }, + + _getEventHandlers() { + return { + onChange: this._onChange, + onKeyDown: this._onKeyDown, + onPaste: this._onPaste, + [this._keyPressPropName()]: this._onKeyPress + } + }, + + focus() { + this.input.focus() + }, + + blur() { + this.input.blur() + }, + + render() { + var ref = r => this.input = r + var maxLength = this.mask.pattern.length + var value = this._getDisplayValue() + var eventHandlers = this._getEventHandlers() + var { size = maxLength, placeholder = this.mask.emptyValue } = this.props + + var {placeholderChar, formatCharacters, ...cleanedProps} = this.props + var inputProps = { ...cleanedProps, ...eventHandlers, ref, maxLength, value, size, placeholder } + return + } +}) + +module.exports = MaskedInput diff --git a/src/index.jsx b/src/index.jsx deleted file mode 100644 index 5b15c6f..0000000 --- a/src/index.jsx +++ /dev/null @@ -1,173 +0,0 @@ -'use strict'; - -var React = require('react') -var {getSelection, setSelection} = require('react/lib/ReactInputSelection') - -var InputMask = require('inputmask-core') - -var KEYCODE_Z = 90 -var KEYCODE_Y = 89 - -function isUndo(e) { - return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Y : KEYCODE_Z) -} - -function isRedo(e) { - return (e.ctrlKey || e.metaKey) && e.keyCode === (e.shiftKey ? KEYCODE_Z : KEYCODE_Y) -} - -var MaskedInput = React.createClass({ - propTypes: { - mask: React.PropTypes.string.isRequired, - - formatCharacters: React.PropTypes.object, - placeholderChar: React.PropTypes.string - }, - - getDefaultProps() { - return { - value: '' - } - }, - - componentWillMount() { - var options = { - pattern: this.props.mask, - value: this.props.value, - formatCharacters: this.props.formatCharacters - } - if (this.props.placeholderChar) { - options.placeholderChar = this.props.placeholderChar - } - this.mask = new InputMask(options) - }, - - componentWillReceiveProps(nextProps) { - if (this.props.value !== nextProps.value) { - this.mask.setValue(nextProps.value) - } - if (this.props.mask !== nextProps.mask) { - this.mask.setPattern(nextProps.mask, {value: this.mask.getRawValue()}) - } - }, - - _updateMaskSelection() { - this.mask.selection = getSelection(this.input) - }, - - _updateInputSelection() { - setSelection(this.input, this.mask.selection) - }, - - _onChange(e) { - // console.log('onChange', JSON.stringify(getSelection(this.input)), e.target.value) - - var maskValue = this.mask.getValue() - if (e.target.value != maskValue) { - // Cut or delete operations will have shortened the value - if (e.target.value.length < maskValue.length) { - var sizeDiff = maskValue.length - e.target.value.length - this._updateMaskSelection() - this.mask.selection.end = this.mask.selection.start + sizeDiff - this.mask.backspace() - } - var value = this._getDisplayValue() - e.target.value = value - if (value) { - this._updateInputSelection() - } - } - if (this.props.onChange) { - this.props.onChange(e) - } - }, - - _onKeyDown(e) { - // console.log('onKeyDown', JSON.stringify(getSelection(this.input)), e.key, e.target.value) - - if (isUndo(e)) { - e.preventDefault() - if (this.mask.undo()) { - e.target.value = this._getDisplayValue() - this._updateInputSelection() - this.props.onChange(e) - } - return - } - else if (isRedo(e)) { - e.preventDefault() - if (this.mask.redo()) { - e.target.value = this._getDisplayValue() - this._updateInputSelection() - this.props.onChange(e) - } - return - } - - if (e.key == 'Backspace') { - e.preventDefault() - this._updateMaskSelection() - if (this.mask.backspace()) { - var value = this._getDisplayValue() - e.target.value = value - if (value) { - this._updateInputSelection() - } - this.props.onChange(e) - } - } - }, - - _onKeyPress(e) { - // console.log('onKeyPress', JSON.stringify(getSelection(this.input)), e.key, e.target.value) - - // Ignore modified key presses - // Ignore enter key to allow form submission - if (e.metaKey || e.altKey || e.ctrlKey || e.key == 'Enter') { return } - - e.preventDefault() - this._updateMaskSelection() - if (this.mask.input(e.key)) { - e.target.value = this.mask.getValue() - this._updateInputSelection() - this.props.onChange(e) - } - }, - - _onPaste(e) { - // console.log('onPaste', JSON.stringify(getSelection(this.input)), e.clipboardData.getData('Text'), e.target.value) - - e.preventDefault() - this._updateMaskSelection() - // getData value needed for IE also works in FF & Chrome - if (this.mask.paste(e.clipboardData.getData('Text'))) { - e.target.value = this.mask.getValue() - // Timeout needed for IE - setTimeout(this._updateInputSelection, 0) - this.props.onChange(e) - } - }, - - _getDisplayValue() { - var value = this.mask.getValue() - return value === this.mask.emptyValue ? '' : value - }, - - render() { - var {mask, formatCharacters, size, placeholder, ...props} = this.props - var patternLength = this.mask.pattern.length - return this.input = r } - maxLength={patternLength} - onChange={this._onChange} - onKeyDown={this._onKeyDown} - onKeyPress={this._onKeyPress} - onPaste={this._onPaste} - placeholder={placeholder || this.mask.emptyValue} - size={size || patternLength} - value={this._getDisplayValue()} - /> - } -}) - -module.exports = MaskedInput diff --git a/tests/index-test.js b/tests/index-test.js new file mode 100644 index 0000000..fa12f70 --- /dev/null +++ b/tests/index-test.js @@ -0,0 +1,279 @@ +/* eslint-env mocha */ +import React from 'react' +import ReactDOM from 'react-dom' +import expect from 'expect' +import MaskedInput from 'src' + +const setup = () => { + const element = document.createElement('div') + document.body.appendChild(element) + return element +} + +const cleanup = (element) => { + ReactDOM.unmountComponentAtNode(element) + document.body.removeChild(element) +} + +describe('MaskedInput', () => { + it('should render (smokescreen test)', () => { + expect.spyOn(console, 'error') + expect().toExist() + expect(console.error.calls[0].arguments[0]).toMatch( + new RegExp('Warning: Failed prop type:') + ) + expect(console.error.calls[0].arguments[0]).toMatch( + new RegExp('`mask`') + ) + expect(console.error.calls[0].arguments[0]).toMatch( + new RegExp('required', 'i') + ) + console.error.restore() + }) + + it('should handle a masking workflow', () => { + const el = setup() + let ref = null + ReactDOM.render( + { + if (r) ref = r + }} + mask="11/11" + />, + el + ) + const input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('') + expect(input.placeholder).toBe('__/__') + expect(input.size).toBe(5) + + cleanup(el) + }) + + it('should handle updating mask', () => { + const el = setup() + let ref = null + let defaultMask = '1111 1111 1111 1111' + let amexMask = '1111 111111 11111' + + function render(props) { + ReactDOM.render( + { + if (r) ref = r + }} + {...props} + />, + el + ) + } + + render({mask: defaultMask}) + let input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('') + expect(input.placeholder).toBe('____ ____ ____ ____') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(0) + + render({mask: amexMask}) + input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('') + expect(input.placeholder).toBe('____ ______ _____') + expect(input.size).toBe(17) + expect(input.selectionStart).toBe(0) + + cleanup(el) + }) + + it('should handle updating value', () => { + const el = setup() + let ref = null + let defaultMask = '1111 1111 1111 1111' + + function render(props) { + ReactDOM.render( + ref = r} + {...props} + />, + el + ) + } + + render({mask: defaultMask, value: ''}) + let input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('') + expect(input.placeholder).toBe('____ ____ ____ ____') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(0) + + // update value + render({mask: defaultMask, value: '4111111111111111'}) + input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('4111 1111 1111 1111') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(19) + + cleanup(el) + }) + + it('should handle updating mask and value', () => { + const el = setup() + let ref = null + let defaultMask = '1111 1111 1111 1111' + let amexMask = '1111 111111 11111' + let value = '' + let mask = defaultMask + + function render(props) { + ReactDOM.render( + ref = r} + {...props} + />, + el + ) + } + + render({mask, value}) + let input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('') + expect(input.placeholder).toBe('____ ____ ____ ____') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(0) + + // update mask and value + render({mask: amexMask, value: '378282246310005'}) + input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('3782 822463 10005') + expect(input.size).toBe(17) + expect(input.selectionStart).toBe(17) + + cleanup(el) + }) + + it('should remove leftover placeholder characters when switching to smaller mask', () => { + const el = setup() + let ref = null + let defaultMask = '1111 1111 1111 1111' + let amexMask = '1111 111111 11111' + let mask = defaultMask + let value = null + + function render(props) { + ReactDOM.render( + { + if (r) ref = r + }} + mask={mask} + value={value} + />, + el + ) + } + + render() + let input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('') + expect(input.placeholder).toBe('____ ____ ____ ____') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(0) + + mask = amexMask + value = '1234 123456 12345' + render() + input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('1234 123456 12345') + expect(input.size).toBe(17) + + cleanup(el) + }) + + it('cleans props from input', () => { + const el = setup() + let ref = null + let defaultMask = '1111 1111 1111 1111' + function render(props) { + ReactDOM.render( + ref = r} {...props} />, + el + ) + } + expect.spyOn(console, 'error') + render({mask: defaultMask, value: '', + placeholderChar: 'X', formatCharacters: {A: null}}) + expect(console.error).toNotHaveBeenCalled() + console.error.restore() + let input = ReactDOM.findDOMNode(ref) + expect(input.getAttribute('placeholderChar')).toNotExist() + expect(input.getAttribute('formatCharacters')).toNotExist() + cleanup(el) + }) + + it('should handle updating multiple values', () => { + const el = setup() + let ref = null + let defaultMask = '1111 1111 1111 1111' + const mastercard = '5555555555554444' + const visa = '4111111111111111' + + function render(props) { + ReactDOM.render( + ref = r} + {...props} + />, + el + ) + } + + render({mask: defaultMask, value: ''}) + let input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('') + expect(input.placeholder).toBe('____ ____ ____ ____') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(0) + + // update mask and value + render({mask: defaultMask, value: visa}) + input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('4111 1111 1111 1111') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(19) + + // update mask and value + render({mask: defaultMask, value: mastercard}) + input = ReactDOM.findDOMNode(ref) + + // initial state + expect(input.value).toBe('5555 5555 5555 4444') + expect(input.size).toBe(19) + expect(input.selectionStart).toBe(19) + + cleanup(el) + }) +})