├── .gitignore ├── public ├── css │ └── index.sass ├── images │ ├── icon-github.png │ └── icon-square.png ├── _sass │ ├── _styles.sass │ └── _syntax.scss └── index.html ├── assets ├── images │ ├── BrandJCB.png │ ├── BrandVisa.png │ ├── CVVBack.png │ ├── CVVFront.png │ ├── BrandJCB@2x.png │ ├── CVVBack@2x.png │ ├── CVVFront@2x.png │ ├── DefaultCard.png │ ├── BrandDiscover.png │ ├── BrandVisa@2x.png │ ├── DefaultCard@2x.png │ ├── BrandDiscover@2x.png │ ├── BrandMastercard.png │ ├── BrandMastercard@2x.png │ ├── BrandAmericanExpress.png │ └── BrandAmericanExpress@2x.png └── stylesheets │ └── card_field.sass ├── .travis.yml ├── test ├── unit │ ├── helpers │ │ ├── passthrough_formatter.js │ │ ├── selection.js │ │ ├── builders.js │ │ ├── fake_event.js │ │ ├── expectations.js │ │ └── setup.js │ ├── amex_card_formatter_test.js │ ├── expiry_date_field_test.js │ ├── social_security_number_formatter_test.js │ ├── employer_identification_number_formatter_test.js │ ├── formatter_test.js │ ├── adaptive_card_formatter_test.js │ ├── caret_test.js │ ├── default_card_formatter_test.js │ ├── expiry_date_formatter_test.js │ ├── card_text_field_test.js │ ├── undo_management_test.js │ ├── number_formatter_settings_formatter_test.js │ └── delimited_text_formatter_test.js └── selenium │ ├── server │ ├── test_pages │ │ ├── text_field.html │ │ ├── card_text_field.html │ │ ├── phone_formatter.html │ │ ├── amex_card_formatter.html │ │ ├── adaptive_card_formatter.html │ │ ├── default_card_formatter.html │ │ ├── expiry_date_formatter.html │ │ ├── social_security_number_formatter.html │ │ ├── employer_identification_number_formatter.html │ │ └── delimited_text_formatter.html │ └── index.js │ ├── amex_card_formatter_test.js │ ├── card_text_field_test.js │ ├── adaptive_card_formatter_test.js │ ├── social_security_number_formatter_test.js │ ├── employer_identification_number_formatter_test.js │ ├── helpers │ └── index.js │ ├── default_card_formatter_test.js │ ├── index.js │ ├── text_field_test.js │ ├── delimited_text_formatter_test.js │ ├── expiry_date_formatter_test.js │ └── phone_formatter_test.js ├── docs ├── Contributing.md ├── Expiry-Date-Formatter.md ├── Social-Security-Number-Formatter.md ├── Phone-Formatter.md ├── Delimited-Text-Formatter.md ├── Installing-&-Using.md ├── Credit-Card-Formatters.md ├── Developing.md ├── Formatters.md └── FieldKit-Fields.md ├── .eslintrc ├── src ├── amex_card_formatter.js ├── expiry_date_field.js ├── social_security_number_formatter.js ├── employer_identification_number_formatter.js ├── caret.js ├── default_card_formatter.js ├── formatter.js ├── index.js ├── adaptive_card_formatter.js ├── card_utils.js ├── card_text_field.js ├── expiry_date_formatter.js ├── number_formatter_settings_formatter.js ├── utils.js ├── undo_manager.js └── delimited_text_formatter.js ├── CONTRIBUTING.md ├── karma.conf.js ├── README.md ├── package.json ├── gulpfile.babel.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | lib/ 3 | node_modules 4 | .publish/ 5 | -------------------------------------------------------------------------------- /public/css/index.sass: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "syntax" 5 | @import "styles" 6 | -------------------------------------------------------------------------------- /assets/images/BrandJCB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandJCB.png -------------------------------------------------------------------------------- /assets/images/BrandVisa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandVisa.png -------------------------------------------------------------------------------- /assets/images/CVVBack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/CVVBack.png -------------------------------------------------------------------------------- /assets/images/CVVFront.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/CVVFront.png -------------------------------------------------------------------------------- /assets/images/BrandJCB@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandJCB@2x.png -------------------------------------------------------------------------------- /assets/images/CVVBack@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/CVVBack@2x.png -------------------------------------------------------------------------------- /assets/images/CVVFront@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/CVVFront@2x.png -------------------------------------------------------------------------------- /assets/images/DefaultCard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/DefaultCard.png -------------------------------------------------------------------------------- /public/images/icon-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/public/images/icon-github.png -------------------------------------------------------------------------------- /public/images/icon-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/public/images/icon-square.png -------------------------------------------------------------------------------- /assets/images/BrandDiscover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandDiscover.png -------------------------------------------------------------------------------- /assets/images/BrandVisa@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandVisa@2x.png -------------------------------------------------------------------------------- /assets/images/DefaultCard@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/DefaultCard@2x.png -------------------------------------------------------------------------------- /assets/images/BrandDiscover@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandDiscover@2x.png -------------------------------------------------------------------------------- /assets/images/BrandMastercard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandMastercard.png -------------------------------------------------------------------------------- /assets/images/BrandMastercard@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandMastercard@2x.png -------------------------------------------------------------------------------- /assets/images/BrandAmericanExpress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandAmericanExpress.png -------------------------------------------------------------------------------- /assets/images/BrandAmericanExpress@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/square/field-kit/master/assets/images/BrandAmericanExpress@2x.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "4.1" 5 | before_script: 6 | - export DISPLAY=:99.0 7 | - sh -e /etc/init.d/xvfb start 8 | -------------------------------------------------------------------------------- /test/unit/helpers/passthrough_formatter.js: -------------------------------------------------------------------------------- 1 | class PassthroughFormatter { 2 | format(value) { 3 | return value; 4 | } 5 | 6 | parse(text) { 7 | return text; 8 | } 9 | } 10 | 11 | export default PassthroughFormatter; 12 | -------------------------------------------------------------------------------- /docs/Contributing.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ **Contributing** 2 | 3 | We’re glad you’re interested in FieldKit, and we’d love to see where you take it. 4 | 5 | Please review [CONTRIBUTING.md](https://github.com/square/field-kit/blob/master/CONTRIBUTING.md) -------------------------------------------------------------------------------- /test/selenium/server/test_pages/text_field.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/card_text_field.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/phone_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/amex_card_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/adaptive_card_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/default_card_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/expiry_date_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaFeatures": { 3 | "modules": true 4 | }, 5 | "useEslintrc": false, 6 | "env": { 7 | "browser": true, 8 | "node": true, 9 | "es6": true 10 | }, 11 | "rules": { 12 | "camelcase": [2, {"properties": "never"}], 13 | "no-unused-vars": 0, 14 | "no-use-before-define": 0, 15 | "strict": 0, 16 | "no-shadow": 0, 17 | "no-multi-spaces": 0, 18 | "no-underscore-dangle": 0, 19 | "quotes": [ 20 | 2, 21 | "single" 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/social_security_number_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/amex_card_formatter.js: -------------------------------------------------------------------------------- 1 | import DefaultCardFormatter from './default_card_formatter'; 2 | 3 | /** 4 | * Amex credit card formatter. 5 | * 6 | * @extends DefaultCardFormatter 7 | */ 8 | class AmexCardFormatter extends DefaultCardFormatter { 9 | /** 10 | * @override 11 | */ 12 | hasDelimiterAtIndex(index) { 13 | return index === 4 || index === 11; 14 | } 15 | 16 | /** 17 | * @override 18 | */ 19 | get maximumLength() { 20 | return 15 + 2; 21 | } 22 | } 23 | 24 | export default AmexCardFormatter; 25 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/employer_identification_number_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /docs/Expiry-Date-Formatter.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ [Formatters](Formatters) ▸ **Expiry Date Formatter** 2 | 3 | ## Expiry Date Formatter 4 | Expiry Date Formatter is a subclass of DelimitedTextFormatter. This formatter has a few helpers built into it. If you type a number greater than 1 as the first digit of the month it will insert a zero before the digit `4| -> 04|`. It will not allow the month `00` or months greater than 12. Parse will return an Object and do the hard work of turning a two digit year into a fully qualified year. -------------------------------------------------------------------------------- /docs/Social-Security-Number-Formatter.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ [Formatters](Formatters) ▸ **Social Security Number Formatter** 2 | 3 | ![](https://cloud.githubusercontent.com/assets/967026/9753727/ac870776-5675-11e5-817a-0b572d9e1548.gif) 4 | 5 | ## Social Security Number Formatter 6 | The Social Security Number Formatter subclasses DelimitedTextFormatter. It will limit input to numbers and will format the input `###-##-####`. 7 | 8 | ### Example 9 | 10 | ```js 11 | formatter.format('123456789') // '123-45-6789' 12 | ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing To FieldKit 2 | 3 | ### Individual Contributor License Agreement 4 | 5 | Want to add support for a new formatter or fix a bug in an obscure version of Android? We'd love for you to participate in the development of FieldKit. Before we can accept your pull request, please sign our [Individual Contributor License Agreement][cla]. It's a short form that covers our bases and makes sure you're eligible to contribute. Thank you! 6 | 7 | ### License 8 | 9 | FieldKit is available under the [Apache License][license]. 10 | 11 | [cla]: https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1 12 | [license]: https://github.com/square/field-kit/blob/master/LICENSE 13 | -------------------------------------------------------------------------------- /src/expiry_date_field.js: -------------------------------------------------------------------------------- 1 | import TextField from './text_field'; 2 | import ExpiryDateFormatter from './expiry_date_formatter'; 3 | 4 | /** 5 | * Adds a default formatter for expiration dates. 6 | * 7 | * @extends TextField 8 | */ 9 | class ExpiryDateField extends TextField { 10 | /** 11 | * @param {HTMLElement} element 12 | */ 13 | constructor(element) { 14 | super(element, new ExpiryDateFormatter()); 15 | } 16 | 17 | /** 18 | * Called by our superclass, used to post-process the text. 19 | * 20 | * @private 21 | */ 22 | textFieldDidEndEditing() { 23 | const value = this.value(); 24 | if (value) { 25 | this.setText(this.formatter().format(value)); 26 | } 27 | } 28 | } 29 | 30 | export default ExpiryDateField; 31 | -------------------------------------------------------------------------------- /test/unit/amex_card_formatter_test.js: -------------------------------------------------------------------------------- 1 | import { buildField } from './helpers/builders'; 2 | import { expectThatTyping } from './helpers/expectations'; 3 | import FieldKit from '../../src'; 4 | 5 | testsWithAllKeyboards('FieldKit.AmexCardFormatter', function() { 6 | var field = null; 7 | 8 | beforeEach(function() { 9 | field = buildField(); 10 | field.setFormatter(new FieldKit.AmexCardFormatter()); 11 | }); 12 | 13 | it('formats Amex card numbers correctly', function() { 14 | expectThatTyping('37251111112000').into(field).willChange('|').to('3725 111111 2000|'); 15 | }); 16 | 17 | it('prevents entering more digits than are allowed', function() { 18 | expectThatTyping('1').into(field).willNotChange('3725 123456 81000|'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | var babelify = require('babelify'); 3 | 4 | config.set({ 5 | basePath : '', 6 | frameworks : ['browserify', 'mocha', 'chai'], 7 | reporters : ['mocha'], 8 | port : process.env.KARMA_PORT || 2000, 9 | browsers : process.env.CONTINUOUS_INTEGRATION ? ['Firefox'] : ['Chrome'], 10 | 11 | files: [ 12 | 'src/index.js', 13 | 'test/unit/**/*_test.js' 14 | ], 15 | 16 | preprocessors: { 17 | 'src/**/*.js': ['eslint', 'browserify'], 18 | 'test/unit/**/*.js': ['browserify'] 19 | }, 20 | 21 | eslint: { 22 | stopOnError: false 23 | }, 24 | 25 | browserify: { 26 | debug: true, 27 | transform: [babelify] 28 | } 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /docs/Phone-Formatter.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ [Formatters](Formatters) ▸ **Phone Formatter** 2 | 3 | ## Phone Formatter 4 | Phone Formatter subclasses DelimitedTextFormatter. This formatter will guess the correct format when entering a phone number. 5 | 6 | ### Example 7 | ```js 8 | formatter.format('4155551234') // '(415) 555-1234' 9 | formatter.format('14155551234') // '1 (415) 555-1234' 10 | formatter.format('+14155551234') // '+1 (415) 555-1234' 11 | formatter.format('1-415-555-1234') // '1 (415) 555-1234' 12 | formatter.format('1 (415) 555 1234') // '1 (415) 555-1234' 13 | formatter.format('1 (415) 555-1234') // '1 (415) 555-1234' 14 | formatter.format('415-555-1234') // '(415) 555-1234' 15 | ``` -------------------------------------------------------------------------------- /docs/Delimited-Text-Formatter.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ [Formatters](Formatters) ▸ **Delimited Text Formatter** 2 | 3 | ## Delimited Text Formatter 4 | A generic delimited formatter. This is used as a base class for `DefaultCardFormatter`, `AmexCardFormatter`, `AdaptiveCardFormatter`, `ExpiryDateFormatter`, `PhoneFormatter`, and `SocialSecurityNumberFormatter`. 5 | 6 | ### Methods 7 | 8 | #### # delimiterAt([_index_]) 9 | > @param {number} index 10 | > @returns {?string} 11 | > 12 | > Determines the delimiter character at the given index. 13 | 14 | #### # isDelimiter() 15 | > @param {string} chr 16 | > @returns {boolean} 17 | > 18 | > Determines whether the given character is a delimiter. -------------------------------------------------------------------------------- /test/unit/expiry_date_field_test.js: -------------------------------------------------------------------------------- 1 | import { expectThatLeaving } from './helpers/expectations'; 2 | import { buildField } from './helpers/builders'; 3 | import FieldKit from '../../src'; 4 | 5 | testsWithAllKeyboards('FieldKit.ExpiryDateField', function() { 6 | var field; 7 | 8 | beforeEach(function() { 9 | field = buildField(FieldKit.ExpiryDateField); 10 | }); 11 | 12 | it('interprets a single digit year as if it had zero prefixed', function() { 13 | // NOTE: this probably ought to result in '12/04|', but changing the caret 14 | // during the blur event makes Chrome unhappy. 15 | expectThatLeaving(field).willChange('12/4|').to('12/04|'); 16 | }); 17 | 18 | it('leaves unparseable values alone on end edit', function() { 19 | expectThatLeaving(field).willNotChange('4|'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/unit/helpers/selection.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import Selection from 'string-selection' 3 | 4 | chai.use(function (_chai, utils) { 5 | utils.addMethod(chai.Assertion.prototype, 'selected', function (description) { 6 | var inputText = this._obj.text(); 7 | var inputSelectedRange = this._obj.selectedRange(); 8 | var inputSelectedEnd = inputSelectedRange.start + inputSelectedRange.length; 9 | var inputDescription = Selection.printDescription({ 10 | caret: { 11 | start: inputSelectedRange.start, 12 | end: inputSelectedEnd 13 | }, 14 | value: inputText, 15 | affinity: this._obj.selectionAffinity 16 | }); 17 | 18 | this.assert( 19 | inputDescription === description, 20 | "expected #{exp} to be '" + inputDescription + "'", 21 | "expected #{exp} not to be '" + inputDescription + "'", 22 | description 23 | ); 24 | }); 25 | }); 26 | 27 | export default Selection; 28 | -------------------------------------------------------------------------------- /test/unit/social_security_number_formatter_test.js: -------------------------------------------------------------------------------- 1 | import { expectThatTyping } from './helpers/expectations'; 2 | import { buildField } from './helpers/builders'; 3 | import FieldKit from '../../src'; 4 | 5 | testsWithAllKeyboards('FieldKit.SocialSecurityNumberFormatter', function() { 6 | var field; 7 | 8 | beforeEach(function() { 9 | field = buildField(); 10 | field.setFormatter(new FieldKit.SocialSecurityNumberFormatter()); 11 | }); 12 | 13 | it('places dashes in the right places', function() { 14 | expectThatTyping('123456789').into(field).willChange('|').to('123-45-6789|'); 15 | }); 16 | 17 | it('prevents extra digits from being entered', function() { 18 | expectThatTyping('0').into(field).willNotChange('123-45-6789|'); 19 | }); 20 | 21 | it('prevents entering non-digit characters', function() { 22 | expectThatTyping('f').into(field).willNotChange('|'); 23 | }); 24 | 25 | it('backspaces words correctly', function() { 26 | expectThatTyping('alt+backspace').into(field).willChange('123-45-|').to('123-|'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/unit/employer_identification_number_formatter_test.js: -------------------------------------------------------------------------------- 1 | import { expectThatTyping } from './helpers/expectations'; 2 | import { buildField } from './helpers/builders'; 3 | import FieldKit from '../../src'; 4 | 5 | testsWithAllKeyboards('FieldKit.EmployerIdentificationNumberFormatter', function() { 6 | var field; 7 | 8 | beforeEach(function() { 9 | field = buildField(); 10 | field.setFormatter(new FieldKit.EmployerIdentificationNumberFormatter()); 11 | }); 12 | 13 | it('places dashes in the right places', function() { 14 | expectThatTyping('123456789').into(field).willChange('|').to('12-3456789|'); 15 | }); 16 | 17 | it('prevents extra digits from being entered', function() { 18 | expectThatTyping('0').into(field).willNotChange('12-3456789|'); 19 | }); 20 | 21 | it('prevents entering non-digit characters', function() { 22 | expectThatTyping('f').into(field).willNotChange('|'); 23 | }); 24 | 25 | it('backspaces words correctly', function() { 26 | expectThatTyping('alt+backspace').into(field).willChange('12-3456789|').to('12-|'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/selenium/server/index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | var port = process.env.PORT || 3000; 4 | var server; 5 | 6 | app.use(express.static(__dirname + '/test_pages')); 7 | app.use(express.static(__dirname + '/../../../dist')); 8 | app.use(express.static(__dirname + '/../../../node_modules')); 9 | 10 | exports.start = function() { 11 | server = app.listen(port); 12 | console.log(' :: Server listening on port ' + port + ' ::'); 13 | }; 14 | 15 | exports.stop = function() { 16 | server.close(); 17 | console.log(' :: Server closed ::'); 18 | }; 19 | 20 | exports.pathFor = function(page) { 21 | return 'http://localhost:' + port + '/' + page; 22 | }; 23 | 24 | exports.goTo = function(page) { 25 | return driver.get(exports.pathFor(page)) 26 | .then(function() { 27 | if(global.ua) return driver.executeScript('return window.navigator.__defineGetter__("userAgent", ' + 28 | 'function() { return "' + global.ua + '"; });'); 29 | }) 30 | .then(function() { 31 | return driver.executeScript('window.installFieldKit();'); 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /src/social_security_number_formatter.js: -------------------------------------------------------------------------------- 1 | import DelimitedTextFormatter from './delimited_text_formatter'; 2 | 3 | /** 4 | * @const 5 | * @private 6 | */ 7 | const DIGITS_PATTERN = /^\d*$/; 8 | 9 | /** 10 | * @extends DelimitedTextFormatter 11 | */ 12 | class SocialSecurityNumberFormatter extends DelimitedTextFormatter { 13 | constructor() { 14 | super('-'); 15 | this.maximumLength = 9 + 2; 16 | } 17 | 18 | /** 19 | * @param {number} index 20 | * @returns {boolean} 21 | */ 22 | hasDelimiterAtIndex(index) { 23 | return index === 3 || index === 6; 24 | } 25 | 26 | /** 27 | * Determines whether the given change should be allowed and, if so, whether 28 | * it should be altered. 29 | * 30 | * @param {TextFieldStateChange} change 31 | * @param {function(string)} error 32 | * @returns {boolean} 33 | */ 34 | isChangeValid(change, error) { 35 | if (DIGITS_PATTERN.test(change.inserted.text)) { 36 | return super.isChangeValid(change, error); 37 | } else { 38 | return false; 39 | } 40 | } 41 | } 42 | 43 | export default SocialSecurityNumberFormatter; 44 | -------------------------------------------------------------------------------- /src/employer_identification_number_formatter.js: -------------------------------------------------------------------------------- 1 | import DelimitedTextFormatter from './delimited_text_formatter'; 2 | 3 | /** 4 | * @const 5 | * @private 6 | */ 7 | const DIGITS_PATTERN = /^\d*$/; 8 | 9 | /** 10 | * @extends DelimitedTextFormatter 11 | */ 12 | class EmployerIdentificationNumberFormatter extends DelimitedTextFormatter { 13 | constructor() { 14 | super('-'); 15 | this.maximumLength = 9 + 1; 16 | } 17 | 18 | /** 19 | * @param {number} index 20 | * @returns {boolean} 21 | */ 22 | hasDelimiterAtIndex(index) { 23 | return index === 2; 24 | } 25 | 26 | /** 27 | * Determines whether the given change should be allowed and, if so, whether 28 | * it should be altered. 29 | * 30 | * @param {TextFieldStateChange} change 31 | * @param {function(string)} error 32 | * @returns {boolean} 33 | */ 34 | isChangeValid(change, error) { 35 | if (DIGITS_PATTERN.test(change.inserted.text)) { 36 | return super.isChangeValid(change, error); 37 | } else { 38 | return false; 39 | } 40 | } 41 | } 42 | 43 | export default EmployerIdentificationNumberFormatter; 44 | -------------------------------------------------------------------------------- /docs/Installing-&-Using.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ **Installing & Using** 2 | 3 | ## Installing 4 | 5 | You can download the latest version from [GitHub](https://github.com/square/field-kit/releases) or from our [CDN](https://cdnjs.com/libraries/field-kit) and include the js file. 6 | 7 | ```html 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | ``` 22 | 23 | You may also link to the CDN file directly. https://cdnjs.com/libraries/field-kit 24 | 25 | NOTE: _Make sure you add the `charset="utf-8"` attribute to your script tag._ 26 | 27 | You can use also [npm](https://www.npmjs.com/) to install FieldKit for use in your project. 28 | 29 | `$ npm i field-kit --save` 30 | 31 | ## Usage 32 | 33 | Once you have the `FieldKit` instance available on your page, the simplest usage is: 34 | 35 | ```html 36 | 37 | 38 | 43 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FieldKit 2 | 3 |

4 | 5 | build status 7 | 8 | 9 | npm version 11 | 12 | 13 | license 15 | 16 |

17 | 18 | FieldKit lets you take control of your text fields. 19 | 20 | FieldKit provides real-time, text field formatting as users type. It simplifies input formatting and creates a more polished experience for users, while outputting standardized data. 21 | 22 | ## Demo 23 | See examples of built-in formatters and fields at [the demo page][demo-page]. 24 | 25 | ## Documentation 26 | Please see our [documentation][docs] for a more in depth overview. 27 | 28 | ## Contributing 29 | We’re glad you’re interested in FieldKit, and we’d love to see where you take it. 30 | 31 | Please review [CONTRIBUTING.md][contributing] 32 | 33 | [demo-page]: http://square.github.io/field-kit 34 | [docs]: https://github.com/square/field-kit/wiki 35 | [contributing]: https://github.com/square/field-kit/blob/master/CONTRIBUTING.md 36 | -------------------------------------------------------------------------------- /test/selenium/amex_card_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.AmexCardFormatter', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('amex_card_formatter.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | describe('typing', function() { 18 | test.it('formats Amex card numbers correctly', function() { 19 | helpers.setInput('37251111112000', input); 20 | 21 | return helpers.getFieldKitValues() 22 | .then(function(values) { 23 | expect(values.raw).to.equal('3725 111111 2000'); 24 | }); 25 | }); 26 | 27 | test.it('prevents entering more digits than are allowed', function() { 28 | helpers.setInput('372512345681000|', input); 29 | 30 | return helpers.getFieldKitValues() 31 | .then(function(values) { 32 | expect(values.fieldKit).to.equal('372512345681000'); 33 | }); 34 | }); 35 | }); 36 | }); 37 | 38 | }; 39 | -------------------------------------------------------------------------------- /test/unit/formatter_test.js: -------------------------------------------------------------------------------- 1 | import { expectThatTyping, expectThatPasting } from './helpers/expectations'; 2 | import { buildField } from './helpers/builders'; 3 | import FieldKit from '../../src'; 4 | 5 | describe('FieldKit.Formatter', function() { 6 | var field; 7 | 8 | beforeEach(function() { 9 | field = buildField(); 10 | field.setFormatter(new FieldKit.Formatter()); 11 | }); 12 | 13 | describe('when #maximumLength is set', function() { 14 | beforeEach(function() { 15 | field.formatter().maximumLength = 3; 16 | }); 17 | 18 | it('allows input that would not make the text longer than the maximum', function() { 19 | expectThatTyping('c').into(field).willChange('ab|').to('abc|'); 20 | expectThatTyping('b').into(field).willChange('a|c').to('ab|c'); 21 | expectThatTyping('c').into(field).willChange('ab|d|').to('abc|'); 22 | }); 23 | 24 | it('prevents input that would make the text longer than the maximum', function() { 25 | expectThatTyping('d').into(field).willNotChange('abc|'); 26 | expectThatTyping('b').into(field).willNotChange('a|cd'); 27 | }); 28 | 29 | it('allows pasted characters up until the maximum', function() { 30 | expectThatPasting('12345').into(field).willChange('a|').to('a12|'); 31 | expectThatPasting('12345').into(field).willChange('a|b|c').to('a1|c'); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/unit/adaptive_card_formatter_test.js: -------------------------------------------------------------------------------- 1 | import { buildField } from './helpers/builders'; 2 | import { expectThatTyping } from './helpers/expectations'; 3 | import FieldKit from '../../src'; 4 | import {expect} from 'chai'; 5 | 6 | testsWithAllKeyboards('FieldKit.AdaptiveCardFormatter', function() { 7 | var field = null; 8 | var formatter = null; 9 | 10 | beforeEach(function() { 11 | field = buildField(); 12 | formatter = new FieldKit.AdaptiveCardFormatter(); 13 | field.setFormatter(formatter); 14 | }); 15 | 16 | describe('typing', function() { 17 | it('formats as Visa once it can tell it is a Visa card', function() { 18 | expectThatTyping('4111111').into(field).willChange('|').to('4111 111|'); 19 | }); 20 | 21 | it('formats as Amex once it can tell it is an Amex card', function() { 22 | expectThatTyping('372512345678901').into(field).willChange('|').to('3725 123456 78901|'); 23 | }); 24 | 25 | it('formats it as the default if it cannot tell what it is', function() { 26 | expectThatTyping('1111111').into(field).willChange('|').to('1111 111|'); 27 | }); 28 | }); 29 | 30 | describe('#format', function() { 31 | it('chooses the right formatter', function() { 32 | expect(formatter.format('4111111111111111')).to.equal('4111 1111 1111 1111'); 33 | expect(formatter.format('371111111111111')).to.equal('3711 111111 11111'); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/caret.js: -------------------------------------------------------------------------------- 1 | export default function installCaret(_document = document) { 2 | let getCaret; 3 | let setCaret; 4 | 5 | if (!_document) { 6 | throw new Error('Caret does not have access to document'); 7 | } else if ('selectionStart' in _document.createElement('input')) { 8 | getCaret = element => { 9 | return { 10 | start: element.selectionStart, 11 | end: element.selectionEnd 12 | }; 13 | }; 14 | setCaret = (element, start, end) => { 15 | element.selectionStart = start; 16 | element.selectionEnd = end; 17 | }; 18 | } else if (_document.selection) { 19 | getCaret = element => { 20 | const selection = _document.selection; 21 | const value = element.value; 22 | let range = selection.createRange().duplicate(); 23 | 24 | range.moveEnd('character', value.length); 25 | 26 | const start = range.text === '' ? value.length : value.lastIndexOf(range.text); 27 | range = selection.createRange().duplicate(); 28 | 29 | range.moveStart('character', -value.length); 30 | 31 | const end = range.text.length; 32 | return { start, end }; 33 | }; 34 | setCaret = (element, start, end) => { 35 | const range = element.createTextRange(); 36 | range.collapse(true); 37 | range.moveStart('character', start); 38 | range.moveEnd('character', end - start); 39 | range.select(); 40 | }; 41 | } else { 42 | throw new Error('Caret unknown input selection capabilities'); 43 | } 44 | 45 | return { getCaret, setCaret }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/default_card_formatter.js: -------------------------------------------------------------------------------- 1 | import DelimitedTextFormatter from './delimited_text_formatter'; 2 | import { validCardLength, luhnCheck } from './card_utils'; 3 | 4 | /** 5 | * A generic credit card formatter. 6 | * 7 | * @extends DelimitedTextFormatter 8 | */ 9 | class DefaultCardFormatter extends DelimitedTextFormatter { 10 | constructor() { 11 | super(' '); 12 | } 13 | 14 | /** 15 | * @param {number} index 16 | * @returns {boolean} 17 | */ 18 | hasDelimiterAtIndex(index) { 19 | return index === 4 || index === 9 || index === 14; 20 | } 21 | 22 | /** 23 | * Will call parse on the formatter. 24 | * 25 | * @param {string} text 26 | * @param {function(string)} error 27 | * @returns {string} returns value with delimiters removed 28 | */ 29 | parse(text, error) { 30 | const value = this._valueFromText(text); 31 | if (typeof error === 'function') { 32 | if (!validCardLength(value)) { 33 | error('card-formatter.number-too-short'); 34 | } 35 | if (!luhnCheck(value)) { 36 | error('card-formatter.invalid-number'); 37 | } 38 | } 39 | return super.parse(text, error); 40 | } 41 | 42 | /** 43 | * Parses the given text by removing delimiters. 44 | * 45 | * @param {?string} text 46 | * @returns {string} 47 | * @private 48 | */ 49 | _valueFromText(text) { 50 | return super._valueFromText((text || '').replace(/[^\d]/g, '')); 51 | } 52 | 53 | /** 54 | * Gets the maximum length of a formatted default card number. 55 | * 56 | * @returns {number} 57 | */ 58 | get maximumLength() { 59 | return 16 + 3; 60 | } 61 | } 62 | 63 | export default DefaultCardFormatter; 64 | -------------------------------------------------------------------------------- /docs/Credit-Card-Formatters.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ [Formatters](Formatters) ▸ **Credit Card Formatters** 2 | 3 | ![](https://cloud.githubusercontent.com/assets/967026/9753726/a9cc6346-5675-11e5-885d-4b9f2dd7d7ec.gif) 4 | 5 | ## Credit Card Formatters 6 | This page describes the credit card formatters used by FieldKit, but most likely you'll simply want to use `CardTextField`. 7 | 8 | We have three types of credit card formatters that all subclass `DelimitedTextFormatter`. `DefaultCardFormatter`, `AmexCardFormatter`, and `AdaptiveCardFormatter`. None of these subclasses expose any extra methods or properties on top of those that `DelimitedTextFormatter` exposes. 9 | 10 | ### # AmexCardFormatter 11 | Amex Card Formatter is a credit card formatter that is specific to the American Express format. For example American Express card numbers are 15 characters long and generally separated into three groups, `3725 123456 78910`. 12 | 13 | ### # DefaultCardFormatter 14 | Default Card Formatter is a credit card formatter that is in a general format. Default card numbers are 16 characters long and generally separated into four groups, `4111 1111 1111 1111`. 15 | 16 | ### # AdaptiveCardFormatter 17 | Adaptive Card Formatter is a cool formatter that tries to make an intelligent decision on which formatter to use. The formatter will examine the PAN and decide if it is an Amex card number. 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "field-kit", 3 | "version": "2.1.2", 4 | "description": "Utilities to effectively manage text entry on the web.", 5 | "main": "dist/field-kit.js", 6 | "jsnext:main": "src/index.js", 7 | "scripts": { 8 | "test": "gulp build && karma start --single-run && mocha --harmony -t 600000 test/selenium/index.js", 9 | "prepublish": "gulp build" 10 | }, 11 | "author": "Square, Inc.", 12 | "license": "Apache-2.0", 13 | "repository": "square/field-kit", 14 | "files": [ 15 | "dist/", 16 | "lib/", 17 | "src/" 18 | ], 19 | "dependencies": { 20 | "input-sim": "^3.0.1", 21 | "stround": "0.3.1" 22 | }, 23 | "devDependencies": { 24 | "babel": "^5.8.21", 25 | "babel-core": "^5.8.21", 26 | "babelify": "^6.1.3", 27 | "browserify": "^11.0.1", 28 | "chai": "^3.2.0", 29 | "chromedriver": "^2.16.0", 30 | "express": "^4.13.1", 31 | "gulp": "^3.9.0", 32 | "gulp-babel": "^5.2.0", 33 | "gulp-cli": "^0.3.0", 34 | "gulp-derequire": "^2.1.0", 35 | "gulp-gh-pages": "^0.5.2", 36 | "gulp-if-else": "^1.0.3", 37 | "gulp-print": "^1.1.0", 38 | "gulp-sourcemaps": "^1.5.2", 39 | "gulp-uglify": "^1.2.0", 40 | "gulp-util": "^3.0.6", 41 | "karma": "^0.13.3", 42 | "karma-browserify": "^4.3.0", 43 | "karma-chai": "^0.1.0", 44 | "karma-chrome-launcher": "^0.2.0", 45 | "karma-eslint": "^2.0.1", 46 | "karma-firefox-launcher": "^0.1.6", 47 | "karma-mocha": "^0.2.0", 48 | "karma-mocha-reporter": "^1.1.1", 49 | "keysim": "^1.3.0", 50 | "mocha": "^2.2.5", 51 | "promise": "^7.0.3", 52 | "rimraf": "^2.4.2", 53 | "selenium-webdriver": "^2.46.1", 54 | "sinon": "^1.9.0", 55 | "string-selection": "^1.0.0", 56 | "vinyl-buffer": "^1.0.0", 57 | "vinyl-source-stream": "^1.1.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /docs/Developing.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ **Developing** 2 | 3 | ## Setup 4 | First, install the development dependencies: 5 | 6 | ```shell 7 | $ npm install 8 | ``` 9 | 10 | Then, try running the tests: 11 | 12 | ```shell 13 | $ npm test 14 | ``` 15 | 16 | ## Development 17 | 18 | As you make changes you may find it useful to have everything automatically 19 | compiled and ready to test interactively in the browser. You can do that using 20 | `script/develop`: 21 | 22 | ```shell 23 | $ $(npm bin)/karma start 24 | ``` 25 | 26 | This will start Karma and a browser. It will rerun the tests anytime the files change. 27 | 28 | _Note this will only run Unit Tests_ 29 | 30 | ### Testing Notes 31 | 32 | - The Unit Tests will run with a default UA of Chrome unless you use a helper that states otherwise. 33 | 34 | ## Testing with FieldKit 35 | 36 | In your application's acceptance tests you'll want to ensure that your FieldKit 37 | fields interact with your application properly. To do this you'll need to 38 | simulate user interaction more precisely than you may be used to in the past. 39 | For example, you can't simply use jQuery to set the value of an input and 40 | trigger its "change" event. You'll want to trigger the same set of events that 41 | a user would trigger when editing the field. Here's an example of entering an 42 | "a" into a field and then backspacing it immediately: 43 | 44 | * `focusin` 45 | * `focus` 46 | * `keydown keyCode=65` 47 | * `keypress keyCode=97` 48 | * `keyup keyCode=65` 49 | * `keydown keyCode=8` 50 | * `keyup keyCode=8` 51 | * `blur` 52 | * `focusout` 53 | 54 | You can trigger these events however you like, but it makes sense to have 55 | helpers for entering text that do the right thing and then use them everywhere. 56 | 57 | We use [KeySim](https://github.com/eventualbuddha/keysim.js) in our test helpers to take care of this. -------------------------------------------------------------------------------- /test/selenium/card_text_field_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.CardTextField', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('card_text_field.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | describe('typing', function() { 18 | test.it('formats as Visa once it can tell it is a Visa card', function() { 19 | helpers.setInput('4111111', input); 20 | 21 | return helpers.getFieldKitValues() 22 | .then(function(values) { 23 | expect(values.fieldKit).to.equal('4111111'); 24 | expect(values.raw).to.equal('4111 111'); 25 | }); 26 | }); 27 | 28 | test.it('formats as Amex once it can tell it is an Amex card', function() { 29 | helpers.setInput('372512345678901', input); 30 | 31 | return helpers.getFieldKitValues() 32 | .then(function(values) { 33 | expect(values.fieldKit).to.equal('372512345678901'); 34 | expect(values.raw).to.equal('3725 123456 78901'); 35 | }); 36 | }); 37 | 38 | test.it('formats it as the default if it cannot tell what it is', function() { 39 | helpers.setInput('1111111', input); 40 | 41 | return helpers.getFieldKitValues() 42 | .then(function(values) { 43 | expect(values.fieldKit).to.equal('1111111'); 44 | expect(values.raw).to.equal('1111 111'); 45 | }); 46 | }); 47 | }); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /test/selenium/adaptive_card_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.AdaptiveCardFormatter', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('adaptive_card_formatter.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | describe('typing', function() { 18 | test.it('formats as Visa once it can tell it is a Visa card', function() { 19 | helpers.setInput('4111111', input); 20 | 21 | return helpers.getFieldKitValues() 22 | .then(function(values) { 23 | expect(values.fieldKit).to.equal('4111111'); 24 | expect(values.raw).to.equal('4111 111'); 25 | }); 26 | }); 27 | 28 | test.it('formats as Amex once it can tell it is an Amex card', function() { 29 | helpers.setInput('372512345678901', input); 30 | 31 | return helpers.getFieldKitValues() 32 | .then(function(values) { 33 | expect(values.fieldKit).to.equal('372512345678901'); 34 | expect(values.raw).to.equal('3725 123456 78901'); 35 | }); 36 | }); 37 | 38 | test.it('formats it as the default if it cannot tell what it is', function() { 39 | helpers.setInput('1111111', input); 40 | 41 | return helpers.getFieldKitValues() 42 | .then(function(values) { 43 | expect(values.fieldKit).to.equal('1111111'); 44 | expect(values.raw).to.equal('1111 111'); 45 | }); 46 | }); 47 | }); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import babel from 'gulp-babel'; 2 | import babelify from 'babelify'; 3 | import browserify from 'browserify'; 4 | import buffer from 'vinyl-buffer'; 5 | import ghPages from 'gulp-gh-pages'; 6 | import gulp from 'gulp'; 7 | import derequire from 'gulp-derequire'; 8 | import gutil from 'gulp-util'; 9 | import ifElse from 'gulp-if-else'; 10 | import print from 'gulp-print'; 11 | import rimraf from 'rimraf'; 12 | import source from 'vinyl-source-stream'; 13 | import sourcemaps from 'gulp-sourcemaps'; 14 | import uglify from 'gulp-uglify'; 15 | 16 | function dist(minified) { 17 | return browserify({entries: 'src/index.js', standalone: 'FieldKit'}) 18 | .transform(babelify) 19 | .bundle() 20 | .pipe(source(minified ? 'field-kit.min.js' : 'field-kit.js')) 21 | .pipe(derequire()) 22 | .pipe(buffer()) 23 | .pipe(sourcemaps.init({loadMaps: true})) 24 | .pipe(ifElse(minified, uglify)) 25 | .on('error', gutil.log) 26 | .pipe(sourcemaps.write('./')) 27 | .pipe(gulp.dest('./dist')); 28 | } 29 | 30 | gulp.task('clean:lib', done => rimraf('./lib', done)); 31 | gulp.task('clean:dist', done => rimraf('./dist', done)); 32 | 33 | gulp.task('dist:not-minified', ['clean:dist'], () => dist(false)); 34 | gulp.task('dist:minified', ['clean:dist'], () => dist(true)); 35 | gulp.task('dist', ['dist:not-minified', 'dist:minified']); 36 | 37 | gulp.task('gh-pages', ['build'], function () { 38 | return gulp.src(['public/**/*']) 39 | .pipe(print()) 40 | .pipe(ghPages()); 41 | }); 42 | 43 | gulp.task('move-fk-to-public', function() { 44 | return gulp.src('dist/field-kit.js') 45 | .pipe(gulp.dest('public/javascript')); 46 | }); 47 | 48 | gulp.task('lib', ['clean:lib'], function () { 49 | return gulp.src(['src/**/*.js', '!src/index.js']) 50 | .pipe(babel()) 51 | .pipe(gulp.dest('lib')); 52 | }); 53 | 54 | gulp.task('build', ['lib', 'dist', 'move-fk-to-public']); 55 | gulp.task('default', ['build']); 56 | -------------------------------------------------------------------------------- /src/formatter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class providing basic formatting, parsing, and change validation to be 3 | * customized in subclasses. 4 | */ 5 | class Formatter { 6 | /** 7 | * @param {string} text 8 | * @returns {string} 9 | */ 10 | format(text) { 11 | if (text === undefined || text === null) { text = ''; } 12 | if (this.maximumLength !== undefined && this.maximumLength !== null) { 13 | text = text.substring(0, this.maximumLength); 14 | } 15 | return text; 16 | } 17 | 18 | /** 19 | * @param {string} text 20 | * @returns {string} 21 | */ 22 | parse(text) { 23 | if (text === undefined || text === null) { text = ''; } 24 | if (this.maximumLength !== undefined && this.maximumLength !== null) { 25 | text = text.substring(0, this.maximumLength); 26 | } 27 | return text; 28 | } 29 | 30 | /** 31 | * Determines whether the given change should be allowed and, if so, whether 32 | * it should be altered. 33 | * 34 | * @param {TextFieldStateChange} change 35 | * @returns {boolean} 36 | */ 37 | isChangeValid(change) { 38 | const selectedRange = change.proposed.selectedRange; 39 | const text = change.proposed.text; 40 | if (this.maximumLength !== undefined && this.maximumLength !== null && text.length > this.maximumLength) { 41 | const available = this.maximumLength - (text.length - change.inserted.text.length); 42 | let newText = change.current.text.substring(0, change.current.selectedRange.start); 43 | if (available > 0) { 44 | newText += change.inserted.text.substring(0, available); 45 | } 46 | newText += change.current.text.substring(change.current.selectedRange.start + change.current.selectedRange.length); 47 | const truncatedLength = text.length - newText.length; 48 | change.proposed.text = newText; 49 | selectedRange.start -= truncatedLength; 50 | } 51 | return true; 52 | } 53 | } 54 | 55 | export default Formatter; 56 | -------------------------------------------------------------------------------- /test/selenium/social_security_number_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.SocialSecurityNumberFormatter', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('social_security_number_formatter.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | test.it('places dashes in the right places', function() { 18 | helpers.setInput('|', input); 19 | 20 | input.sendKeys('123456789'); 21 | 22 | return helpers.getFieldKitValues() 23 | .then(function(values) { 24 | expect(values.raw).to.equal('123-45-6789'); 25 | }); 26 | }); 27 | 28 | test.it('prevents extra digits from being entered', function() { 29 | helpers.setInput('123-45-6789|', input); 30 | 31 | input.sendKeys('0'); 32 | 33 | return helpers.getFieldKitValues() 34 | .then(function(values) { 35 | expect(values.raw).to.equal('123-45-6789'); 36 | }); 37 | }); 38 | 39 | test.it('prevents entering non-digit characters', function() { 40 | helpers.setInput('|', input); 41 | 42 | input.sendKeys('f'); 43 | 44 | return helpers.getFieldKitValues() 45 | .then(function(values) { 46 | expect(values.raw).to.equal(''); 47 | }); 48 | }); 49 | 50 | test.it('backspaces words correctly', function() { 51 | helpers.setInput('123-45-|', input); 52 | 53 | input.sendKeys(Key.chord(Key.ALT, Key.BACK_SPACE)); 54 | 55 | return helpers.getFieldKitValues() 56 | .then(function(values) { 57 | expect(values.raw).to.equal('123-'); 58 | }); 59 | }); 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /test/selenium/employer_identification_number_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.EmployerIdentificationNumberFormatter', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('employer_identification_number_formatter.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | test.it('places dashes in the right places', function() { 18 | helpers.setInput('|', input); 19 | 20 | input.sendKeys('123456789'); 21 | 22 | return helpers.getFieldKitValues() 23 | .then(function(values) { 24 | expect(values.raw).to.equal('12-3456789'); 25 | }); 26 | }); 27 | 28 | test.it('prevents extra digits from being entered', function() { 29 | helpers.setInput('12-3456789|', input); 30 | 31 | input.sendKeys('0'); 32 | 33 | return helpers.getFieldKitValues() 34 | .then(function(values) { 35 | expect(values.raw).to.equal('12-3456789'); 36 | }); 37 | }); 38 | 39 | test.it('prevents entering non-digit characters', function() { 40 | helpers.setInput('|', input); 41 | 42 | input.sendKeys('f'); 43 | 44 | return helpers.getFieldKitValues() 45 | .then(function(values) { 46 | expect(values.raw).to.equal(''); 47 | }); 48 | }); 49 | 50 | test.it('backspaces words correctly', function() { 51 | helpers.setInput('12-3456789|', input); 52 | 53 | input.sendKeys(Key.chord(Key.ALT, Key.BACK_SPACE)); 54 | 55 | return helpers.getFieldKitValues() 56 | .then(function(values) { 57 | expect(values.raw).to.equal('12-'); 58 | }); 59 | }); 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /test/selenium/server/test_pages/delimited_text_formatter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/selenium/helpers/index.js: -------------------------------------------------------------------------------- 1 | var Promise = require('promise'); 2 | var Selection = require('string-selection'); 3 | var Key = require('selenium-webdriver').Key; 4 | 5 | exports.setInput = function(description, input) { 6 | var keys; 7 | 8 | if(description.indexOf('|') >= 0) { 9 | var selection = Selection.parseDescription(description); 10 | var start = selection.caret.start; 11 | var end = selection.caret.end; 12 | var affinity = selection.affinity; 13 | 14 | keys = [ selection.value ]; 15 | var startOffset = (affinity) ? 16 | (selection.value.length - start) : 17 | (selection.value.length - end); 18 | 19 | for(var i = 0; i < startOffset; i++) { 20 | keys.push(Key.ARROW_LEFT); 21 | } 22 | 23 | for(var i = 0; i < (end - start); i++) { 24 | keys.push( 25 | Key.chord( 26 | Key.SHIFT, 27 | (affinity) ? Key.ARROW_RIGHT : Key.ARROW_LEFT 28 | ) 29 | ); 30 | } 31 | } else { 32 | keys = [ description ]; 33 | } 34 | 35 | input.sendKeys.apply(input, keys); 36 | }; 37 | 38 | exports.runJSMethod = function(method, fieldKitWindowVariable) { 39 | field = (fieldKitWindowVariable) ? 40 | fieldKitWindowVariable : 41 | 'testField'; 42 | 43 | var promise = new Promise(function (resolve, reject) { 44 | driver.executeScript('return window.' + field + '.' + method) 45 | .then(function(value) { 46 | resolve(value); 47 | }); 48 | }); 49 | 50 | return promise; 51 | }; 52 | 53 | exports.getFieldKitValues = function(fieldKitWindowVariable) { 54 | field = (fieldKitWindowVariable) ? 55 | fieldKitWindowVariable : 56 | 'testField'; 57 | 58 | var promise = new Promise(function (resolve, reject) { 59 | Promise.all([ 60 | exports.runJSMethod('value()', field), 61 | exports.runJSMethod('element.value', field) 62 | ]) 63 | .then(function(responses) { 64 | resolve({ 65 | fieldKit: responses[0], 66 | raw: responses[1] 67 | }); 68 | }); 69 | }); 70 | 71 | return promise; 72 | }; 73 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import AdaptiveCardFormatter from './adaptive_card_formatter'; 2 | import AmexCardFormatter from './amex_card_formatter'; 3 | import CardTextField from './card_text_field'; 4 | import { AMEX, DISCOVER, VISA, MASTERCARD, determineCardType, luhnCheck, validCardLength } from './card_utils'; 5 | import DefaultCardFormatter from './default_card_formatter'; 6 | import DelimitedTextFormatter from './delimited_text_formatter'; 7 | import EmployerIdentificationNumberFormatter from './employer_identification_number_formatter'; 8 | import ExpiryDateField from './expiry_date_field'; 9 | import ExpiryDateFormatter from './expiry_date_formatter'; 10 | import Formatter from './formatter'; 11 | import NumberFormatter from './number_formatter'; 12 | import NumberFormatterSettingsFormatter from './number_formatter_settings_formatter'; 13 | import PhoneFormatter from './phone_formatter'; 14 | import SocialSecurityNumberFormatter from './social_security_number_formatter'; 15 | import TextField from './text_field'; 16 | import UndoManager from './undo_manager'; 17 | 18 | /** 19 | * @namespace FieldKit 20 | * @readonly 21 | */ 22 | module.exports = { 23 | AdaptiveCardFormatter: AdaptiveCardFormatter, 24 | AmexCardFormatter: AmexCardFormatter, 25 | CardTextField: CardTextField, 26 | CardUtils: { 27 | AMEX: AMEX, 28 | DISCOVER: DISCOVER, 29 | VISA: VISA, 30 | MASTERCARD: MASTERCARD, 31 | determineCardType: determineCardType, 32 | luhnCheck: luhnCheck, 33 | validCardLength: validCardLength 34 | }, 35 | DefaultCardFormatter: DefaultCardFormatter, 36 | DelimitedTextFormatter: DelimitedTextFormatter, 37 | EmployerIdentificationNumberFormatter: EmployerIdentificationNumberFormatter, 38 | ExpiryDateField: ExpiryDateField, 39 | ExpiryDateFormatter: ExpiryDateFormatter, 40 | Formatter: Formatter, 41 | NumberFormatter: NumberFormatter, 42 | NumberFormatterSettingsFormatter: NumberFormatterSettingsFormatter, 43 | PhoneFormatter: PhoneFormatter, 44 | SocialSecurityNumberFormatter: SocialSecurityNumberFormatter, 45 | TextField: TextField, 46 | UndoManager: UndoManager 47 | }; 48 | -------------------------------------------------------------------------------- /src/adaptive_card_formatter.js: -------------------------------------------------------------------------------- 1 | import AmexCardFormatter from './amex_card_formatter'; 2 | import DefaultCardFormatter from './default_card_formatter'; 3 | import { determineCardType, AMEX } from './card_utils'; 4 | 5 | /** 6 | * AdaptiveCardFormatter will decide if it needs to use 7 | * {@link AmexCardFormatter} or {@link DefaultCardFormatter}. 8 | */ 9 | class AdaptiveCardFormatter { 10 | constructor() { 11 | /** @private */ 12 | this.amexCardFormatter = new AmexCardFormatter(); 13 | /** @private */ 14 | this.defaultCardFormatter = new DefaultCardFormatter(); 15 | /** @private */ 16 | this.formatter = this.defaultCardFormatter; 17 | } 18 | 19 | /** 20 | * Will pick the right formatter based on the `pan` and will return the 21 | * formatted string. 22 | * 23 | * @param {string} pan 24 | * @returns {string} formatted string 25 | */ 26 | format(pan) { 27 | return this._formatterForPan(pan).format(pan); 28 | } 29 | 30 | /** 31 | * Will call parse on the formatter. 32 | * 33 | * @param {string} text 34 | * @param {function(string)} error 35 | * @returns {string} returns value with delimiters removed 36 | */ 37 | parse(text, error) { 38 | return this.formatter.parse(text, error); 39 | } 40 | 41 | /** 42 | * Determines whether the given change should be allowed and, if so, whether 43 | * it should be altered. 44 | * 45 | * @param {TextFieldStateChange} change 46 | * @param {function(!string)} error 47 | * @returns {boolean} 48 | */ 49 | isChangeValid(change, error) { 50 | this.formatter = this._formatterForPan(change.proposed.text); 51 | return this.formatter.isChangeValid(change, error); 52 | } 53 | 54 | /** 55 | * Decides which formatter to use. 56 | * 57 | * @param {string} pan 58 | * @returns {Formatter} 59 | * @private 60 | */ 61 | _formatterForPan(pan) { 62 | if (determineCardType(pan.replace(/[^\d]+/g, '')) === AMEX) { 63 | return this.amexCardFormatter; 64 | } else { 65 | return this.defaultCardFormatter; 66 | } 67 | } 68 | } 69 | 70 | export default AdaptiveCardFormatter; 71 | -------------------------------------------------------------------------------- /test/selenium/default_card_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.DefaultCardFormatter', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('default_card_formatter.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | describe('typing', function() { 18 | test.it('adds a space after the first four digits', function() { 19 | helpers.setInput('411|', input); 20 | input.sendKeys('1'); 21 | 22 | return helpers.getFieldKitValues() 23 | .then(function(values) { 24 | expect(values.raw).to.equal('4111 '); 25 | }); 26 | }); 27 | 28 | test.it('formats as Visa once it can tell it is a Visa card', function() { 29 | helpers.setInput('4111111', input); 30 | 31 | return helpers.getFieldKitValues() 32 | .then(function(values) { 33 | expect(values.fieldKit).to.equal('4111111'); 34 | expect(values.raw).to.equal('4111 111'); 35 | }); 36 | }); 37 | 38 | test.it('does not format as Amex', function() { 39 | helpers.setInput('372512345678901', input); 40 | 41 | return helpers.getFieldKitValues() 42 | .then(function(values) { 43 | expect(values.fieldKit).to.equal('372512345678901'); 44 | expect(values.raw).to.equal('3725 1234 5678 901'); 45 | }); 46 | }); 47 | 48 | test.it('formats it as the default if it cannot tell what it is', function() { 49 | helpers.setInput('1111111', input); 50 | 51 | return helpers.getFieldKitValues() 52 | .then(function(values) { 53 | expect(values.fieldKit).to.equal('1111111'); 54 | expect(values.raw).to.equal('1111 111'); 55 | }); 56 | }); 57 | }); 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /test/selenium/index.js: -------------------------------------------------------------------------------- 1 | var test = require('selenium-webdriver/testing'); 2 | var helpers = require('./helpers'); 3 | var server = require('./server'); 4 | global.driver = null; 5 | 6 | function setUpDriverFor(test) { 7 | test.before(function() { 8 | var webdriver = require('selenium-webdriver'); 9 | var builder = new webdriver.Builder(); 10 | 11 | if (process.env.CONTINUOUS_INTEGRATION) { 12 | builder.forBrowser('firefox'); 13 | } else { 14 | var chrome = require('selenium-webdriver/chrome'); 15 | var path = require('chromedriver').path; 16 | 17 | var service = new chrome.ServiceBuilder(path).build(); 18 | chrome.setDefaultService(service); 19 | 20 | builder.withCapabilities(webdriver.Capabilities.chrome()) 21 | } 22 | 23 | driver = builder.build(); 24 | 25 | server.start(); 26 | }); 27 | 28 | test.after(function() { 29 | driver.quit(); 30 | server.stop(); 31 | }); 32 | } 33 | 34 | test.describe('FieldKit Selenium Test', function() { 35 | setUpDriverFor(test); 36 | 37 | ['DEFAULT', 'android'].forEach(function(ua) { 38 | var adaptiveCardFormatterTest = require('./adaptive_card_formatter_test'); 39 | var amexCardFormatterTest = require('./amex_card_formatter_test'); 40 | var cardTextFieldTest = require('./card_text_field_test'); 41 | var defaultCardFormatterTest = require('./default_card_formatter_test'); 42 | var delimitedTextFormatterTest = require('./delimited_text_formatter_test'); 43 | var expiryDateFormatterTest = require('./expiry_date_formatter_test'); 44 | var phoneFormatterTest = require('./phone_formatter_test'); 45 | var socialSecurityNumberFormatterTest = require('./social_security_number_formatter_test'); 46 | var textFieldTest = require('./text_field_test'); 47 | 48 | test.describe('testing with UA: ' + ua, function() { 49 | test.beforeEach(function() { 50 | if (ua !== 'DEFAULT') return global.ua = ua; 51 | }); 52 | 53 | adaptiveCardFormatterTest(); 54 | amexCardFormatterTest(); 55 | cardTextFieldTest(); 56 | defaultCardFormatterTest(); 57 | delimitedTextFormatterTest(); 58 | expiryDateFormatterTest(ua); 59 | phoneFormatterTest(ua); 60 | socialSecurityNumberFormatterTest(); 61 | textFieldTest(); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/card_utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @TODO Make this an enum 3 | */ 4 | export const AMEX = 'amex'; 5 | export const DISCOVER = 'discover'; 6 | export const JCB = 'jcb'; 7 | export const MASTERCARD = 'mastercard'; 8 | export const VISA = 'visa'; 9 | 10 | /** 11 | * Pass in a credit card number and it'll return the 12 | * type of card it is. 13 | * 14 | * @param {string} pan 15 | * @returns {?string} returns the type of card based in the digits 16 | */ 17 | export function determineCardType(pan) { 18 | if (pan === null || pan === undefined) { 19 | return null; 20 | } 21 | 22 | pan = pan.toString(); 23 | const firsttwo = parseInt(pan.slice(0, 2), 10); 24 | const iin = parseInt(pan.slice(0, 6), 10); 25 | const halfiin = parseInt(pan.slice(0, 3), 10); 26 | 27 | if (pan[0] === '4') { 28 | return VISA; 29 | } else if (pan.slice(0, 4) === '6011' || firsttwo === 65 || (halfiin >= 664 && halfiin <= 649) || (iin >= 622126 && iin <= 622925)) { 30 | return DISCOVER; 31 | } else if (pan.slice(0, 4) === '2131' || pan.slice(0, 4) === '1800' || firsttwo === 35) { 32 | return JCB; 33 | } else if (firsttwo >= 51 && firsttwo <= 55) { 34 | return MASTERCARD; 35 | } else if (firsttwo === 34 || firsttwo === 37) { 36 | return AMEX; 37 | } 38 | } 39 | 40 | /** 41 | * Pass in a credit card number and it'll return if it 42 | * passes the [luhn algorithm](http://en.wikipedia.org/wiki/Luhn_algorithm) 43 | * 44 | * @param {string} pan 45 | * @returns {boolean} 46 | */ 47 | export function luhnCheck(pan) { 48 | let sum = 0; 49 | let flip = true; 50 | for (let i = pan.length - 1; i >= 0; i--) { 51 | const digit = parseInt(pan.charAt(i), 10); 52 | sum += (flip = !flip) ? Math.floor((digit * 2) / 10) + Math.floor(digit * 2 % 10) : digit; 53 | } 54 | 55 | return sum % 10 === 0; 56 | } 57 | 58 | /** 59 | * Pass in a credit card number and it'll return if it 60 | * is a valid length for that type. If it doesn't know the 61 | * type it'll return false 62 | * 63 | * @param {string} pan 64 | * @returns {boolean} 65 | */ 66 | export function validCardLength(pan) { 67 | switch (determineCardType(pan)) { 68 | case VISA: 69 | return pan.length === 13 || pan.length === 16; 70 | case DISCOVER: case MASTERCARD: 71 | return pan.length === 16; 72 | case JCB: 73 | return pan.length === 15 || pan.length === 16; 74 | case AMEX: 75 | return pan.length === 15; 76 | default: 77 | return false; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/selenium/text_field_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.TextField', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('text_field.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | test.it('should return the correct value', function() { 18 | helpers.setInput('OUTATIME', input); 19 | 20 | return helpers.getFieldKitValues() 21 | .then(function(values) { 22 | expect(values.fieldKit).to.equal('OUTATIME'); 23 | expect(values.raw).to.equal('OUTATIME'); 24 | }); 25 | }); 26 | 27 | test.it('should work with deleting selected text', function() { 28 | helpers.setInput('1.21 gig|awatt>s', input); 29 | 30 | input.sendKeys(Key.DELETE); 31 | 32 | return helpers.getFieldKitValues() 33 | .then(function(values) { 34 | expect(values.fieldKit).to.equal('1.21 gigs'); 35 | expect(values.raw).to.equal('1.21 gigs'); 36 | }); 37 | }); 38 | 39 | test.it('should work with backspacing selected text', function() { 40 | helpers.setInput('1.21 gig|awatt>s', input); 41 | 42 | input.sendKeys(Key.BACK_SPACE); 43 | 44 | return helpers.getFieldKitValues() 45 | .then(function(values) { 46 | expect(values.fieldKit).to.equal('1.21 gigs'); 47 | expect(values.raw).to.equal('1.21 gigs'); 48 | }); 49 | }); 50 | 51 | test.it('should allow placeholders', function() { 52 | helpers.runJSMethod('setPlaceholder("Doc")'); 53 | return input.getAttribute('placeholder') 54 | .then(function(placeholder) { 55 | expect(placeholder).to.equal('Doc'); 56 | }); 57 | }); 58 | 59 | test.describe('textDidChange', function() { 60 | beforeEach(function() { 61 | return helpers.runJSMethod( 62 | "setDelegate({" + 63 | "textDidChange: function(field) {" + 64 | "window.currentValue = field.value();" + 65 | "}" + 66 | "});" 67 | ); 68 | }); 69 | 70 | test.it('should have current value', function() { 71 | helpers.setInput('B', input); 72 | 73 | return driver.executeScript('return window.currentValue') 74 | .then(function(currentValue) { 75 | expect(currentValue).to.equal('B'); 76 | }); 77 | }); 78 | }); 79 | }); 80 | }; 81 | 82 | -------------------------------------------------------------------------------- /assets/stylesheets/card_field.sass: -------------------------------------------------------------------------------- 1 | // Retina screens have a 1.5 pixel ratio, not 2 2 | @mixin retina 3 | @media only screen and (-webkit-min-device-pixel-ratio : 1.5), only screen and (min-device-pixel-ratio : 1.5) 4 | @content 5 | 6 | $card-field-icon-width: image_width('DefaultCard.png') 7 | $card-field-icon-height: image_height('DefaultCard.png') 8 | 9 | .card-field 10 | padding: 5px 11 | border-radius: 5px 12 | border: 1px solid #e0e0e0 13 | width: 290px 14 | height: 37px 15 | margin: 30px auto 16 | position: relative 17 | overflow: hidden 18 | &:hover 19 | box-shadow: 0 0 0 1px rgba(104, 189, 244, 0.9),0 0 3px 1px #68BDF4 20 | &:before 21 | content: '' 22 | background: white image_url('DefaultCard.png') no-repeat 23 | width: $card-field-icon-width + 10 24 | height: $card-field-icon-height 25 | display: block 26 | position: absolute 27 | top: 5px 28 | left: 0 29 | z-index: 99 30 | background-position-x: 5px 31 | .card-field-inner 32 | input 33 | padding: 7px 34 | display: inline-block 35 | height: 31px 36 | border: 0 37 | &:focus 38 | outline: none 39 | .card-field-pan 40 | width: 172px 41 | position: absolute 42 | left: 40px 43 | top: 2px 44 | -webkit-transition: left 0.5s ease 45 | .card-field-cvv 46 | width: 52px 47 | .card-field-expiry 48 | width: 68px 49 | .card-field-postal-code 50 | width: 62px 51 | 52 | .card-field-extras 53 | position: absolute 54 | top: 3px 55 | left: 95px 56 | opacity: 0 57 | width: 185px 58 | padding: 0 59 | -webkit-transition: opacity .5s ease .2s 60 | 61 | .card-field-pan-complete 62 | .card-field-pan 63 | left: -80px 64 | .card-field-extras 65 | opacity: 1 66 | 67 | 68 | .card-field.card-field-amex:before 69 | background-image: image_url('BrandAmericanExpress.png') 70 | 71 | .card-field.card-field-discover:before 72 | background-image: image_url('BrandDiscover.png') 73 | 74 | .card-field.card-field-jcb:before 75 | background-image: image_url('BrandJCB.png') 76 | 77 | .card-field.card-field-mastercard:before 78 | background-image: image_url('BrandMastercard.png') 79 | 80 | .card-field.card-field-visa:before 81 | background-image: image_url('BrandVisa.png') 82 | 83 | +retina 84 | .card-field.card-field-amex:before 85 | background-image: image_url('BrandAmericanExpress@2x.png') 86 | 87 | .card-field.card-field-discover:before 88 | background-image: image_url('BrandDiscover@2x.png') 89 | 90 | .card-field.card-field-jcb:before 91 | background-image: image_url('BrandJCB@2x.png') 92 | 93 | .card-field.card-field-mastercard:before 94 | background-image: image_url('BrandMastercard@2x.png') 95 | 96 | .card-field.card-field-visa:before 97 | background-image: image_url('BrandVisa@2x.png') 98 | -------------------------------------------------------------------------------- /test/unit/helpers/builders.js: -------------------------------------------------------------------------------- 1 | import PassthroughFormatter from './passthrough_formatter'; 2 | import FieldKit from '../../../src'; 3 | import installCaret from '../../../src/caret'; 4 | 5 | const { setCaret } = installCaret(); 6 | 7 | export function buildField(textFieldClass, options) { 8 | if (!textFieldClass) { 9 | textFieldClass = FieldKit.TextField; 10 | } 11 | if (!options) { 12 | options = {}; 13 | } 14 | 15 | if (arguments.length === 1) { 16 | if (typeof textFieldClass !== 'function') { 17 | options = textFieldClass; 18 | textFieldClass = FieldKit.TextField; 19 | } 20 | } 21 | 22 | var input = options.input || buildInput(options); 23 | if (options.userAgent) { 24 | navigator.__defineGetter__('userAgent', function(){ 25 | return options.userAgent; 26 | }); 27 | } 28 | 29 | var field; 30 | if (options.formatter) { 31 | // formatter is specified, so use it as part of the constructor 32 | field = new textFieldClass(input, options.formatter); 33 | } else { 34 | // since a default formatter may be provided by the text field, use #setFormatter 35 | field = new textFieldClass(input); 36 | if (!field.formatter()) { 37 | field.setFormatter(new PassthroughFormatter()); 38 | } 39 | } 40 | 41 | // This is necessary because of a Chrome "feature" where it won't do any focusing 42 | // or blurring if the browser window not in focus itself. Otherwise running Karma 43 | // testing in the background is impossible. 44 | if (field) { 45 | let hasFocus = false; 46 | 47 | field.hasFocus = () => hasFocus; 48 | 49 | field.element.focus = function() { 50 | hasFocus = true; 51 | field.element.dispatchEvent(new UIEvent('focus')); 52 | }; 53 | 54 | field.element.blur = function() { 55 | hasFocus = false; 56 | field.element.dispatchEvent(new UIEvent('blur')); 57 | }; 58 | } 59 | 60 | return field; 61 | } 62 | 63 | export function buildInput(options) { 64 | var currentInputs = document.getElementsByTagName('input'); 65 | if (!options) { 66 | options = {}; 67 | } 68 | 69 | for (var i = 0; i < currentInputs.length; i++) { 70 | currentInputs[i].parentNode.removeChild(currentInputs[i]); 71 | } 72 | var input = document.createElement('input'); 73 | input.type = 'text'; 74 | 75 | var value = options.value; 76 | if (value) { 77 | input.value = value; 78 | // Many tests assume that when creating a new input and setting the value 79 | // that the caret will be placed after the text. This is not the case in 80 | // Firefox, so we set it that way here to normalize the behavior. 81 | setCaret(input, value.length, value.length); 82 | } 83 | 84 | if (options.autocapitalize) { 85 | input.setAttribute('autocapitalize', 'on'); 86 | } 87 | 88 | document.body.appendChild(input); 89 | return document.getElementsByTagName('input')[0]; 90 | } 91 | -------------------------------------------------------------------------------- /docs/Formatters.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ **Formatters** 2 | 3 | **FieldKit** [Fields](https://github.com/square/field-kit/wiki/FieldKit-Fields) call out to formatters for information on how the text should be formatted, if the text is valid, and how to parse formatted text. 4 | 5 | Formatters provide two main methods: `parse()` and `format()`. `parse()` takes 6 | a string and returns the value represented by that string, e.g. a `Date` object 7 | if the formatter provided date and time formatting. `format()` takes an object 8 | of the same type `parse()` returns and turns it into a string representation 9 | suitable for display to or editing by an end user. 10 | 11 | Formatters may also assist with as-you-type editing by implementing the 12 | `isChangeValid()` method which can by used to prevent or alter changes to a 13 | text field that would make the value invalid. 14 | 15 | FieldKit comes bundled with a few useful formatters, such as 16 | `FieldKit.NumberFormatter`, which implements `isChangeValid()` to help users to 17 | only enter valid numbers: 18 | 19 | ```js 20 | var field = new FieldKit.TextField(document.getElementById('quantity'), 21 | new FieldKit.NumberFormatter() 22 | .setMinimum(0) 23 | .setMaximum(10)) 24 | .setValue(quantity); 25 | ``` 26 | 27 | `NumberFormatter` can format integers, decimals (safely using 28 | [stround](https://github.com/square/stround)), percentages, and currency amounts. Use `#setNumberStyle()` 29 | to choose which style to use. 30 | 31 | `FieldKit.Formatter` and its subclasses are modeled after Cocoa's 32 | [`NSFormatter`](https://developer.apple.com/library/mac/documentation/cocoa/reference/foundation/classes/NSFormatter_Class/Reference/Reference.html) class and its subclasses. They share many of the 33 | same API methods and relationships with other objects, so any guides and 34 | documentation for use Cocoa formatting may be useful in understanding how 35 | FieldKit works. 36 | 37 | ## Implementing a Formatter 38 | Formatters should implement the following methods: 39 | 40 | ### # format 41 | > @param {string} text - raw unformatted text 42 | > @returns {string} - formatted text 43 | > 44 | > This method is called when a Field sets value. 45 | 46 | ### # parse 47 | > @param {string} text - formatted text 48 | > @returns {string} - parsed text 49 | > 50 | > This is the text that is returned when you ask a field for the value. e.g. `field.value()`. 51 | 52 | ### # isChangeValid 53 | > @param {TextFieldStateChange} change 54 | > @returns {boolean} 55 | > 56 | > Determines whether the given change (`TextFieldStateChange`) should be allowed and, if so, whether it should be altered. 57 | 58 | ## Provided Formatters 59 | * [Delimited Text Formatter](Delimited-Text-Formatter) 60 | * [Credit Card Formatters](Credit-Card-Formatters) 61 | * [Expiry Date Formatter](Expiry-Date-Formatter) 62 | * [Number Formatter](Number-Formatter) 63 | * [Phone Formatter](Phone-Formatter) 64 | * [Social Security Number Formatter](Social-Security-Number-Formatter) -------------------------------------------------------------------------------- /public/_sass/_styles.sass: -------------------------------------------------------------------------------- 1 | $padding-large : 20px 2 | $padding-small : 10px 3 | $border-radius : 5px 4 | $input-bottom-margin : 35px // visual match with top margin 5 | $font-size-small : 12px 6 | 7 | $color-base : rgb(61,69,77) 8 | $color-medium : rgb(133,137,140) 9 | $color-light : rgb(194,199,204) 10 | $color-ultra-light : rgb(242,244,245) 11 | 12 | =mobile 13 | @media screen and (max-width: 580px) 14 | @content 15 | 16 | * 17 | box-sizing: border-box 18 | 19 | body 20 | font-family: "Square Market", Helvetica, Arial, sans-serif 21 | color: $color-base 22 | max-width: 660px 23 | margin: 0 auto 24 | padding: $padding-large 25 | line-height: 1.6 26 | 27 | .links 28 | position: absolute 29 | top: 0 30 | left: 0 31 | background-color: $color-base 32 | padding: $padding-large 33 | border-bottom-right-radius: $border-radius 34 | line-height: 1 35 | a 36 | text-decoration: none 37 | &:first-child img 38 | margin-right: 10px 39 | img 40 | width: 30px 41 | 42 | h1 43 | font-weight: 100 44 | font-size: 80px 45 | 46 | h2, 47 | h3 48 | font-weight: 500 49 | 50 | section 51 | margin-bottom: 70px 52 | 53 | td:first-child 54 | text-align: right 55 | &::after 56 | content: " →" 57 | 58 | .demo 59 | margin: 1.6em 0 60 | 61 | .input-container 62 | float: left 63 | margin-bottom: $input-bottom-margin 64 | +mobile 65 | float: none 66 | width: 100% 67 | max-width: 360px 68 | margin-left: auto 69 | margin-right: auto 70 | &:before 71 | content: "Example" 72 | display: block 73 | font-size: $font-size-small 74 | margin-bottom: 4px 75 | 76 | input 77 | font-family: "Square Market", Helvetica, Arial, sans-serif 78 | padding: 8px 79 | font-size: 20px 80 | outline: none 81 | border: solid 1px $color-light 82 | +mobile 83 | width: 100% 84 | 85 | #card-type 86 | text-align: center 87 | font-size: small 88 | margin-top: 6px 89 | 90 | table 91 | float: left 92 | font-size: smaller 93 | margin-left: $padding-large 94 | margin-bottom: $input-bottom-margin 95 | color: $color-medium 96 | border-collapse: collapse 97 | +mobile 98 | float: none 99 | margin-left: auto 100 | margin-right: auto 101 | 102 | .highlight 103 | clear: both 104 | position: relative 105 | background-color: $color-ultra-light 106 | padding: $padding-large $padding-small $padding-small 107 | &:nth-last-child(2) 108 | border-top-left-radius: $border-radius 109 | border-top-right-radius: $border-radius 110 | &:last-child 111 | margin-top: 4px 112 | border-bottom-left-radius: $border-radius 113 | border-bottom-right-radius: $border-radius 114 | 115 | code::before 116 | position: absolute 117 | right: $padding-small 118 | top: $padding-small 119 | font-size: $font-size-small 120 | color: rgb(133,137,140) 121 | 122 | code.language-html::before 123 | content: "HTML" 124 | 125 | code.language-javascript::before 126 | content: "JavaScript" 127 | 128 | pre, 129 | code 130 | white-space: pre-wrap 131 | word-wrap: break-word 132 | 133 | #fork-me 134 | position: absolute 135 | top: 0 136 | right: 0 137 | +mobile 138 | display: none 139 | -------------------------------------------------------------------------------- /public/_sass/_syntax.scss: -------------------------------------------------------------------------------- 1 | /* Github syntax highlighting syles 2 | 3 | .hll { background-color: #ffffcc } 4 | .c { color: #999988; font-style: italic } /* Comment */ 5 | .err { color: #a61717; background-color: #e3d2d2 } /* Error */ 6 | .k { color: #000000; font-weight: bold } /* Keyword */ 7 | .o { color: #000000; font-weight: bold } /* Operator */ 8 | .cm { color: #999988; font-style: italic } /* Comment.Multiline */ 9 | .cp { color: #999999; font-weight: bold; font-style: italic } /* Comment.Preproc */ 10 | .c1 { color: #999988; font-style: italic } /* Comment.Single */ 11 | .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ 12 | .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ 13 | .ge { color: #000000; font-style: italic } /* Generic.Emph */ 14 | .gr { color: #aa0000 } /* Generic.Error */ 15 | .gh { color: #999999 } /* Generic.Heading */ 16 | .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ 17 | .go { color: #888888 } /* Generic.Output */ 18 | .gp { color: #555555 } /* Generic.Prompt */ 19 | .gs { font-weight: bold } /* Generic.Strong */ 20 | .gu { color: #aaaaaa } /* Generic.Subheading */ 21 | .gt { color: #aa0000 } /* Generic.Traceback */ 22 | .kc { color: #000000; font-weight: bold } /* Keyword.Constant */ 23 | .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */ 24 | .kn { color: #000000; font-weight: bold } /* Keyword.Namespace */ 25 | .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */ 26 | .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */ 27 | .kt { color: #445588; font-weight: bold } /* Keyword.Type */ 28 | .m { color: #009999 } /* Literal.Number */ 29 | .s { color: #d01040 } /* Literal.String */ 30 | .na { color: #008080 } /* Name.Attribute */ 31 | .nb { color: #0086B3 } /* Name.Builtin */ 32 | .nc { color: #445588; font-weight: bold } /* Name.Class */ 33 | .no { color: #008080 } /* Name.Constant */ 34 | .nd { color: #3c5d5d; font-weight: bold } /* Name.Decorator */ 35 | .ni { color: #800080 } /* Name.Entity */ 36 | .ne { color: #990000; font-weight: bold } /* Name.Exception */ 37 | .nf { color: #990000; font-weight: bold } /* Name.Function */ 38 | .nl { color: #990000; font-weight: bold } /* Name.Label */ 39 | .nn { color: #555555 } /* Name.Namespace */ 40 | .nt { color: #000080 } /* Name.Tag */ 41 | .nv { color: #008080 } /* Name.Variable */ 42 | .ow { color: #000000; font-weight: bold } /* Operator.Word */ 43 | .w { color: #bbbbbb } /* Text.Whitespace */ 44 | .mf { color: #009999 } /* Literal.Number.Float */ 45 | .mh { color: #009999 } /* Literal.Number.Hex */ 46 | .mi { color: #009999 } /* Literal.Number.Integer */ 47 | .mo { color: #009999 } /* Literal.Number.Oct */ 48 | .sb { color: #d01040 } /* Literal.String.Backtick */ 49 | .sc { color: #d01040 } /* Literal.String.Char */ 50 | .sd { color: #d01040 } /* Literal.String.Doc */ 51 | .s2 { color: #d01040 } /* Literal.String.Double */ 52 | .se { color: #d01040 } /* Literal.String.Escape */ 53 | .sh { color: #d01040 } /* Literal.String.Heredoc */ 54 | .si { color: #d01040 } /* Literal.String.Interpol */ 55 | .sx { color: #d01040 } /* Literal.String.Other */ 56 | .sr { color: #009926 } /* Literal.String.Regex */ 57 | .s1 { color: #d01040 } /* Literal.String.Single */ 58 | .ss { color: #990073 } /* Literal.String.Symbol */ 59 | .bp { color: #999999 } /* Name.Builtin.Pseudo */ 60 | .vc { color: #008080 } /* Name.Variable.Class */ 61 | .vg { color: #008080 } /* Name.Variable.Global */ 62 | .vi { color: #008080 } /* Name.Variable.Instance */ 63 | .il { color: #009999 } /* Literal.Number.Integer.Long */ 64 | -------------------------------------------------------------------------------- /test/unit/caret_test.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import {expect} from 'chai'; 3 | import installCaret from '../../src/caret'; 4 | 5 | describe('Caret', () => { 6 | describe('with no document', () => { 7 | it('throws an error', () => { 8 | expect(() => installCaret(null)).to.throw(/Caret does not have access to document/); 9 | }); 10 | }); 11 | 12 | describe('with no native caret', () => { 13 | it('throws an error', () => { 14 | expect(() => installCaret({ 15 | createElement: () => ({}) 16 | })).to.throw(/Caret unknown input selection capabilities/); 17 | }); 18 | }); 19 | 20 | describe('with selectionStart', () => { 21 | let getCaret, setCaret; 22 | 23 | beforeEach(() => { 24 | ({getCaret, setCaret} = installCaret({ 25 | createElement: () => ({ selectionStart: true }) 26 | })); 27 | }); 28 | 29 | it('getCaret calls selectionStart and selectionEnd', () => { 30 | const fakeElement = { 31 | selectionStart: 4, 32 | selectionEnd: 8 33 | }; 34 | expect(getCaret(fakeElement)).to.eql({ 35 | start: 4, 36 | end: 8 37 | }); 38 | }); 39 | 40 | it('setCaret sets selectionStart and selectionEnd', () => { 41 | const fakeElement = { 42 | selectionStart: 4, 43 | selectionEnd: 8 44 | }; 45 | setCaret(fakeElement, 1, 5); 46 | 47 | expect(fakeElement).to.eql({ 48 | selectionStart: 1, 49 | selectionEnd: 5 50 | }); 51 | }); 52 | }); 53 | 54 | describe('with document.selection', function() { 55 | let getCaret, setCaret; 56 | 57 | beforeEach(() => { 58 | const selectedText = this.selectedText; 59 | class TextRange { 60 | get text() { 61 | return this.selectedText; 62 | } 63 | 64 | moveEnd() { 65 | this.selectedText = selectedText; 66 | } 67 | 68 | moveStart(noop, negLength) { 69 | if(selectedText) { 70 | this.selectedText = selectedText; 71 | } else { 72 | this.selectedText = ''; 73 | for(let i = 0; i < -negLength; i++) { 74 | this.selectedText += 'x'; 75 | } 76 | } 77 | } 78 | } 79 | ({getCaret, setCaret} = installCaret({ 80 | createElement: () => ({}), 81 | selection: { 82 | createRange: () => { 83 | return { 84 | duplicate: () => { 85 | return new TextRange(); 86 | } 87 | }; 88 | } 89 | } 90 | })); 91 | }); 92 | 93 | describe('nothing selected', () => { 94 | before(() => { 95 | this.selectedText = ''; 96 | }); 97 | 98 | it('getCaret with nothing selected', () => { 99 | const fakeElement = { 100 | value: 'delorean' 101 | }; 102 | expect(getCaret(fakeElement)).to.eql({ 103 | start: 8, 104 | end: 8 105 | }); 106 | }); 107 | }); 108 | 109 | describe('first half selected', () => { 110 | before(() => { 111 | this.selectedText = 'Run For I'; 112 | }); 113 | 114 | it('getCaret with nothing selected', () => { 115 | const fakeElement = { 116 | value: 'Run For It Marty!' 117 | }; 118 | expect(getCaret(fakeElement)).to.eql({ 119 | start: 0, 120 | end: 9 121 | }); 122 | }); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /test/unit/default_card_formatter_test.js: -------------------------------------------------------------------------------- 1 | import { expectThatTyping } from './helpers/expectations'; 2 | import { buildField } from './helpers/builders'; 3 | import Keysim from 'keysim'; 4 | import FieldKit from '../../src'; 5 | import {expect} from 'chai'; 6 | import sinon from 'sinon'; 7 | 8 | testsWithAllKeyboards('FieldKit.DefaultCardFormatter', function() { 9 | var field; 10 | var keyboard = Keysim.Keyboard.US_ENGLISH; 11 | 12 | beforeEach(function() { 13 | field = buildField(); 14 | field.setFormatter(new FieldKit.DefaultCardFormatter()); 15 | }); 16 | 17 | it('adds a space after the first four digits', function() { 18 | expectThatTyping('1').into(field).willChange('411|').to('4111 |'); 19 | }); 20 | 21 | it('groups digits into four groups of four separated by spaces', function() { 22 | expectThatTyping('4111111111111111').into(field).willChange('|').to('4111 1111 1111 1111|'); 23 | }); 24 | 25 | it('prevents entering more digits than are allowed', function() { 26 | expectThatTyping('1').into(field).willNotChange('4111 1111 1111 1111|'); 27 | }); 28 | 29 | it('backspaces both the space and the character before it', function() { 30 | expectThatTyping('backspace').into(field).willChange('4111 |').to('411|'); 31 | }); 32 | 33 | it('allows backspacing a whole group of digits', function() { 34 | expectThatTyping('alt+backspace').into(field).willChange('4111 1111 |').to('4111 |'); 35 | expectThatTyping('alt+backspace').into(field).willChange('4111 1|1').to('4111 |1'); 36 | }); 37 | 38 | it('prevents adding more than 16 digits', function() { 39 | expectThatTyping('8').into(field).willNotChange('4111 1111 1111 1111|'); 40 | }); 41 | 42 | it('allows moving left over a space', function() { 43 | expectThatTyping('left').into(field).willChange('4111 |').to('411|1 '); 44 | }); 45 | 46 | it('selects not including spaces if possible', function() { 47 | expectThatTyping('shift+left').into(field).willChange('4111 1<1|').to('4111 <11|'); 48 | expectThatTyping('shift+right').into(field).willChange('41|1>1 11').to('41|11> 11'); 49 | expectThatTyping('shift+right').into(field).willChange('41|11> 11').to('41|11 1>1'); 50 | }); 51 | 52 | it('selects past spaces as if they are not there', function() { 53 | expectThatTyping('shift+left').into(field).willChange('4111 |1').to('411<1| 1'); 54 | expectThatTyping('shift+left').into(field).willChange('4111 <1|1').to('411<1 1|1'); 55 | }); 56 | 57 | describe('error checking', function() { 58 | var textFieldDidFailToParseString; 59 | 60 | beforeEach(function() { 61 | textFieldDidFailToParseString = sinon.spy(); 62 | field.setDelegate({ 63 | textFieldDidFailToParseString: textFieldDidFailToParseString 64 | }); 65 | }); 66 | 67 | it('fails to parse a number that is too short', function() { 68 | keyboard.dispatchEventsForInput('4', field.element); 69 | expect(field.value()).to.equal('4'); 70 | expect(textFieldDidFailToParseString.firstCall.args) 71 | .to.eql([field, '4', 'card-formatter.number-too-short']); 72 | }); 73 | 74 | it('successfully parses a number that is the right length and passes the luhn check', function() { 75 | keyboard.dispatchEventsForInput('4111 1111 1111 1111', field.element); 76 | expect(field.value()).to.equal('4111111111111111'); 77 | expect(textFieldDidFailToParseString.callCount).to.equal(0); 78 | }); 79 | 80 | it('fails to parse a number that is the right length but fails the luhn check', function() { 81 | keyboard.dispatchEventsForInput('4111 1111 1111 1112', field.element); 82 | expect(field.value()).to.equal('4111111111111112'); 83 | expect(textFieldDidFailToParseString.firstCall.args).to.eql([field, '4111 1111 1111 1112', 'card-formatter.invalid-number']); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/unit/expiry_date_formatter_test.js: -------------------------------------------------------------------------------- 1 | import { expectThatTyping } from './helpers/expectations'; 2 | import { buildField } from './helpers/builders'; 3 | import FieldKit from '../../src'; 4 | import {expect} from 'chai'; 5 | import sinon from 'sinon'; 6 | 7 | testsWithAllKeyboards('FieldKit.ExpiryDateFormatter', function() { 8 | var field; 9 | var formatter; 10 | 11 | beforeEach(function() { 12 | field = buildField(); 13 | formatter = new FieldKit.ExpiryDateFormatter(); 14 | field.setFormatter(formatter); 15 | }); 16 | 17 | it('adds a preceding zero and a succeeding slash if an unambiguous month is typed', function() { 18 | expectThatTyping('4').into(field).willChange('|').to('04/|'); 19 | }); 20 | 21 | it('does not add anything when the typed first character is an ambiguous month', function() { 22 | expectThatTyping('1').into(field).willChange('|').to('1|'); 23 | }); 24 | 25 | it('adds a slash after a two-digit month is typed', function() { 26 | expectThatTyping('0').into(field).willChange('1|').to('10/|'); 27 | expectThatTyping('7').into(field).willChange('0|').to('07/|'); 28 | }); 29 | 30 | it('adds a preceding zero when a slash is typed after an ambiguous month', function() { 31 | expectThatTyping('/').into(field).willChange('1|').to('01/|'); 32 | }); 33 | 34 | it('prevents 00 as a month', function() { 35 | expectThatTyping('0').into(field).willNotChange('0|').withError('expiry-date-formatter.invalid-month'); 36 | }); 37 | 38 | it('prevents entry of an invalid two-digit month', function() { 39 | expectThatTyping('3').into(field).willChange('1|1/5').to('11/5'); 40 | }); 41 | 42 | it('prevents entry of an additional slash', function() { 43 | expectThatTyping('/').into(field).willNotChange('11/|'); 44 | }); 45 | 46 | it('allows any two digits for the year', function() { 47 | expectThatTyping('0', '9', '8').into(field).willChange('11/|').to('11/09|'); 48 | }); 49 | 50 | it('allows backspacing ignoring the slash', function() { 51 | expectThatTyping('backspace').into(field).willChange('11/|').to('1|'); 52 | }); 53 | 54 | it('allows backspacing words to delete just the year', function() { 55 | expectThatTyping('alt+backspace').into(field).willChange('11/14|').to('11/|'); 56 | }); 57 | 58 | it('backspaces to the beginning if the last character after backspacing is 0', function() { 59 | expectThatTyping('backspace').into(field).willChange('01/|').to('|'); 60 | }); 61 | 62 | it('allows typing a character matching the suffix that hits the end of the allowed input', function() { 63 | expectThatTyping('1').into(field).willChange('12/1|').to('12/11|'); 64 | }); 65 | 66 | it('calls its delegate when parsing the text fails', function() { 67 | var textFieldDidFailToParseString = sinon.spy(); 68 | field.setDelegate({ textFieldDidFailToParseString: textFieldDidFailToParseString }); 69 | field.setText('abc'); 70 | expect(field.value()).to.be.null; 71 | expect(textFieldDidFailToParseString.firstCall.args).to.eql([field, 'abc', 'expiry-date-formatter.invalid-date']); 72 | }); 73 | 74 | describe('#parse', function() { 75 | var clock; 76 | 77 | it('parses high two digit years as happening in the 20th century', function() { 78 | clock = sinon.useFakeTimers(new Date(2013, 0).getTime()); 79 | expect(formatter.parse('04/99')).to.eql({month: 4, year: 1999}); 80 | }); 81 | 82 | it('parses low two digit years as happening in the 21st century', function() { 83 | clock = sinon.useFakeTimers(new Date(2013, 0).getTime()); 84 | expect(formatter.parse('04/04')).to.eql({month: 4, year: 2004}); 85 | }); 86 | 87 | it('when near the end of a century, parses low numbers as the beginning of the next century', function() { 88 | clock = sinon.useFakeTimers(new Date(2099, 0).getTime()); 89 | expect(formatter.parse('04/04')).to.eql({month: 4, year: 2104}); 90 | }); 91 | 92 | it('parses incomplete dates as formatted', function() { 93 | clock = sinon.useFakeTimers(new Date(2013, 0).getTime()); 94 | expect(formatter.parse('12/3')).to.eql({month: 12, year: 2003}); 95 | }); 96 | 97 | afterEach(function() { 98 | clock.restore(); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /test/unit/helpers/fake_event.js: -------------------------------------------------------------------------------- 1 | var KEYS = { 2 | A: 65, 3 | Z: 90, 4 | a: 97, 5 | z: 122, 6 | ZERO: 48, 7 | NINE: 57, 8 | LEFT: 37, 9 | RIGHT: 39, 10 | UP: 38, 11 | DOWN: 40, 12 | BACKSPACE: 8, 13 | DELETE: 46, 14 | ENTER: 13, 15 | TAB: 9, 16 | 17 | PRINTABLE_START: 32, 18 | PRINTABLE_END: 126 19 | }; 20 | 21 | KEYS.CHARCODE_ZERO = [ 22 | KEYS.LEFT, 23 | KEYS.RIGHT, 24 | KEYS.UP, 25 | KEYS.DOWN 26 | ]; 27 | 28 | var MODIFIERS = [ 29 | 'meta', 30 | 'alt', 31 | 'shift', 32 | 'ctrl' 33 | ]; 34 | 35 | class FakeEvent { 36 | constructor() { 37 | this.keyCode = 0; 38 | this.altKey = false; 39 | this.shiftKey = false; 40 | this.metaKey = false; 41 | this.ctrlKey = false; 42 | this.type = null; 43 | this._defaultPrevented = false; 44 | } 45 | 46 | initEvent(type, bubbles, cancelable) { 47 | this.type = type; 48 | } 49 | 50 | preventDefault() { 51 | this._defaultPrevented = true; 52 | } 53 | 54 | isDefaultPrevented() { 55 | return this._defaultPrevented; 56 | } 57 | 58 | isPrintable() { 59 | return !this.metaKey && 60 | !this.ctrlKey && 61 | this.keyCode >= KEYS.PRINTABLE_START && 62 | this.keyCode <= KEYS.PRINTABLE_END; 63 | } 64 | 65 | get charCode() { 66 | if (KEYS.CHARCODE_ZERO.indexOf(this.keyCode) >= 0) { 67 | return 0; 68 | } else if (this.type === 'keypress' && this.isPrintable()) { 69 | return this._charCode; 70 | } else { 71 | return 0; 72 | } 73 | } 74 | 75 | set charCode(charCode) { 76 | this._charCode = charCode; 77 | } 78 | 79 | static eventsForKeys(keys) { 80 | var events = []; 81 | for (var i = 0, l = keys.length; i < l; i++) { 82 | var key = keys[i]; 83 | var event = this.withKey(key); 84 | if (event) { 85 | events.push(event); 86 | } else { 87 | events.push(...this.eventsForKeys(key)); 88 | } 89 | } 90 | return events; 91 | } 92 | 93 | static withKey(key) { 94 | var event = this.withSimpleKey(key); 95 | if (event) { return event; } 96 | 97 | // try to parse it as e.g. ctrl+shift+a 98 | var parts = key.split('+'); 99 | key = parts.pop(); 100 | var modifiers = parts; 101 | var modifiersAreReal = true; 102 | for (var i = 0, l = modifiers.length; i < l; i++) { 103 | var modifier = modifiers[i]; 104 | if (MODIFIERS.indexOf(modifier) < 0) { 105 | modifiersAreReal = false; 106 | break; 107 | } 108 | } 109 | 110 | if (modifiersAreReal) { 111 | event = this.withSimpleKey(key); 112 | } 113 | 114 | // return early if we can't parse it 115 | if (!modifiersAreReal || !event) { 116 | return null; 117 | } 118 | 119 | modifiers.forEach(function(modifier) { 120 | event[modifier+'Key'] = true; 121 | }); 122 | return event; 123 | } 124 | 125 | static withSimpleKey(key) { 126 | if (key.length === 1) { 127 | return this.withKeyCode(key.charCodeAt(0)); 128 | } else if (key.toUpperCase() in KEYS) { 129 | return this.withKeyCode(KEYS[key.toUpperCase()]); 130 | } 131 | } 132 | 133 | static withKeyCode(keyCode) { 134 | var event = new this(); 135 | var charCode = keyCode; 136 | 137 | // specially handle A-Z and a-z 138 | if (KEYS.A <= keyCode && keyCode <= KEYS.Z) { 139 | event.shiftKey = true; 140 | } else if (KEYS.a <= keyCode && keyCode <= KEYS.z) { 141 | keyCode -= KEYS.a - KEYS.A; 142 | } 143 | 144 | event.keyCode = keyCode; 145 | event.charCode = charCode; 146 | return event; 147 | } 148 | 149 | static pasteEventWithData(data) { 150 | var event = new this(); 151 | event.clipboardData = new ClipboardData(data); 152 | return event; 153 | } 154 | 155 | // jQuery has an originalEvent property to get the real DOM event, but since 156 | // we're already faking it we might as well just use ourselves. 157 | get originalEvent() { 158 | return this; 159 | } 160 | } 161 | 162 | class ClipboardData { 163 | constructor(data) { 164 | this.data = data; 165 | } 166 | 167 | getData(type) { 168 | return this.data[type]; 169 | } 170 | 171 | setData(type, value) { 172 | this.data[type] = value; 173 | } 174 | } 175 | 176 | export default FakeEvent; 177 | -------------------------------------------------------------------------------- /docs/FieldKit-Fields.md: -------------------------------------------------------------------------------- 1 | > [Wiki](Home) ▸ **Fields** 2 | 3 | **FieldKit** builds all its more complex fields with `FieldKit.TextField` as the base class. Using TextField is very easy. 4 | 5 | ```html 6 | 7 | 8 | 13 | ``` 14 | 15 | Fields that add additional functionality to a specific use case. 16 | 17 | All FieldKit Fields enable you to listen on some basic events over the lifetime of an input. 18 | 19 | ### Note 20 | FieldKit will disable `autocapitalization` unless you specifically turn it on 21 | with an attribute on the `input`. We recommend that you use a formatter to handle 22 | capitalizations for your fields instead of using `autocapitalization`. 23 | 24 | **Caution on iOS this causes a bug that will cause the text to be all Caps unless 25 | the user manually uncaps the text** 26 | 27 | ## Delegates (Events) 28 | 29 | ### Events 30 | #### # textDidChange 31 | > Called when the user has changed the text of the field. 32 | 33 | #### # textFieldDidEndEditing 34 | > Called when the user has in some way declared that they are done editing, such as leaving the field or perhaps pressing enter. 35 | 36 | #### # textFieldDidBeginEditing 37 | > Called when the user has in some way started editing 38 | 39 | 40 | ### Usage 41 | You can as many, or none of the event delegates you'd like. 42 | 43 | ```js 44 | var field = new FieldKit.TextField(document.getElementById('spy-on-user')); 45 | field.setDelegate({ 46 | textFieldDidBeginEditing: function(field) { 47 | console.log(field.value()); 48 | }, 49 | textFieldDidEndEditing: function(field) { 50 | console.log(field.value()); 51 | }, 52 | textDidChange: function(field) { 53 | console.log(field.value()); 54 | } 55 | }); 56 | ``` 57 | 58 | ## Card Text Field 59 | 60 | This Field automatically uses the Adaptive Card Formatter, exposes a [Luhn Check](https://en.wikipedia.org/wiki/Luhn_algorithm) method, and allows you to mask a card number to only show the last 4 digits on blur. 61 | 62 | ### Methods 63 | 64 | #### # field.setCardMaskStrategy([_CardMaskStrategy_]) 65 | > Sets the type of masking this field uses. 66 | 67 | #### # field.cardMaskStrategy() 68 | > Gets the type of masking this field uses. 69 | 70 | #### # field.cardType() 71 | > Gets the card type for the current value. 72 | 73 | ### Properties 74 | 75 | #### # CardTextField.CardMaskStrategy.DoneEditing 76 | > Property can be passed into [setCardMaskStrategy](FieldKit-Fields#setCardMaskStrategy) to make the field mask on the [textFieldDidEndEditing](FieldKit-Fields#textFieldDidEndEditing) event. 77 | 78 | 79 | #### # CardTextField.CardMaskStrategy.None 80 | > Property can be passed into [setCardMaskStrategy](FieldKit-Fields#setCardMaskStrategy) to make the field have no masking. 81 | 82 | ### Usage 83 | 84 | ```html 85 | 86 |
Card type: unknown
87 | 88 | 89 | 98 | ``` 99 | 100 | ## Expiry Date Field 101 | 102 | This Field automatically uses the Expiry Date Formatter. The field doesn't do much on top of adding the formatter. It only runs the current value through the formatter on [`textFieldDidEndEditing`](FieldKit-Fields#textFieldDidEndEditing) so that a value like `12/4` will turn to `12/04`. 103 | -------------------------------------------------------------------------------- /src/card_text_field.js: -------------------------------------------------------------------------------- 1 | import TextField from './text_field'; 2 | import AdaptiveCardFormatter from './adaptive_card_formatter'; 3 | import { determineCardType } from './card_utils'; 4 | 5 | /** 6 | * Enum for card mask strategies. 7 | * 8 | * @readonly 9 | * @enum {number} 10 | * @private 11 | */ 12 | const CardMaskStrategy = { 13 | None: 'None', 14 | DoneEditing: 'DoneEditing' 15 | }; 16 | 17 | /** 18 | * CardTextField add some functionality for credit card inputs 19 | * 20 | * @extends TextField 21 | */ 22 | class CardTextField extends TextField { 23 | /** 24 | * @param {HTMLElement} element 25 | */ 26 | constructor(element) { 27 | super(element, new AdaptiveCardFormatter()); 28 | this.setCardMaskStrategy(CardMaskStrategy.None); 29 | 30 | /** 31 | * Whether we are currently masking the displayed text. 32 | * 33 | * @private 34 | */ 35 | this._masked = false; 36 | 37 | /** 38 | * Whether we are currently editing. 39 | * 40 | * @private 41 | */ 42 | this._editing = false; 43 | } 44 | 45 | /** 46 | * Gets the card type for the current value. 47 | * 48 | * @returns {string} Returns one of 'visa', 'mastercard', 'amex' and 'discover'. 49 | */ 50 | cardType() { 51 | return determineCardType(this.value()); 52 | } 53 | 54 | /** 55 | * Gets the type of masking this field uses. 56 | * 57 | * @returns {CardMaskStrategy} 58 | */ 59 | cardMaskStrategy() { 60 | return this._cardMaskStrategy; 61 | } 62 | 63 | /** 64 | * Sets the type of masking this field uses. 65 | * 66 | * @param {CardMaskStrategy} cardMaskStrategy One of CardMaskStrategy. 67 | */ 68 | setCardMaskStrategy(cardMaskStrategy) { 69 | if (cardMaskStrategy !== this._cardMaskStrategy) { 70 | this._cardMaskStrategy = cardMaskStrategy; 71 | this._syncMask(); 72 | } 73 | } 74 | 75 | /** 76 | * Returns a masked version of the current formatted PAN. Example: 77 | * 78 | * @example 79 | * field.setText('4111 1111 1111 1111'); 80 | * field.cardMask(); // "•••• •••• •••• 1111" 81 | * 82 | * @returns {string} Returns a masked card string. 83 | */ 84 | cardMask() { 85 | const text = this.text(); 86 | const last4 = text.slice(-4); 87 | let toMask = text.slice(0, -4); 88 | 89 | return toMask.replace(/\d/g, '•') + last4; 90 | } 91 | 92 | /** 93 | * Gets the formatted PAN for this field. 94 | * 95 | * @returns {string} 96 | */ 97 | text() { 98 | if (this._masked) { 99 | return this._unmaskedText; 100 | } else { 101 | return super.text(); 102 | } 103 | } 104 | 105 | /** 106 | * Sets the formatted PAN for this field. 107 | * 108 | * @param {string} text A formatted PAN. 109 | */ 110 | setText(text) { 111 | if (this._masked) { 112 | this._unmaskedText = text; 113 | text = this.cardMask(); 114 | } 115 | super.setText(text); 116 | } 117 | 118 | /** 119 | * Called by our superclass, used to implement card masking. 120 | * 121 | * @private 122 | */ 123 | textFieldDidEndEditing() { 124 | this._editing = false; 125 | this._syncMask(); 126 | } 127 | 128 | /** 129 | * Called by our superclass, used to implement card masking. 130 | * 131 | * @private 132 | */ 133 | textFieldDidBeginEditing() { 134 | this._editing = true; 135 | this._syncMask(); 136 | } 137 | 138 | /** 139 | * Enables masking if it is not already enabled. 140 | * 141 | * @private 142 | */ 143 | _enableMasking() { 144 | if (!this._masked) { 145 | this._unmaskedText = this.text(); 146 | this._masked = true; 147 | this.setText(this._unmaskedText); 148 | } 149 | } 150 | 151 | /** 152 | * Disables masking if it is currently enabled. 153 | * 154 | * @private 155 | */ 156 | _disableMasking() { 157 | if (this._masked) { 158 | this._masked = false; 159 | this.setText(this._unmaskedText); 160 | this._unmaskedText = null; 161 | } 162 | } 163 | 164 | /** 165 | * Enables or disables masking based on the mask settings. 166 | * 167 | * @private 168 | */ 169 | _syncMask() { 170 | if (this.cardMaskStrategy() === CardMaskStrategy.DoneEditing) { 171 | if (this._editing) { 172 | this._disableMasking(); 173 | } else { 174 | this._enableMasking(); 175 | } 176 | } 177 | } 178 | 179 | /** 180 | * Enum for card mask strategies. 181 | * 182 | * @readonly 183 | * @enum {number} 184 | */ 185 | static get CardMaskStrategy() { 186 | return CardMaskStrategy; 187 | } 188 | } 189 | 190 | export default CardTextField; 191 | -------------------------------------------------------------------------------- /test/unit/card_text_field_test.js: -------------------------------------------------------------------------------- 1 | import { buildField } from './helpers/builders'; 2 | import { expectThatTyping } from './helpers/expectations'; 3 | import Keysim from 'keysim'; 4 | import FieldKit from '../../src'; 5 | import {expect} from 'chai'; 6 | 7 | testsWithAllKeyboards('FieldKit.CardTextField', function() { 8 | var textField; 9 | var visa = '4111 1111 1111 1111'; 10 | 11 | beforeEach(function() { 12 | textField = buildField(FieldKit.CardTextField); 13 | }); 14 | 15 | it('uses an adaptive card formatter by default', function() { 16 | expect(textField.formatter() instanceof FieldKit.AdaptiveCardFormatter).to.be.true; 17 | }); 18 | 19 | describe('#cardType', function() { 20 | it('is VISA when the card number starts with 4', function() { 21 | textField.setValue('4'); 22 | expect(textField.cardType()).to.equal('visa'); 23 | }); 24 | 25 | it('is DISCOVER when the card number matches', function() { 26 | textField.setValue('6011'); 27 | expect(textField.cardType()).to.equal('discover'); 28 | }); 29 | 30 | it('is MASTERCARD when the card number matches', function() { 31 | textField.setValue('51'); 32 | expect(textField.cardType()).to.equal('mastercard'); 33 | }); 34 | 35 | it('is AMEX when the card number matches', function() { 36 | textField.setValue('34'); 37 | expect(textField.cardType()).to.equal('amex'); 38 | }); 39 | }); 40 | 41 | describe('#cardMaskStrategy', function() { 42 | describe('when set to None (the default)', function() { 43 | beforeEach(function() { 44 | textField.setCardMaskStrategy(FieldKit.CardTextField.CardMaskStrategy.None); 45 | }); 46 | 47 | it('does not change the displayed card number on end editing', function() { 48 | textField.textFieldDidBeginEditing(); 49 | expectThatTyping(visa) 50 | .into(textField) 51 | .before(() => textField.textFieldDidEndEditing()) 52 | .willChange('|') 53 | .to(`${visa}|`); 54 | }); 55 | }); 56 | 57 | describe('when set to DoneEditing', function() { 58 | beforeEach(function() { 59 | textField.setCardMaskStrategy(FieldKit.CardTextField.CardMaskStrategy.DoneEditing); 60 | }); 61 | 62 | it('does not change the displayed card number while typing', function() { 63 | textField.textFieldDidBeginEditing(); 64 | expectThatTyping(visa) 65 | .into(textField) 66 | .willChange('|') 67 | .to(`${visa}|`); 68 | }); 69 | 70 | it('masks the displayed card number on end editing', function() { 71 | textField.textFieldDidBeginEditing(); 72 | expectThatTyping(visa) 73 | .into(textField) 74 | .before(() => textField.textFieldDidEndEditing.call(textField)) 75 | .willChange('|') 76 | .to('•••• •••• •••• 1111|'); 77 | }); 78 | 79 | it('does change the selected range on end editing', function() { 80 | textField.textFieldDidBeginEditing(); 81 | expectThatTyping('enter').into(textField).willChange(`|${visa}>`).to('•••• •••• •••• 1111|'); 82 | }); 83 | 84 | it('restores the original value on beginning editing', function() { 85 | textField.textFieldDidBeginEditing(); 86 | expectThatTyping(visa) 87 | .into(textField) 88 | .before(() => { 89 | textField.textFieldDidEndEditing(); 90 | textField.textFieldDidBeginEditing(); 91 | }) 92 | .willChange('|') 93 | .to(`${visa}|`); 94 | }); 95 | 96 | it('masks when a value is set before editing', function() { 97 | textField.setValue('1234567890123456'); 98 | expect(textField.element.value).to.equal('•••• •••• •••• 3456'); 99 | }); 100 | 101 | // PENDING 102 | // 103 | // it('restores the original value when disabling masking', function() { 104 | // type(visa).into(textField); 105 | // textField.textFieldDidEndEditing(); 106 | // textField.setCardMaskStrategy(FieldKit.CardTextField.CardMaskStrategy.None); 107 | // expect(textField.element.value).to.equal(visa); 108 | // }); 109 | 110 | // PENDING 111 | // 112 | // it('masks the value when enabling masking', function() { 113 | // type(visa).into(textField); 114 | // textField.textFieldDidEndEditing(); 115 | // textField.setCardMaskStrategy(FieldKit.CardTextField.CardMaskStrategy.None); 116 | // textField.setCardMaskStrategy(FieldKit.CardTextField.CardMaskStrategy.DoneEditing); 117 | // expect(textField.element.value).to.equal('•••• •••• •••• 1111'); 118 | // }); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/expiry_date_formatter.js: -------------------------------------------------------------------------------- 1 | import DelimitedTextFormatter from './delimited_text_formatter'; 2 | import { zpad2 } from './utils'; 3 | 4 | /** 5 | * Give this function a 2 digit year it'll return with 4. 6 | * 7 | * @example 8 | * interpretTwoDigitYear(15); 9 | * // => 2015 10 | * interpretTwoDigitYear(97); 11 | * // => 1997 12 | * @param {number} year 13 | * @returns {number} 14 | * @private 15 | */ 16 | function interpretTwoDigitYear(year) { 17 | const thisYear = new Date().getFullYear(); 18 | const thisCentury = thisYear - (thisYear % 100); 19 | const centuries = [thisCentury, thisCentury - 100, thisCentury + 100].sort(function(a, b) { 20 | return Math.abs(thisYear - (year + a)) - Math.abs(thisYear - (year + b)); 21 | }); 22 | return year + centuries[0]; 23 | } 24 | 25 | /** 26 | * @extends DelimitedTextFormatter 27 | */ 28 | class ExpiryDateFormatter extends DelimitedTextFormatter { 29 | constructor() { 30 | super('/'); 31 | this.maximumLength = 5; 32 | } 33 | 34 | /** 35 | * @param {number} index 36 | * @returns {boolean} 37 | */ 38 | hasDelimiterAtIndex(index) { 39 | return index === 2; 40 | } 41 | 42 | /** 43 | * Formats the given value by adding delimiters where needed. 44 | * 45 | * @param {?string} value 46 | * @returns {string} 47 | */ 48 | format(value) { 49 | if (!value) { return ''; } 50 | 51 | let {month, year} = value; 52 | year = year % 100; 53 | 54 | return super.format(zpad2(month) + zpad2(year)); 55 | } 56 | 57 | /** 58 | * Parses the given text 59 | * 60 | * @param {string} text 61 | * @param {Function(string)} error 62 | * @returns {?Object} { month: month, year: year } 63 | */ 64 | parse(text, error) { 65 | const monthAndYear = text.split(this.delimiter); 66 | let month = monthAndYear[0]; 67 | let year = monthAndYear[1]; 68 | if (month && month.match(/^(0?[1-9]|1\d)$/) && year && year.match(/^\d\d?$/)) { 69 | month = Number(month); 70 | year = interpretTwoDigitYear(Number(year)); 71 | return { month: month, year: year }; 72 | } else { 73 | if (typeof error === 'function') { 74 | error('expiry-date-formatter.invalid-date'); 75 | } 76 | return null; 77 | } 78 | } 79 | 80 | /** 81 | * Determines whether the given change should be allowed and, if so, whether 82 | * it should be altered. 83 | * 84 | * @param {TextFieldStateChange} change 85 | * @param {function(string)} error 86 | * @returns {boolean} 87 | */ 88 | isChangeValid(change, error) { 89 | if (!error) { error = function(){}; } 90 | 91 | const isBackspace = change.proposed.text.length < change.current.text.length; 92 | let newText = change.proposed.text; 93 | 94 | if (change.inserted.text === this.delimiter && change.current.text === '1') { 95 | newText = '01' + this.delimiter; 96 | } else if (change.inserted.text.length > 0 && !/^\d$/.test(change.inserted.text)) { 97 | error('expiry-date-formatter.only-digits-allowed'); 98 | return false; 99 | } else { 100 | if (isBackspace) { 101 | if (change.deleted.text === this.delimiter) { 102 | newText = newText[0]; 103 | } 104 | if (newText === '0') { 105 | newText = ''; 106 | } 107 | if (change.inserted.text.length > 0 && !/^\d$/.test(change.inserted.text)) { 108 | error('expiry-date-formatter.only-digits-allowed'); 109 | return false; 110 | } 111 | } 112 | 113 | // 4| -> 04| 114 | if (/^[2-9]$/.test(newText)) { 115 | newText = '0' + newText; 116 | } 117 | 118 | // 1|1|/5 -> 11|/5 119 | if (/^1[3-9].+$/.test(newText)) { 120 | error('expiry-date-formatter.invalid-month'); 121 | return false; 122 | } 123 | 124 | // 15| -> 01/5| 125 | if (/^1[3-9]$/.test(newText)) { 126 | newText = '01' + this.delimiter + newText.slice(-1); 127 | } 128 | 129 | // Don't allow 00 130 | if (newText === '00') { 131 | error('expiry-date-formatter.invalid-month'); 132 | return false; 133 | } 134 | 135 | // 11| -> 11/ 136 | if (/^(0[1-9]|1[0-2])$/.test(newText)) { 137 | newText += this.delimiter; 138 | } 139 | 140 | const match = newText.match(/^(\d\d)(.)(\d\d?).*$/); 141 | if (match && match[2] === this.delimiter) { 142 | newText = match[1] + this.delimiter + match[3]; 143 | } 144 | } 145 | 146 | change.proposed.text = newText; 147 | change.proposed.selectedRange = { start: newText.length, length: 0 }; 148 | 149 | return true; 150 | } 151 | } 152 | 153 | export default ExpiryDateFormatter; 154 | -------------------------------------------------------------------------------- /test/unit/undo_management_test.js: -------------------------------------------------------------------------------- 1 | import FieldKit from '../../src'; 2 | import {expect} from 'chai'; 3 | 4 | function Shoe() { 5 | // This is here so our test can omit getters on the object. 6 | Object.defineProperty(this, 'getterOnObject', { get: function(){ return 99; } }); 7 | } 8 | 9 | Shoe.prototype._size = 0; 10 | 11 | Shoe.prototype.size = function() { 12 | return this._size; 13 | }; 14 | 15 | Shoe.prototype.setSize = function(size) { 16 | this._undoProxy.setSize(this._size); 17 | this._size = size; 18 | }; 19 | 20 | Shoe.prototype.undoManager = function() { 21 | return this._undoManager; 22 | }; 23 | 24 | Shoe.prototype.setUndoManager = function(undoManager) { 25 | this._undoManager = undoManager; 26 | this._undoProxy = this._undoManager.proxyFor(this); 27 | }; 28 | 29 | // This is here so our test can omit getters in the prototype. 30 | Object.defineProperty(Shoe.prototype, 'getterOnPrototype', { get: function(){ return 42; } }); 31 | 32 | testsWithAllKeyboards('FieldKit.UndoManager', function() { 33 | var undoManager; 34 | var shoe; 35 | 36 | beforeEach(function() { 37 | undoManager = new FieldKit.UndoManager(); 38 | shoe = new Shoe(); 39 | shoe.setUndoManager(undoManager); 40 | }); 41 | 42 | describe('#canUndo', function() { 43 | it('is false when no undos have been registered', function() { 44 | expect(undoManager.canUndo()).to.be.false; 45 | }); 46 | 47 | describe('after registering an undo', function() { 48 | beforeEach(function() { 49 | shoe.setSize(20); 50 | }); 51 | 52 | it('is true', function() { 53 | expect(undoManager.canUndo()).to.be.true; 54 | }); 55 | 56 | describe('and after undoing the registered undo', function() { 57 | beforeEach(function() { 58 | undoManager.undo(); 59 | }); 60 | 61 | it('is false again', function() { 62 | expect(undoManager.canUndo()).to.be.false; 63 | }); 64 | }); 65 | }); 66 | }); 67 | 68 | describe('#canRedo', function() { 69 | it('is false when no undos have been performed', function() { 70 | expect(undoManager.canRedo()).to.be.false; 71 | }); 72 | 73 | describe('after undoing', function() { 74 | beforeEach(function() { 75 | shoe.setSize(20); 76 | undoManager.undo(); 77 | }); 78 | 79 | it('is true', function() { 80 | expect(undoManager.canRedo()).to.be.true; 81 | }); 82 | 83 | describe('and after making another change', function() { 84 | beforeEach(function() { 85 | shoe.setSize(30); 86 | }); 87 | 88 | it('is false', function() { 89 | expect(undoManager.canRedo()).to.be.false; 90 | }); 91 | }); 92 | }); 93 | }); 94 | 95 | describe('#undo', function() { 96 | describe('when there are no registered undos', function() { 97 | it('will raise an error', function() { 98 | try { 99 | undoManager.undo(); 100 | throw new Error('this should have thrown an exception'); 101 | } catch (ex) { 102 | expect(ex.message).to.equal('there are no registered undos'); 103 | } 104 | }); 105 | }); 106 | 107 | describe('when there are registered undos', function() { 108 | beforeEach(function() { 109 | shoe.setSize(1); 110 | shoe.setSize(2); 111 | }); 112 | 113 | it('reverts the values in reverse order', function() { 114 | undoManager.undo(); // 2 -> 1 115 | expect(shoe.size()).to.equal(1); 116 | undoManager.undo(); // 1 -> 0 117 | expect(shoe.size()).to.equal(0); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('#redo', function() { 123 | describe('when nothing has been undone', function() { 124 | it('will raise an error', function() { 125 | try { 126 | undoManager.redo(); 127 | throw new Error('this should have thrown an exception'); 128 | } catch (ex) { 129 | expect(ex.message).to.equal('there are no registered redos'); 130 | } 131 | }); 132 | }); 133 | 134 | describe('when something has been undone', function() { 135 | beforeEach(function() { 136 | shoe.setSize(20); 137 | undoManager.undo(); 138 | }); 139 | 140 | it('changes the value back to before the undo', function() { 141 | expect(shoe.size()).to.equal(0); 142 | undoManager.redo(); 143 | expect(shoe.size()).to.equal(20); 144 | }); 145 | }); 146 | }); 147 | 148 | describe('#proxyFor', function() { 149 | it('proxies all properties that are not functions or getters', function() { 150 | var proxy = undoManager.proxyFor(shoe); 151 | expect(proxy.size).to.be.defined; 152 | expect(proxy.setSize).to.be.defined; 153 | expect(proxy.getterOnPrototype).not.to.be.defined; 154 | expect(proxy.getterOnObject).not.to.be.defined; 155 | }); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/number_formatter_settings_formatter.js: -------------------------------------------------------------------------------- 1 | import Formatter from './formatter'; 2 | 3 | class NumberFormatterSettings { 4 | constructor() { 5 | /** @type boolean */ 6 | this.alwaysShowsDecimalSeparator = false; 7 | 8 | /** @type number */ 9 | this.groupingSize = 0; 10 | 11 | /** @type number */ 12 | this.maximumFractionDigits = 0; 13 | 14 | /** @type number */ 15 | this.minimumFractionDigits = 0; 16 | 17 | /** @type number */ 18 | this.minimumIntegerDigits = 0; 19 | 20 | /** @type string */ 21 | this.prefix = ''; 22 | 23 | /** @type string */ 24 | this.suffix = ''; 25 | 26 | /** @type boolean */ 27 | this.usesGroupingSeparator = false; 28 | } 29 | } 30 | 31 | /** 32 | * Returns a string composed of the given character repeated `length` times. 33 | * 34 | * @param {string} character 35 | * @param {number} length 36 | * @returns {string} 37 | * @private 38 | */ 39 | function chars(character, length) { 40 | return new Array(length + 1).join(character); 41 | } 42 | 43 | /** 44 | * @const 45 | * @private 46 | */ 47 | const DIGIT = '#'; 48 | 49 | /** 50 | * @const 51 | * @private 52 | */ 53 | const PADDING = '0'; 54 | 55 | /** 56 | * @const 57 | * @private 58 | */ 59 | const DECIMAL_SEPARATOR = '.'; 60 | 61 | /** 62 | * @const 63 | * @private 64 | */ 65 | const GROUPING_SEPARATOR = ','; 66 | 67 | class NumberFormatterSettingsFormatter extends Formatter { 68 | /** 69 | * @param {NumberFormatterSettings} settings 70 | * @returns {string} 71 | */ 72 | format(settings) { 73 | let result = ''; 74 | 75 | const minimumIntegerDigits = settings.minimumIntegerDigits; 76 | if (minimumIntegerDigits !== 0) { 77 | result += chars(PADDING, minimumIntegerDigits); 78 | } 79 | 80 | result = DIGIT + result; 81 | 82 | if (settings.usesGroupingSeparator) { 83 | while (result.length <= settings.groupingSize) { 84 | result = DIGIT + result; 85 | } 86 | 87 | result = result.slice(0, -settings.groupingSize) + 88 | GROUPING_SEPARATOR + 89 | result.slice(-settings.groupingSize); 90 | } 91 | 92 | const minimumFractionDigits = settings.minimumFractionDigits; 93 | const maximumFractionDigits = settings.maximumFractionDigits; 94 | const hasFractionalPart = settings.alwaysShowsDecimalSeparator || 95 | minimumFractionDigits > 0 || 96 | maximumFractionDigits > 0; 97 | 98 | if (hasFractionalPart) { 99 | result += DECIMAL_SEPARATOR; 100 | for (let i = 0, length = maximumFractionDigits; i < length; i++) { 101 | result += (i < minimumFractionDigits) ? PADDING : DIGIT; 102 | } 103 | } 104 | 105 | return settings.prefix + result + settings.suffix; 106 | } 107 | 108 | /** 109 | * @param {string} string 110 | * @returns {?NumberFormatterSettings} 111 | */ 112 | parse(string) { 113 | const result = new NumberFormatterSettings(); 114 | 115 | let hasPassedPrefix = false; 116 | let hasStartedSuffix = false; 117 | let decimalSeparatorIndex = null; 118 | let groupingSeparatorIndex = null; 119 | let lastIntegerDigitIndex = null; 120 | 121 | for (var i = 0, length = string.length; i < length; i++) { 122 | const c = string[i]; 123 | 124 | switch (c) { 125 | case DIGIT: 126 | if (hasStartedSuffix) { return null; } 127 | hasPassedPrefix = true; 128 | if (decimalSeparatorIndex !== null) { 129 | result.maximumFractionDigits++; 130 | } 131 | break; 132 | 133 | case PADDING: 134 | if (hasStartedSuffix) { return null; } 135 | hasPassedPrefix = true; 136 | if (decimalSeparatorIndex === null) { 137 | result.minimumIntegerDigits++; 138 | } else { 139 | result.minimumFractionDigits++; 140 | result.maximumFractionDigits++; 141 | } 142 | break; 143 | 144 | case DECIMAL_SEPARATOR: 145 | if (hasStartedSuffix) { return null; } 146 | hasPassedPrefix = true; 147 | decimalSeparatorIndex = i; 148 | lastIntegerDigitIndex = i - 1; 149 | break; 150 | 151 | case GROUPING_SEPARATOR: 152 | if (hasStartedSuffix) { return null; } 153 | hasPassedPrefix = true; 154 | groupingSeparatorIndex = i; 155 | break; 156 | 157 | default: 158 | if (hasPassedPrefix) { 159 | hasStartedSuffix = true; 160 | result.suffix += c; 161 | } else { 162 | result.prefix += c; 163 | } 164 | } 165 | } 166 | 167 | if (decimalSeparatorIndex === null) { 168 | lastIntegerDigitIndex = length - 1; 169 | } 170 | 171 | if (decimalSeparatorIndex === length - 1) { 172 | result.alwaysShowsDecimalSeparator = true; 173 | } 174 | 175 | if (groupingSeparatorIndex !== null) { 176 | result.groupingSize = lastIntegerDigitIndex - groupingSeparatorIndex; 177 | result.usesGroupingSeparator = true; 178 | } 179 | 180 | return result; 181 | } 182 | } 183 | 184 | export default NumberFormatterSettingsFormatter; 185 | -------------------------------------------------------------------------------- /test/unit/number_formatter_settings_formatter_test.js: -------------------------------------------------------------------------------- 1 | import FieldKit from '../../src'; 2 | import {expect} from 'chai'; 3 | 4 | testsWithAllKeyboards('FieldKit.NumberFormatterSettingsFormatter', function() { 5 | var formatter; 6 | 7 | beforeEach(function() { 8 | formatter = new FieldKit.NumberFormatterSettingsFormatter(); 9 | }); 10 | 11 | var DEFAULT_SETTINGS = /** @type NumberFormatterSettings */ { 12 | decimalSeparator: '.', 13 | minimumFractionDigits: 0, 14 | maximumFractionDigits: 0, 15 | minimumIntegerDigits: 0, 16 | prefix: '', 17 | suffix: '', 18 | alwaysShowsDecimalSeparator: false, 19 | groupingSize: 0, 20 | groupingSeparator: '' 21 | }; 22 | 23 | /** 24 | * Builds a full settings object from defaults with the given overrides. 25 | * 26 | * @param {Object=} overrides 27 | * @returns {NumberFormatterSettings} 28 | */ 29 | function buildSettings(overrides) { 30 | var key; 31 | var result = /** @type NumberFormatterSettings */ {}; 32 | 33 | for (key in DEFAULT_SETTINGS) { 34 | if (DEFAULT_SETTINGS.hasOwnProperty(key)) { 35 | result[key] = DEFAULT_SETTINGS[key]; 36 | } 37 | } 38 | 39 | if (overrides) { 40 | for (key in overrides) { 41 | if (overrides.hasOwnProperty(key)) { 42 | result[key] = overrides[key]; 43 | } 44 | } 45 | } 46 | 47 | return result; 48 | } 49 | 50 | describe('#format', function() { 51 | it('formats number formatter settings as a number format string', function() { 52 | expect(formatter.format(buildSettings())).to.equal('#'); 53 | }); 54 | 55 | it('pads with zeroes for every required integer digit', function() { 56 | var settings = buildSettings({ minimumIntegerDigits: 3 }); 57 | expect(formatter.format(settings)).to.equal('#000'); 58 | }); 59 | 60 | it('pads with hashes up to the maximum fraction digits', function() { 61 | var settings = buildSettings({ maximumFractionDigits: 4 }); 62 | expect(formatter.format(settings)).to.equal('#.####'); 63 | }); 64 | 65 | it('includes the decimal separator when alwaysShowsDecimalSeparator is true', function() { 66 | var settings = buildSettings({ alwaysShowsDecimalSeparator: true }); 67 | expect(formatter.format(settings)).to.equal('#.'); 68 | }); 69 | 70 | it('pads with zeroes up to the minimum and with hashes past that for maximum fraction digits', function() { 71 | var settings = buildSettings({ minimumFractionDigits: 2, maximumFractionDigits: 4 }); 72 | expect(formatter.format(settings)).to.equal('#.00##'); 73 | }); 74 | 75 | it('prepends whatever prefix is set', function() { 76 | var settings = buildSettings({ prefix: '¤' }); 77 | expect(formatter.format(settings)).to.equal('¤#'); 78 | }); 79 | 80 | it('appends whatever suffix is set', function() { 81 | var settings = buildSettings({ suffix: '¤' }); 82 | expect(formatter.format(settings)).to.equal('#¤'); 83 | }); 84 | 85 | it('pads the integer part with digit placeholders when using grouping separators', function() { 86 | var settings = buildSettings({ usesGroupingSeparator: true, groupingSize: 4 }); 87 | expect(formatter.format(settings)).to.equal('#,####'); 88 | 89 | settings = buildSettings({ usesGroupingSeparator: true, groupingSize: 2, minimumIntegerDigits: 4 }); 90 | expect(formatter.format(settings)).to.equal('#00,00'); 91 | }); 92 | }); 93 | 94 | describe('#parse', function() { 95 | it('expects exactly one integer hash', function() { 96 | expect(formatter.parse('#')).to.be.defined; 97 | }); 98 | 99 | it('treats every zero after the leading hash as a required digit', function() { 100 | expect(formatter.parse('#00').minimumIntegerDigits).to.equal(2); 101 | }); 102 | 103 | it('treats a decimal separator with no following zeroes or hashes as requiring the separator', function() { 104 | expect(formatter.parse('#.').alwaysShowsDecimalSeparator).to.equal(true); 105 | }); 106 | 107 | it('treats every zero after the decimal separator as a required digit', function() { 108 | expect(formatter.parse('#.000').minimumFractionDigits).to.equal(3); 109 | }); 110 | 111 | it('treats every trailing hash after the decimal separator as an additional allowed digit', function() { 112 | var settings = formatter.parse('#.00#'); 113 | expect(settings.minimumFractionDigits).to.equal(2); 114 | expect(settings.maximumFractionDigits).to.equal(3); 115 | }); 116 | 117 | it('can determine the grouping size by looking for the grouping separator', function() { 118 | var settings = formatter.parse('#,####'); 119 | expect(settings.groupingSize).to.equal(4); 120 | expect(settings.usesGroupingSeparator).to.equal(true); 121 | }); 122 | 123 | it('treats everything before a recognized character as part of a prefix', function() { 124 | expect(formatter.parse('prefix#').prefix).to.equal('prefix'); 125 | }); 126 | 127 | it('treats everything after a recognized character as part of a suffix', function() { 128 | expect(formatter.parse('#suffix').suffix).to.equal('suffix'); 129 | }); 130 | 131 | it('treats unrecognized characters between recognized characters as an error', function() { 132 | expect(formatter.parse('0f0')).to.be.null; 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @const 3 | * @private 4 | */ 5 | const DIGITS_PATTERN = /^\d*$/; 6 | 7 | /** 8 | * @const 9 | * @private 10 | */ 11 | const SURROUNDING_SPACE_PATTERN = /(^\s+|\s+$)/; 12 | 13 | /** 14 | * @param {string} string 15 | * @returns {boolean} 16 | */ 17 | export function isDigits(string) { 18 | return DIGITS_PATTERN.test(string); 19 | } 20 | 21 | /** 22 | * @param {string} prefix 23 | * @param {string} string 24 | * @returns {boolean} 25 | */ 26 | export function startsWith(prefix, string) { 27 | return string.slice(0, prefix.length) === prefix; 28 | } 29 | 30 | /** 31 | * @param {string} suffix 32 | * @param {string} string 33 | * @returns {boolean} 34 | */ 35 | export function endsWith(suffix, string) { 36 | return string.slice(string.length - suffix.length) === suffix; 37 | } 38 | 39 | /** 40 | * @param {string} string 41 | * @returns {string} 42 | */ 43 | export const trim = (typeof ''.trim === 'function') ? 44 | function(string) { return string.trim(); } : 45 | function(string) { return string.replace(SURROUNDING_SPACE_PATTERN, ''); }; 46 | 47 | /** 48 | * Will pad n with `0` up until length. 49 | * 50 | * @example 51 | * zpad(16, '1234'); 52 | * // => 0000000000001234 53 | * 54 | * @param {number} length 55 | * @param {(string|number)} n 56 | * @returns {string} 57 | */ 58 | export function zpad(length, n) { 59 | let result = ''+n; 60 | while (result.length < length) { 61 | result = '0'+result; 62 | } 63 | return result; 64 | } 65 | 66 | /** 67 | * Will pad n with `0` up until length is 2. 68 | * 69 | * @example 70 | * zpad2('2'); 71 | * // => 02 72 | * 73 | * @param {(string|number)} n 74 | * @returns {string} 75 | */ 76 | export function zpad2(n) { 77 | return zpad(2, n); 78 | } 79 | 80 | /** 81 | * PhantomJS 1.9 does not have Function.bind. 82 | * 83 | * @param {Function} fn 84 | * @param {*} context 85 | * @returns {*} 86 | */ 87 | export function bind(fn, context) { 88 | return fn.bind(context); 89 | } 90 | 91 | if (!Function.prototype.bind) { 92 | Function.prototype.bind = function(context, ...prependedArgs) { 93 | const self = this; 94 | return function(...args) { 95 | return self.apply(context, prependedArgs.concat(args)); 96 | }; 97 | }; 98 | } 99 | 100 | /** 101 | * Replaces the characters within the selection with given text. 102 | * 103 | * @example 104 | * // 12|34567|8 105 | * replaceStringSelection('12345678', '00', { start: 2, length: 5 }); 106 | * // 12|00|8 107 | * 108 | * @param {string} replacement 109 | * @param {string} text 110 | * @param {object} {start: number, length: number} 111 | * @returns {string} 112 | */ 113 | export function replaceStringSelection(replacement, text, range) { 114 | const end = range.start + range.length; 115 | return text.substring(0, range.start) + replacement + text.substring(end); 116 | } 117 | 118 | const hasOwnProp = Object.prototype.hasOwnProperty; 119 | /** 120 | * @param {*} iterable 121 | * @param {Function} iterator 122 | */ 123 | export function forEach(iterable, iterator) { 124 | if (iterable && typeof iterable.forEach === 'function') { 125 | iterable.forEach(iterator); 126 | } else if ({}.toString.call(iterable) === '[object Array]') { 127 | for (let i = 0, l = iterable.length; i < l; i++) { 128 | iterator.call(null, iterable[i], i, iterable); 129 | } 130 | } else { 131 | for (let key in iterable) { 132 | if (hasOwnProp.call(iterable, key)) { 133 | iterator.call(null, iterable[key], key, iterable); 134 | } 135 | } 136 | } 137 | } 138 | 139 | const getOwnPropertyNames = (function() { 140 | let getOwnPropertyNames = Object.getOwnPropertyNames; 141 | 142 | try { 143 | Object.getOwnPropertyNames({}, 'sq'); 144 | } catch (e) { 145 | // IE 8 146 | getOwnPropertyNames = function(object) { 147 | const result = []; 148 | for (let key in object) { 149 | if (hasOwnProp.call(object, key)) { 150 | result.push(key); 151 | } 152 | } 153 | return result; 154 | }; 155 | } 156 | 157 | return getOwnPropertyNames; 158 | })(); 159 | 160 | const getPrototypeOf = Object.getPrototypeOf || (object => object.__proto__); 161 | /** 162 | * @param {Object} object 163 | * @param {string} property 164 | * @returns {boolean} 165 | */ 166 | export function hasGetter(object, property) { 167 | // Skip if getOwnPropertyDescriptor throws (IE8) 168 | try { 169 | Object.getOwnPropertyDescriptor({}, 'sq'); 170 | } catch (e) { 171 | return false; 172 | } 173 | 174 | let descriptor; 175 | 176 | if (object && object.constructor && object.constructor.prototype) { 177 | descriptor = Object.getOwnPropertyDescriptor(object.constructor.prototype, property); 178 | } 179 | 180 | if (!descriptor) { 181 | descriptor = Object.getOwnPropertyDescriptor(object, property); 182 | } 183 | 184 | if (descriptor && descriptor.get) { 185 | return true; 186 | } else { 187 | return false; 188 | } 189 | } 190 | 191 | /** 192 | * @param {Object} object 193 | * @returns {?string[]} 194 | */ 195 | export function getAllPropertyNames(object) { 196 | if (object === null || object === undefined) { 197 | return []; 198 | } 199 | 200 | const result = getOwnPropertyNames(object); 201 | 202 | let prototype = object.constructor && object.constructor.prototype; 203 | while (prototype) { 204 | result.push(...getOwnPropertyNames(prototype)); 205 | prototype = getPrototypeOf(prototype); 206 | } 207 | 208 | return result; 209 | } 210 | -------------------------------------------------------------------------------- /src/undo_manager.js: -------------------------------------------------------------------------------- 1 | import { getAllPropertyNames, hasGetter } from './utils'; 2 | 3 | /** 4 | * UndoManager is a general-purpose recorder of operations for undo and redo. 5 | * 6 | * Registering an undo action is done by specifying the changed object, along 7 | * with a method to invoke to revert its state and the arguments for that 8 | * method. When performing undo an UndoManager saves the operations reverted so 9 | * that you can redo the undos. 10 | */ 11 | class UndoManager { 12 | constructor() { 13 | /** @private */ 14 | this._undos = []; 15 | /** @private */ 16 | this._redos = []; 17 | /** @private */ 18 | this._isUndoing = false; 19 | /** @private */ 20 | this._isRedoing = false; 21 | } 22 | 23 | /** 24 | * Determines whether there are any undo actions on the stack. 25 | * 26 | * @returns {boolean} 27 | */ 28 | canUndo() { 29 | return this._undos.length !== 0; 30 | } 31 | 32 | /** 33 | * Determines whether there are any redo actions on the stack. 34 | * 35 | * @returns {boolean} 36 | */ 37 | canRedo() { 38 | return this._redos.length !== 0; 39 | } 40 | 41 | /** 42 | * Indicates whether or not this manager is currently processing an undo. 43 | * 44 | * @returns {boolean} 45 | */ 46 | isUndoing() { 47 | return this._isUndoing; 48 | } 49 | 50 | /** 51 | * Indicates whether or not this manager is currently processing a redo. 52 | * 53 | * @returns {boolean} 54 | */ 55 | isRedoing() { 56 | return this._isRedoing; 57 | } 58 | 59 | /** 60 | * Manually registers an simple undo action with the given args. 61 | * 62 | * If this undo manager is currently undoing then this will register a redo 63 | * action instead. If this undo manager is neither undoing or redoing then the 64 | * redo stack will be cleared. 65 | * 66 | * @param {Object} target call `selector` on this object 67 | * @param {string} selector the method name to call on `target` 68 | * @param {...Object} args arguments to pass when calling `selector` on `target` 69 | */ 70 | registerUndo(target, selector, ...args) { 71 | if (this._isUndoing) { 72 | this._appendRedo(target, selector, ...args); 73 | } else { 74 | if (!this._isRedoing) { 75 | this._redos.length = 0; 76 | } 77 | this._appendUndo(target, selector, ...args); 78 | } 79 | } 80 | 81 | /** 82 | * Appends an undo action to the internal stack. 83 | * 84 | * @param {Object} target call `selector` on this object 85 | * @param {string} selector the method name to call on `target` 86 | * @param {...Object} args arguments to pass when calling `selector` on `target` 87 | * @private 88 | */ 89 | _appendUndo(target, selector, ...args) { 90 | this._undos.push({ 91 | target: target, 92 | selector: selector, 93 | args: args 94 | }); 95 | } 96 | 97 | /** 98 | * Appends a redo action to the internal stack. 99 | * 100 | * @param {Object} target call `selector` on this object 101 | * @param {string} selector the method name to call on `target` 102 | * @param {...Object} args arguments to pass when calling `selector` on `target` 103 | * @private 104 | */ 105 | _appendRedo(target, selector, ...args) { 106 | this._redos.push({ 107 | target: target, 108 | selector: selector, 109 | args: args 110 | }); 111 | } 112 | 113 | /** 114 | * Performs the top-most undo action on the stack. 115 | * 116 | * @throws {Error} Raises an error if there are no available undo actions. 117 | */ 118 | undo() { 119 | if (!this.canUndo()) { 120 | throw new Error('there are no registered undos'); 121 | } 122 | const data = this._undos.pop(); 123 | const target = data.target; 124 | const selector = data.selector; 125 | const args = data.args; 126 | this._isUndoing = true; 127 | target[selector].apply(target, args); 128 | this._isUndoing = false; 129 | } 130 | 131 | /** 132 | * Performs the top-most redo action on the stack. 133 | * 134 | * @throws {Error} Raises an error if there are no available redo actions. 135 | */ 136 | redo() { 137 | if (!this.canRedo()) { 138 | throw new Error('there are no registered redos'); 139 | } 140 | const data = this._redos.pop(); 141 | const target = data.target; 142 | const selector = data.selector; 143 | const args = data.args; 144 | this._isRedoing = true; 145 | target[selector].apply(target, args); 146 | this._isRedoing = false; 147 | } 148 | 149 | /** 150 | * Returns a proxy object based on target that will register undo/redo actions 151 | * by calling methods on the proxy. 152 | * 153 | * @example 154 | * setSize(size) { 155 | * this.undoManager.proxyFor(this).setSize(this._size); 156 | * this._size = size; 157 | * } 158 | * 159 | * @param {Object} target call `selector` on this object 160 | * @returns {Object} 161 | */ 162 | proxyFor(target) { 163 | const proxy = {}; 164 | const self = this; 165 | 166 | function proxyMethod(selector) { 167 | return function(...args) { 168 | self.registerUndo(target, selector, ...args); 169 | }; 170 | } 171 | 172 | getAllPropertyNames(target).forEach(selector => { 173 | // don't trigger anything that has a getter 174 | if (hasGetter(target, selector)) { return; } 175 | 176 | // don't try to proxy properties that aren't functions 177 | if (typeof target[selector] !== 'function') { return; } 178 | 179 | // set up a proxy function to register an undo 180 | proxy[selector] = proxyMethod(selector); 181 | }); 182 | 183 | return proxy; 184 | } 185 | } 186 | 187 | export default UndoManager; 188 | -------------------------------------------------------------------------------- /test/unit/helpers/expectations.js: -------------------------------------------------------------------------------- 1 | import './setup'; 2 | import Keysim from 'keysim'; 3 | import FakeEvent from './fake_event'; 4 | import Selection from './selection'; 5 | import TextField from '../../../src/text_field'; 6 | import { buildField } from './builders'; 7 | import installCaret from '../../../src/caret'; 8 | import {expect} from 'chai'; 9 | 10 | const { getCaret, setCaret } = installCaret(); 11 | 12 | class FieldExpectationBase { 13 | into(field) { 14 | this.field = field; 15 | return this; 16 | } 17 | 18 | withFormatter(formatter) { 19 | this.field.setFormatter(formatter); 20 | return this; 21 | } 22 | 23 | willChange(currentDescription) { 24 | this.currentDescription = currentDescription; 25 | return this; 26 | } 27 | 28 | willNotChange(currentDescription) { 29 | this.currentDescription = currentDescription; 30 | return this.to(currentDescription); 31 | } 32 | 33 | to(expectedDescription) { 34 | this.expectedDescription = expectedDescription; 35 | this.applyDescription(); 36 | this.proxyDelegate(); 37 | this.perform(); 38 | 39 | if (this.beforeAssert) { 40 | this.beforeAssert(); 41 | } 42 | 43 | this.assert(); 44 | return this; 45 | } 46 | 47 | before(fn) { 48 | this.beforeAssert = fn; 49 | return this; 50 | } 51 | 52 | withError(errorType) { 53 | expect(this.actualErrorType).to.equal(errorType); 54 | } 55 | 56 | onOSX() { 57 | return this.withUserAgent('Mozilla/5.0 (Macintosh Chrome'); 58 | } 59 | 60 | onWindows() { 61 | return this.withUserAgent('windows.chrome.latest'); 62 | } 63 | 64 | onAndroid() { 65 | return this.withUserAgent('android.chrome.latest'); 66 | } 67 | 68 | withUserAgent(userAgent) { 69 | this.userAgent = userAgent; 70 | return this; 71 | } 72 | 73 | applyDescription() { 74 | var description = Selection.parseDescription(this.currentDescription); 75 | var caret = description.caret; 76 | var affinity = description.affinity; 77 | var value = description.value; 78 | 79 | this.field.setText(value); 80 | 81 | setCaret(this.field.element, caret.start, caret.end); 82 | this.field.selectionAffinity = affinity; 83 | } 84 | 85 | proxyDelegate() { 86 | var currentDelegate = this.field.delegate(); 87 | this.field.setDelegate({ 88 | textFieldDidFailToValidateChange: (textField, change, errorType) => { 89 | this.actualErrorType = errorType; 90 | if (currentDelegate && typeof currentDelegate.textFieldDidFailToValidateChange === 'function') { 91 | currentDelegate.textFieldDidFailToValidateChange(change, errorType); 92 | } 93 | }, 94 | 95 | textFieldDidFailToParseString: (textField, change, errorType) => { 96 | this.actualErrorType = errorType; 97 | if (currentDelegate && typeof currentDelegate.textFieldDidFailToParseString === 'function') { 98 | currentDelegate.textFieldDidFailToParseString(change, errorType); 99 | } 100 | } 101 | }); 102 | } 103 | 104 | assert() { 105 | var actual = 106 | Selection.printDescription({ 107 | caret: getCaret(this.field.element), 108 | affinity: this.field.selectionAffinity, 109 | value: this.field.element.value 110 | }); 111 | 112 | if (this.expectedDescription.indexOf('|') < 0) { 113 | actual = actual.replace('|', ''); 114 | } 115 | 116 | expect(actual).to.equal(this.expectedDescription); 117 | } 118 | 119 | get field() { 120 | if (!this._field) { 121 | var options = {}; 122 | if (this.userAgent) { 123 | options['userAgent'] = this.userAgent; 124 | } 125 | this._field = buildField(TextField, options); 126 | } 127 | return this._field; 128 | } 129 | 130 | set field(field) { 131 | this._field = field; 132 | } 133 | } 134 | 135 | class ExpectThatTyping extends FieldExpectationBase { 136 | constructor(keys) { 137 | super(); 138 | this.keys = keys; 139 | } 140 | 141 | perform() { 142 | this.typeKeys(); 143 | } 144 | 145 | typeKeys() { 146 | var modifier = false; 147 | var keyboard = window.keyboard; 148 | var KEYS = [ 149 | 'zero', 150 | 'nine', 151 | 'left', 152 | 'right', 153 | 'up', 154 | 'down', 155 | 'backspace', 156 | 'delete', 157 | 'enter', 158 | 'tab', 159 | 'printable_start', 160 | 'printable_end', 161 | 'meta', 162 | 'alt', 163 | 'shift', 164 | 'ctrl' 165 | ]; 166 | 167 | this.keys.forEach((key) => { 168 | key.split(',').forEach((command) => { 169 | command.split('+').forEach((key) => { 170 | if(KEYS.indexOf(key) >= 0) { 171 | modifier = true; 172 | } 173 | }); 174 | 175 | if (modifier) { 176 | keyboard.dispatchEventsForAction(command, this.field.element); 177 | } else { 178 | keyboard.dispatchEventsForInput(command, this.field.element); 179 | } 180 | }); 181 | }); 182 | } 183 | } 184 | 185 | class ExpectThatPasting extends FieldExpectationBase { 186 | constructor(text) { 187 | super(); 188 | this.text = text; 189 | } 190 | 191 | perform() { 192 | this.paste(); 193 | } 194 | 195 | paste() { 196 | var event = FakeEvent.pasteEventWithData({Text: this.text}); 197 | this.field._paste(event); 198 | } 199 | } 200 | 201 | 202 | class ExpectThatLeaving extends FieldExpectationBase { 203 | constructor(field) { 204 | super(); 205 | this.field = field; 206 | } 207 | 208 | perform() { 209 | this.field.element.focus(); 210 | this.field.element.blur(); 211 | } 212 | } 213 | 214 | export function expectThatTyping(...keys) { 215 | return new ExpectThatTyping(keys); 216 | } 217 | 218 | export function expectThatPasting(text) { 219 | return new ExpectThatPasting(text); 220 | } 221 | 222 | export function expectThatLeaving(field) { 223 | return new ExpectThatLeaving(field); 224 | } 225 | -------------------------------------------------------------------------------- /test/unit/delimited_text_formatter_test.js: -------------------------------------------------------------------------------- 1 | import { expectThatTyping, expectThatPasting } from './helpers/expectations'; 2 | import { buildField } from './helpers/builders'; 3 | import FieldKit from '../../src'; 4 | import {expect} from 'chai'; 5 | 6 | function LeadingDelimiterFormatter() { 7 | FieldKit.DelimitedTextFormatter.apply(this, arguments); 8 | } 9 | LeadingDelimiterFormatter.prototype = Object.create(FieldKit.DelimitedTextFormatter.prototype); 10 | LeadingDelimiterFormatter.prototype.hasDelimiterAtIndex = function(index) { 11 | return index % 4 === 0; // 0, 4, 8, 12, … 12 | }; 13 | 14 | function ConsecutiveDelimiterFormatter() { 15 | FieldKit.DelimitedTextFormatter.apply(this, arguments); 16 | } 17 | ConsecutiveDelimiterFormatter.prototype = Object.create(FieldKit.DelimitedTextFormatter.prototype); 18 | ConsecutiveDelimiterFormatter.prototype.hasDelimiterAtIndex = function(index) { 19 | return index === 0 || index === 2 || index === 3 || index === 6 || index === 7; 20 | }; 21 | 22 | function LazyDelimiterFormatter() { 23 | FieldKit.DelimitedTextFormatter.apply(this, arguments); 24 | } 25 | LazyDelimiterFormatter.prototype = Object.create(FieldKit.DelimitedTextFormatter.prototype); 26 | LazyDelimiterFormatter.prototype.hasDelimiterAtIndex = function(index) { 27 | return index === 2 || index === 3; 28 | }; 29 | 30 | testsWithAllKeyboards('LeadingDelimiterFormatter', function() { 31 | var field; 32 | 33 | beforeEach(function() { 34 | field = buildField(); 35 | field.setFormatter(new LeadingDelimiterFormatter('-')); 36 | }); 37 | 38 | context('with a maximum length', function() { 39 | beforeEach(function() { 40 | field.formatter().maximumLength = 4; 41 | }); 42 | 43 | it('truncates values to the maximum length', function() { 44 | expect(field.formatter().format('21111')).to.equal('-211'); 45 | }); 46 | }); 47 | 48 | it('adds a delimiter before the first character', function() { 49 | expectThatTyping('1').into(field).willChange('|').to('-1|'); 50 | expectThatTyping('1').into(field).willChange('-2|').to('-21|'); 51 | }); 52 | 53 | it('backspaces both the character leading delimiter', function() { 54 | expectThatTyping('backspace').into(field).willChange('-1|').to('|'); 55 | }); 56 | 57 | it('adds a delimiter wherever they need to be', function() { 58 | expectThatTyping('1').into(field).willChange('-41|').to('-411-|'); 59 | }); 60 | 61 | it('groups digits into four groups of four separated by spaces', function() { 62 | expectThatTyping('abcdef').into(field).willChange('|').to('-abc-def-|'); 63 | }); 64 | 65 | it('backspaces both the space and the character before it', function() { 66 | expectThatTyping('backspace').into(field).willChange('-411-|').to('-41|'); 67 | expectThatTyping('backspace').into(field).willChange('-123-|4').to('-12|4-'); 68 | }); 69 | 70 | it('allows backspacing a whole group of digits', function() { 71 | expectThatTyping('alt+backspace').into(field).willChange('-411-111-|').to('-411-|'); 72 | expectThatTyping('alt+backspace').into(field).willChange('-411-1|1').to('-411-|1'); 73 | }); 74 | 75 | it('allows moving left over a delimiter', function() { 76 | expectThatTyping('left').into(field).willChange('-411-|').to('-41|1-'); 77 | }); 78 | 79 | it('selects not including delimiters if possible', function() { 80 | expectThatTyping('shift+left').into(field).willChange('-411-1<1|').to('-411-<11|'); 81 | expectThatTyping('shift+right').into(field).willChange('-4|1>1-11').to('-4|11>-11'); 82 | expectThatTyping('shift+right').into(field).willChange('-4|11>-11').to('-4|11-1>1'); 83 | }); 84 | 85 | it('selects past delimiters as if they are not there', function() { 86 | expectThatTyping('shift+left').into(field).willChange('-411-|1').to('-41<1|-1'); 87 | expectThatTyping('shift+right').into(field).willChange('-411|-1').to('-411-|1>'); 88 | expectThatTyping('shift+left').into(field).willChange('-411-<1|1').to('-41<1-1|1'); 89 | }); 90 | 91 | it('deselects past delimiters as if they are not there', function() { 92 | expectThatTyping('shift+right').into(field).willChange('-411-<1|1').to('-411-1|1'); 93 | }); 94 | 95 | it('prevents entering the delimiter character', function() { 96 | expectThatTyping('-').into(field).willNotChange('-123-456-|'); 97 | }); 98 | 99 | it('positions the caret correctly after pastes', function() { 100 | expectThatPasting('1234567890').into(field).willChange('|').to('-123-456-789-0|'); 101 | expectThatPasting('456').into(field).willChange('-123-|789-0').to('-123-456-|789-0'); 102 | }); 103 | }); 104 | 105 | describe('ConsecutiveDelimiterFormatter', function() { 106 | var field; 107 | 108 | beforeEach(function() { 109 | field = buildField(); 110 | field.setFormatter(new ConsecutiveDelimiterFormatter('-')); 111 | }); 112 | 113 | it('adds consecutive delimiters where needed', function() { 114 | expectThatTyping('3').into(field).willChange('|').to('-3--|'); 115 | expectThatTyping('3').into(field).willChange('-1--2|').to('-1--23--|'); 116 | }); 117 | 118 | it('backspaces character and all consecutive delimiters', function() { 119 | expectThatTyping('backspace').into(field).willChange('-3--|').to('|'); 120 | expectThatTyping('backspace').into(field).willChange('-1--23--|').to('-1--2|'); 121 | }); 122 | }); 123 | 124 | describe('LazyDelimiterFormatter', function() { 125 | var field; 126 | 127 | beforeEach(function() { 128 | field = buildField(); 129 | field.setFormatter(new LazyDelimiterFormatter('-', true)); 130 | }); 131 | 132 | it('adds delimiters at the end only when typing past delimiter', function() { 133 | expectThatTyping('2').into(field).willChange('1|').to('12|'); 134 | expectThatTyping('3').into(field).willChange('12|').to('12--3|'); 135 | }); 136 | 137 | it('backspaces delimiters before character', function() { 138 | expectThatTyping('backspace').into(field).willChange('12--34|').to('12--3|'); 139 | expectThatTyping('backspace').into(field).willChange('12--3|').to('12|'); 140 | }); 141 | 142 | }); 143 | -------------------------------------------------------------------------------- /test/selenium/delimited_text_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function() { 10 | test.describe('FieldKit.DelimiterFormatter', function() { 11 | var leadingInput, consecutiveInput, lazyInput; 12 | test.beforeEach(function() { 13 | server.goTo('delimited_text_formatter.html'); 14 | leadingInput = driver.findElement(By.id('leading-input')); 15 | consecutiveInput = driver.findElement(By.id('consecutive-input')); 16 | lazyInput = driver.findElement(By.id('lazy-input')); 17 | }); 18 | 19 | describe('leading delimiter', function() { 20 | test.it('backspaces both the character leading delimiter', function() { 21 | helpers.setInput('-1|', leadingInput); 22 | 23 | leadingInput.sendKeys(Key.BACK_SPACE); 24 | 25 | return helpers.getFieldKitValues('leadingField') 26 | .then(function(values) { 27 | expect(values.raw).to.equal(''); 28 | }); 29 | }); 30 | 31 | test.it('adds a delimiter wherever they need to be', function() { 32 | helpers.setInput('-41|', leadingInput); 33 | 34 | leadingInput.sendKeys('1'); 35 | 36 | return helpers.getFieldKitValues('leadingField') 37 | .then(function(values) { 38 | expect(values.raw).to.equal('-411-'); 39 | }); 40 | }); 41 | 42 | describe('backspaces both the space and the character before it', function() { 43 | test.it('cursor at the end', function() { 44 | helpers.setInput('-411-|', leadingInput); 45 | 46 | leadingInput.sendKeys(Key.BACK_SPACE); 47 | 48 | return helpers.getFieldKitValues('leadingField') 49 | .then(function(values) { 50 | expect(values.raw).to.equal('-41'); 51 | }); 52 | }); 53 | 54 | test.it('nummber at the end', function() { 55 | helpers.setInput('-123-|4', leadingInput); 56 | 57 | leadingInput.sendKeys(Key.BACK_SPACE); 58 | 59 | return helpers.getFieldKitValues('leadingField') 60 | .then(function(values) { 61 | expect(values.raw).to.equal('-124-'); 62 | }); 63 | }); 64 | }); 65 | 66 | describe('allows backspacing a whole group of digits', function() { 67 | test.it('cursor at the end', function() { 68 | helpers.setInput('-411-111-|', leadingInput); 69 | 70 | leadingInput.sendKeys(Key.chord(Key.ALT, Key.BACK_SPACE)); 71 | 72 | return helpers.getFieldKitValues('leadingField') 73 | .then(function(values) { 74 | expect(values.raw).to.equal('-411-'); 75 | }); 76 | }); 77 | 78 | test.it('number at the end', function() { 79 | helpers.setInput('-411-1|1', leadingInput); 80 | 81 | leadingInput.sendKeys(Key.chord(Key.ALT, Key.BACK_SPACE)); 82 | 83 | return helpers.getFieldKitValues('leadingField') 84 | .then(function(values) { 85 | expect(values.raw).to.equal('-411-1'); 86 | }); 87 | }); 88 | }); 89 | 90 | it('prevents entering the delimiter character', function() { 91 | helpers.setInput('-123-456|', leadingInput); 92 | 93 | leadingInput.sendKeys('-'); 94 | 95 | return helpers.getFieldKitValues('leadingField') 96 | .then(function(values) { 97 | expect(values.raw).to.equal('-123-456-'); 98 | }); 99 | }); 100 | }); 101 | 102 | 103 | describe('consecutive delimiter', function() { 104 | describe('adds consecutive delimiters where needed', function() { 105 | test.it('nothing to start with', function() { 106 | helpers.setInput('|', consecutiveInput); 107 | 108 | consecutiveInput.sendKeys('3'); 109 | 110 | return helpers.getFieldKitValues('consecutiveField') 111 | .then(function(values) { 112 | expect(values.raw).to.equal('-3--'); 113 | }); 114 | }); 115 | 116 | test.it('something to start with', function() { 117 | helpers.setInput('-1--2|', consecutiveInput); 118 | 119 | consecutiveInput.sendKeys('3'); 120 | 121 | return helpers.getFieldKitValues('consecutiveField') 122 | .then(function(values) { 123 | expect(values.raw).to.equal('-1--23--'); 124 | }); 125 | }); 126 | }); 127 | 128 | describe('backspaces character and all consecutive delimiters', function() { 129 | test.it('delete first set of consecutive delimiters', function() { 130 | helpers.setInput('-3--|', consecutiveInput); 131 | 132 | consecutiveInput.sendKeys(Key.BACK_SPACE); 133 | 134 | return helpers.getFieldKitValues('consecutiveField') 135 | .then(function(values) { 136 | expect(values.raw).to.equal(''); 137 | }); 138 | }); 139 | 140 | test.it('delete second set of consecutive delimiters', function() { 141 | helpers.setInput('-1--23--|', consecutiveInput); 142 | 143 | consecutiveInput.sendKeys(Key.BACK_SPACE); 144 | 145 | return helpers.getFieldKitValues('consecutiveField') 146 | .then(function(values) { 147 | expect(values.raw).to.equal('-1--2'); 148 | }); 149 | }); 150 | }); 151 | }); 152 | 153 | describe('lazy delimiter', function() { 154 | describe('adds delimiters at the end only when typing past delimiter', function() { 155 | test.it('no delimiter', function() { 156 | helpers.setInput('1|', lazyInput); 157 | 158 | lazyInput.sendKeys('2'); 159 | 160 | return helpers.getFieldKitValues('lazyField') 161 | .then(function(values) { 162 | expect(values.raw).to.equal('12'); 163 | }); 164 | }); 165 | 166 | test.it('with delimiter', function() { 167 | helpers.setInput('12|', lazyInput); 168 | 169 | lazyInput.sendKeys('3'); 170 | 171 | return helpers.getFieldKitValues('lazyField') 172 | .then(function(values) { 173 | expect(values.raw).to.equal('12--3'); 174 | }); 175 | }); 176 | }); 177 | }); 178 | }); 179 | }; 180 | -------------------------------------------------------------------------------- /test/selenium/expiry_date_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function(ua) { 10 | test.describe('FieldKit.ExpiryDateFormatter', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('expiry_date_formatter.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | test.it('adds a preceding zero and a succeeding slash if an unambiguous month is typed', function() { 18 | helpers.setInput('|', input); 19 | 20 | input.sendKeys('4'); 21 | 22 | return helpers.getFieldKitValues() 23 | .then(function(values) { 24 | expect(values.raw).to.equal('04/'); 25 | }); 26 | }); 27 | 28 | test.it('does not add anything when the typed first character is an ambiguous month', function() { 29 | helpers.setInput('|', input); 30 | 31 | input.sendKeys('1'); 32 | 33 | return helpers.getFieldKitValues() 34 | .then(function(values) { 35 | expect(values.raw).to.equal('1'); 36 | }); 37 | }); 38 | 39 | test.it('adds a slash after 10 is typed', function() { 40 | helpers.setInput('1|', input); 41 | 42 | input.sendKeys('0'); 43 | 44 | return helpers.getFieldKitValues() 45 | .then(function(values) { 46 | expect(values.raw).to.equal('10/'); 47 | }); 48 | }); 49 | 50 | test.it('adds a slash after 07 is typed', function() { 51 | helpers.setInput('0|', input); 52 | 53 | input.sendKeys('7'); 54 | 55 | return helpers.getFieldKitValues() 56 | .then(function(values) { 57 | expect(values.raw).to.equal('07/'); 58 | }); 59 | }); 60 | 61 | test.it('adds a preceding zero when a slash is typed after an ambiguous month', function() { 62 | helpers.setInput('1|', input); 63 | 64 | input.sendKeys('/'); 65 | 66 | return helpers.getFieldKitValues() 67 | .then(function(values) { 68 | expect(values.raw).to.equal('01/'); 69 | }); 70 | }); 71 | 72 | test.it('prevents 00 as a month', function() { 73 | helpers.setInput('0|', input); 74 | 75 | input.sendKeys('0'); 76 | 77 | return helpers.getFieldKitValues() 78 | .then(function(values) { 79 | expect(values.raw).to.equal('0'); 80 | }); 81 | }); 82 | 83 | test.it('prevents entry of an additional slash', function() { 84 | helpers.setInput('11/|', input); 85 | 86 | input.sendKeys('/'); 87 | 88 | return helpers.getFieldKitValues() 89 | .then(function(values) { 90 | expect(values.raw).to.equal('11/'); 91 | }); 92 | }); 93 | 94 | test.it('allows any two digits for the year', function() { 95 | helpers.setInput('11/|', input); 96 | 97 | input.sendKeys('098'); 98 | 99 | return helpers.getFieldKitValues() 100 | .then(function(values) { 101 | expect(values.raw).to.equal('11/09'); 102 | }); 103 | }); 104 | 105 | test.it('allows backspacing ignoring the slash', function() { 106 | helpers.setInput('11/|', input); 107 | 108 | input.sendKeys(Key.BACK_SPACE); 109 | 110 | return helpers.getFieldKitValues() 111 | .then(function(values) { 112 | expect(values.raw).to.equal('1'); 113 | }); 114 | }); 115 | 116 | test.it('allows backspacing words to delete just the year', function() { 117 | helpers.setInput('11/14|', input); 118 | 119 | input.sendKeys(Key.chord(Key.ALT, Key.BACK_SPACE)); 120 | 121 | return helpers.getFieldKitValues() 122 | .then(function(values) { 123 | expect(values.raw).to.equal('11/'); 124 | }); 125 | }); 126 | 127 | test.it('backspaces to the beginning if the last character after backspacing is 0', function() { 128 | helpers.setInput('01/|', input); 129 | 130 | input.sendKeys(Key.BACK_SPACE); 131 | 132 | return helpers.getFieldKitValues() 133 | .then(function(values) { 134 | expect(values.raw).to.equal(''); 135 | }); 136 | }); 137 | 138 | test.it('allows typing a character matching the suffix that hits the end of the allowed input', function() { 139 | helpers.setInput('12/1|', input); 140 | 141 | input.sendKeys('1'); 142 | 143 | return helpers.getFieldKitValues() 144 | .then(function(values) { 145 | expect(values.raw).to.equal('12/11'); 146 | }); 147 | }); 148 | 149 | test.it('selecting all and typing a non number', function() { 150 | helpers.setInput('12/11|', input); 151 | return helpers.runJSMethod('element.setSelectionRange(0, 9999);') 152 | .then(function() { 153 | input.sendKeys('f'); 154 | 155 | return helpers.getFieldKitValues() 156 | .then(function(values) { 157 | expect(values.raw).to.equal('12/11'); 158 | }); 159 | }); 160 | }); 161 | 162 | test.it('typing a 1 then starting year should add the prefixing 0', function() { 163 | helpers.setInput('1|', input); 164 | input.sendKeys('4'); 165 | 166 | return helpers.getFieldKitValues() 167 | .then(function(values) { 168 | expect(values.raw).to.equal('01/4'); 169 | }); 170 | }); 171 | 172 | if(ua === 'DEFAULT') { 173 | test.it('prevents entry of an invalid two-digit month', function() { 174 | helpers.setInput('1|1|/4', input); 175 | var element = 'window.testField.element'; 176 | 177 | return driver.executeScript(element + '.selectionStart = 1; ' + element + '.selectionEnd = 2;') 178 | .then(function() { 179 | input.sendKeys('3'); 180 | 181 | return helpers.getFieldKitValues() 182 | .then(function(values) { 183 | expect(values.raw).to.equal('11/4'); 184 | }); 185 | }); 186 | }); 187 | 188 | test.it('full selecting and typing', function() { 189 | helpers.setInput('12/12', input); 190 | var element = 'window.testField.element'; 191 | 192 | return driver.executeScript(element + '.selectionStart = 0; ' + element + '.selectionEnd = 7;') 193 | .then(function() { 194 | input.sendKeys('444'); 195 | 196 | return helpers.getFieldKitValues() 197 | .then(function(values) { 198 | expect(values.raw).to.equal('04/44'); 199 | }); 200 | }); 201 | }); 202 | } 203 | }); 204 | }; 205 | -------------------------------------------------------------------------------- /test/selenium/phone_formatter_test.js: -------------------------------------------------------------------------------- 1 | var By = require('selenium-webdriver').By; 2 | var Key = require('selenium-webdriver').Key; 3 | var until = require('selenium-webdriver').until; 4 | var test = require('selenium-webdriver/testing'); 5 | var expect = require('chai').expect; 6 | var server = require('./server'); 7 | var helpers = require('./helpers'); 8 | 9 | module.exports = function(ua) { 10 | test.describe('FieldKit.PhoneFormatter', function() { 11 | var input; 12 | test.beforeEach(function() { 13 | server.goTo('phone_formatter.html'); 14 | input = driver.findElement(By.tagName('input')); 15 | }); 16 | 17 | describe('adds a ( before the first digit', function() { 18 | test.it('with nothing to start', function() { 19 | helpers.setInput('|', input); 20 | 21 | input.sendKeys('4'); 22 | 23 | return helpers.getFieldKitValues() 24 | .then(function(values) { 25 | expect(values.raw).to.equal('(4'); 26 | }); 27 | }); 28 | 29 | test.it('with something to start', function() { 30 | helpers.setInput('(4|', input); 31 | 32 | input.sendKeys('1'); 33 | 34 | return helpers.getFieldKitValues() 35 | .then(function(values) { 36 | expect(values.raw).to.equal('(41'); 37 | }); 38 | }); 39 | }); 40 | 41 | test.it('backspaces both the digit leading delimiter', function() { 42 | helpers.setInput('(4|', input); 43 | 44 | input.sendKeys(Key.BACK_SPACE); 45 | 46 | return helpers.getFieldKitValues() 47 | .then(function(values) { 48 | expect(values.raw).to.equal(''); 49 | }); 50 | }); 51 | 52 | test.it('adds a delimiter wherever they need to be', function() { 53 | helpers.setInput('(41|', input); 54 | 55 | input.sendKeys('5'); 56 | 57 | return helpers.getFieldKitValues() 58 | .then(function(values) { 59 | expect(values.raw).to.equal('(415) '); 60 | }); 61 | }); 62 | 63 | test.it('groups digits into four groups of four separated by spaces', function() { 64 | helpers.setInput('|', input); 65 | 66 | input.sendKeys('415555'); 67 | 68 | return helpers.getFieldKitValues() 69 | .then(function(values) { 70 | expect(values.raw).to.equal('(415) 555-'); 71 | }); 72 | }); 73 | 74 | describe('backspaces all delimiters and the character before it', function() { 75 | test.it('at the end', function() { 76 | helpers.setInput('(415) |', input); 77 | 78 | input.sendKeys(Key.BACK_SPACE); 79 | 80 | return helpers.getFieldKitValues() 81 | .then(function(values) { 82 | expect(values.raw).to.equal('(41'); 83 | }); 84 | }); 85 | 86 | test.it('in the middle', function() { 87 | helpers.setInput('(213) |4', input); 88 | 89 | input.sendKeys(Key.BACK_SPACE); 90 | 91 | return helpers.getFieldKitValues() 92 | .then(function(values) { 93 | expect(values.raw).to.equal('(214) '); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('allows backspacing a whole group of digits', function() { 99 | test.it('at the end', function() { 100 | helpers.setInput('(411) 111-|', input); 101 | 102 | input.sendKeys(Key.chord(Key.ALT, Key.BACK_SPACE)); 103 | 104 | return helpers.getFieldKitValues() 105 | .then(function(values) { 106 | expect(values.raw).to.equal('(411) '); 107 | }); 108 | }); 109 | 110 | test.it('digit at the end', function() { 111 | helpers.setInput('(411) 1|1', input); 112 | 113 | input.sendKeys(Key.chord(Key.ALT, Key.BACK_SPACE)); 114 | 115 | return helpers.getFieldKitValues() 116 | .then(function(values) { 117 | expect(values.raw).to.equal('(411) 1'); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('prevents entering the delimiter character', function() { 123 | test.it('typing (', function() { 124 | helpers.setInput('(', input); 125 | 126 | return helpers.getFieldKitValues() 127 | .then(function(values) { 128 | expect(values.raw).to.equal(''); 129 | }); 130 | }); 131 | 132 | test.it('typing space', function() { 133 | helpers.setInput('(213) |', input); 134 | 135 | return helpers.getFieldKitValues() 136 | .then(function(values) { 137 | expect(values.raw).to.equal('(213) '); 138 | }); 139 | }); 140 | 141 | test.it('typing -', function() { 142 | helpers.setInput('(213) 456-|', input); 143 | 144 | return helpers.getFieldKitValues() 145 | .then(function(values) { 146 | expect(values.raw).to.equal('(213) 456-'); 147 | }); 148 | }); 149 | }); 150 | 151 | if(ua === 'DEFAULT') { 152 | describe('deletes the correct character', function() { 153 | test.it('deleting the country code', function() { 154 | helpers.setInput('+1 (234) 567-8910', input); 155 | var element = 'window.testField.element'; 156 | 157 | return driver.executeScript('return ' + element + '.selectionStart = ' + element + '.selectionEnd = 4') 158 | .then(function() { 159 | input.sendKeys(Key.BACK_SPACE); 160 | 161 | return helpers.getFieldKitValues() 162 | .then(function(values) { 163 | expect(values.raw).to.equal('+234 (567) 891-0'); 164 | }); 165 | }); 166 | }); 167 | 168 | test.it('deleting the country code', function() { 169 | helpers.setInput('1 (888) 888-8888', input); 170 | var element = 'window.testField.element'; 171 | 172 | return driver.executeScript('return ' + element + '.selectionStart = ' + element + '.selectionEnd = 1') 173 | .then(function() { 174 | input.sendKeys(Key.BACK_SPACE); 175 | 176 | return helpers.getFieldKitValues() 177 | .then(function(values) { 178 | expect(values.raw).to.equal('(888) 888-8888'); 179 | }); 180 | }); 181 | }); 182 | 183 | test.it('deleting the space after the country code', function() { 184 | helpers.setInput('1 (888) 888-8888', input); 185 | var element = 'window.testField.element'; 186 | 187 | return driver.executeScript('return ' + element + '.selectionStart = ' + element + '.selectionEnd = 2') 188 | .then(function() { 189 | input.sendKeys(Key.BACK_SPACE); 190 | 191 | return helpers.getFieldKitValues() 192 | .then(function(values) { 193 | expect(values.raw).to.equal('(888) 888-8888'); 194 | }); 195 | }); 196 | }); 197 | }); 198 | } 199 | }); 200 | }; 201 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | --- 2 | title: FieldKit 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{ page.title }} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 26 |

{{ page.title }}

27 | 28 |
29 |

FieldKit provides real-time, text field formatting as users type. It simplifies input formatting and creates a more polished experience for users, while outputting standardized data.

30 |
31 | 32 |

Demos

33 | 34 |
35 |

Credit Card Fields

36 | 37 |

38 | The CardTextField and AdaptiveCardFormatter provide formatting for credit card PANs, preventing the input of non-digit characters and automatically breaking into digit groups according to the rules for the entered card type. 39 |

40 | 41 |
42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 |
“411111111”“4111 1111 1” (Visa)
“3725123456”“3725 123456” (Amex)
“4111” + backspace“411”
58 | 59 | {% highlight html %} 60 | 61 |
Card type: Unknown
62 | {% endhighlight %} 63 | 64 | {% highlight javascript %} 65 | var field = new FieldKit.CardTextField(document.getElementById('card-number')); 66 | field.setCardMaskStrategy(FieldKit.CardTextField.CardMaskStrategy.DoneEditing); 67 | field.setDelegate({ 68 | textDidChange: function() { 69 | var cardTypeString = field.cardType() || 'unknown'; 70 | document.getElementById('card-type-span').innerHTML = cardTypeString; 71 | } 72 | }); 73 | {% endhighlight %} 74 |
75 |
76 | 77 |
78 |

NumberFormatter

79 | 80 |

81 | The NumberFormatter provides formatting for all kinds of numbers, percentages, currencies, etc. 82 |

83 | 84 |
85 | {% highlight html %} 86 | 87 | {% endhighlight %} 88 | 89 | {% highlight javascript %} 90 | var field = new FieldKit.TextField(document.getElementById('number')); 91 | field.setFormatter(new FieldKit.NumberFormatter() 92 | .setNumberStyle(Math.random() < 0.5 ? 93 | FieldKit.NumberFormatter.Style.PERCENT : 94 | FieldKit.NumberFormatter.Style.CURRENCY)); 95 | field.setValue((Math.random() < 0.5 ? 1 : -1) * Math.random() * 10); 96 | {% endhighlight %} 97 |
98 |
99 | 100 |
101 |

ExpiryDateField / ExpiryDateFormatter

102 | 103 |

104 | The ExpiryDateField wraps ExpiryDateFormatter for credit card expiry 105 | dates, preventing the input of nonsense months or non-date characters. It also adds the preceding zero and the slash between the month and year automatically, but treats them as if they are not 106 | there. In order to do this predictably it forces the cursor to remain at the end of the field and prevents any kind of selection. 107 |

108 | 109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |
“4”“04/”
“1212”“12/12”
“12/” + backspace“1”
126 | 127 | {% highlight html %} 128 | 129 | {% endhighlight %} 130 | 131 | {% highlight javascript %} 132 | new FieldKit.ExpiryDateField(document.getElementById('expiry')); 133 | {% endhighlight %} 134 |
135 |
136 | 137 |
138 |

SocialSecurityNumberFormatter

139 | 140 |

141 | The SocialSecurityNumberFormatter provides formatting for US Social Security numbers, preventing the input of non-digit characters and automatically breaking into digit groups like NNN-NN-NNNN. 142 |

143 | 144 |
145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 |
“1234”“123-4”
“123-45-” + backspace“123-4”
157 | 158 | {% highlight html %} 159 | 160 | {% endhighlight %} 161 | 162 | {% highlight javascript %} 163 | var ssnFormatter = new FieldKit.SocialSecurityNumberFormatter(); 164 | new FieldKit.TextField(document.getElementById('ssn'), ssnFormatter); 165 | {% endhighlight %} 166 |
167 |
168 | 169 |
170 |

PhoneFormatter

171 | 172 |

173 | The PhoneFormatter provides formatting for phone numbers, preventing the input of non-digit characters and automatically formatting like (NNN) NNN-NNNN. 174 |

175 | 176 |
177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 |
“4155”“(415) 5”
185 | 186 | {% highlight html %} 187 | 188 | {% endhighlight %} 189 | 190 | {% highlight javascript %} 191 | new FieldKit.TextField(document.getElementById('phone'), new FieldKit.PhoneFormatter()); 192 | {% endhighlight %} 193 |
194 |
195 | 196 | 197 | Fork me on GitHub 198 | 199 | 200 | 201 | 202 | 203 | 204 | 218 | 219 | 220 | -------------------------------------------------------------------------------- /src/delimited_text_formatter.js: -------------------------------------------------------------------------------- 1 | import Formatter from './formatter'; 2 | 3 | /** 4 | * A generic delimited formatter. 5 | * 6 | * @extends Formatter 7 | */ 8 | class DelimitedTextFormatter extends Formatter { 9 | /** 10 | * @param {string=} delimiter 11 | * @param {boolean=} isLazy 12 | * @throws {Error} delimiter must have just one character 13 | */ 14 | constructor(delimiter, isLazy=false) { 15 | super(); 16 | 17 | if (arguments.length === 0) { 18 | return; 19 | } 20 | 21 | if (delimiter === null || delimiter === undefined || delimiter.length !== 1) { 22 | throw new Error('delimiter must have just one character'); 23 | } 24 | this.delimiter = delimiter; 25 | 26 | // If the formatter is lazy, delimiter will not be added until input has gone 27 | // past the delimiter index. Useful for 'optional' extension, like zip codes. 28 | // 94103 -> type '1' -> 94103-1 29 | this.isLazy = isLazy; 30 | } 31 | 32 | /** 33 | * Determines the delimiter character at the given index. 34 | * 35 | * @param {number} index 36 | * @returns {?string} 37 | */ 38 | delimiterAt(index) { 39 | if (!this.hasDelimiterAtIndex(index)) { 40 | return null; 41 | } 42 | return this.delimiter; 43 | } 44 | 45 | /** 46 | * Determines whether the given character is a delimiter. 47 | * 48 | * @param {string} chr 49 | * @returns {boolean} 50 | */ 51 | isDelimiter(chr) { 52 | return chr === this.delimiter; 53 | } 54 | 55 | /** 56 | * Formats the given value by adding delimiters where needed. 57 | * 58 | * @param {?string} value 59 | * @returns {string} 60 | */ 61 | format(value) { 62 | return this._textFromValue(value); 63 | } 64 | 65 | /** 66 | * Formats the given value by adding delimiters where needed. 67 | * 68 | * @param {?string} value 69 | * @returns {string} 70 | * @private 71 | */ 72 | _textFromValue(value) { 73 | if (!value) { return ''; } 74 | 75 | let result = ''; 76 | let delimiter; 77 | let maximumLength = this.maximumLength; 78 | 79 | for (let i = 0, l = value.length; i < l; i++) { 80 | while ((delimiter = this.delimiterAt(result.length))) { 81 | result += delimiter; 82 | } 83 | result += value[i]; 84 | if (!this.isLazy) { 85 | while ((delimiter = this.delimiterAt(result.length))) { 86 | result += delimiter; 87 | } 88 | } 89 | } 90 | 91 | if (maximumLength !== undefined && maximumLength !== null) { 92 | return result.slice(0, maximumLength); 93 | } else { 94 | return result; 95 | } 96 | } 97 | 98 | /** 99 | * Parses the given text by removing delimiters. 100 | * 101 | * @param {?string} text 102 | * @returns {string} 103 | */ 104 | parse(text) { 105 | return this._valueFromText(text); 106 | } 107 | 108 | /** 109 | * Parses the given text by removing delimiters. 110 | * 111 | * @param {?string} text 112 | * @returns {string} 113 | * @private 114 | */ 115 | _valueFromText(text) { 116 | if (!text) { return ''; } 117 | let result = ''; 118 | for (let i = 0, l = text.length; i < l; i++) { 119 | if (!this.isDelimiter(text[i])) { 120 | result += text[i]; 121 | } 122 | } 123 | return result; 124 | } 125 | 126 | /** 127 | * Determines whether the given change should be allowed and, if so, whether 128 | * it should be altered. 129 | * 130 | * @param {TextFieldStateChange} change 131 | * @param {function(string)} error 132 | * @returns {boolean} 133 | */ 134 | isChangeValid(change, error) { 135 | if (!super.isChangeValid(change, error)) { 136 | return false; 137 | } 138 | 139 | let newText = change.proposed.text; 140 | let range = change.proposed.selectedRange; 141 | const hasSelection = range.length !== 0; 142 | 143 | const startMovedLeft = range.start < change.current.selectedRange.start; 144 | const startMovedRight = range.start > change.current.selectedRange.start; 145 | const endMovedLeft = (range.start + range.length) < (change.current.selectedRange.start + change.current.selectedRange.length); 146 | const endMovedRight = (range.start + range.length) > (change.current.selectedRange.start + change.current.selectedRange.length); 147 | 148 | const startMovedOverADelimiter = startMovedLeft && this.hasDelimiterAtIndex(range.start) || 149 | startMovedRight && this.hasDelimiterAtIndex(range.start - 1); 150 | const endMovedOverADelimiter = endMovedLeft && this.hasDelimiterAtIndex(range.start + range.length) || 151 | endMovedRight && this.hasDelimiterAtIndex(range.start + range.length - 1); 152 | 153 | if (this.isDelimiter(change.deleted.text)) { 154 | let newCursorPosition = change.deleted.start - 1; 155 | // delete any immediately preceding delimiters 156 | while (this.isDelimiter(newText.charAt(newCursorPosition))) { 157 | newText = newText.substring(0, newCursorPosition) + newText.substring(newCursorPosition + 1); 158 | newCursorPosition--; 159 | } 160 | // finally delete the real character that was intended 161 | newText = newText.substring(0, newCursorPosition) + newText.substring(newCursorPosition + 1); 162 | } 163 | 164 | // adjust the cursor / selection 165 | if (startMovedLeft && startMovedOverADelimiter) { 166 | // move left over any immediately preceding delimiters 167 | while (this.delimiterAt(range.start - 1)) { 168 | range.start--; 169 | range.length++; 170 | } 171 | // finally move left over the real intended character 172 | range.start--; 173 | range.length++; 174 | } 175 | 176 | if (startMovedRight) { 177 | // move right over any delimiters found on the way, including any leading delimiters 178 | for (let i = change.current.selectedRange.start; i < range.start + range.length; i++) { 179 | if (this.delimiterAt(i)) { 180 | range.start++; 181 | if(range.length > 0) { 182 | range.length--; 183 | } 184 | } 185 | } 186 | 187 | while (this.delimiterAt(range.start)) { 188 | range.start++; 189 | range.length--; 190 | } 191 | } 192 | 193 | if (hasSelection) { // Otherwise, the logic for the range start takes care of everything. 194 | if (endMovedOverADelimiter) { 195 | if (endMovedLeft) { 196 | // move left over any immediately preceding delimiters 197 | while (this.delimiterAt(range.start + range.length - 1)) { 198 | range.length--; 199 | } 200 | // finally move left over the real intended character 201 | range.length--; 202 | } 203 | 204 | if (endMovedRight) { 205 | // move right over any immediately following delimiters 206 | while (this.delimiterAt(range.start + range.length)) { 207 | range.length++; 208 | } 209 | // finally move right over the real intended character 210 | range.length++; 211 | } 212 | } 213 | 214 | // trailing delimiters in the selection 215 | while (this.hasDelimiterAtIndex(range.start + range.length - 1)) { 216 | if (startMovedLeft || endMovedLeft) { 217 | range.length--; 218 | } else { 219 | range.length++; 220 | } 221 | } 222 | 223 | while (this.hasDelimiterAtIndex(range.start)) { 224 | if (startMovedRight || endMovedRight) { 225 | range.start++; 226 | range.length--; 227 | } else { 228 | range.start--; 229 | range.length++; 230 | } 231 | } 232 | } else { 233 | range.length = 0; 234 | } 235 | 236 | let result = true; 237 | 238 | const value = this._valueFromText(newText, function(...args) { 239 | result = false; 240 | error(...args); 241 | }); 242 | 243 | if (result) { 244 | change.proposed.text = this._textFromValue(value); 245 | } 246 | 247 | return result; 248 | } 249 | } 250 | 251 | export default DelimitedTextFormatter; 252 | -------------------------------------------------------------------------------- /test/unit/helpers/setup.js: -------------------------------------------------------------------------------- 1 | import Keysim from 'keysim'; 2 | 3 | const DEFAULT_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36"; 4 | 5 | const Keyboard = Keysim.Keyboard; 6 | const Keystroke = Keysim.Keystroke; 7 | const KeyEvents = Keysim.KeyEvents; 8 | 9 | const DEFAULT_KEYBOARD = Keyboard.US_ENGLISH; 10 | 11 | const keyboardWrapper = (type, keyboardFilter=false) => (title, fn) => { 12 | [ 13 | { 14 | keyboard: Keyboard.US_ENGLISH, 15 | name: 'English', 16 | tag: ['desktop'] 17 | }, 18 | { 19 | keyboard: ANDROID_CHROME, 20 | name: 'Android', 21 | tag: ['mobile'] 22 | } 23 | ] 24 | .filter(keyboard => { 25 | return !keyboardFilter || 26 | keyboard.tag.indexOf(keyboardFilter) >= 0; 27 | }) 28 | .forEach(testKeyboard => { 29 | type(`KEYBOARD-${testKeyboard.name} :: ${title}`, () => { 30 | beforeEach(() => window.keyboard = testKeyboard.keyboard ); 31 | 32 | fn.call(this); 33 | 34 | afterEach(() => window.keyboard = DEFAULT_KEYBOARD); 35 | }); 36 | }); 37 | }; 38 | 39 | window.testsWithAllKeyboards = keyboardWrapper(describe); 40 | window.testsWithDesktopKeyboards = keyboardWrapper(describe, 'desktop'); 41 | 42 | 43 | beforeEach(() => { 44 | navigator.__defineGetter__('userAgent', function(){ 45 | return DEFAULT_UA; 46 | }); 47 | }); 48 | 49 | const SHIFT = 1 << 3; 50 | 51 | const ANDROID_CHROME_CHARCODE_KEYCODE_MAP = { 52 | 32: new Keystroke(0, 32), // 53 | 33: new Keystroke(SHIFT, 33), // ! 54 | 34: new Keystroke(SHIFT, 34), // " 55 | 35: new Keystroke(SHIFT, 35), // # 56 | 36: new Keystroke(SHIFT, 36), // $ 57 | 37: new Keystroke(SHIFT, 37), // % 58 | 38: new Keystroke(SHIFT, 38), // & 59 | 39: new Keystroke(0, 39), // ' 60 | 40: new Keystroke(SHIFT, 40), // ( 61 | 41: new Keystroke(SHIFT, 41), // ) 62 | 42: new Keystroke(SHIFT, 42), // * 63 | 43: new Keystroke(SHIFT, 43), // + 64 | 44: new Keystroke(0, 44), // , 65 | 45: new Keystroke(0, 45), // - 66 | 46: new Keystroke(0, 46), // . 67 | 47: new Keystroke(0, 47), // / 68 | 48: new Keystroke(0, 48), // 0 69 | 49: new Keystroke(0, 49), // 1 70 | 50: new Keystroke(0, 50), // 2 71 | 51: new Keystroke(0, 51), // 3 72 | 52: new Keystroke(0, 52), // 4 73 | 53: new Keystroke(0, 53), // 5 74 | 54: new Keystroke(0, 54), // 6 75 | 55: new Keystroke(0, 55), // 7 76 | 56: new Keystroke(0, 56), // 8 77 | 57: new Keystroke(0, 57), // 9 78 | 58: new Keystroke(SHIFT, 58), // : 79 | 59: new Keystroke(0, 59), // ; 80 | 60: new Keystroke(SHIFT, 60), // < 81 | 61: new Keystroke(0, 61), // = 82 | 62: new Keystroke(SHIFT, 62), // > 83 | 63: new Keystroke(SHIFT, 63), // ? 84 | 64: new Keystroke(SHIFT, 64), // @ 85 | 65: new Keystroke(SHIFT, 65), // A 86 | 66: new Keystroke(SHIFT, 66), // B 87 | 67: new Keystroke(SHIFT, 67), // C 88 | 68: new Keystroke(SHIFT, 68), // D 89 | 69: new Keystroke(SHIFT, 69), // E 90 | 70: new Keystroke(SHIFT, 70), // F 91 | 71: new Keystroke(SHIFT, 71), // G 92 | 72: new Keystroke(SHIFT, 72), // H 93 | 73: new Keystroke(SHIFT, 73), // I 94 | 74: new Keystroke(SHIFT, 74), // J 95 | 75: new Keystroke(SHIFT, 75), // K 96 | 76: new Keystroke(SHIFT, 76), // L 97 | 77: new Keystroke(SHIFT, 77), // M 98 | 78: new Keystroke(SHIFT, 78), // N 99 | 79: new Keystroke(SHIFT, 79), // O 100 | 80: new Keystroke(SHIFT, 80), // P 101 | 81: new Keystroke(SHIFT, 81), // Q 102 | 82: new Keystroke(SHIFT, 82), // R 103 | 83: new Keystroke(SHIFT, 83), // S 104 | 84: new Keystroke(SHIFT, 84), // T 105 | 85: new Keystroke(SHIFT, 85), // U 106 | 86: new Keystroke(SHIFT, 86), // V 107 | 87: new Keystroke(SHIFT, 87), // W 108 | 88: new Keystroke(SHIFT, 88), // X 109 | 89: new Keystroke(SHIFT, 89), // Y 110 | 90: new Keystroke(SHIFT, 90), // Z 111 | 91: new Keystroke(0, 219), // [ 112 | 92: new Keystroke(0, 220), // \ 113 | 93: new Keystroke(0, 221), // ] 114 | 96: new Keystroke(0, 192), // ` 115 | 97: new Keystroke(0, 97), // a 116 | 98: new Keystroke(0, 98), // b 117 | 99: new Keystroke(0, 99), // c 118 | 100: new Keystroke(0, 100), // d 119 | 101: new Keystroke(0, 101), // e 120 | 102: new Keystroke(0, 102), // f 121 | 103: new Keystroke(0, 103), // g 122 | 104: new Keystroke(0, 104), // h 123 | 105: new Keystroke(0, 105), // i 124 | 106: new Keystroke(0, 106), // j 125 | 107: new Keystroke(0, 107), // k 126 | 108: new Keystroke(0, 108), // l 127 | 109: new Keystroke(0, 109), // m 128 | 110: new Keystroke(0, 110), // n 129 | 111: new Keystroke(0, 111), // o 130 | 112: new Keystroke(0, 112), // p 131 | 113: new Keystroke(0, 113), // q 132 | 114: new Keystroke(0, 114), // r 133 | 115: new Keystroke(0, 115), // s 134 | 116: new Keystroke(0, 116), // t 135 | 117: new Keystroke(0, 117), // u 136 | 118: new Keystroke(0, 118), // v 137 | 119: new Keystroke(0, 119), // w 138 | 120: new Keystroke(0, 120), // x 139 | 121: new Keystroke(0, 121), // y 140 | 122: new Keystroke(0, 122), // z 141 | 123: new Keystroke(SHIFT, 123), // { 142 | 124: new Keystroke(SHIFT, 124), // | 143 | 125: new Keystroke(SHIFT, 125), // } 144 | 126: new Keystroke(SHIFT, 126) // ~ 145 | }; 146 | 147 | const ANDROID_CHROME_ACTION_KEYCODE_MAP = { 148 | BACKSPACE: 8, 149 | TAB: 9, 150 | ENTER: 13, 151 | SHIFT: 16, 152 | CTRL: 17, 153 | ALT: 18, 154 | PAUSE: 19, 155 | CAPSLOCK: 20, 156 | ESCAPE: 27, 157 | PAGEUP: 33, 158 | PAGEDOWN: 34, 159 | END: 35, 160 | HOME: 36, 161 | LEFT: 37, 162 | UP: 38, 163 | RIGHT: 39, 164 | DOWN: 40, 165 | INSERT: 45, 166 | DELETE: 46, 167 | META: 91, 168 | F1: 112, 169 | F2: 113, 170 | F3: 114, 171 | F4: 115, 172 | F5: 116, 173 | F6: 117, 174 | F7: 118, 175 | F8: 119, 176 | F9: 120, 177 | F10: 121, 178 | F11: 122, 179 | F12: 123 180 | }; 181 | 182 | function replaceStringSelection(replacement, text, range) { 183 | var end = range.start + range.length; 184 | return text.substring(0, range.start) + replacement + text.substring(end); 185 | } 186 | 187 | /** 188 | * NOTE: Some Androids keyboards will always return 229 189 | * This keyboard also only produces the keyDown and 190 | * keyUp event. FieldKit uses what the browser enters in keyDown 191 | * to process with the formatter. So this keyboard needs to also 192 | * insert the character into the field. 193 | * 194 | * https://code.google.com/p/chromium/issues/detail?id=118639 195 | */ 196 | const ANDROID_CHROME = new Keyboard( 197 | ANDROID_CHROME_CHARCODE_KEYCODE_MAP, 198 | ANDROID_CHROME_ACTION_KEYCODE_MAP 199 | ); 200 | 201 | const originalDispatch = ANDROID_CHROME.dispatchEventsForKeystroke; 202 | 203 | ANDROID_CHROME.dispatchEventsForKeystroke = function(keystroke, target, mods=true) { 204 | if (!keystroke.modifiers && !mods) { 205 | const transitionModifiers = false; 206 | // Dispatch keyDown Events 207 | originalDispatch.call(this, new Keystroke(0, 229), target, transitionModifiers, KeyEvents.DOWN); 208 | 209 | const field = target['field-kit-text-field']; 210 | const currentSelectedRange = field.selectedRange(); 211 | 212 | // Mock the browser inputting the character from keyDown 213 | field.element.value = replaceStringSelection( 214 | String.fromCharCode(keystroke.keyCode), 215 | field.text(), 216 | currentSelectedRange 217 | ); 218 | field.setSelectedRange({ start: currentSelectedRange.start + 1, length: 0 }); 219 | 220 | // Dispatch keyUp 221 | // This is where we do most of the processing for this type of Android Keyboard 222 | return originalDispatch.call(this, new Keystroke(0, 229), target, transitionModifiers, KeyEvents.UP); 223 | } else { 224 | if (keystroke.keyCode === 97) { 225 | keystroke.keyCode = 65; 226 | } else if (keystroke.keyCode === 122) { 227 | keystroke.keyCode = 90; 228 | } 229 | originalDispatch.call(this, keystroke, target, mods); 230 | } 231 | }; 232 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------