From daa04d22b267847b382a4fd3bc9f69b2dcb1a971 Mon Sep 17 00:00:00 2001 From: Nikita Barsukov Date: Wed, 26 Jun 2024 14:16:02 +0300 Subject: [PATCH] feat(core): new built-in `maskitoChangeEventPlugin` (#1338) --- projects/core/src/index.ts | 7 ++- .../src/lib/plugins/change-event-plugin.ts | 30 +++++++++ projects/core/src/lib/plugins/index.ts | 3 + .../initial-calibration-plugin.ts | 3 +- .../strict-composition-plugin.ts | 4 +- projects/core/src/lib/utils/index.ts | 2 - .../change-event-plugin.cy.ts | 62 +++++++++++++++++++ .../src/tests/component-testing/utils.ts | 4 ++ .../examples/4-change-event/component.ts | 36 +++++++++++ .../plugins/examples/4-change-event/mask.ts | 15 +++++ .../plugins/plugins.component.ts | 8 +++ .../plugins/plugins.template.html | 39 ++++++++++++ 12 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 projects/core/src/lib/plugins/change-event-plugin.ts create mode 100644 projects/core/src/lib/plugins/index.ts rename projects/core/src/lib/{utils => plugins}/initial-calibration-plugin.ts (81%) rename projects/core/src/lib/{utils => plugins}/strict-composition-plugin.ts (86%) create mode 100644 projects/demo-integrations/src/tests/component-testing/change-event-plugin/change-event-plugin.cy.ts create mode 100644 projects/demo/src/pages/documentation/plugins/examples/4-change-event/component.ts create mode 100644 projects/demo/src/pages/documentation/plugins/examples/4-change-event/mask.ts diff --git a/projects/core/src/index.ts b/projects/core/src/index.ts index ead10d94b..bfe52b74d 100644 --- a/projects/core/src/index.ts +++ b/projects/core/src/index.ts @@ -3,6 +3,11 @@ export { MASKITO_DEFAULT_OPTIONS, } from './lib/constants'; export {Maskito} from './lib/mask'; +export { + maskitoChangeEventPlugin, + maskitoInitialCalibrationPlugin, + maskitoStrictCompositionPlugin, +} from './lib/plugins'; export { MaskitoElement, MaskitoElementPredicate, @@ -15,9 +20,7 @@ export { } from './lib/types'; export { maskitoAdaptContentEditable, - maskitoInitialCalibrationPlugin, maskitoPipe, - maskitoStrictCompositionPlugin, maskitoTransform, maskitoUpdateElement, } from './lib/utils'; diff --git a/projects/core/src/lib/plugins/change-event-plugin.ts b/projects/core/src/lib/plugins/change-event-plugin.ts new file mode 100644 index 000000000..64ff35f1a --- /dev/null +++ b/projects/core/src/lib/plugins/change-event-plugin.ts @@ -0,0 +1,30 @@ +import type {MaskitoPlugin} from '../types'; + +export function maskitoChangeEventPlugin(): MaskitoPlugin { + return element => { + if (element.isContentEditable) { + return; + } + + let value = element.value; + + const valueListener = (): void => { + value = element.value; + }; + const blurListener = (): void => { + if (element.value !== value) { + element.dispatchEvent(new Event('change', {bubbles: true})); + } + }; + + element.addEventListener('focus', valueListener); + element.addEventListener('change', valueListener); + element.addEventListener('blur', blurListener); + + return () => { + element.removeEventListener('focus', valueListener); + element.removeEventListener('change', valueListener); + element.removeEventListener('blur', blurListener); + }; + }; +} diff --git a/projects/core/src/lib/plugins/index.ts b/projects/core/src/lib/plugins/index.ts new file mode 100644 index 000000000..bb2fece4b --- /dev/null +++ b/projects/core/src/lib/plugins/index.ts @@ -0,0 +1,3 @@ +export * from './change-event-plugin'; +export * from './initial-calibration-plugin'; +export * from './strict-composition-plugin'; diff --git a/projects/core/src/lib/utils/initial-calibration-plugin.ts b/projects/core/src/lib/plugins/initial-calibration-plugin.ts similarity index 81% rename from projects/core/src/lib/utils/initial-calibration-plugin.ts rename to projects/core/src/lib/plugins/initial-calibration-plugin.ts index 0f4851bd9..7c48aaf30 100644 --- a/projects/core/src/lib/utils/initial-calibration-plugin.ts +++ b/projects/core/src/lib/plugins/initial-calibration-plugin.ts @@ -1,6 +1,5 @@ import type {MaskitoOptions, MaskitoPlugin} from '../types'; -import {maskitoUpdateElement} from './dom/update-element'; -import {maskitoTransform} from './transform'; +import {maskitoTransform, maskitoUpdateElement} from '../utils'; export function maskitoInitialCalibrationPlugin( customOptions?: MaskitoOptions, diff --git a/projects/core/src/lib/utils/strict-composition-plugin.ts b/projects/core/src/lib/plugins/strict-composition-plugin.ts similarity index 86% rename from projects/core/src/lib/utils/strict-composition-plugin.ts rename to projects/core/src/lib/plugins/strict-composition-plugin.ts index e2eb170a0..5778093e9 100644 --- a/projects/core/src/lib/utils/strict-composition-plugin.ts +++ b/projects/core/src/lib/plugins/strict-composition-plugin.ts @@ -1,7 +1,5 @@ import type {ElementState, MaskitoPlugin, TypedInputEvent} from '../types'; -import {maskitoUpdateElement} from './dom/update-element'; -import {areElementStatesEqual} from './element-states-equality'; -import {maskitoTransform} from './transform'; +import {areElementStatesEqual, maskitoTransform, maskitoUpdateElement} from '../utils'; export function maskitoStrictCompositionPlugin(): MaskitoPlugin { return (element, maskitoOptions) => { diff --git a/projects/core/src/lib/utils/index.ts b/projects/core/src/lib/utils/index.ts index 3849ab5e8..bcc76c995 100644 --- a/projects/core/src/lib/utils/index.ts +++ b/projects/core/src/lib/utils/index.ts @@ -8,7 +8,5 @@ export * from './element-states-equality'; export * from './get-line-selection'; export * from './get-not-empty-selection'; export * from './get-word-selection'; -export * from './initial-calibration-plugin'; export * from './pipe'; -export * from './strict-composition-plugin'; export * from './transform'; diff --git a/projects/demo-integrations/src/tests/component-testing/change-event-plugin/change-event-plugin.cy.ts b/projects/demo-integrations/src/tests/component-testing/change-event-plugin/change-event-plugin.cy.ts new file mode 100644 index 000000000..541c92d47 --- /dev/null +++ b/projects/demo-integrations/src/tests/component-testing/change-event-plugin/change-event-plugin.cy.ts @@ -0,0 +1,62 @@ +import type {MaskitoOptions} from '@maskito/core'; +import {maskitoChangeEventPlugin} from '@maskito/core'; +import {maskitoNumberOptionsGenerator} from '@maskito/kit'; +import {createOutputSpy} from 'cypress/angular'; + +import {TestInput} from '../utils'; + +describe('maskitoChangeEventPlugin', () => { + const numberMask = maskitoNumberOptionsGenerator({ + thousandSeparator: ' ', + decimalSeparator: '.', + precision: 2, + }); + const maskitoOptions: MaskitoOptions = { + ...numberMask, + plugins: [...numberMask.plugins, maskitoChangeEventPlugin()], + }; + + beforeEach(() => { + cy.mount(TestInput, { + componentProperties: { + maskitoOptions, + change: createOutputSpy('changeEvent'), + }, + }); + }); + + it('Enter only valid value (Maskito does not prevent any typed character) => only 1 change event on blur', () => { + cy.get('input').type('123').should('have.value', '123'); + cy.get('@changeEvent').should('not.be.called'); + cy.get('input').blur(); + cy.get('@changeEvent').should('have.callCount', 1); + }); + + it('Enter valid value + pseudo decimal separator (Maskito replaces pseudo separator with valid one) => only 1 change event on blur', () => { + cy.get('input').type('123,').should('have.value', '123.'); + cy.get('@changeEvent').should('not.be.called'); + cy.get('input').blur(); + cy.get('@changeEvent').should('have.callCount', 1); + }); + + it('Enter only decimal separator (Maskito pads it with zero) => only 1 change event on blur', () => { + cy.get('input').type('.').should('have.value', '0.'); + cy.get('@changeEvent').should('not.be.called'); + cy.get('input').blur(); + cy.get('@changeEvent').should('have.callCount', 1); + }); + + it('Enter only invalid value (Maskito rejects all typed characters) => no change event', () => { + cy.get('input').type('abc').should('have.value', ''); + cy.get('@changeEvent').should('not.be.called'); + cy.get('input').blur(); + cy.get('@changeEvent').should('not.be.called'); + }); + + it('Enter any value value and then erase it again => no change event', () => { + cy.get('input').type('123').should('have.value', '123'); + cy.get('@changeEvent').should('not.be.called'); + cy.get('input').clear().blur(); + cy.get('@changeEvent').should('not.be.called'); + }); +}); diff --git a/projects/demo-integrations/src/tests/component-testing/utils.ts b/projects/demo-integrations/src/tests/component-testing/utils.ts index 0db1d13fd..266ccd468 100644 --- a/projects/demo-integrations/src/tests/component-testing/utils.ts +++ b/projects/demo-integrations/src/tests/component-testing/utils.ts @@ -13,6 +13,7 @@ import {MASKITO_DEFAULT_ELEMENT_PREDICATE} from '@maskito/core'; [attr.value]="initialValue" [maskito]="maskitoOptions" [maskitoElement]="maskitoElementPredicate" + (change)="change.emit($event)" (input)="input.emit($event)" /> `, @@ -31,6 +32,9 @@ export class TestInput { @Output() public input = new EventEmitter(); + @Output() + public change = new EventEmitter(); + @Input() public maxLength = Infinity; diff --git a/projects/demo/src/pages/documentation/plugins/examples/4-change-event/component.ts b/projects/demo/src/pages/documentation/plugins/examples/4-change-event/component.ts new file mode 100644 index 000000000..eae0db1a0 --- /dev/null +++ b/projects/demo/src/pages/documentation/plugins/examples/4-change-event/component.ts @@ -0,0 +1,36 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MaskitoDirective} from '@maskito/angular'; +import {TuiInputModule} from '@taiga-ui/kit'; + +import mask from './mask'; + +@Component({ + standalone: true, + selector: 'plugins-change-event-doc-example-4', + imports: [FormsModule, MaskitoDirective, TuiInputModule], + template: ` + + Enter number + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PluginsDocExample4 { + protected readonly maskitoOptions = mask; + protected value = ''; + + protected log(anything: any): void { + console.info(anything); + } +} diff --git a/projects/demo/src/pages/documentation/plugins/examples/4-change-event/mask.ts b/projects/demo/src/pages/documentation/plugins/examples/4-change-event/mask.ts new file mode 100644 index 000000000..e577b5d1f --- /dev/null +++ b/projects/demo/src/pages/documentation/plugins/examples/4-change-event/mask.ts @@ -0,0 +1,15 @@ +import type {MaskitoOptions} from '@maskito/core'; +import {maskitoChangeEventPlugin} from '@maskito/core'; +import {maskitoNumberOptionsGenerator} from '@maskito/kit'; + +const numberOptions = maskitoNumberOptionsGenerator({ + precision: 2, +}); + +export default { + ...numberOptions, + plugins: [ + ...numberOptions.plugins, + maskitoChangeEventPlugin(), // <--- Enable it + ], +} as MaskitoOptions; diff --git a/projects/demo/src/pages/documentation/plugins/plugins.component.ts b/projects/demo/src/pages/documentation/plugins/plugins.component.ts index a989b4f0d..16ac384ca 100644 --- a/projects/demo/src/pages/documentation/plugins/plugins.component.ts +++ b/projects/demo/src/pages/documentation/plugins/plugins.component.ts @@ -9,6 +9,7 @@ import {NextStepsComponent} from '../next-steps/next-steps.component'; import {PluginsDocExample1} from './examples/1-reject/component'; import {PluginsDocExample2} from './examples/2-initial-calibration/component'; import {PluginsDocExample3} from './examples/3-strict-composition/component'; +import {PluginsDocExample4} from './examples/4-change-event/component'; @Component({ standalone: true, @@ -22,6 +23,7 @@ import {PluginsDocExample3} from './examples/3-strict-composition/component'; PluginsDocExample1, PluginsDocExample2, PluginsDocExample3, + PluginsDocExample4, ], templateUrl: './plugins.template.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -47,4 +49,10 @@ export default class PluginsDocPageComponent { './examples/3-strict-composition/mask.ts?raw' ), }; + + protected readonly changeEventExample: TuiDocExample = { + [DocExamplePrimaryTab.MaskitoOptions]: import( + './examples/4-change-event/mask.ts?raw' + ), + }; } diff --git a/projects/demo/src/pages/documentation/plugins/plugins.template.html b/projects/demo/src/pages/documentation/plugins/plugins.template.html index c812da449..2a2de4e66 100644 --- a/projects/demo/src/pages/documentation/plugins/plugins.template.html +++ b/projects/demo/src/pages/documentation/plugins/plugins.template.html @@ -82,5 +82,44 @@ + + + Native + + beforeinput + + event default behavior is cancelled to process user entered invalid value. This causes native + + change + + event to + NOT + be dispatched by browser. A + change + event, as opposed to + input + , is triggered only when user left the field and value was changed during interaction. If you rely on this + behavior, add + maskitoChangeEventPlugin + to your mask configuration. It will dispatch synthetic + change + event using the same logic. + + + +