├── .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 | 
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 |
7 |
8 |
9 |
11 |
12 |
13 |
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 | 
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 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 | “411111111”
46 | “4111 1111 1” (Visa)
47 |
48 |
49 | “3725123456”
50 | “3725 123456” (Amex)
51 |
52 |
53 | “4111” + backspace
54 | “411”
55 |
56 |
57 |
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 |
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 | “4”
114 | “04/”
115 |
116 |
117 | “1212”
118 | “12/12”
119 |
120 |
121 | “12/” + backspace
122 | “1”
123 |
124 |
125 |
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 | “1234”
149 | “123-4”
150 |
151 |
152 | “123-45-” + backspace
153 | “123-4”
154 |
155 |
156 |
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 | “4155”
181 | “(415) 5”
182 |
183 |
184 |
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 |
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 |
--------------------------------------------------------------------------------