diff --git a/README.md b/README.md index 6e85c56..2c8e7cc 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Ember Inputmask currently has two branches: mode. Critical bugs will be fixed, but minor issues will not be fixed and new features will not be added. - - [v0.4.x (master)](https://github.com/pzuraq/ember-inputmask/tree/master) + - [>v0.4.x](https://github.com/pzuraq/ember-inputmask/tree/master) pulls Inputmask 3.3.x from NPM. Bower and jQuery are not required. Versions prior to 0.4.0 automatically add `jquery.inputmask` into your @@ -46,7 +46,26 @@ upgrade to the latest version of either v0.2.x or v0.4.x. $ ember install ember-inputmask ``` -## Usage +## One Way Input Mask + +```hbs +{{one-way-input-mask value mask='999-aaa-***' update=(action (mut value))}} +``` + +This component extends from the [ember-one-way-controls](https://github.com/DockYard/ember-one-way-controls) addon and follows the data-down-actions-up (DDAU) pattern. You should use the "one-way" components in this addon as the "non-one-way" versions are deprecated as of `0.5.0` and will be removed in `1.0.0`. + +### Usage + +This component has the same interface as it's [ember-one-way-controls counterpart](https://github.com/DockYard/ember-one-way-controls/blob/master/docs/one-way-input.md), but accepts two additional arguments: + +* `mask` The type of mask to put on the input +* `options` Any additional masking options from [Inputmask](https://github.com/RobinHerbots/Inputmask) you would like to add + +## Other One Way Masks + +* [{{one-way-number-mask}}](docs/one-way-number-mask.md) + +## Input Mask Component (deprecated) The standard `input-mask` component: @@ -179,7 +198,7 @@ following: {{input-mask mask='email' unmaskedValue=foo}} ``` -### Number Inputs +### Number Inputs (deprecated) ```hbs {{number-input unmaskedValue=foo group=false groupSize=3 separator=','' diff --git a/addon/components/one-way-input-mask.js b/addon/components/one-way-input-mask.js new file mode 100644 index 0000000..d9aea22 --- /dev/null +++ b/addon/components/one-way-input-mask.js @@ -0,0 +1,179 @@ +/* global Inputmask */ +import { OneWayInput } from 'ember-one-way-controls'; +import { computed, get, set } from '@ember/object'; +import { schedule } from '@ember/runloop'; + +const DEFAULT_OPTIONS = { + rightAlign: false, +}; + +/** + * Displays an input with the specified mask applied to it + * using Inputmask library. Follows Data-down actions up pattern + * + * @param {string} value The unmasked value to display in the input + * @param {action} update The function to perform when the value changes. Will be passed the + * unmasked value and the masked values + * @param {string} mask The mask to use on the input + * @param {object} options The options to pass into the Inputmask library + */ +export default OneWayInput.extend({ + /** + * Set the `_value` to be whatever the `element.value` is + */ + attributeBindings: [ + 'type', + '_value:value' + ], + + // In ember-one-way-controls all attributes are bound dynamically via a mixin, except for + // the ones specified in this property. We need to include 'mask', and 'options' to the list + NON_ATTRIBUTE_BOUND_PROPS: [ + 'keyEvents', + 'classNames', + 'positionalParamValue', + 'update', + 'mask', + 'options', + ], + + /** + * mask - Pass in the `mask` string to set it on the element + * + * @public + */ + mask: '', + + /** + * options - Options accepted by the Inputmask library + */ + options: null, + + /** + * Setup _value to be a positional param or the passed param if that is not defined + * + * @private + */ + _value: computed('positionalParamValue', 'value', { + get() { + let value = get(this, 'positionalParamValue'); + if (value === undefined) { + value = get(this, 'value'); + } + + return value; + } + }), + + init() { + this._super(...arguments); + + // Give the mask some default options that can be overridden + let options = get(this, 'options'); + set(this, 'options', Object.assign({}, DEFAULT_OPTIONS, options)); + }, + + /** + * update - This action will be called when the value changes and will be passed the unmasked value + * and the masked value + * + * @public + */ + update() {}, + + didInsertElement() { + this._setupMask(); + }, + + willDestroyElement() { + this._destroyMask(); + this.element.removeEventListener('input', this._changeEventListener); + }, + + /** + * Disabling this so we don't have conflicts with manual addEventListener in case something + * changes one day + * + * @override + */ + change(){}, + + /** + * Disabling thi so we don't have conflicts with manual addEventListener in case something + * changes one day + * + * @override + */ + input(){}, + + /** + * _changeEventListener - A place to store the event listener we setup to listen to the 'input' + * events, because the Inputmask library events don't play nice with the Ember components event + * + * @private + */ + _changeEventListener() {}, + + /** + * _processNewValue - Handle when a new value changes + * + * @private + * @param {string} value - The masked value visible in the element + */ + _processNewValue(value) { + let cursorStart = this.element.selectionStart; + let cursorEnd = this.element.selectionEnd; + let unmaskedValue = this._getUnmaskedValue(); + let oldUnmaskedValue = get(this, '_value'); + let options = get(this, 'options'); + + // We only want to make changes if something is different so we don't cause infinite loops or + // double renders. + // We want to make sure that that values we compare are going to come out the same through + // the masking algorithm, to ensure that we only call `update` if the values are actually different + // (e.g. '1234.' will be masked as '1234' and so when `update` is called and passed back + // into the component the decimal will be removed, we don't want this) + if (Inputmask.format(String(oldUnmaskedValue), options) !== Inputmask.format(unmaskedValue, options)) { + get(this, 'update')(unmaskedValue, value); + + // When the value is updated, and then sent back down the cursor moves to the end of the field. + // We therefore need to put it back to where the user was typing so they don't get janked around + schedule('afterRender', () => { + this.element.setSelectionRange(cursorStart, cursorEnd); + }); + } + }, + + /** + * _setupMask - Connect the 3rd party input masking library to the element + * + * @private + */ + _setupMask() { + let mask = get(this, 'mask'), options = get(this, 'options'); + let inputmask = new Inputmask(mask, options); + inputmask.mask(this.element); + + // We need to setup a manual event listener for the change event instead of using the Ember + // Component event methods, because the Inputmask events don't play nice with the Component + // ones. Similar issue happens in React.js as well + // https://github.com/RobinHerbots/Inputmask/issues/1377 + let eventListener = event => this._processNewValue(event.target.value); + set(this, '_changeEventListener', eventListener); + this.element.addEventListener('input', eventListener); + }, + + /** + * _getUnmaskedValue - Get the value of the element without the mask + * + * @private + * @return {string} The unmasked value + */ + _getUnmaskedValue() { + return this.element.inputmask.unmaskedvalue(); + }, + + _destroyMask() { + this.element.inputmask.remove(); + }, +}); diff --git a/addon/components/one-way-number-mask.js b/addon/components/one-way-number-mask.js new file mode 100644 index 0000000..30d594c --- /dev/null +++ b/addon/components/one-way-number-mask.js @@ -0,0 +1,37 @@ +import OneWayInputMask from 'ember-inputmask/components/one-way-input-mask'; +import { get, set } from '@ember/object'; +import { isBlank } from '@ember/utils'; + +const DEFAULT_OPTIONS = { + groupSeparator: ',', + radixPoint: '.', + groupSize: '3', + autoGroup: true, +}; + +export default OneWayInputMask.extend({ + /** + * @override + */ + mask: 'integer', + + /** + * Set this to true to include decimals + */ + decimal: false, + + init() { + this._super(...arguments); + + set(this, 'options', Object.assign({}, get(this, 'options'), DEFAULT_OPTIONS)); + + if (get(this, 'decimal')) { + set(this, 'mask', 'decimal'); + + // Give default digits if we don't have them aleady + if (isBlank(get(this, 'options.digits'))) { + set(this, 'options.digits', 2); + } + } + }, +}); diff --git a/app/components/one-way-input-mask.js b/app/components/one-way-input-mask.js new file mode 100644 index 0000000..bac9156 --- /dev/null +++ b/app/components/one-way-input-mask.js @@ -0,0 +1,2 @@ +import OneWayInputMaskComponent from 'ember-inputmask/components/one-way-input-mask'; +export default OneWayInputMaskComponent; diff --git a/app/components/one-way-number-mask.js b/app/components/one-way-number-mask.js new file mode 100644 index 0000000..30a6d2d --- /dev/null +++ b/app/components/one-way-number-mask.js @@ -0,0 +1 @@ +export { default } from 'ember-inputmask/components/one-way-number-mask'; \ No newline at end of file diff --git a/docs/one-way-number-mask.md b/docs/one-way-number-mask.md new file mode 100644 index 0000000..5cf2f55 --- /dev/null +++ b/docs/one-way-number-mask.md @@ -0,0 +1,18 @@ +One Way Number Mask +=================== + +``` +{{one-way-number-mask value update=(action (mut value))}} +``` + +This component defaults to masking as an `integer`. You can pass other number based options supported by [Inputmask](https://github.com/RobinHerbots/Inputmask) in the `options` hash. + +## Arguments + +### decimal + +``` +{{one-way-number-mask value decimal=true update=(action (mut value))}} +``` + +Pass in `decimal` and it will mask as a decimal with 2 digits. If you'd like more or less digits then you can pass in `options.digits`. diff --git a/package.json b/package.json index eca335a..11bc885 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,9 @@ "test": "ember try:each" }, "dependencies": { - "ember-cli-babel": "^6.6.0", + "ember-cli-babel": "^6.10.0", "ember-cli-node-assets": "^0.2.2", + "ember-one-way-controls": "^3.0.1", "fastboot-transform": "^0.1.1", "inputmask": "3.3.6" }, diff --git a/tests/integration/components/one-way-number-mask-test.js b/tests/integration/components/one-way-number-mask-test.js new file mode 100644 index 0000000..110b315 --- /dev/null +++ b/tests/integration/components/one-way-number-mask-test.js @@ -0,0 +1,42 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { find, fillIn } from 'ember-native-dom-helpers'; + +moduleForComponent('one-way-number-mask', 'Integration | Component | one way number mask', { + integration: true +}); + +test('It defaults to an integer mask', function(assert) { + this.set('value', 1234.56) + this.render(hbs`{{one-way-number-mask value}}`); + + assert.equal(find('input').value, '1,234'); +}); + +test('It can be a decimal mask with 2 digits with one argument', function(assert) { + this.set('value', 1234.567) + this.render(hbs`{{one-way-number-mask value decimal=true}}`); + + assert.equal(find('input').value, '1,234.57'); +}); + +test('Can change default digits with options', function(assert) { + this.set('value', 1234.567) + this.render(hbs`{{one-way-number-mask value decimal=true options=(hash digits=3)}}`); + + assert.equal(find('input').value, '1,234.567'); +}); + +test('Can change default digits with options', function(assert) { + this.set('value', 1234.567) + this.render(hbs`{{one-way-number-mask value decimal=true options=(hash digits=3)}}`); + + assert.equal(find('input').value, '1,234.567'); +}); + +test('The parent can receive the updated value via the `update` action', async function(assert) { + this.set('value', 123) + this.render(hbs`{{one-way-number-mask value update=(action (mut value))}}`); + await fillIn('input', 456); + assert.equal(this.get('value'), '456'); +}); diff --git a/tests/integration/one-way-input-mask-test.js b/tests/integration/one-way-input-mask-test.js new file mode 100644 index 0000000..2ebd81f --- /dev/null +++ b/tests/integration/one-way-input-mask-test.js @@ -0,0 +1,57 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; +import { fillIn, find } from 'ember-native-dom-helpers'; + +moduleForComponent('one-way-input-mask', 'Integration | Component | one-way-input-mask', { + integration: true +}); + +test('It masks a passed in value', function(assert) { + this.set('value', 123) + this.render(hbs`{{one-way-input-mask value mask='9-9+9'}}`); + assert.equal(find('input').value, '1-2+3'); +}); + +test('The mask updates if the passed value is mutated in the parent', function(assert) { + this.set('value', 123) + this.render(hbs`{{one-way-input-mask value mask='9-9+9'}}`); + assert.equal(find('input').value, '1-2+3'); + this.set('value', 456) + assert.equal(find('input').value, '4-5+6'); +}); + +test('The parent can receive the updated value via the `update` action', async function(assert) { + this.set('value', 123) + this.render(hbs`{{one-way-input-mask value mask='9-9+9' update=(action (mut value))}}`); + await fillIn('input', 456); + assert.equal(this.get('value'), '456'); +}); + +test('Update action works when `value` begins as undefined', async function(assert) { + this.render(hbs`{{one-way-input-mask value mask='9-9+9' update=(action (mut value))}}`); + await fillIn('input', 456); + assert.equal(this.get('value'), '456'); +}); + +test('The parent can receive the masked value via the `update` action', async function(assert) { + this.set('update', (unmasked, masked) => { + this.set('masked', masked) + }) + this.render(hbs`{{one-way-input-mask value mask='9-9+9' update=update}}`); + await fillIn('input', 456); + assert.equal(this.get('masked'), '4-5+6'); +}); + +test('It can accept options', function(assert) { + this.set('value', 1) + this.set('options', { placeholder: '*' }); + this.render(hbs`{{one-way-input-mask value mask='9-9+9' options=options}}`); + assert.equal(find('input').value, '1-*+*'); +}); + +test('mask and options are not bound attributes', function(assert) { + this.set('options', { placeholder: '*' }); + this.render(hbs`{{one-way-input-mask value mask='9-9+9' options=options}}`); + assert.notOk(find('input').getAttribute('mask'), 'mask is not bound'); + assert.notOk(find('input').getAttribute('options'), 'options is not bound'); +}); diff --git a/tests/unit/components/one-way-number-mask-test.js b/tests/unit/components/one-way-number-mask-test.js new file mode 100644 index 0000000..239bc4b --- /dev/null +++ b/tests/unit/components/one-way-number-mask-test.js @@ -0,0 +1,15 @@ +import { moduleForComponent, test } from 'ember-qunit'; + +moduleForComponent('one-way-number-mask', 'Unit | Component | one way number mask', { + unit: true +}); + +test('It can show a trailing decimal', function(assert) { + let callCount = 0; + let update = () => callCount++; + let value = '1234'; + let component = this.subject({ update, value, decimal: true, }); + this.render(); + component._processNewValue('1234.'); + assert.equal(callCount, 0, ''); +}); diff --git a/yarn.lock b/yarn.lock index 2936555..809c2cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -873,10 +873,6 @@ bower-endpoint-parser@0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/bower-endpoint-parser/-/bower-endpoint-parser-0.2.2.tgz#00b565adbfab6f2d35addde977e97962acbcb3f6" -bower@^1.8.2: - version "1.8.2" - resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.2.tgz#adf53529c8d4af02ef24fb8d5341c1419d33e2f7" - brace-expansion@^1.0.0: version "1.1.6" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.6.tgz#7197d7eaa9b87e648390ea61fc66c84427420df9" @@ -1851,7 +1847,7 @@ ember-ajax@^3.0.0: dependencies: ember-cli-babel "^6.0.0" -ember-cli-babel@^6.0.0, ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.0.0-beta.7, ember-cli-babel@^6.3.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.8.1: +ember-cli-babel@^6.0.0, ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.0.0-beta.7, ember-cli-babel@^6.10.0, ember-cli-babel@^6.3.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.8.1, ember-cli-babel@^6.9.0: version "6.10.0" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-6.10.0.tgz#81424acd1d97fb13658168121eeb2007d6edee84" dependencies: @@ -2160,6 +2156,12 @@ ember-export-application-global@^2.0.0: dependencies: ember-cli-babel "^6.0.0-beta.7" +ember-invoke-action@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/ember-invoke-action/-/ember-invoke-action-1.5.0.tgz#0370f187f39f22d54ddd039cd01aa7e685edbbec" + dependencies: + ember-cli-babel "^6.6.0" + ember-load-initializers@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/ember-load-initializers/-/ember-load-initializers-1.0.0.tgz#4919eaf06f6dfeca7e134633d8c05a6c9921e6e7" @@ -2182,6 +2184,15 @@ ember-native-dom-helpers@^0.5.8: broccoli-funnel "^1.1.0" ember-cli-babel "^6.6.0" +ember-one-way-controls@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ember-one-way-controls/-/ember-one-way-controls-3.0.1.tgz#bd18dab7a3fe59413bde4da3ae84bf9a2c76a1eb" + dependencies: + ember-cli-babel "^6.0.0" + ember-cli-htmlbars "^2.0.1" + ember-invoke-action "^1.5.0" + ember-runtime-enumerable-includes-polyfill "^2.0.0" + ember-qunit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/ember-qunit/-/ember-qunit-3.1.0.tgz#4995a6207ab66b5d0cf807d0459d48f55f9eee5f" @@ -2216,7 +2227,14 @@ ember-router-generator@^1.0.0, ember-router-generator@^1.2.3: dependencies: recast "^0.11.3" -ember-source@^2.17.0: +ember-runtime-enumerable-includes-polyfill@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ember-runtime-enumerable-includes-polyfill/-/ember-runtime-enumerable-includes-polyfill-2.1.0.tgz#dc6d4a028471e4acc350dfd2a149874fb20913f5" + dependencies: + ember-cli-babel "^6.9.0" + ember-cli-version-checker "^2.1.0" + +ember-source@~2.17.0: version "2.17.0" resolved "https://registry.yarnpkg.com/ember-source/-/ember-source-2.17.0.tgz#b78871dd49bd8d642b80176df4faf7fd7d059dac" dependencies: