diff --git a/packages/library/components/inputter/src/inputter-component.js b/packages/library/components/inputter/src/inputter-component.js index f061852b..9957f90c 100644 --- a/packages/library/components/inputter/src/inputter-component.js +++ b/packages/library/components/inputter/src/inputter-component.js @@ -1,4 +1,4 @@ -import { html, MuonElement, classMap, ScopedElementsMixin } from '@muons/library'; +import { html, MuonElement, ScopedElementsMixin, classMap, styleMap } from '@muons/library'; import { INPUTTER_TYPE, INPUTTER_DETAIL_TOGGLE_OPEN, @@ -7,23 +7,23 @@ import { INPUTTER_VALIDATION_WARNING_ICON } from '@muons/library/build/tokens/es6/muon-tokens'; import { ValidationMixin } from '@muons/library/mixins/validation-mixin'; +import { MaskMixin } from '@muons/library/mixins/mask-mixin'; import { DetailMixin } from '@muons/library/mixins/detail-mixin'; import { Icon } from '@muons/library/components/icon'; import styles from './styles.css'; /** - * Allow for inputs + * A component to allow for user inputs of type text, radio, checkbox, select, + * date, tel, number, textarea, search. * * @element inputter */ -export class Inputter extends ScopedElementsMixin(ValidationMixin(MuonElement)) { +export class Inputter extends ScopedElementsMixin(MaskMixin(ValidationMixin(MuonElement))) { static get properties() { return { helper: { type: String }, - mask: { type: String }, - separator: { type: String }, isHelperOpen: { type: Boolean } }; } @@ -52,12 +52,6 @@ export class Inputter extends ScopedElementsMixin(ValidationMixin(MuonElement)) return html``; } - get validity() { - this.pristine = false; - this.validate(); - return this._validity; - } - /** * A method to check availability of tip details slot. * @returns {Boolean} - availability of tip details slot. @@ -94,15 +88,24 @@ export class Inputter extends ScopedElementsMixin(ValidationMixin(MuonElement)) get standardTemplate() { const classes = { 'slotted-content': true, - 'select-arrow': this._inputType === this._isSelect + 'select-arrow': this._isSelect, + 'has-mask': this.mask }; + let styles = {}; + if (this.mask) { + styles = { + '--maxlength': this.mask.length + }; + } + return html ` -
+
${this._isMultiple ? this._headingTemplate : this._labelTemplate} ${this._helperTemplate}
${super.standardTemplate} + ${this._maskTemplate}
${this._validationMessageTemplate}`; diff --git a/packages/library/components/inputter/src/styles.css b/packages/library/components/inputter/src/styles.css index f26d7012..8dd49ffb 100644 --- a/packages/library/components/inputter/src/styles.css +++ b/packages/library/components/inputter/src/styles.css @@ -3,6 +3,35 @@ :host { display: block; + & .has-mask { + position: relative; + + & .input-mask, + & ::slotted(input) { + padding: 0.5rem 1rem; + margin: 0.75rem 0; + letter-spacing: 0.5rem; + max-width: calc((var(--maxlength) + 1) * 1rem); + } + + & .input-mask { + display: inline-block; + position: absolute; + left: 0; + color: lightslategray; + white-space: pre; + z-index: -1; + text-align: start; + font-size: 1.5rem; + } + + & ::slotted(input) { + background-color: transparent; + font-size: 1.25rem; + padding-right: 1.25rem; + } + } + & .validation { display: flex; margin: 0.5rem 0; diff --git a/packages/library/components/inputter/story.js b/packages/library/components/inputter/story.js index 0872ccbc..af73d6e2 100644 --- a/packages/library/components/inputter/story.js +++ b/packages/library/components/inputter/story.js @@ -49,6 +49,12 @@ const textareaInputText = (args) => ` export const Textarea = (args) => details.template(args, textareaInputText); Textarea.args = { label: 'A label', value: 'gas', validation: '["isRequired"]' }; +export const Mask = (args) => details.template(args, innerInputText); +Mask.args = { label: 'A label', value: '', mask: '000000' }; + +export const Separator = (args) => details.template(args, innerInputText); +Separator.args = { label: 'A label', value: '', separator: '-', mask: ' - - ' }; + const innerInputDate = (args) => ` @@ -56,10 +62,20 @@ const innerInputDate = (args) => ` export const Date = (args) => details.template(args, innerInputDate); Date.args = { label: 'A label', value: '', validation: '["isRequired","minDate(\'11/11/2021\')"]' }; +export const DateMask = (args) => details.template(args, innerInputDate); +DateMask.args = { label: 'A label', value: '', mask: 'dd/mm/yyyy', separator: '/', validation: '["isRequired","minDate(\'11/11/2021\')"]' }; + const innerInputTel = (args) => ` `; export const Tel = (args) => details.template(args, innerInputTel); -Tel.args = { label: 'A label', value: '', validation: '["isRequired"]' }; +Tel.args = { label: 'A label', value: '', validation: '["isRequired"]', mask: '000-000-0000', separator: '-' }; + +const innerInputNumber = (args) => ` + + +`; +export const Number = (args) => details.template(args, innerInputNumber); +Number.args = { label: 'A label', value: '', validation: '["isRequired"]' }; diff --git a/packages/library/mixins/form-element-mixin.js b/packages/library/mixins/form-element-mixin.js index 91a599cd..420d6c75 100644 --- a/packages/library/mixins/form-element-mixin.js +++ b/packages/library/mixins/form-element-mixin.js @@ -88,7 +88,7 @@ export const FormElementMixin = (superClass) => } firstUpdated() { - + super.firstUpdated(); this._slottedInputs.forEach((input) => { input.addEventListener('change', this._onChange.bind(this)); input.addEventListener('blur', this._onBlur.bind(this)); diff --git a/packages/library/mixins/mask-mixin.js b/packages/library/mixins/mask-mixin.js new file mode 100644 index 00000000..00fee662 --- /dev/null +++ b/packages/library/mixins/mask-mixin.js @@ -0,0 +1,151 @@ +import { html, ifDefined } from '@muons/library'; +import { dedupeMixin } from '@open-wc/dedupe-mixin'; +import { FormElementMixin } from './form-element-mixin'; + +/** + * A mixin to enable mask and separator features to a form element. + * `mask` property is supported for input of type text, date, tel. + * `separator` property is supported for input of type text, date, tel. + * @mixin + */ + +export const MaskMixin = dedupeMixin((superclass) => + class MaskMixinClass extends FormElementMixin(superclass) { + static get properties() { + return { + mask: { + type: String + }, + + separator: { + type: String + } + }; + } + + constructor() { + super(); + + this.mask = ''; + this.separator = ''; + } + + firstUpdated() { + super.firstUpdated(); + + if (this.mask) { + this._slottedInputs.map((input) => { + input.addEventListener('input', this._onInput.bind(this)); + input.setAttribute('maxlength', this.mask.length); + }); + } + } + + /** + * A method to handle `input` event when `mask` is provided. + * @param {Event} inputEvent - event while 'input. + * @returns {undefined} + * @protected + * @override + */ + _onInput(inputEvent) { + inputEvent.stopPropagation(); + inputEvent.preventDefault(); + const inputElement = this._slottedInputs[0]; + if (ifDefined(this.separator)) { + this.updateValue(inputElement); + } else { + this.value = inputElement.value; + } + } + + _processValue(value) { + value = super._processValue(value); + if (ifDefined(this.separator)) { + value = this.formatWithMaskAndSeparator(value); + this._slottedInputs[0].value = value; + } + return value; + } + + /** + * A method to update the form element value with separator in adjusted indices and cursor position. + * + * @param {HTMLInputElement} input - HTMLInputElement value to be updated with seperators + * @returns {undefined} + */ + updateValue(input) { + let value = input.value; + let cursor = input.selectionStart; + const diff = this.value.length - value.length; + + if (diff > 0 && this.mask.charAt(cursor) === this.separator) { + value = value.slice(0, cursor - 1) + (cursor < value.length ? value.slice(cursor) : ''); + cursor -= 1; + } + const formattedValue = this.formatWithMaskAndSeparator(value); + input.value = formattedValue; + this.value = formattedValue; + + if (this.mask.charAt(cursor) === this.separator) { + cursor += 1; + } + this.updateComplete.then(() => { + input.setSelectionRange(cursor, cursor); + }); + } + + /** + * A method to format the form element value with separator adjusted to correct indices + * after editing the form element value. + * + * @param {String} value - value of the form element. + * @return {String} - value with adjusted separator in correct indices. + */ + formatWithMaskAndSeparator(value) { + const formattedValue = this.__formatInputWithoutSeparator(value); + const parts = this.mask.split(this.separator); + let processedValue = ''; + let length = 0; + let currentLength = 0; + + for (let i = 0; i < parts.length && length < formattedValue.length; i++) { + const remainingLength = formattedValue.length - length; + const splitPoint = remainingLength > parts[i].length ? parts[i].length : remainingLength; + + processedValue += formattedValue.substr(length, splitPoint); + currentLength += parts[i].length; + + if (i < (parts.length - 1) && processedValue.length === currentLength) { + processedValue += this.separator; + currentLength += 1; + } + + length += parts[i].length; + } + + return processedValue; + } + + /** + * A method to remove separator from the value of the form element. + * + * @param {String} value - form element value. + * @return {String} - value with separator removed. + */ + __formatInputWithoutSeparator(value) { + return value.split(this.separator).join(''); + } + + get _maskTemplate() { + if (this.mask) { + const length = this.value ? this.value.length : 0; + let updatedMask = new Array(length + 1).join(' '); + updatedMask += this.mask.slice(length); + return html``; + } else { + return undefined; + } + } + } +); diff --git a/packages/library/tests/components/inputter/__snapshots__/inputter.test.snap.js b/packages/library/tests/components/inputter/__snapshots__/inputter.test.snap.js new file mode 100644 index 00000000..1752d2dc --- /dev/null +++ b/packages/library/tests/components/inputter/__snapshots__/inputter.test.snap.js @@ -0,0 +1,55 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["Inputter standard default default checks"] = +`
+ + +
+ + +
+
+`; +/* end snapshot Inputter standard default default checks */ + +snapshots["Inputter text input mask text default checks"] = +`
+ + +
+ + + +
+
+`; +/* end snapshot Inputter text input mask text default checks */ + +snapshots["Inputter radio input standard radio default checks"] = +`
+ + What is your heating source? + +
+ + +
+
+`; +/* end snapshot Inputter radio input standard radio default checks */ + diff --git a/packages/library/tests/components/inputter/inputter.test.js b/packages/library/tests/components/inputter/inputter.test.js new file mode 100644 index 00000000..4db85509 --- /dev/null +++ b/packages/library/tests/components/inputter/inputter.test.js @@ -0,0 +1,79 @@ +/* eslint-disable no-undef */ +import { expect, fixture, html, defineCE, unsafeStatic } from '@open-wc/testing'; +import { Inputter } from '@muons/library/components/inputter'; +import { defaultChecks } from '../../helpers'; + +const tagName = defineCE(Inputter); +const tag = unsafeStatic(tagName); + +describe('Inputter', () => { + describe('standard default', async () => { + let inputter; + before(async () => { + inputter = await fixture(html` + <${tag}> + `); + }); + + it('default checks', async () => { + await defaultChecks(inputter); + }); + + it('default properties', async () => { + expect(inputter.type).to.equal('standard', 'default type is set'); + expect(inputter.id).to.not.be.null; // eslint-disable-line no-unused-expressions + }); + }); + describe('text input', async () => { + describe('mask text', async () => { + let inputter; + let shadowRoot; + before(async () => { + inputter = await fixture(html` + <${tag} mask="0000"> + + + `); + shadowRoot = inputter.shadowRoot; + }); + + it('default checks', async () => { + await defaultChecks(inputter); + }); + + it('default properties', async () => { + expect(inputter.type).to.equal('standard', 'default type is set'); + expect(inputter.id).to.not.be.null; // eslint-disable-line no-unused-expressions + expect(shadowRoot.querySelector('.has-mask')).to.not.be.null; // eslint-disable-line no-unused-expressions + }); + }); + }); + + describe('radio input', async () => { + describe('standard radio', async () => { + let inputter; + let shadowRoot; + before(async () => { + inputter = await fixture(html` + <${tag} heading="What is your heating source?"> + + + + + `); + shadowRoot = inputter.shadowRoot; + }); + + it('default checks', async () => { + await defaultChecks(inputter); + }); + + it('default properties', async () => { + expect(inputter.type).to.equal('standard', 'default type is set'); + expect(inputter.id).to.not.be.null; // eslint-disable-line no-unused-expressions + expect(shadowRoot.querySelector('.input-heading')).to.not.be.null; // eslint-disable-line no-unused-expressions + expect(shadowRoot.querySelector('.input-mask')).to.be.null; // eslint-disable-line no-unused-expressions + }); + }); + }); +}); diff --git a/packages/library/tests/helpers/index.js b/packages/library/tests/helpers/index.js index 0f16b6cd..52833eea 100644 --- a/packages/library/tests/helpers/index.js +++ b/packages/library/tests/helpers/index.js @@ -10,14 +10,18 @@ export const defaultChecks = async (el) => { await expect(el).to.be.accessible(); }; +export const fireEvent = async (element, event) => { + const customEvent = new CustomEvent(event, { bubbles: true }); + await element.dispatchEvent(customEvent); +}; + const fireChangeEvent = async (element) => { - const event = new CustomEvent('change', { bubbles: true }); - await element.dispatchEvent(event); + await fireEvent(element, 'change'); }; -export const fillIn = async (element, content) => { +export const fillIn = async (element, content, event = 'change') => { element.value = content; - await fireChangeEvent(element); + await fireEvent(element, event); }; export const selectEvent = async (element, value) => { diff --git a/packages/library/tests/mixins/__snapshots__/form-element.test.snap.js b/packages/library/tests/mixins/__snapshots__/form-element.test.snap.js index a2fe50d4..f1a5a4cc 100644 --- a/packages/library/tests/mixins/__snapshots__/form-element.test.snap.js +++ b/packages/library/tests/mixins/__snapshots__/form-element.test.snap.js @@ -52,7 +52,7 @@ snapshots["form-element standard checkbox input"] = /* end snapshot form-element standard checkbox input */ snapshots["form-element standard select input"] = -`
+`
diff --git a/packages/library/tests/mixins/__snapshots__/mask.test.snap.js b/packages/library/tests/mixins/__snapshots__/mask.test.snap.js new file mode 100644 index 00000000..fe7e4d17 --- /dev/null +++ b/packages/library/tests/mixins/__snapshots__/mask.test.snap.js @@ -0,0 +1,45 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["mask & separator mask default checks"] = +` + + + + +`; +/* end snapshot mask & separator mask default checks */ + +snapshots["mask & separator mask separator default checks"] = +` + + + + +`; +/* end snapshot mask & separator mask separator default checks */ + +snapshots["mask & separator tel mask seprator default checks"] = +` + + + + +`; +/* end snapshot mask & separator tel mask seprator default checks */ + diff --git a/packages/library/tests/mixins/form-element.test.js b/packages/library/tests/mixins/form-element.test.js index 82737320..7fc72adb 100644 --- a/packages/library/tests/mixins/form-element.test.js +++ b/packages/library/tests/mixins/form-element.test.js @@ -10,7 +10,7 @@ const MuonFormElement = class extends FormElementMixin(MuonElement) { get standardTemplate() { const classes = { 'slotted-content': true, - 'select-arrow': this._inputType === this._isSelect + 'select-arrow': this._isSelect }; return html ` @@ -62,12 +62,12 @@ const MuonFormElement = class extends FormElementMixin(MuonElement) { if (this._isSelect) { const classes = { 'slotted-content': true, - 'select-arrow': this._inputType === this._isSelect + 'select-arrow': this._isSelect }; return html `
- ${this._isMultiple ? this._headingTemplate : this._labelTemplate} + ${this._labelTemplate}
${super.standardTemplate}
diff --git a/packages/library/tests/mixins/mask.test.js b/packages/library/tests/mixins/mask.test.js new file mode 100644 index 00000000..12f68279 --- /dev/null +++ b/packages/library/tests/mixins/mask.test.js @@ -0,0 +1,186 @@ +/* eslint-disable no-undef */ +import { expect, fixture, html, defineCE, unsafeStatic, waitUntil } from '@open-wc/testing'; +import { MuonElement } from '@muons/library'; +import { MaskMixin } from '@muons/library/mixins/mask-mixin'; +import { defaultChecks, fillIn } from '../helpers'; + +const Inputter = class extends MaskMixin(MuonElement) { + get standardTemplate() { + return html` + ${this._labelTemplate} + ${this._htmlFormElementTemplate} + ${this._maskTemplate} + `; + } +}; +const tagName = defineCE(Inputter); +const tag = unsafeStatic(tagName); + +describe('mask & separator', () => { + describe('mask', async () => { + let inputter; + let shadowRoot; + let maskedInput; + let inputElement; + before(async () => { + inputter = await fixture(html` + <${tag} mask="00000" > + + + `); + shadowRoot = inputter.shadowRoot; + maskedInput = shadowRoot.querySelector('.input-mask'); + inputElement = inputter.querySelector('input'); + }); + + it('default checks', async () => { + await defaultChecks(inputter); + }); + + it('masked input check', async () => { + expect(maskedInput).to.be.not.null; // eslint-disable-line no-unused-expressions + expect(maskedInput.textContent).to.be.equal('00000', '`input-mask` has correct value'); + }); + + it('input value `1`', async () => { + await fillIn(inputElement, '1', 'input'); + await waitUntil(() => inputter.value === '1'); + maskedInput = shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('1', 'Input has correct value'); + expect(inputter.value).to.be.equal('1', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 0000', '`input-mask` has correct value'); + }); + + it('input value `12`', async () => { + await fillIn(inputElement, '12', 'input'); + await waitUntil(() => inputter.value === '12'); + maskedInput = shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('12', 'Input has correct value'); + expect(inputter.value).to.be.equal('12', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 000', '`input-mask` has correct value'); + }); + }); + + describe('mask separator', async () => { + let inputter; + let shadowRoot; + let maskedInput; + let inputElement; + before(async () => { + inputter = await fixture(html` + <${tag} mask="00-00-00" separator="-"> + + + `); + shadowRoot = inputter.shadowRoot; + maskedInput = shadowRoot.querySelector('.input-mask'); + inputElement = inputter.querySelector('input'); + }); + + it('default checks', async () => { + await defaultChecks(inputter); + }); + + it('masked input check', async () => { + expect(maskedInput).to.be.not.null; // eslint-disable-line no-unused-expressions + expect(maskedInput.textContent).to.be.equal('00-00-00', '`input-mask` has correct value'); + }); + + it('input value `1`', async () => { + await fillIn(inputElement, '1', 'input'); + await waitUntil(() => inputter.value === '1'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('1', 'Input has correct value'); + expect(inputter.value).to.be.equal('1', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 0-00-00', '`input-mask` has correct value'); + }); + + it('input value `12`', async () => { + await fillIn(inputElement, '12', 'input'); + await waitUntil(() => inputter.value === '12-'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('12-', 'Input has correct value'); + expect(inputter.value).to.be.equal('12-', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 00-00', '`input-mask` has correct value'); + }); + + it('input value `123`', async () => { + await fillIn(inputElement, '123', 'input'); + await waitUntil(() => inputter.value === '12-3'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('12-3', 'Input has correct value'); + expect(inputter.value).to.be.equal('12-3', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 0-00', '`input-mask` has correct value'); + }); + + it('delete value `3`', async () => { + await fillIn(inputElement, '12-', 'input'); + await waitUntil(() => inputter.value === '12-'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('12-', 'Input has correct value'); + expect(inputter.value).to.be.equal('12-', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 00-00', '`input-mask` has correct value'); + }); + + it('delete value `2`', async () => { + await fillIn(inputElement, '12', 'input'); + await waitUntil(() => inputter.value === '1'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('1', 'Input has correct value'); + expect(inputter.value).to.be.equal('1', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 0-00-00', '`input-mask` has correct value'); + }); + + it('input value `123`', async () => { + await fillIn(inputElement, '123'); + await waitUntil(() => inputter.value === '12-3'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('12-3', 'Input has correct value'); + expect(inputter.value).to.be.equal('12-3', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 0-00', '`input-mask` has correct value'); + }); + }); + + describe('tel mask seprator', async () => { + let inputter; + let shadowRoot; + let maskedInput; + let inputElement; + before(async () => { + inputter = await fixture(html` + <${tag} mask="00-00-00" separator="-"> + + + `); + shadowRoot = inputter.shadowRoot; + maskedInput = shadowRoot.querySelector('.input-mask'); + inputElement = inputter.querySelector('input'); + }); + + it('default checks', async () => { + await defaultChecks(inputter); + }); + + it('masked input check', async () => { + expect(maskedInput).to.be.not.null; // eslint-disable-line no-unused-expressions + expect(maskedInput.textContent).to.be.equal('00-00-00', '`input-mask` has correct value'); + }); + + it('input value `1`', async () => { + await fillIn(inputElement, '1', 'input'); + await waitUntil(() => inputter.value === '1'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputter.value).to.be.equal('1', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 0-00-00', '`input-mask` has correct value'); + }); + + it('input value `12`', async () => { + await fillIn(inputElement, '12', 'input'); + await waitUntil(() => inputter.value === '12-'); + maskedInput = inputter.shadowRoot.querySelector('.input-mask'); + expect(inputElement.value).to.be.equal('12-', 'Input has correct value'); + expect(inputter.value).to.be.equal('12-', 'Inputter has correct value'); + expect(maskedInput.textContent).to.be.equal(' 00-00', '`input-mask` has correct value'); + }); + }); +}); diff --git a/packages/library/tests/mixins/validation.test.js b/packages/library/tests/mixins/validation.test.js index 4266cf1a..cd5fdac5 100644 --- a/packages/library/tests/mixins/validation.test.js +++ b/packages/library/tests/mixins/validation.test.js @@ -3,7 +3,7 @@ import { expect, fixture, html, defineCE, unsafeStatic } from '@open-wc/testing' import { MuonElement } from '@muons/library'; import sinon from 'sinon'; import { defaultChecks, fillIn, selectEvent } from '../helpers'; -import { ValidationMixin } from '../../mixins/validation-mixin'; +import { ValidationMixin } from '@muons/library/mixins/validation-mixin'; const isFirstName = (inputter, value) => { const isName = /^[A-Za-zÀ-ÖØ-öø-ÿ\-\s]{1,24}$/i.test(value);