├── .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 | ![Travis build status](https://travis-ci.org/ScottLogic/finput.svg?branch=master) 3 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](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 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 | 38 | 39 | 40 |
41 |
42 | 43 |

Native Controls

44 |
45 |
46 |
47 | 48 | 49 |
50 |
51 | 52 | 53 |
54 |
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 | --------------------------------------------------------------------------------