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.
+
+
+
+