├── .gitignore
├── .npmignore
├── .nvmrc
├── .travis.yml
├── LICENSE.txt
├── README.md
├── config.example.json
├── index.d.ts
├── index.html
├── package-lock.json
├── package.json
├── rollup.config.js
├── src
├── actions.ts
├── constants.ts
├── finput.ts
├── helpers.ts
├── key.ts
├── keyHandlers.ts
└── valueHistory.ts
├── test
├── .babelrc
├── capabilities.js
├── customCommands.js
├── helpers.js
├── jestConfig.json
├── keys.js
├── pageObjects
│ └── index.js
├── setupTests.js
├── specs
│ ├── copy-paste.js
│ ├── cutting.js
│ ├── deletions.js
│ ├── formatting-decimals.js
│ ├── formatting-negatives.js
│ ├── modifiers.js
│ ├── shortcuts.js
│ ├── switching-delimiters.js
│ └── traversals.js
└── unit
│ └── setRawValue.js
├── tsconfig.json
├── tslint.json
└── types
└── is_js
└── index.d.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | npm-debug.log
3 | selenium-debug.log
4 | config.json
5 | dist/
6 | lib/
7 | .*
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # --------------------
2 | # OSX Files
3 | # --------------------
4 | .DS_Store
5 | .AppleDouble
6 | .LSOverride
7 | Icon
8 | ._*
9 | .Spotlight-V100
10 | .Trashes
11 |
12 | # --------------------
13 | # IntelliJ Files
14 | # --------------------
15 | *.iml
16 | *.ipr
17 | *.iws
18 | .idea/
19 |
20 | # --------------------
21 | # Sublime Files
22 | # --------------------
23 | *.sublime-project
24 | *.sublime-workspace
25 |
26 | # --------------------
27 | # App Files
28 | # --------------------
29 | node_modules/
30 | config.*
31 | selenium-debug.log
32 | .rpt2_cache
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 8.3.0
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | notifications:
4 | email:
5 | on_success: change
6 | on_failure: always
7 | before_script:
8 | - npm prune
9 | jobs:
10 | include:
11 | - stage: build
12 | script:
13 | - if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then npm run lint; npm run compile; npm run build:prod; npm run test:unit; fi
14 | - if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then npm run lint; npm run test; fi
15 | - stage: deploy
16 | script: skip # skip re-running tests
17 | deploy:
18 | on:
19 | branch: master
20 | provider: script
21 | skip_cleanup: true
22 | script:
23 | if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then npm run build:prod; npm run compile; npx semantic-release; fi
24 | addons:
25 | browserstack:
26 | username:
27 | secure: f/lU4lF3HeFJei8KVsVsN92BXHf9BvKwA2tBqIN/PqdtpunVrETlmpf9gFbxfqe3Vwtig0l7X4eCYU0Qp0cG9cMEpO4TMiKMaSLyoGE2rheyYVeYGAZ7nIdxXKa9MzpvhyYNu16z6oNAtOCt1kxLjVtfGr4xrANUiFKs+/uyQqRBm8eajom5lsLeQkNTvh2hI17tVfBa0oWseKSkKp6yTn9c/CyeW9O6GHFLjBFFYWdqNCnEWFPOR4dR3dyajjqltC4MwHI8ysnX6+4OjbETxfCCiudtd3WCoUus5WlXdRI13SMNzRPntoJvZxowSVEiormH3Sbk3OJImEkYNKAr0eMGiF6PZitJ183fxxKyQro0dPgzR/sIWpk1/nB5HbOXFApH2dm1vnEWyGW5B5WiAE0r8rp8fhXvVhaZ4mvK2v/U+aZfSlvurvUnE8h4iJ6x+GZN883s/3m1f/Z/hJDBepkmYIA6TIpNz8pkdKjtwW7UXCDDxzAFSlXcgYVa+KDPmlhPrpL5EnKc3WDh4AgGm+NRnX8JV3OZyWK7mXCLaBBgXaph38FT+CX2NavYnm35sexYsWaZ2UqPjpvefpfBWfvoAb0RlA2dFtBpliTHLyFKfut6UtuzT6jH2ccMPKk2mlbaHF16zMgskUUYSCD6Fh4oWCMsVqJU2hUsLDiq7NI=
28 | access_key:
29 | secure: W7ICrd8F1D4AO/Movdrj5GCGhw/ScJ0jjdAdFcOzucGdNzeKITtQbhGKiNiZ6UWiQ+z3c2fwm0v6YPtZB2Gkjtez/IiReeMZ8e1OOU9NmjcgoOmd5zj6Nj0LaUq2uIo9mqQ9uUKNCCrNd8Muo9l87VKjxg2gfUbZ633eJdfO16psKvMkoiCxREwZ4dVoNBwahEuPV6o3GcAQwJ4GbAPfe0+6PzmmInjdj6BC5rsXlG1uQUVpMyfzDaA5wev44pInZvWEBLhZ4B5lcX6ZIQcUOc2ROEznNJ3Oq6FovdffCVCo36xoRPnUZB+j5tWosyVDvebWV+0Xp6Ppv41h3DWmlu/EwT0RaIra3zjfZ7+5KP9CKSO4kmw8P/4jLiZBIRY5INGWSBAlnCypdJk3bPvAckEz8ABoV5wChDyL+oPwJn9yogpQmlw3MEO40G4nMIJ41nkpmcazh+Hi40FFPuKHSxVEMj992SuCJ4CxKjTWg8eqCdLdgcaz3TRIGBzcWU3MHMIaX8Xm33prYtEOPkQhoyMJNcuwYh4sT/9w/CPu4MlVGv/iFnZ+Rt4c7Xe8jBma3YG8AVEyu0IYYzgW8PzjXr6He3pWHgIkIaB5b5jW2KPWUAOkEDB6vmDx6KHRWNCYW/6L0TfO3+AxiS3bfIIL7WOLCkc+WtUMSjvd01gQJMw=
30 | chrome: stable
31 | env:
32 | global:
33 | - secure: gZkLEExtLiDXbkZ6a2kKl6BdFYzTztpnF/TMse0dVPZgC46dOZ3i0KClGo6Q5OhAVu2+97qbW8ye9iWmShF23nyJUqItqfi5wqn5ytzEoN10eEE/jcxn2BwBtpAB4VuiIh2RtPjWK4q4ji7r18O8jub3H64pofTr6tX1b069a/N4huw9xebGjcAcb8CP9hv184QDKEVT/ksr8F9f1/3n2uB7dofeERZTw6fmxTsHcGJ9y1irI/A9xp4HVXOshQWE6A/h5kJHuRdjyd6y/yNts2hC6vpbcWDr1eWKwYUALkQoYciB7RZaJOHKLMKWeDqfMEm5gx92Q1hLENfu4HQBWZglR7qhpZVWhk6Tfaj3p2OfM1IolQNG8mhk7GUslqsGo5YX6gQttls4OieXFPft0qMZnFReTcasXgIlBmhYQn7CqgxTtSqhP8vmIB90zHwb8Ou8FnsF1oae5vl93+UuhUjJsT62Zlw5f8VmUQODu7wkQYtb39tfzgX7KKtuTI5D+RmI73CpZaTrQMeQVl22Dpp0N1+7MKBxj4unCpi3J1xPoNWfXlgP+uld9L6bYUgfxXustPTSfZ6RuJpI4wahzBY6SksLwBtGLo47xrKi3W22BhP0oi+jPQmqacOH627HD62mD8PFfLB00TlqwtLP/vjAgPGOR8af7vYVzkN0lRA=
34 | - secure: aTXWe620VGyqmvC9uaziATyyBOzeMbzp75w9pYU95kBb1IlHo6H0+04OveLu4eGRVS8kNTKgoMq727im53NGExfAMnSAQrhOmZuNWpUo3cEtTmwT70pfL9MDT6OMhIqvugf2bijGfxH/pUYF1fdjGbmXT05QpqbTJrrE2jY903DQ6CJINzAb2M2jaC2/xYMK1wNE2I+/ROWp2oiDx+7EVkap228zy2axiZpJJ5FpE1khps3zahjRgjTlv5MnltYSTDpB8XZpVEp1dh61J0IuN7by1VJ3BLdbU9UClD3pcZ4FgCGGtmAGsLveepuiNblujDvMeyK4WQvoPHF/robF75kzbjMimiLXpyNaWKGWv7t0JsqjHClMPuk06GGmBzfpkxGbYSfj8hmhiD6rGxrcFZ13kNrNWkgZLqiG1eX9TZf1p0XOD8OOM+NITlo1PqGxWnAOQFhmXtfUuEsjGVoaA0ZSymZuabV6nQAEbUF2JwJ8ch01OASTqewAPLterGm1n5MM18kecEhs77u/vYL8URBcptbvpC4S4Zo+BJkjMfsxc5aw8g7AZws/+x9hvhqgbtDtsf/331R0e/vM0o07Tvb7ogFWyj+1eBa6FbjMgqDERGCliBlxPUg+B3lHt14xIuAUnn4ow9AdtZ44/MFMxFC9nDxg0SmC42COzPxU3EQ=
35 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Scott Logic
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | finput
2 | 
3 | [](https://github.com/semantic-release/semantic-release)
4 | ======
5 |
6 | A vanilla-JS financial amount input control. Supports the following features:
7 |
8 | * auto-formatting
9 | * prevents invalid input whether typed, dragged or pasted
10 | * 'k', 'm', 'b', etc. multiplier keys
11 |
12 | Required Browser Features
13 | -------------------------
14 |
15 | The below table lists features that `finput` requires in order to function properly. If you wish to use `finput` with a browser that does not support a required feature then using the suggested polyfill may help. Note that there may be more appropriate polyfills than the ones listed.
16 |
17 | | Required Feature | Suggested Polyfill |
18 | |-|-|
19 | | [KeyboardEvent.key](https://caniuse.com/#feat=keyboardevent-key) | [keyboardevent-key-polyfill](https://www.npmjs.com/package/keyboardevent-key-polyfill) |
20 | | [Symbol](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Symbol) | [babel-polyfill](http://babeljs.io/docs/usage/polyfill)
21 |
22 | Usage
23 | -----
24 | See an example finput [here](http://scottlogic.github.io/finput)
25 |
26 | #### Install package
27 | `npm install finput`
28 |
29 | #### Initialise input
30 | To initialise the finput, simply pass the element and any options into the finput constructor.
31 | An object is returned which allows you to interact with the finput API.
32 |
33 | Options
34 | -----
35 |
36 | ##### scale
37 | Type: `Number`
38 | Default: `2`
39 |
40 | Maximum number of decimal digits the value can take
41 |
42 | ##### range
43 | Type: `string`
44 | Default: `ALL`
45 |
46 | The possible range of values that the value can take
47 |
48 | Possible Values:
49 | - `'ALL'`: Number can take any value
50 | - `'POSITIVE'`: Number can only be positive
51 |
52 | ##### fixed
53 | Type: `Boolean`
54 | Default: `true`
55 | If true, after focus is lost value is formatted to *scale* number of decimal places
56 |
57 | ##### thousands
58 | Type: `string`
59 | Default: `,`
60 | The character used to separate thousands in the formatted value.
61 | `E.g. 1,000`
62 |
63 | ##### decimal
64 | Type: `string`
65 | Default: `.`
66 | The character used for the decimal point
67 |
68 | ##### shortcuts
69 | Type: `Object { character: multiplier }`
70 | Default: `{
71 | 'k': 1000,
72 | 'm': 1000000,
73 | 'b': 1000000000
74 | }`
75 | An object mapping of shortcuts that the user can use to quickly enter common values.
76 | E.g. with the default shortcuts, typing `k` will multiply the number value by 1000
77 |
78 | ##### onInvalidKey
79 | Type: `Function(e)`
80 | Default: `() => {}`
81 | A callback function that is fired each time a invalid key is pressed.
82 | the callback is called with the [KeyboardEvent](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent) object that was raised on keydown.
83 |
84 | ##### onFocus
85 | Type: `Function(e)`
86 | Default: `undefined`
87 | A callback function that is fired each time the input is focussed.
88 | the callback is called with the `Event` object.
89 |
90 | the function used needs to return an object with a start and end value, a numerical
91 | representation of the postions to select.
92 |
93 | `{
94 | start: 0,
95 | end: 1
96 | }`
97 |
98 | setting both values to 0 or failing to return both values will disable selecting functionality
99 |
100 | API
101 | --------------------
102 |
103 | The following properties are exposed on the returned finput instance:
104 |
105 | ##### options
106 | Retrieves the options on the input
107 |
108 | ##### rawValue
109 | Retrieves the raw value of the input (numerical)
110 |
111 | #### value
112 | Retrieves the formatted value of the input (string)
113 |
114 | The following functions are exposed on the returned finput instance:
115 |
116 | ##### setOptions
117 | Sets the options on the input
118 | * `options` New options to set. Copied before being set.
119 |
120 | Note that `setOptions` supplements the current options rather than replacing.
121 | ```
122 | element.setOptions({ thousands: '.' });
123 | element.setOptions({ decimal: ',' });
124 | ```
125 | The above therefore results in the following `options`:
126 | ```
127 | {
128 | thousands: '.',
129 | decimal: ','
130 | }
131 | ```
132 | ##### setValue
133 | Sets the value, fully formatted, for the input
134 | * `val` New value to set
135 | * `notNull` When true, restricts setting the value if it is null.
136 |
137 | ##### setRawValue
138 | Sets and formats the value for the input
139 | * `val` New value to set
140 |
141 | ##### removeListeners
142 | Removes finputs listeners from the provided element, returning it to a standard native control
143 |
144 | Developing
145 | ----------
146 |
147 | Install dependencies:
148 | - `npm install`
149 |
150 | Adding dependencies:
151 | - Do not commit `yarn.lock`
152 | - Do commit `package-lock.json`
153 |
154 | Run dev server:
155 | - `npm start`
156 |
157 | Building Library
158 | ----------------
159 | - `npm run build:dev` - Builds a development friendly version of the application
160 | - `npm run build:prod` - Builds a minified version of the application
161 | - `npm run compile` - Compiles typescript dependency-free version of library
162 |
163 | Running tests
164 | -------------
165 |
166 | Execute the tests locally:
167 |
168 | - `npm test`
169 |
170 | This takes care of doing the following:
171 | - Updating webdriver server
172 | - Starting background webdriver server
173 | - Starting background web server
174 | - Starting tests
175 | - Shutting down webdriver server, webserver and tests
176 |
177 | The tests can be run for CI using:
178 | - `npm run test:ci`
179 |
180 | This is the same as `npm test` but it does not update or start webdriver. We assume that CI/Browserstack takes care of webdriver for us.
181 |
182 | Releasing
183 | ---------
184 |
185 | [semantic-release](https://github.com/semantic-release/semantic-release) is used with Travis CI to perform releases on merged PRs to `master` branch.
186 |
187 | Commit messages must follow [AngularJS Commit Message Conventions](https://github.com/semantic-release/semantic-release#default-commit-message-format) for `semantic-release` to correctly choose the next version.
188 |
189 | If the Travis CI build for a new release is successful, it is published to npm.
190 | `./lib/finput.js` is used by npm installs, and `./dist/finput.min.js` is
191 | automatically served by [UNPKG](https://unpkg.com/) CDN at `https://unpkg.com/finput@latest/dist/finput.min.js` to directly load finput
192 | in a browser environment.
193 |
--------------------------------------------------------------------------------
/config.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "browserstackUsername": "YOUR_USERNAME",
3 | "browserstackKey": "YOUR_KEY"
4 | }
5 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | import { ActionType, Key, Range } from "./src/constants";
2 | import ValueHistory from "./src/valueHistory";
3 |
4 | interface IState {
5 | value: string;
6 | caretStart: number;
7 | caretEnd: number;
8 | valid: boolean;
9 | }
10 |
11 | interface IKeyInfo {
12 | name: string;
13 | modifiers: string[];
14 | }
15 |
16 | interface IOptions {
17 | thousands: string;
18 | decimal: string;
19 | fixed: boolean;
20 | range: Range;
21 | scale: number;
22 | shortcuts: { [shortcut: string]: number };
23 | onInvalidKey: (event: KeyboardEvent) => void;
24 | onFocus: (event: FocusEvent) => ISelection | void;
25 | }
26 |
27 | interface IAction {
28 | type: ActionType;
29 | names: string[];
30 | modifiers?: Key[];
31 | }
32 |
33 | interface ISelection {
34 | start: number;
35 | end: number;
36 | }
37 |
38 | type ActionHandler = (currentState: IState, keyInfo: IKeyInfo, options: IOptions, history: ValueHistory) => IState;
39 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Finput
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
FINPUT
13 |
A test harness for the vanilla-JS financial amount input control
14 |
15 |
16 |
17 |
Controls
18 |
19 |
42 |
43 |
Native Controls
44 |
45 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "finput",
3 | "version": "0.0.0-development",
4 | "description": "A vanilla-JS financial amount input control",
5 | "license": "MIT",
6 | "homepage": "http://scottlogic.github.io/finput/",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/ScottLogic/finput.git"
10 | },
11 | "bugs": "https://github.com/ScottLogic/finput/issues",
12 | "dependencies": {
13 | "is_js": "0.9.0"
14 | },
15 | "main": "./lib/finput.js",
16 | "unpkg": "./dist/finput.js",
17 | "devDependencies": {
18 | "@babel/core": "^7.4.0",
19 | "@babel/preset-env": "7.4.2",
20 | "browserstacktunnel-wrapper": "2.0.4",
21 | "cross-env": "5.2.0",
22 | "http-server": "0.11.1",
23 | "husky": "1.3.1",
24 | "jest": "24.6.0",
25 | "mkdirp": "0.5.1",
26 | "npm-run-all": "4.1.5",
27 | "rimraf": "2.6.3",
28 | "rollup": "1.8.0",
29 | "rollup-plugin-babel-minify": "8.0.0",
30 | "rollup-plugin-commonjs": "9.2.3",
31 | "rollup-plugin-node-resolve": "4.0.1",
32 | "rollup-plugin-typescript2": "0.20.1",
33 | "selenium-webdriver": "3.6.0",
34 | "semantic-release": "15.13.3",
35 | "tslint": "5.15.0",
36 | "typescript": "3.4.1",
37 | "webdriver-manager": "13.0.0"
38 | },
39 | "scripts": {
40 | "compile": "tsc",
41 | "clean": "rimraf ./dist && rimraf ./lib && mkdirp ./dist/ && mkdirp ./lib/",
42 | "build:dev": "cross-env environment=DEVELOPMENT rollup -c",
43 | "build:prod": "cross-env environment=PRODUCTION rollup -c",
44 | "lint": "tslint src/**/*.ts",
45 | "watch": "cross-env environment=DEVELOPMENT rollup -cw",
46 | "serve": "http-server",
47 | "webdriver:update": "webdriver-manager update",
48 | "webdriver:start": "webdriver-manager start --quiet",
49 | "test": "npm-run-all webdriver:update build:prod --parallel --race serve webdriver:start test:ci",
50 | "test:unit": "jest --runInBand --config ./test/jestConfig.json test/unit",
51 | "test:e2e": "jest --runInBand --config ./test/jestConfig.json test/specs",
52 | "test:ci": "npm-run-all --parallel test:unit test:e2e",
53 | "semantic-release": "semantic-release pre && npm publish && semantic-release post"
54 | },
55 | "husky": {
56 | "hooks": {
57 | "pre-commit": "npm run lint && npm run compile"
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import commonjs from 'rollup-plugin-commonjs';
2 | import resolve from 'rollup-plugin-node-resolve';
3 | import typescript from 'rollup-plugin-typescript2';
4 | import minify from 'rollup-plugin-babel-minify';
5 |
6 | import pkg from './package.json'
7 |
8 | export default {
9 | input: 'src/finput.ts',
10 | output: {
11 | file: pkg.unpkg,
12 | format: 'umd',
13 | name: 'finput'
14 | },
15 | plugins: [
16 | commonjs(),
17 | resolve(),
18 | typescript({
19 | typescript: require('typescript'),
20 | }),
21 | process.env.environment === "PRODUCTION" && minify({
22 | comments: false
23 | })
24 | ],
25 | }
--------------------------------------------------------------------------------
/src/actions.ts:
--------------------------------------------------------------------------------
1 | import { ActionType } from "./constants";
2 | import * as keyUtils from "./key";
3 | import * as keyHandlers from "./keyHandlers";
4 |
5 | import { ActionHandler, IAction, IKeyInfo, IOptions } from "../index";
6 |
7 | const createActions = (options: IOptions): IAction[] => [
8 | {
9 | names: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"],
10 | type: ActionType.NUMBER,
11 | },
12 | {
13 | names: ["-"],
14 | type: ActionType.MINUS,
15 | },
16 | {
17 | names: [options.decimal, "decimal"],
18 | type: ActionType.DECIMAL,
19 | },
20 | {
21 | names: [options.thousands, "separator"],
22 | type: ActionType.THOUSANDS,
23 | },
24 | {
25 | names: Object.keys(options.shortcuts),
26 | type: ActionType.SHORTCUT,
27 | },
28 | {
29 | names: ["backspace"],
30 | type: ActionType.BACKSPACE,
31 | },
32 | {
33 | names: [
34 | "delete", // Chrome & Firefox
35 | "del", // Edge & IE
36 | ],
37 | type: ActionType.DELETE,
38 | },
39 | {
40 | modifiers: [keyUtils.getHistoryKey()],
41 | names: ["z"],
42 | type: ActionType.UNDO,
43 | },
44 | {
45 | modifiers: [keyUtils.getHistoryKey()],
46 | names: ["y"],
47 | type: ActionType.REDO,
48 | },
49 | ];
50 |
51 | export const getActionType = (keyInfo: IKeyInfo, options: IOptions): ActionType => {
52 | const actionTypes = createActions(options);
53 |
54 | const foundType = actionTypes.find((actionType) =>
55 | actionType.names.indexOf(keyInfo.name) > -1 &&
56 | JSON.stringify(keyInfo.modifiers) === JSON.stringify(keyInfo.modifiers),
57 | );
58 |
59 | return foundType ? foundType.type : ActionType.UNKNOWN;
60 | };
61 |
62 | export const getHandlerForAction = (action: ActionType): ActionHandler => {
63 | const handlerForAction = {
64 | [ActionType.NUMBER]: keyHandlers.onNumber,
65 | [ActionType.DECIMAL]: keyHandlers.onDecimal,
66 | [ActionType.THOUSANDS]: keyHandlers.onThousands,
67 | [ActionType.MINUS]: keyHandlers.onMinus,
68 | [ActionType.SHORTCUT]: keyHandlers.onShortcut,
69 | [ActionType.BACKSPACE]: keyHandlers.onBackspace,
70 | [ActionType.DELETE]: keyHandlers.onDelete,
71 | [ActionType.UNDO]: keyHandlers.onUndo,
72 | [ActionType.REDO]: keyHandlers.onRedo,
73 | [ActionType.UNKNOWN]: keyHandlers.onUnknown,
74 | };
75 |
76 | return handlerForAction[action];
77 | };
78 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export enum Key {
2 | NUMPAD_ADD = "add",
3 | NUMPAD_SUBTRACT = "subtract",
4 | NUMPAD_MULTIPLY = "multiply",
5 | NUMPAD_DIVIDE = "divide",
6 | META = "metaKey",
7 | CTRL = "ctrlKey",
8 | SHIFT = "shiftKey",
9 | ALT = "altKey",
10 | }
11 |
12 | export enum ActionType {
13 | NUMBER = "NUMBER",
14 | SHORTCUT = "SHORTCUT",
15 | DECIMAL = "DECIMAL",
16 | THOUSANDS = "THOUSANDS",
17 | MINUS = "MINUS",
18 | UNKNOWN = "UNKNOWN",
19 | BACKSPACE = "BACKSPACE",
20 | DELETE = "DELETE",
21 | UNDO = "UNDO",
22 | REDO = "REDO",
23 | }
24 |
25 | export enum DragState {
26 | NONE = "NONE",
27 | INTERNAL = "INTERNAL",
28 | EXTERNAL = "EXTERNAL",
29 | }
30 |
31 | export enum Range {
32 | ALL = "ALL",
33 | POSITIVE = "POSITIVE",
34 | }
35 |
--------------------------------------------------------------------------------
/src/finput.ts:
--------------------------------------------------------------------------------
1 | import { getActionType, getHandlerForAction } from "./actions";
2 | import { ActionType, DragState, Range } from "./constants";
3 | import * as helpers from "./helpers";
4 | import * as keyUtils from "./key";
5 | import ValueHistory from "./valueHistory";
6 |
7 | import { IKeyInfo, IOptions, IState } from "../index";
8 |
9 | interface IEventHandler {
10 | element: E;
11 | handler: EventListener;
12 | }
13 |
14 | interface IListenerMap {
15 | blur: IEventHandler;
16 | dragend: IEventHandler;
17 | dragstart: IEventHandler;
18 | drop: IEventHandler;
19 | focus: IEventHandler;
20 | input: IEventHandler;
21 | keydown: IEventHandler;
22 | paste: IEventHandler;
23 | }
24 |
25 | const noop = () => {/**/};
26 |
27 | const DEFAULTS: IOptions = {
28 | decimal: ".",
29 | fixed: true,
30 | onFocus: noop,
31 | onInvalidKey: noop,
32 | range: Range.ALL,
33 | scale: 2,
34 | shortcuts: {
35 | b: 1000000000,
36 | k: 1000,
37 | m: 1000000,
38 | },
39 | thousands: ",",
40 | };
41 |
42 | class Finput {
43 | public options: IOptions;
44 | private readonly element: HTMLInputElement;
45 |
46 | private readonly history: ValueHistory;
47 | private readonly listeners: IListenerMap;
48 | private dragState: DragState = DragState.NONE;
49 |
50 | constructor(element: HTMLInputElement, options: IOptions) {
51 | this.element = element;
52 | this.options = { ...DEFAULTS, ...options };
53 |
54 | this.history = new ValueHistory();
55 |
56 | this.listeners = {
57 | blur: { element: this.element, handler: () => this.onBlur() },
58 | dragend: { element: document, handler: () => this.onDragend() },
59 | dragstart: { element: document, handler: (e) => this.onDragstart(e as DragEvent) },
60 | drop: { element: this.element, handler: (e) => this.onDrop(e as DragEvent) },
61 | focus: { element: this.element, handler: (e) => this.onFocus(e as FocusEvent) },
62 | input: { element: this.element, handler: () => this.onInput() },
63 | keydown: { element: this.element, handler: (e) => this.onKeydown(e as KeyboardEvent) },
64 | paste: { element: this.element, handler: (e) => this.onPaste(e as ClipboardEvent) },
65 | };
66 |
67 | this.removeListeners();
68 | (Object.keys(this.listeners) as Array)
69 | .forEach((key) => this.listeners[key].element.addEventListener(key , this.listeners[key].handler));
70 | }
71 |
72 | public setOptions(options: Partial) {
73 | this.options = { ...this.options, ...options };
74 | }
75 |
76 | public setValue(val: string, notNull: boolean) {
77 | const newValue = helpers.fullFormat(val, this.options);
78 |
79 | if (notNull ? val : true) {
80 | this.element.value = newValue;
81 | this.history.addValue(newValue);
82 | }
83 | }
84 |
85 | public get rawValue() {
86 | return helpers.formattedToRaw(this.element.value, this.options);
87 | }
88 |
89 | public setRawValue(val: any) {
90 | let value: string;
91 | if (typeof val === "number" && !isNaN(val)) {
92 | value = helpers.rawToFormatted(val, this.options);
93 | } else if (typeof val === "string") {
94 | value = val;
95 | } else if (!val) {
96 | value = "";
97 | } else {
98 | return;
99 | }
100 |
101 | const newValue = helpers.parseString(value, this.options);
102 | this.setValue(newValue, false);
103 | }
104 |
105 | public destroy() {
106 | this.removeListeners();
107 | }
108 |
109 | private removeListeners() {
110 | (Object.keys(this.listeners) as Array)
111 | .forEach((key) =>
112 | this.listeners[key].element.removeEventListener(key, this.listeners[key].handler));
113 | }
114 |
115 | private onBlur() {
116 | this.setValue(this.element.value, false);
117 | }
118 |
119 | private onFocus(e: FocusEvent) {
120 | const selection = this.options.onFocus(e);
121 | if (selection) {
122 | this.element.selectionStart = selection ? selection.start : 0;
123 | this.element.selectionEnd = selection ? selection.end : this.element.value.length;
124 | }
125 | }
126 |
127 | private onDrop(e: DragEvent) {
128 | switch (this.dragState) {
129 | case DragState.INTERNAL:
130 | // This case is handled by the 'onInput' function
131 | break;
132 | case DragState.EXTERNAL:
133 | const val = helpers.parseString(e.dataTransfer ? e.dataTransfer.getData("text") : "", this.options);
134 | this.setValue(val, true);
135 | e.preventDefault();
136 | break;
137 | default:
138 | // Do nothing;
139 | break;
140 | }
141 | }
142 |
143 | private onDragstart(e: DragEvent) {
144 | this.dragState = (e.target === this.element)
145 | ? DragState.INTERNAL
146 | : DragState.EXTERNAL;
147 | }
148 |
149 | private onDragend() {
150 | this.dragState = DragState.NONE;
151 | }
152 |
153 | private onPaste(e: ClipboardEvent) {
154 | // paste uses a DragEvent on IE and clipboard data is stored on the window
155 | const clipboardData = e.clipboardData || (window as any).clipboardData;
156 | const val = helpers.parseString(clipboardData.getData("text"), this.options);
157 | this.setValue(val, true);
158 | e.preventDefault();
159 | }
160 |
161 | private onKeydown(e: KeyboardEvent) {
162 | const currentState: IState = {
163 | caretEnd: this.element.selectionEnd || 0,
164 | caretStart: this.element.selectionStart || 0,
165 | valid: true,
166 | value: this.element.value,
167 | };
168 | const keyInfo: IKeyInfo = {
169 | modifiers: keyUtils.getPressedModifiers(e),
170 | name: e.key.toLowerCase(),
171 | };
172 |
173 | const actionType = getActionType(keyInfo, this.options);
174 | const handler = getHandlerForAction(actionType);
175 | const newState = handler(currentState, keyInfo, this.options, this.history);
176 |
177 | if (!newState.valid) {
178 | this.options.onInvalidKey(e);
179 | e.preventDefault();
180 | return;
181 | }
182 |
183 | const shouldHandleValue = actionType !== ActionType.UNKNOWN;
184 | if (!shouldHandleValue) {
185 | return;
186 | }
187 |
188 | e.preventDefault();
189 |
190 | const valueWithThousandsDelimiter = helpers.partialFormat(newState.value, this.options);
191 | const valueWithoutThousandsDelimiter = newState.value;
192 |
193 | this.element.value = valueWithThousandsDelimiter;
194 |
195 | const offset = helpers.calculateOffset(
196 | valueWithoutThousandsDelimiter,
197 | valueWithThousandsDelimiter,
198 | newState.caretStart,
199 | this.options,
200 | );
201 | const newCaretPos = newState.caretStart + offset;
202 | this.element.setSelectionRange(newCaretPos, newCaretPos);
203 |
204 | const shouldRecord = actionType !== ActionType.UNDO && actionType !== ActionType.REDO;
205 | if (shouldRecord) {
206 | this.history.addValue(valueWithThousandsDelimiter);
207 | }
208 | }
209 |
210 | private onInput() {
211 | this.setValue(this.element.value, false);
212 | }
213 | }
214 |
215 | export default (element: HTMLInputElement, options: IOptions) => new Finput(element, options);
216 |
--------------------------------------------------------------------------------
/src/helpers.ts:
--------------------------------------------------------------------------------
1 | import is from "is_js";
2 | import { IOptions } from "../index";
3 |
4 | const getDecimalIndex = (val: string, decimal: string): number =>
5 | val.indexOf(decimal) > -1
6 | ? val.indexOf(decimal)
7 | : val.length;
8 |
9 | export const editString = (str: string, toAdd: string, caretStart: number, caretEnd: number = caretStart): string =>
10 | `${str.slice(0, caretStart)}${toAdd}${str.slice(caretEnd, str.length)}`;
11 |
12 | export const formatThousands = (val: string, options: IOptions): string => {
13 | const startIndex = val.indexOf(options.decimal) > -1
14 | ? val.indexOf(options.decimal) - 1
15 | : val.length - 1;
16 | const endIndex = val[0] === "-" ? 1 : 0;
17 |
18 | // i must be greater than zero because number cannot start with comma
19 | let i = startIndex;
20 | let j = 1;
21 | while (i > endIndex) {
22 | // Every 3 characters, add a comma
23 | if (j % 3 === 0) {
24 | val = editString(val, options.thousands, i);
25 | }
26 | i--;
27 | j++;
28 | }
29 |
30 | return val;
31 | };
32 |
33 | export const partialFormat = (val: string, options: IOptions): string => {
34 | val = val.replace(new RegExp(`[${options.thousands}]`, "g"), "");
35 | val = removeLeadingZeros(val, options);
36 | val = removeExtraDecimals(val, options);
37 | val = formatThousands(val, options);
38 |
39 | return val;
40 | };
41 |
42 | export const fullFormat = (val: string, options: IOptions): string => {
43 | val = partialFormat(val, options);
44 |
45 | if (val === "") {
46 | return "";
47 | }
48 |
49 | // Fully format decimal places
50 | const decimalIndex = getDecimalIndex(val, options.decimal);
51 | const sign = val[0] === "-" ? val[0] : "";
52 | let integerPart = val.slice(sign ? 1 : 0, decimalIndex);
53 | let decimalPart = val.slice(decimalIndex + 1);
54 |
55 | if (options.fixed) {
56 |
57 | // If there should be some decimals
58 | if (options.scale > 0) {
59 | decimalPart = decimalPart.length >= options.scale
60 | ? decimalPart.slice(0, options.scale)
61 | : decimalPart + Array(options.scale - decimalPart.length + 1).join("0");
62 |
63 | if (!integerPart.length) {
64 | integerPart = "0";
65 | }
66 |
67 | return `${sign}${integerPart}${options.decimal}${decimalPart}`;
68 | } else {
69 | return `${sign}${integerPart}`;
70 | }
71 | } else {
72 | return val;
73 | }
74 | };
75 |
76 | const removeLeadingZeros = (val: string, options: IOptions): string => {
77 | const decimalIndex = getDecimalIndex(val, options.decimal);
78 | const sign = val[0] === "-" ? val[0] : "";
79 | let integerPart = val.slice(sign ? 1 : 0, decimalIndex + 1);
80 | const decimalPart = val.slice(decimalIndex + 1);
81 |
82 | const i = 0;
83 |
84 | while (
85 | integerPart[i] === "0"
86 | && integerPart[i + 1] !== options.decimal
87 | && integerPart.length > 1
88 | ) {
89 | integerPart = integerPart.slice(0, i) + integerPart.slice(i + 1);
90 | }
91 |
92 | return `${sign}${integerPart}${decimalPart}`;
93 | };
94 |
95 | const removeExtraDecimals = (val: string, options: IOptions): string => {
96 | const decimalIndex = getDecimalIndex(val, options.decimal);
97 | const integerPart = val.slice(0, decimalIndex + 1);
98 | const decimalPart = val.slice(decimalIndex + 1)
99 | .slice(0, options.scale);
100 |
101 | return `${integerPart}${decimalPart}`;
102 | };
103 |
104 | export const allowedDecimal = (val: string, options: IOptions): boolean => {
105 | const decimalPart = val.slice(getDecimalIndex(val, options.decimal) + 1);
106 | return decimalPart.length <= options.scale;
107 | };
108 |
109 | export const calculateOffset = (prev: string, curr: string, pos: number, options: IOptions): number => {
110 | let i;
111 | let prevSymbols = 0;
112 | let currentSymbols = 0;
113 |
114 | for (i = 0; i < pos; i++) {
115 | if (prev[i] === options.thousands) {
116 | prevSymbols++;
117 | }
118 | }
119 | for (i = 0; i < pos; i++) {
120 | if (curr[i] === options.thousands) {
121 | currentSymbols++;
122 | }
123 | }
124 | return currentSymbols - prevSymbols;
125 | };
126 |
127 | export const allowedZero = (val: string, char: string, caretPos: number, options: IOptions): boolean => {
128 | if (char !== "0") {
129 | return true;
130 | }
131 |
132 | const isNegative = val[0] === "-";
133 | const integerPart = val.slice((isNegative ? 1 : 0), getDecimalIndex(val, options.decimal));
134 | caretPos = isNegative ? caretPos - 1 : caretPos;
135 |
136 | // If there is some integer part and the caret is to the left of
137 | // the decimal point
138 | if ((integerPart.length > 0) && (caretPos < integerPart.length + 1)) {
139 | // IF integer part is just a zero then no zeros can be added
140 | // ELSE the zero can not be added at the front of the value
141 | return integerPart === "0" ? false : caretPos > 0;
142 | } else {
143 | return true;
144 | }
145 | };
146 |
147 | export const formattedToRaw = (formattedValue: string, options: IOptions): number | undefined => {
148 | if (is.not.string(formattedValue)) {
149 | return NaN;
150 | }
151 |
152 | if (!formattedValue.length) {
153 | return undefined;
154 | }
155 |
156 | // Number(...) accepts thousands ',' or '' and decimal '.' so we must:
157 |
158 | // 1. Remove thousands delimiter to cover case it is not ','
159 | // Cannot replace with ',' in case decimal uses this
160 | formattedValue = formattedValue.replace(new RegExp(`[${options.thousands}]`, "g"), "");
161 |
162 | // 2. Replace decimal with '.' to cover case it is not '.'
163 | // Ok to replace as thousands delimiter removed above
164 | formattedValue = formattedValue.replace(new RegExp(`[${options.decimal}]`, "g"), ".");
165 | return Number(formattedValue);
166 | };
167 |
168 | export const rawToFormatted = (rawValue: number, options: IOptions): string => {
169 | if (is.not.number(rawValue) || is.not.finite(rawValue)) {
170 | return "";
171 | }
172 |
173 | let stringValue = String(rawValue);
174 |
175 | // String(...) has normalised formatting of:
176 | const rawThousands = ",";
177 | const rawDecimal = ".";
178 |
179 | // ensure string we are returning adheres to options
180 | stringValue = stringValue.replace(new RegExp(`[${rawThousands}]`, "g"), options.thousands);
181 | stringValue = stringValue.replace(new RegExp(`[${rawDecimal}]`, "g"), options.decimal);
182 |
183 | return stringValue;
184 | };
185 |
186 | export const parseString = (str: string, options: IOptions): string => {
187 | let multiplier = 1;
188 | let parsed = "";
189 |
190 | for (const c of str) {
191 | if (!isNaN(Number(c))) { // If a number
192 | parsed += c;
193 | } else if (c === options.decimal && parsed.indexOf(c) === -1) { // If a decimal (and no decimals exist so far)
194 | parsed += options.decimal;
195 | } else if (options.shortcuts[c]) { // If a shortcut
196 | multiplier *= options.shortcuts[c];
197 | } else if (c === "-" && !parsed.length) { // If a minus sign (and parsed string is currently empty)
198 | parsed = c;
199 | }
200 | }
201 |
202 | if (!parsed.length) {
203 | return "";
204 | }
205 |
206 | // Need to ensure that delimiter is a '.' before parsing to number
207 | const normalisedNumber = formattedToRaw(parsed, options);
208 | // Then swap it back in
209 | const adjusted = rawToFormatted((normalisedNumber || 0) * multiplier, options);
210 | const tooLarge = adjusted.indexOf("e") !== -1;
211 |
212 | if (tooLarge) {
213 | return "";
214 | } else {
215 | return adjusted;
216 | }
217 | };
218 |
--------------------------------------------------------------------------------
/src/key.ts:
--------------------------------------------------------------------------------
1 | import { IKeyInfo } from "../index";
2 | import { Key } from "./constants";
3 |
4 | const isMac = (): boolean => navigator.platform.toUpperCase().indexOf("MAC") >= 0;
5 |
6 | export const isPrintable = (keyInfo: IKeyInfo) => {
7 | let isOneChar: boolean = keyInfo.name.length === 1;
8 | let hasBrowserShortcutKey: boolean;
9 |
10 | if (isMac()) {
11 | hasBrowserShortcutKey = keyInfo.modifiers.indexOf(Key.META) > -1;
12 | } else {
13 | const alt = keyInfo.modifiers.indexOf(Key.ALT) > -1;
14 | const ctrl = keyInfo.modifiers.indexOf(Key.CTRL) > -1;
15 | hasBrowserShortcutKey = alt || ctrl;
16 | }
17 |
18 | // Older browsers use these for the 'key' element of KeyboardEvents for the numpad.
19 | const numpadKeys: string[] = [Key.NUMPAD_ADD, Key.NUMPAD_SUBTRACT, Key.NUMPAD_MULTIPLY, Key.NUMPAD_DIVIDE];
20 | const isNumpad: boolean = numpadKeys.indexOf(keyInfo.name) > -1;
21 | isOneChar = isOneChar || isNumpad;
22 |
23 | return (isOneChar && !hasBrowserShortcutKey);
24 | };
25 |
26 | export const getPressedModifiers = (event: KeyboardEvent): string[] => {
27 | const modifierKeys: Array = [Key.META, Key.CTRL, Key.SHIFT, Key.ALT];
28 | return modifierKeys.filter((key: keyof KeyboardEvent) => event[key]);
29 | };
30 |
31 | export const getHistoryKey = (): Key => {
32 | return isMac() ? Key.META : Key.CTRL;
33 | };
34 |
--------------------------------------------------------------------------------
/src/keyHandlers.ts:
--------------------------------------------------------------------------------
1 | import { Range } from "./constants";
2 | import * as helpers from "./helpers";
3 | import * as keyUtils from "./key";
4 | import ValueHistory from "./valueHistory";
5 |
6 | import { ActionHandler, IKeyInfo, IOptions, IState } from "../index";
7 |
8 | export const onNumber: ActionHandler = (currentState: IState, keyInfo: IKeyInfo, options: IOptions): IState => {
9 | // Remove characters in current selection
10 | const tempCurrent = helpers.editString(currentState.value, "", currentState.caretStart,
11 | currentState.caretEnd);
12 | const tempNew = helpers.editString(currentState.value, keyInfo.name, currentState.caretStart,
13 | currentState.caretEnd);
14 |
15 | const allowedNumber =
16 | !(currentState.value[0] === "-"
17 | && currentState.caretStart === 0
18 | && currentState.caretEnd === 0)
19 | && helpers.allowedZero(tempCurrent, keyInfo.name, currentState.caretStart, options)
20 | && helpers.allowedDecimal(tempNew, options);
21 |
22 | const newState = { ...currentState };
23 | if (allowedNumber) {
24 | newState.value = helpers.editString(currentState.value, keyInfo.name, currentState.caretStart,
25 | currentState.caretEnd);
26 | newState.caretStart += 1;
27 | } else {
28 | newState.valid = false;
29 | }
30 |
31 | return newState;
32 | };
33 |
34 | export const onMinus: ActionHandler = (currentState: IState, keyInfo: IKeyInfo, options: IOptions): IState => {
35 | const minusAllowed = currentState.caretStart === 0
36 | && (currentState.value[0] !== "-" || currentState.caretEnd > 0)
37 | && options.range !== Range.POSITIVE;
38 |
39 | const newState = { ...currentState };
40 | if (minusAllowed) {
41 | newState.value = helpers.editString(
42 | currentState.value,
43 | "-",
44 | currentState.caretStart,
45 | currentState.caretEnd,
46 | );
47 | newState.caretStart += 1;
48 | } else {
49 | newState.valid = false;
50 | }
51 |
52 | return newState;
53 | };
54 |
55 | export const onDecimal: ActionHandler = (currentState: IState, keyInfo: IKeyInfo, options: IOptions): IState => {
56 | const decimalIndex = currentState.value.indexOf(options.decimal);
57 |
58 | // If there is not already a decimal or the original would be replaced
59 | // Add the decimal
60 | const decimalAllowed =
61 | options.scale > 0
62 | && (decimalIndex === -1
63 | || (decimalIndex >= currentState.caretStart
64 | && decimalIndex < currentState.caretEnd));
65 |
66 | const newState = { ...currentState };
67 | if (decimalAllowed) {
68 | newState.value = helpers.editString(
69 | currentState.value,
70 | options.decimal,
71 | currentState.caretStart,
72 | currentState.caretEnd,
73 | );
74 | newState.caretStart += 1;
75 | } else {
76 | newState.valid = false;
77 | }
78 |
79 | return newState;
80 | };
81 |
82 | export const onThousands: ActionHandler = (currentState: IState): IState => {
83 | const newState = { ...currentState };
84 | newState.valid = false;
85 | return newState;
86 | };
87 |
88 | export const onShortcut: ActionHandler = (currentState: IState, keyInfo: IKeyInfo, options: IOptions): IState => {
89 | const multiplier = options.shortcuts[keyInfo.name] || 1;
90 | const adjustedVal = helpers.editString(currentState.value, "", currentState.caretStart, currentState.caretEnd);
91 | const rawValue = (helpers.formattedToRaw(adjustedVal, options) || 1) * multiplier;
92 |
93 | const newState = { ...currentState };
94 | if (multiplier) {
95 | // If number contains 'e' then it is too large to display
96 | if (rawValue.toString().indexOf("e") === -1) {
97 | newState.value = String(rawValue);
98 | }
99 | newState.caretStart = newState.value.length + Math.log10(1000);
100 | }
101 |
102 | return newState;
103 | };
104 |
105 | export const onBackspace: ActionHandler = (currentState: IState, keyInfo: IKeyInfo): IState => {
106 | let firstHalf;
107 | let lastHalf;
108 |
109 | const newState = { ...currentState };
110 | if (currentState.caretStart === currentState.caretEnd) {
111 | if (keyInfo.modifiers.length) {
112 | // If CTRL key is held down - delete everything BEFORE caret
113 | firstHalf = "";
114 | lastHalf = currentState.value.slice(currentState.caretStart, currentState.value.length);
115 | newState.caretStart = 0;
116 | } else {
117 | // Assume as there is a comma then there must be a number before it
118 | let caretJump = 1;
119 |
120 | caretJump = ((currentState.caretStart - caretJump) >= 0) ? caretJump : 0;
121 | firstHalf = currentState.value.slice(0, currentState.caretStart - caretJump);
122 | lastHalf = currentState.value.slice(currentState.caretStart, currentState.value.length);
123 | newState.caretStart += -caretJump;
124 | }
125 | } else {
126 | // Same code as onDelete handler for deleting a selection range
127 | firstHalf = currentState.value.slice(0, currentState.caretStart);
128 | lastHalf = currentState.value.slice(currentState.caretEnd, currentState.value.length);
129 | }
130 |
131 | newState.value = firstHalf + lastHalf;
132 |
133 | return newState;
134 | };
135 |
136 | export const onDelete: ActionHandler = (currentState: IState, keyInfo: IKeyInfo, options: IOptions): IState => {
137 | const thousands = options.thousands;
138 | let firstHalf;
139 | let lastHalf;
140 |
141 | const newState = { ...currentState };
142 | if (currentState.caretStart === currentState.caretEnd) {
143 | const nextChar = currentState.value[currentState.caretStart];
144 |
145 | // TODO: this was originally typed as "modifierKey", write tests for these cases
146 | if (keyInfo.modifiers.length) {
147 | // If CTRL key is held down - delete everything AFTER caret
148 | firstHalf = currentState.value.slice(0, currentState.caretStart);
149 | lastHalf = "";
150 | } else {
151 | // Assume as there is a comma then there must be a number after it
152 | const thousandsNext = nextChar === thousands;
153 |
154 | // If char to delete is thousands and number is not to be deleted - skip over it
155 | newState.caretStart += thousandsNext ? 1 : 0;
156 |
157 | const lastHalfStart = newState.caretStart
158 | + (thousandsNext ? 0 : 1);
159 | firstHalf = currentState.value.slice(0, newState.caretStart);
160 | lastHalf = currentState.value.slice(lastHalfStart, currentState.value.length);
161 | }
162 | } else {
163 | // Same code as onBackspace handler for deleting a selection range
164 | firstHalf = currentState.value.slice(0, currentState.caretStart);
165 | lastHalf = currentState.value.slice(currentState.caretEnd, currentState.value.length);
166 | }
167 |
168 | newState.value = firstHalf + lastHalf;
169 |
170 | return newState;
171 | };
172 |
173 | export const onUndo: ActionHandler = (currentState: IState, keyInfo: IKeyInfo,
174 | options: IOptions, history: ValueHistory): IState => {
175 | const newState = { ...currentState };
176 | newState.value = history.undo();
177 | newState.caretStart = newState.value.length;
178 |
179 | return newState;
180 | };
181 |
182 | export const onRedo: ActionHandler = (currentState: IState, keyInfo: IKeyInfo,
183 | options: IOptions, history: ValueHistory): IState => {
184 | const newState = { ...currentState };
185 | newState.value = history.redo();
186 | newState.caretStart = newState.value.length;
187 |
188 | return newState;
189 | };
190 |
191 | export const onUnknown: ActionHandler = (currentState: IState, keyInfo: IKeyInfo) => {
192 | const newState = { ...currentState };
193 | newState.valid = !keyUtils.isPrintable(keyInfo);
194 |
195 | return newState;
196 | };
197 |
--------------------------------------------------------------------------------
/src/valueHistory.ts:
--------------------------------------------------------------------------------
1 | export default class ValueHistory {
2 | public history: string[] = [];
3 | public currentIndex: number = 0;
4 |
5 | get currentValue(): string {
6 | return this.history[this.currentIndex];
7 | }
8 |
9 | public undo(): string {
10 | if (this.currentIndex > 0) {
11 | this.currentIndex--;
12 | }
13 | return this.currentValue;
14 | }
15 |
16 | public redo(): string {
17 | if (this.currentIndex < this.history.length - 1) {
18 | this.currentIndex++;
19 | }
20 | return this.currentValue;
21 | }
22 |
23 | public addValue(val: string): string {
24 | // Delete everything AFTER current value
25 | if (val !== this.currentValue) {
26 | this.history = [...this.history.slice(0, this.currentIndex), val];
27 |
28 | if (this.history.length > 50) {
29 | this.history.shift();
30 | }
31 | }
32 |
33 | this.currentIndex = this.history.length - 1;
34 |
35 | return this.currentValue;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/test/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "node": "8.1.4" // matches engine in package.json
8 | }
9 | }
10 | ]
11 | ]
12 | }
--------------------------------------------------------------------------------
/test/capabilities.js:
--------------------------------------------------------------------------------
1 | import {Capability, Browser} from 'selenium-webdriver';
2 |
3 | const capabilities = {
4 | [Capability.PLATFORM]: 'WINDOWS',
5 | [Capability.BROWSER_NAME]: Browser.CHROME,
6 | };
7 |
8 | // browserstack specific capabilities - https://www.browserstack.com/automate/capabilities
9 | if (process.env.CI) {
10 | capabilities['browserstack.local'] = true;
11 | capabilities['browserstack.localIdentifier'] = process.env.BROWSERSTACK_LOCAL_IDENTIFIER || process.env.TRAVIS_JOB_NUMBER;
12 | capabilities['browserstack.networkLogs'] = true;
13 | capabilities.os = 'WINDOWS';
14 | capabilities.os_version = '10';
15 | capabilities.browser = Browser.CHROME;
16 | capabilities.browser_version = '73';
17 | }
18 |
19 | export default capabilities;
--------------------------------------------------------------------------------
/test/customCommands.js:
--------------------------------------------------------------------------------
1 | import {Key, WebElement} from 'selenium-webdriver';
2 | import {nativeText, finputSwitchOptionsButton} from './pageObjects/index';
3 | import {isMac, isChrome, getModifierKey, driver} from './helpers';
4 | import {mapKeys} from './keys';
5 |
6 | const shouldSkipModifierKeyTest = async () => {
7 | const mac = await isMac();
8 | const chrome = await isChrome();
9 | return mac && chrome;
10 | };
11 |
12 | export default (finputElement) => {
13 | const typing = (keys) => {
14 | let blurAfter = false;
15 | let pressModifier = false;
16 | let switchDelimiter = false;
17 |
18 | const chainFunctions = {};
19 |
20 |
21 | chainFunctions.thenSwitchingDelimiters = () => {
22 | switchDelimiter = true;
23 | return chainFunctions;
24 | };
25 |
26 | chainFunctions.thenBlurring = () => {
27 | blurAfter = true;
28 | return chainFunctions;
29 | };
30 |
31 | chainFunctions.whileModifierPressed = () => {
32 | pressModifier = true;
33 | return chainFunctions;
34 | };
35 |
36 | chainFunctions.shouldShow = (expected) => {
37 | const withModifierMsg = pressModifier ? "with modifier key" : "";
38 | const testName = `should show "${expected}" when "${keys}" ${keys.length === 1 ? 'is' : 'are' } pressed ${withModifierMsg}`;
39 |
40 | it(testName, async () => {
41 | await finputElement().clear();
42 | await finputElement().click();
43 |
44 | if (pressModifier) {
45 | const mac = await isMac();
46 | const chrome = await isChrome();
47 |
48 | if (mac && chrome) {
49 | console.warn(`Skipping test as Command key fails on Chrome/Mac. Note that this will show as a passing test. Test: '${testName}'`);
50 | return;
51 | }
52 |
53 | const modifierKey = await getModifierKey();
54 | await finputElement().sendKeys(Key.chord(modifierKey, mapKeys(keys)));
55 | } else {
56 | await finputElement().sendKeys(mapKeys(keys));
57 | }
58 |
59 | if (switchDelimiter) {
60 | await finputSwitchOptionsButton().click();
61 | }
62 |
63 | if (blurAfter) {
64 | await nativeText().click();
65 | }
66 |
67 | const observed = await finputElement().getAttribute('value');
68 | expect(observed).toBe(expected);
69 | });
70 |
71 | return chainFunctions;
72 | };
73 |
74 | chainFunctions.shouldHaveFocus = (expected) => {
75 | it(`should have focus: ` + expected, async () => {
76 | const element = await finputElement();
77 | const activeElement = await driver.switchTo().activeElement();
78 | const observed = await WebElement.equals(element, activeElement);
79 | expect(observed).toBe(expected);
80 | });
81 |
82 | return chainFunctions;
83 | };
84 |
85 | chainFunctions.shouldHaveCaretAt = (expected) => {
86 | it('should have caret at index: ' + expected, async () => {
87 | const selection = await driver.executeScript(() => {
88 | return [document.activeElement.selectionStart, document.activeElement.selectionEnd];
89 | });
90 |
91 | expect(selection[0]).toEqual(selection[1]); // no selection, only caret cursor
92 | expect(selection[0]).toEqual(expected);
93 | });
94 |
95 | return chainFunctions;
96 | };
97 |
98 | return chainFunctions;
99 | };
100 |
101 | const copyingAndPasting = (text) => {
102 | const chainFunctions = {};
103 |
104 | chainFunctions.shouldShow = (expected) => {
105 | const testName = `should show "${expected}" when "${text}" is copied and pasted`;
106 | it(testName, async () => {
107 | if (await shouldSkipModifierKeyTest()) {
108 | console.warn(`Skipping test as Command key fails on Chrome/Mac. Note that this will show as a passing test. Test: '${testName}'`)
109 | return;
110 | }
111 | const modifierKey = await getModifierKey();
112 |
113 | await nativeText().clear();
114 | await nativeText().click();
115 | await nativeText().sendKeys(text);
116 | await nativeText().sendKeys(Key.chord(modifierKey, 'a'));
117 | await nativeText().sendKeys(Key.chord(modifierKey, 'c'));
118 | await nativeText().clear();
119 |
120 | await finputElement().clear();
121 | await finputElement().click();
122 | await finputElement().sendKeys(Key.chord(modifierKey, 'v'));
123 |
124 | const observed = await finputElement().getAttribute('value');
125 | expect(observed).toBe(expected);
126 | });
127 |
128 | return chainFunctions;
129 | };
130 |
131 | return chainFunctions;
132 | };
133 |
134 | const cutting = (count) => {
135 | let text, startPos;
136 | const chainFunctions = {};
137 |
138 | chainFunctions.characters = () => chainFunctions;
139 |
140 | chainFunctions.from = (t) => {
141 | text = t;
142 | return chainFunctions;
143 | };
144 |
145 | chainFunctions.startingFrom = (start) => {
146 | startPos = start;
147 | return chainFunctions;
148 | };
149 |
150 | chainFunctions.shouldShow = (expected) => {
151 | const testName = `should show "${expected}" when "${text}" has chars cut`;
152 | it(testName, async () => {
153 | if (await shouldSkipModifierKeyTest()) {
154 | console.warn(`Skipping test as Command key fails on Chrome/Mac. Note that this will show as a passing test. Test: '${testName}'`)
155 | return;
156 | }
157 | const modifierKey = await getModifierKey();
158 |
159 | await finputElement().clear();
160 | await finputElement().click();
161 | await finputElement().sendKeys(text);
162 | await finputElement().sendKeys(Key.chord(modifierKey, 'a'));
163 | await finputElement().sendKeys(mapKeys('←'));
164 | await finputElement().sendKeys(Array(startPos + 1).join(mapKeys('→')));
165 | await finputElement().sendKeys(Key.chord(Key.SHIFT, Array(count + 1).join(mapKeys('→'))));
166 | await finputElement().sendKeys(Key.chord(modifierKey, 'x'));
167 |
168 | const observed = await finputElement().getAttribute('value');
169 | expect(observed).toBe(expected);
170 | });
171 | return chainFunctions;
172 | };
173 |
174 | return chainFunctions;
175 | };
176 |
177 | return {typing, copyingAndPasting, cutting};
178 | };
179 |
--------------------------------------------------------------------------------
/test/helpers.js:
--------------------------------------------------------------------------------
1 | import chrome from 'selenium-webdriver/chrome';
2 | import {Builder, Key, Capability, Browser} from 'selenium-webdriver';
3 | import capabilities from './capabilities';
4 |
5 | const Platform = {
6 | MAC: 'MAC'
7 | };
8 |
9 | const getSeleniumURL = () => {
10 | if (process.env.BROWSERSTACK_USERNAME && process.env.BROWSERSTACK_ACCESS_KEY) {
11 | return `http://${process.env.BROWSERSTACK_USERNAME}:${process.env.BROWSERSTACK_ACCESS_KEY}` +
12 | `@hub-cloud.browserstack.com/wd/hub`;
13 | }
14 |
15 | return 'http://localhost:4444/wd/hub';
16 | };
17 |
18 | export const driver = new Builder()
19 | .withCapabilities(capabilities)
20 | .setChromeOptions(
21 | new chrome.Options()
22 | .addArguments('--disable-dev-shm-usage')
23 | .addArguments('--disable-extensions')
24 | .addArguments('--no-sandbox')
25 | .headless()
26 | )
27 | .usingServer(getSeleniumURL())
28 | .build();
29 |
30 | export const isMac = async () => {
31 | const capabilities = await driver.getCapabilities();
32 | const os = capabilities.get(Capability.PLATFORM);
33 | return os.toUpperCase().indexOf(Platform.MAC) >= 0;
34 | };
35 |
36 | export const isBrowser = async (browserName) => {
37 | const capabilities = await driver.getCapabilities();
38 | const browser = capabilities.get(Capability.BROWSER_NAME);
39 | return browser.indexOf(browserName) >= 0;
40 | };
41 |
42 | export const isChrome = async () => isBrowser(Browser.CHROME);
43 |
44 | export const getModifierKey = async () => {
45 | const mac = await isMac();
46 | return mac ? Key.COMMAND : Key.CONTROL;
47 | };
48 |
49 | afterAll(async () => {
50 | // Cleanup `process.on('exit')` event handlers to prevent a memory leak caused by the combination of `jest` & `tmp`.
51 | for (const listener of process.listeners('exit')) {
52 | listener();
53 | process.removeListener('exit', listener);
54 | }
55 | await driver.quit();
56 | });
57 |
58 | export const defaultTimeout = 10e3;
59 |
--------------------------------------------------------------------------------
/test/jestConfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "setupFilesAfterEnv": ["./setupTests.js"],
3 | "testMatch": ["**/*.js"],
4 | "globals": {
5 | "__baseUrl__": "http://localhost:8080"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/test/keys.js:
--------------------------------------------------------------------------------
1 | const keyMap = {
2 | '↚': '\u{e003}', // BACKSPACE
3 | '↛': '\u{e017}', // DEL
4 | '←': '\u{e012}', // ARROW KEYS
5 | '↑': '\u{e013}',
6 | '→': '\u{e014}',
7 | '↓': '\u{e015}',
8 | '⇤': '\u{e011}', // HOME
9 | '⇥': '\u{e010}', // END
10 | '⓪': '\u{e01a}', // NUM KEYS
11 | '①': '\u{e01b}',
12 | '②': '\u{e01c}',
13 | '③': '\u{e01d}',
14 | '④': '\u{e01e}',
15 | '⑤': '\u{e01f}',
16 | '⑥': '\u{e020}',
17 | '⑦': '\u{e021}',
18 | '⑧': '\u{e022}',
19 | '⑨': '\u{e023}'
20 | };
21 |
22 | export const mapKeys = (keys) => keys.replace(/./g, (c) => keyMap[c] || c);
23 |
--------------------------------------------------------------------------------
/test/pageObjects/index.js:
--------------------------------------------------------------------------------
1 | import {until} from 'selenium-webdriver';
2 | import {driver, defaultTimeout} from '../helpers';
3 |
4 | const rootSelector = {css: '#root'};
5 | const finputDefaultSelector = {css: '#finput-default'};
6 | const finputReversedDelimitersSelector = {css: '#finput-reversed-delimiters'};
7 | const finputSwitchOptionsSelector = {css: '#finput-switch-options'};
8 | const finputSwitchOptionsButtonSelector = {css: '#finput-switch-options-button'};
9 | const nativeTextSelector = {css: '#native-text'};
10 |
11 | export const finputDefaultDelimiters = () => driver.findElement(finputDefaultSelector);
12 | export const finputReversedDelimiters = () => driver.findElement(finputReversedDelimitersSelector);
13 | export const finputSwitchOptions = () => driver.findElement(finputSwitchOptionsSelector);
14 | export const finputSwitchOptionsButton = () => driver.findElement(finputSwitchOptionsButtonSelector);
15 | export const nativeText = () => driver.findElement(nativeTextSelector);
16 |
17 | const root = () => driver.findElement(rootSelector);
18 | export const load = async () => {
19 | await driver.get(`${__baseUrl__}/`);
20 | await driver.wait(until.elementLocated(root), defaultTimeout);
21 | };
22 |
--------------------------------------------------------------------------------
/test/setupTests.js:
--------------------------------------------------------------------------------
1 | process.env.USE_PROMISE_MANAGER = false;
2 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 60e3;
3 |
--------------------------------------------------------------------------------
/test/specs/copy-paste.js:
--------------------------------------------------------------------------------
1 | import {load, finputReversedDelimiters, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | describe('copy and paste', () => {
5 | beforeAll(load);
6 |
7 | describe('default delimiters', () => {
8 | const {copyingAndPasting} = customCommandsFactory(finputDefaultDelimiters);
9 |
10 | copyingAndPasting(`aaaaa`).shouldShow(``);
11 | copyingAndPasting(`-12`).shouldShow(`-12.00`);
12 | copyingAndPasting(`-.9`).shouldShow(`-0.90`);
13 | copyingAndPasting(`7a7a.8a.`).shouldShow(`77.80`);
14 | });
15 |
16 | describe('reversed delimiters', () => {
17 | const {copyingAndPasting} = customCommandsFactory(finputReversedDelimiters);
18 |
19 | copyingAndPasting(`aaaaa`).shouldShow(``);
20 | copyingAndPasting(`-12`).shouldShow(`-12,00`);
21 | copyingAndPasting(`-,9`).shouldShow(`-0,90`);
22 | copyingAndPasting(`7a7a,8a,`).shouldShow(`77,80`);
23 | });
24 | });
--------------------------------------------------------------------------------
/test/specs/cutting.js:
--------------------------------------------------------------------------------
1 | import {load, finputReversedDelimiters, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | describe('cutting', () => {
5 | beforeAll(load);
6 |
7 | describe('default delimiters', () => {
8 | const {cutting} = customCommandsFactory(finputDefaultDelimiters);
9 |
10 | // Cutting from input (should fully format unless no characters selected)
11 | // None selected
12 | cutting(0).characters().from(`123456`).startingFrom(0).shouldShow(`123,456`);
13 | cutting(2).characters().from(`12`).startingFrom(2).shouldShow(`12`);
14 |
15 | cutting(4).characters().from(`123456`).startingFrom(1).shouldShow(`156.00`);
16 | cutting(5).characters().from(`1234`).startingFrom(0).shouldShow(``);
17 | });
18 |
19 | describe('reversed delimiters', () => {
20 | const {cutting} = customCommandsFactory(finputReversedDelimiters);
21 |
22 | // Cutting from input (should fully format unless no characters selected)
23 | // None selected
24 | cutting(0).characters().from(`123456`).startingFrom(0).shouldShow(`123.456`);
25 | cutting(2).characters().from(`12`).startingFrom(2).shouldShow(`12`);
26 |
27 | cutting(4).characters().from(`123456`).startingFrom(1).shouldShow(`156,00`);
28 | cutting(5).characters().from(`1234`).startingFrom(0).shouldShow(``);
29 | });
30 | });
--------------------------------------------------------------------------------
/test/specs/deletions.js:
--------------------------------------------------------------------------------
1 | import {load, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | const {typing} = customCommandsFactory(finputDefaultDelimiters);
5 |
6 | describe('deletions', () => {
7 | beforeAll(load);
8 |
9 | describe('deletes digit', () => {
10 | describe('when BACKSPACE is pressed while caret is positioned directly ahead of it', () => {
11 | typing('123456.78↚').shouldShow('123,456.7').shouldHaveCaretAt(9);
12 | typing('123456.78↚↚').shouldShow('123,456.').shouldHaveCaretAt(8);
13 | typing('123456.78↚↚↚').shouldShow('123,456').shouldHaveCaretAt(7);
14 | typing('123456.78↚↚↚↚').shouldShow('12,345').shouldHaveCaretAt(6);
15 | typing('123456.78↚↚↚↚↚').shouldShow('1,234').shouldHaveCaretAt(5);
16 | typing('123456.78↚↚↚↚↚↚').shouldShow('123').shouldHaveCaretAt(3);
17 | typing('123456.78↚↚↚↚↚↚↚').shouldShow('12').shouldHaveCaretAt(2);
18 | typing('123456.78↚↚↚↚↚↚↚↚').shouldShow('1').shouldHaveCaretAt(1);
19 |
20 | typing('123456←↚').shouldShow('12,346').shouldHaveCaretAt(5);
21 | typing('123456←←↚').shouldShow('12,356').shouldHaveCaretAt(4);
22 | typing('123456←←←←↚').shouldShow('12,456').shouldHaveCaretAt(2);
23 | typing('123456←←←←←←↚').shouldShow('23,456').shouldHaveCaretAt(0);
24 | });
25 |
26 | describe('when DELETE is pressed while caret is positioned directly behind of it', () => {
27 | typing('123456.78⇤↛').shouldShow('23,456.78').shouldHaveCaretAt(0);
28 | typing('123456.78⇤↛↛').shouldShow('3,456.78').shouldHaveCaretAt(0);
29 | typing('123456.78⇤↛↛↛').shouldShow('456.78').shouldHaveCaretAt(0);
30 | typing('123456.78⇤↛↛↛↛').shouldShow('56.78').shouldHaveCaretAt(0);
31 | typing('123456.78⇤↛↛↛↛↛').shouldShow('6.78').shouldHaveCaretAt(0);
32 | typing('123456.78⇤↛↛↛↛↛↛').shouldShow('.78').shouldHaveCaretAt(0);
33 | typing('123456.78⇤↛↛↛↛↛↛↛').shouldShow('78').shouldHaveCaretAt(0);
34 | typing('123456.78⇤↛↛↛↛↛↛↛↛').shouldShow('8').shouldHaveCaretAt(0);
35 | typing('123456.78⇤↛↛↛↛↛↛↛↛↛').shouldShow('').shouldHaveCaretAt(0);
36 |
37 | typing('123456.78←↛').shouldShow('123,456.7').shouldHaveCaretAt(9);
38 | typing('123456.78←←↛').shouldShow('123,456.8').shouldHaveCaretAt(8);
39 | typing('123456.78←←←←↛').shouldShow('12,345.78').shouldHaveCaretAt(6);
40 | typing('123456.78←←←←←↛').shouldShow('12,346.78').shouldHaveCaretAt(5);
41 | });
42 | });
43 |
44 | describe('traverses thousands delimiter', () => {
45 | describe('backwards one place if BACKSPACE is pressed when caret is positioned directly ahead of it', () => {
46 | typing('123456←←←↚').shouldShow('123,456').shouldHaveCaretAt(3);
47 | });
48 |
49 | describe('forwards one place if DELETE is pressed when caret is positioned directly behind of it', () => {
50 | typing('123456←←←←↛').shouldShow('123,456').shouldHaveCaretAt(4);
51 | });
52 | });
53 |
54 | describe('deletes decimal delimiter', () => {
55 | describe('when BACKSPACE is pressed while caret is positioned directly ahead of it', () => {
56 | typing('123456.78←←↚').shouldShow('12,345,678').shouldHaveCaretAt(8);
57 | typing('123456.78←←↚.↚').shouldShow('12,345,678').shouldHaveCaretAt(8);
58 | });
59 |
60 | describe('when DELETE is pressed while caret is positioned directly behind of it', () => {
61 | typing('123456.78←←←↛').shouldShow('12,345,678').shouldHaveCaretAt(8);
62 | typing('123456.78←←←↛.←↛').shouldShow('12,345,678').shouldHaveCaretAt(8);
63 | });
64 | })
65 | });
66 |
--------------------------------------------------------------------------------
/test/specs/formatting-decimals.js:
--------------------------------------------------------------------------------
1 | import {load, finputReversedDelimiters, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | describe('formatting decimals', () => {
5 | beforeAll(load);
6 |
7 |
8 | describe('default delimiters', () => {
9 | const {typing} = customCommandsFactory(finputDefaultDelimiters);
10 |
11 | describe('while focused', () => {
12 | typing(`0`).shouldShow(`0`);
13 | typing(`10`).shouldShow(`10`);
14 | typing(`1←0`).shouldShow(`1`);
15 | typing(`0.5←0`).shouldShow(`0.05`);
16 | typing(`0.5←0`).shouldShow(`0.05`);
17 | typing(`0.5←←0`).shouldShow(`0.5`);
18 | typing(`1.5←←0`).shouldShow(`10.5`);
19 | typing(`0.5←←7`).shouldShow(`7.5`);
20 | typing(`0.5←←←0`).shouldShow(`0.5`);
21 | typing(`.8`).shouldShow(`.8`);
22 | typing(`.8←0`).shouldShow(`.08`);
23 | typing(`.8←←0`).shouldShow(`0.8`);
24 | typing(`123456←←←←←.`).shouldShow(`12.34`);
25 | typing(`12.345`).shouldShow(`12.34`);
26 | typing(`12.34←←↚`).shouldShow(`1,234`);
27 | typing(`12.34←←↚`).shouldShow(`1,234`);
28 | });
29 |
30 | describe('on blur', () => {
31 | typing(`0.8`).thenBlurring().shouldShow(`0.80`);
32 | typing(`.8`).thenBlurring().shouldShow(`0.80`);
33 | typing(`8.88`).thenBlurring().shouldShow(`8.88`);
34 | });
35 | });
36 |
37 | describe('reversed delimiters', () => {
38 | const {typing} = customCommandsFactory(finputReversedDelimiters);
39 |
40 | describe('while focused', () => {
41 | typing(`0`).shouldShow(`0`);
42 | typing(`10`).shouldShow(`10`);
43 | typing(`1←0`).shouldShow(`1`);
44 | typing(`0,5←0`).shouldShow(`0,05`);
45 | typing(`0,5←0`).shouldShow(`0,05`);
46 | typing(`0,5←←0`).shouldShow(`0,5`);
47 | typing(`1,5←←0`).shouldShow(`10,5`);
48 | typing(`0,5←←7`).shouldShow(`7,5`);
49 | typing(`0,5←←←0`).shouldShow(`0,5`);
50 | typing(`,8`).shouldShow(`,8`);
51 | typing(`,8←0`).shouldShow(`,08`);
52 | typing(`,8←←0`).shouldShow(`0,8`);
53 | typing(`123456←←←←←,`).shouldShow(`12,34`);
54 | typing(`12,345`).shouldShow(`12,34`);
55 | typing(`12,34←←↚`).shouldShow(`1.234`);
56 | typing(`12,34←←↚`).shouldShow(`1.234`);
57 | });
58 |
59 | describe('on blur', () => {
60 | typing(`0,8`).thenBlurring().shouldShow(`0,80`);
61 | typing(`,8`).thenBlurring().shouldShow(`0,80`);
62 | typing(`8,88`).thenBlurring().shouldShow(`8,88`);
63 | })
64 | });
65 |
66 | });
67 |
--------------------------------------------------------------------------------
/test/specs/formatting-negatives.js:
--------------------------------------------------------------------------------
1 | import {load, finputReversedDelimiters, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | describe('formatting negatives', () => {
5 | beforeAll(load);
6 |
7 |
8 | describe('default delimiters', () => {
9 | const {typing} = customCommandsFactory(finputDefaultDelimiters);
10 |
11 | describe('while focused', () => {
12 | typing(`-`).shouldShow(`-`);
13 | typing(`-0`).shouldShow(`-0`);
14 | typing(`--`).shouldShow(`-`);
15 | typing(`-←0`).shouldShow(`-`);
16 | typing(`0-`).shouldShow(`0`);
17 | typing(`0-`).shouldShow(`0`);
18 | typing(`-1000`).shouldShow(`-1,000`);
19 | typing(`-1k`).shouldShow(`-1,000`);
20 | });
21 |
22 | describe('on blur', () => {
23 | typing(`-.`).thenBlurring().shouldShow(`-0.00`);
24 | typing(`-`).thenBlurring().shouldShow(`-0.00`);
25 | typing(`-0`).thenBlurring().shouldShow(`-0.00`);
26 | typing(`-0.`).thenBlurring().shouldShow(`-0.00`);
27 | typing(`-.66`).thenBlurring().shouldShow(`-0.66`);
28 | typing(`-1000`).thenBlurring().shouldShow(`-1,000.00`);
29 | });
30 | });
31 |
32 | describe('reversed delimiters', () => {
33 | const {typing} = customCommandsFactory(finputReversedDelimiters);
34 |
35 | describe('while focused', () => {
36 | typing(`-`).shouldShow(`-`);
37 | typing(`-0`).shouldShow(`-0`);
38 | typing(`--`).shouldShow(`-`);
39 | typing(`-←0`).shouldShow(`-`);
40 | typing(`0-`).shouldShow(`0`);
41 | typing(`0-`).shouldShow(`0`);
42 | typing(`-1000`).shouldShow(`-1.000`);
43 | typing(`-1k`).shouldShow(`-1.000`);
44 | });
45 |
46 | describe('on blur', () => {
47 | typing(`-,`).thenBlurring().shouldShow(`-0,00`);
48 | typing(`-`).thenBlurring().shouldShow(`-0,00`);
49 | typing(`-0`).thenBlurring().shouldShow(`-0,00`);
50 | typing(`-0,`).thenBlurring().shouldShow(`-0,00`);
51 | typing(`-,66`).thenBlurring().shouldShow(`-0,66`);
52 | typing(`-1000`).thenBlurring().shouldShow(`-1.000,00`);
53 | });
54 | });
55 |
56 | });
57 |
--------------------------------------------------------------------------------
/test/specs/modifiers.js:
--------------------------------------------------------------------------------
1 | import {load, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | const {typing} = customCommandsFactory(finputDefaultDelimiters);
5 |
6 | describe('modifiers', () => {
7 | beforeAll(load);
8 |
9 | describe('are not blocked from activating keyboard shortcuts', () => {
10 | // TODO: find a way to test browser shortcuts blur the input (not currently working with chromedriver)
11 | typing('k').whileModifierPressed().shouldShow('1,000');
12 | typing('m').whileModifierPressed().shouldShow('1,000,000');
13 | typing('b').whileModifierPressed().shouldShow('1,000,000,000');
14 |
15 | typing('0').whileModifierPressed().shouldShow('0');
16 | typing('1').whileModifierPressed().shouldShow('1');
17 | typing('2').whileModifierPressed().shouldShow('2');
18 | typing('3').whileModifierPressed().shouldShow('3');
19 |
20 | typing('-').whileModifierPressed().shouldShow('-');
21 | typing('+').whileModifierPressed().shouldShow('');
22 | typing('.').whileModifierPressed().shouldShow('.');
23 | typing(',').whileModifierPressed().shouldShow('');
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/test/specs/shortcuts.js:
--------------------------------------------------------------------------------
1 | import {load, finputReversedDelimiters, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | describe('shortcuts', () => {
5 | beforeAll(load);
6 |
7 | describe('default delimiters', () => {
8 | const {typing} = customCommandsFactory(finputDefaultDelimiters);
9 |
10 | describe('typed into empty field', () => {
11 | // TODO: fix bug which causes shortcuts to be capped to a limit
12 | for (let i = 1; i <= 2; i++) {
13 | typing(`k`.padEnd(i, `k`)).shouldShow(`1` + `,000`.padEnd(i * 4, `,000`));
14 | typing(`m`.padEnd(i, `m`)).shouldShow(`1` + `,000`.padEnd(i * 8, `,000`));
15 | typing(`b`.padEnd(i, `b`)).shouldShow(`1` + `,000`.padEnd(i * 12, `,000`));
16 | }
17 | });
18 |
19 | describe('entered onto end of integer number', () => {
20 | typing(`1k`).shouldShow(`1,000`);
21 | typing(`2m`).shouldShow(`2,000,000`);
22 | typing(`3b`).shouldShow(`3,000,000,000`);
23 |
24 | typing(`1k1`).shouldShow(`10,001`);
25 | typing(`2m2`).shouldShow(`20,000,002`);
26 | typing(`3b3`).shouldShow(`30,000,000,003`);
27 |
28 | typing(`1k1k`).shouldShow(`10,001,000`);
29 | typing(`1m1m`).shouldShow(`10,000,001,000,000`);
30 | typing(`1b1b`).shouldShow(`10,000,000,001,000,000,000`);
31 | });
32 |
33 | describe('entered onto end of decimal number', () => {
34 | typing('.1k').shouldShow('100');
35 | typing('.1m').shouldShow('100,000');
36 | typing('.1b').shouldShow('100,000,000');
37 |
38 | typing('1.1k').shouldShow('1,100');
39 | typing('1.1m').shouldShow('1,100,000');
40 | typing('1.1b').shouldShow('1,100,000,000');
41 |
42 | typing('1.01k').shouldShow('1,010');
43 | typing('1.01m').shouldShow('1,010,000');
44 | typing('1.01b').shouldShow('1,010,000,000');
45 | });
46 |
47 | describe('entered into middle of whole number', () => {
48 | typing('12345←k').shouldShow('12,345,000');
49 | typing('12345←m').shouldShow('12,345,000,000');
50 | typing('12345←b').shouldShow('12,345,000,000,000');
51 |
52 | typing('12345←←k').shouldShow('12,345,000');
53 | typing('12345←←m').shouldShow('12,345,000,000');
54 | typing('12345←←b').shouldShow('12,345,000,000,000');
55 | });
56 |
57 | describe('combined', () => {
58 | typing(`kb`).shouldShow(`1,000,000,000,000`);
59 | typing(`bk`).shouldShow(`1,000,000,000,000`);
60 |
61 | typing(`km`).shouldShow(`1,000,000,000`);
62 | typing(`mk`).shouldShow(`1,000,000,000`);
63 |
64 | typing(`bm`).shouldShow(`1,000,000,000,000,000`);
65 | typing(`mb`).shouldShow(`1,000,000,000,000,000`);
66 | });
67 | });
68 |
69 | describe('reversed delimiters', () => {
70 | const {typing} = customCommandsFactory(finputReversedDelimiters);
71 |
72 | describe('typed into empty field', () => {
73 | // TODO: fix bug which causes shortcuts to be capped to a limit
74 | for (let i = 1; i <= 2; i++) {
75 | typing(`k`.padEnd(i, `k`)).shouldShow(`1` + `.000`.padEnd(i * 4, `.000`));
76 | typing(`m`.padEnd(i, `m`)).shouldShow(`1` + `.000`.padEnd(i * 8, `.000`));
77 | typing(`b`.padEnd(i, `b`)).shouldShow(`1` + `.000`.padEnd(i * 12, `.000`));
78 | }
79 | });
80 |
81 | describe('entered onto end of integer number', () => {
82 | typing(`1k`).shouldShow(`1.000`);
83 | typing(`2m`).shouldShow(`2.000.000`);
84 | typing(`3b`).shouldShow(`3.000.000.000`);
85 |
86 | typing(`1k1`).shouldShow(`10.001`);
87 | typing(`2m2`).shouldShow(`20.000.002`);
88 | typing(`3b3`).shouldShow(`30.000.000.003`);
89 |
90 | typing(`1k1k`).shouldShow(`10.001.000`);
91 | typing(`1m1m`).shouldShow(`10.000.001.000.000`);
92 | typing(`1b1b`).shouldShow(`10.000.000.001.000.000.000`);
93 | });
94 |
95 | describe('entered onto end of decimal number', () => {
96 | typing(',1k').shouldShow('100');
97 | typing(',1m').shouldShow('100.000');
98 | typing(',1b').shouldShow('100.000.000');
99 |
100 | typing('1,1k').shouldShow('1.100');
101 | typing('1,1m').shouldShow('1.100.000');
102 | typing('1,1b').shouldShow('1.100.000.000');
103 |
104 | typing('1,01k').shouldShow('1.010');
105 | typing('1,01m').shouldShow('1.010.000');
106 | typing('1,01b').shouldShow('1.010.000.000');
107 | });
108 |
109 | describe('entered into middle of whole number', () => {
110 | typing('12345←k').shouldShow('12.345.000');
111 | typing('12345←m').shouldShow('12.345.000.000');
112 | typing('12345←b').shouldShow('12.345.000.000.000');
113 |
114 | typing('12345←←k').shouldShow('12.345.000');
115 | typing('12345←←m').shouldShow('12.345.000.000');
116 | typing('12345←←b').shouldShow('12.345.000.000.000');
117 | });
118 |
119 | describe('combined', () => {
120 | typing(`kb`).shouldShow(`1.000.000.000.000`);
121 | typing(`bk`).shouldShow(`1.000.000.000.000`);
122 |
123 | typing(`km`).shouldShow(`1.000.000.000`);
124 | typing(`mk`).shouldShow(`1.000.000.000`);
125 |
126 | typing(`bm`).shouldShow(`1.000.000.000.000.000`);
127 | typing(`mb`).shouldShow(`1.000.000.000.000.000`);
128 | });
129 | });
130 | });
131 |
--------------------------------------------------------------------------------
/test/specs/switching-delimiters.js:
--------------------------------------------------------------------------------
1 | import { load, finputSwitchOptions } from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | const { typing } = customCommandsFactory(finputSwitchOptions);
5 |
6 | describe('Switching delimiters', () => {
7 | beforeAll(load);
8 |
9 | // only decimal
10 | typing('.99').thenSwitchingDelimiters().shouldShow('0,99');
11 | typing(',99').thenSwitchingDelimiters().shouldShow('0.99');
12 | typing('.99').thenSwitchingDelimiters().shouldShow('0,99');
13 | typing(',99').thenSwitchingDelimiters().shouldShow('0.99');
14 |
15 | typing('-.99').thenSwitchingDelimiters().shouldShow('-0,99');
16 | typing('-,99').thenSwitchingDelimiters().shouldShow('-0.99');
17 | typing('-.99').thenSwitchingDelimiters().shouldShow('-0,99');
18 | typing('-,99').thenSwitchingDelimiters().shouldShow('-0.99');
19 |
20 | // only thousands
21 | typing('1k').thenSwitchingDelimiters().shouldShow('1.000,00');
22 | typing('1k').thenSwitchingDelimiters().shouldShow('1,000.00');
23 | typing('1k').thenSwitchingDelimiters().shouldShow('1.000,00');
24 | typing('1k').thenSwitchingDelimiters().shouldShow('1,000.00');
25 |
26 | typing('-1k').thenSwitchingDelimiters().shouldShow('-1.000,00');
27 | typing('-1k').thenSwitchingDelimiters().shouldShow('-1,000.00');
28 | typing('-1k').thenSwitchingDelimiters().shouldShow('-1.000,00');
29 | typing('-1k').thenSwitchingDelimiters().shouldShow('-1,000.00');
30 |
31 | // thousands and decimals
32 | typing('123456.78').thenSwitchingDelimiters().shouldShow('123.456,78');
33 | typing('123456,78').thenSwitchingDelimiters().shouldShow('123,456.78');
34 | typing('123456.78').thenSwitchingDelimiters().shouldShow('123.456,78');
35 | typing('123456,78').thenSwitchingDelimiters().shouldShow('123,456.78');
36 |
37 | typing('-123456.78').thenSwitchingDelimiters().shouldShow('-123.456,78');
38 | typing('-123456,78').thenSwitchingDelimiters().shouldShow('-123,456.78');
39 | typing('-123456.78').thenSwitchingDelimiters().shouldShow('-123.456,78');
40 | typing('-123456,78').thenSwitchingDelimiters().shouldShow('-123,456.78');
41 |
42 | // many thousands
43 | typing('1b.99').thenSwitchingDelimiters().shouldShow('1.000.000.000,99');
44 | typing('1b,99').thenSwitchingDelimiters().shouldShow('1,000,000,000.99');
45 | typing('1b.99').thenSwitchingDelimiters().shouldShow('1.000.000.000,99');
46 | typing('1b,99').thenSwitchingDelimiters().shouldShow('1,000,000,000.99');
47 |
48 | typing('-1b.99').thenSwitchingDelimiters().shouldShow('-1.000.000.000,99');
49 | typing('-1b,99').thenSwitchingDelimiters().shouldShow('-1,000,000,000.99');
50 | typing('-1b.99').thenSwitchingDelimiters().shouldShow('-1.000.000.000,99');
51 | typing('-1b,99').thenSwitchingDelimiters().shouldShow('-1,000,000,000.99');
52 | });
--------------------------------------------------------------------------------
/test/specs/traversals.js:
--------------------------------------------------------------------------------
1 | import {load, finputDefaultDelimiters} from '../pageObjects/index';
2 | import customCommandsFactory from '../customCommands';
3 |
4 | const {typing} = customCommandsFactory(finputDefaultDelimiters);
5 |
6 | describe('traversals', () => {
7 | beforeAll(load);
8 |
9 | describe('supports HOME and END keys sending caret to start / end of field', () => {
10 | typing(`12⇤3`).shouldShow(`312`).shouldHaveFocus(true).shouldHaveCaretAt(1);
11 | typing(`123⇤⇤4`).shouldShow(`4,123`).shouldHaveCaretAt(1);
12 | typing(`123⇤⇥4`).shouldShow(`1,234`).shouldHaveCaretAt(5);
13 | typing(`123⇤⇥4⇤5`).shouldShow(`51,234`).shouldHaveCaretAt(1);
14 | });
15 |
16 | describe('supports left and right ARROW keys shifting caret left / right one caret', () => {
17 | typing(`123456←8`).shouldShow(`1,234,586`);
18 | typing(`123456←←8`).shouldShow(`1,234,856`);
19 | typing(`123456←←←8`).shouldShow(`1,238,456`);
20 | });
21 |
22 | describe('supports up and down ARROW keys sending caret to start / end of field', () => {
23 | typing(`12↑3`).shouldShow(`312`).shouldHaveFocus(true).shouldHaveCaretAt(1);
24 | typing(`123↑↑4`).shouldShow(`4,123`).shouldHaveCaretAt(1);
25 | typing(`123↑↓4`).shouldShow(`1,234`).shouldHaveCaretAt(5);
26 | typing(`123↑↓4↑5`).shouldShow(`51,234`).shouldHaveCaretAt(1);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/test/unit/setRawValue.js:
--------------------------------------------------------------------------------
1 | import finput from '../../dist/finput';
2 |
3 | describe('setRawValue', () => {
4 |
5 | let element;
6 | let api;
7 |
8 | beforeEach(() => {
9 | element = document.createElement('input');
10 | api = finput(element);
11 | });
12 |
13 | afterEach(() => {
14 | api.destroy();
15 | });
16 |
17 | it('when passed 0 sets value 0', () => {
18 | api.setRawValue(0);
19 | expect(element.value).toBe('0.00');
20 | });
21 |
22 | it('has an initial value of empty string and rawValue of undefined', () => {
23 | expect(element.value).toBe('');
24 | expect(api.rawValue).toBe(undefined);
25 | });
26 |
27 | it('resets back to empty string and undefined when entry is deleted', () => {
28 | api.setRawValue(100);
29 | api.setRawValue('');
30 |
31 | expect(element.value).toBe('');
32 | expect(api.rawValue).toBe(undefined);
33 | });
34 |
35 | });
36 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "es2015",
4 | "moduleResolution": "node",
5 | "target": "es2015",
6 | "outDir": "lib",
7 | "strict": true,
8 | "typeRoots": ["types"]
9 | },
10 | "include": [
11 | "src"
12 | ],
13 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": [
4 | "tslint:recommended"
5 | ],
6 | "jsRules": {},
7 | "rules": {},
8 | "rulesDirectory": []
9 | }
--------------------------------------------------------------------------------
/types/is_js/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module "is_js" {
2 | interface INotMatchers {
3 | finite: (toMatch: any) => boolean;
4 | number: (toMatch: any) => boolean;
5 | string: (toMatch: any) => boolean;
6 | }
7 |
8 | export default class Matcher {
9 | public static not: INotMatchers;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------