From 9201c6e5eb28597fbd11598057d7f1e45f9e3495 Mon Sep 17 00:00:00 2001 From: Zinovev Nikita Date: Wed, 1 Oct 2025 11:23:41 +0300 Subject: [PATCH] feat: define ignoreCompositionState option to control composition events --- docs/guide.html | 44 +++++++++++++++++++ packages/angular-imask/src/imask.directive.ts | 6 +-- .../imask/src/controls/html-mask-element.ts | 14 ++++-- packages/imask/src/controls/input.ts | 10 ++++- packages/imask/src/controls/mask-element.ts | 5 ++- packages/imask/src/index.ts | 1 + packages/imask/src/masked/factory.ts | 9 ++++ packages/react-imask/src/mixin.ts | 4 +- packages/solid-imask/src/input.tsx | 4 +- packages/svelte-imask/src/action.ts | 8 ++-- packages/vue-imask/src/props.ts | 3 +- 11 files changed, 91 insertions(+), 17 deletions(-) diff --git a/docs/guide.html b/docs/guide.html index bd8fa5d8..a1e18b4d 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -344,6 +344,35 @@

Skip Invalid Mode +

Ignore Composition State since 7.6.1

+

When enabled, ignores IME composition state and processes input events anyway. Useful for Android keyboards that incorrectly set isComposing=true for Latin characters, causing data loss on blur.

+
+ +
+ + +
+ +
+ + +
+{% highlight javascript %} +IMask( + document.getElementById('ignoreCompositionState-mask'), + { + mask: '000-000-0000', + ignoreCompositionState: , + } +) +{% endhighlight %} +
+
+
+

Number Mask

Number mask restricts input to integer or decimal numbers in many ways:

@@ -1683,5 +1712,20 @@

Treeshaking s skipInvalid: e.target.checked, }); }); + + var ignoreCompositionStateMask = IMask( + document.getElementById('ignoreCompositionState-mask'), + { + mask: '000-000-0000', + ignoreCompositionState: false, + } + ); + + var toggleIgnoreCompositionState = document.getElementById('toggle-ignoreCompositionState-mask'); + toggleIgnoreCompositionState.addEventListener('change', function (e) { + ignoreCompositionStateMask.updateOptions({ + ignoreCompositionState: e.target.checked, + }); + }); }); diff --git a/packages/angular-imask/src/imask.directive.ts b/packages/angular-imask/src/imask.directive.ts index 1e1c9d4b..1c4caf7a 100644 --- a/packages/angular-imask/src/imask.directive.ts +++ b/packages/angular-imask/src/imask.directive.ts @@ -1,4 +1,4 @@ -import { type InputMask, type InputMaskElement, type FactoryArg, type UpdateOpts } from 'imask'; +import { type InputMask, type InputMaskElement, type FactoryArg, type UpdateOpts, type ControlOptions } from 'imask'; import { isPlatformBrowser } from '@angular/common'; import { Directive, ElementRef, Input, Output, forwardRef, Provider, Renderer2, @@ -38,7 +38,7 @@ export const DEFAULT_IMASK_ELEMENT = (elementRef: any) => elementRef.nativeEleme providers: [MASKEDINPUT_VALUE_ACCESSOR], }) export class IMaskDirective< - Opts extends FactoryArg, + Opts extends FactoryArg & ControlOptions, Unmask extends ('typed' | boolean) = false, V = Value, > implements ControlValueAccessor, AfterViewInit, OnDestroy, OnChanges { @@ -97,7 +97,7 @@ export class IMaskDirective< if (!changes['imask'] || !this._viewInitialized) return; if (this.imask) { - if (this.maskRef) this.maskRef.updateOptions(this.imask as UpdateOpts); + if (this.maskRef) this.maskRef.updateOptions(this.imask as any); else { this.initMask(); this.onChange(this.maskValue); diff --git a/packages/imask/src/controls/html-mask-element.ts b/packages/imask/src/controls/html-mask-element.ts index 03ff1ed9..6bae5a22 100644 --- a/packages/imask/src/controls/html-mask-element.ts +++ b/packages/imask/src/controls/html-mask-element.ts @@ -10,7 +10,9 @@ export default abstract class HTMLMaskElement extends MaskElement { /** HTMLElement to use mask on */ declare input: HTMLElement; - declare _handlers: EventHandlers; + declare ignoreCompositionState?: boolean; + declare _handlers: EventHandlers; + abstract value: string; constructor (input: HTMLElement) { @@ -58,7 +60,7 @@ abstract class HTMLMaskElement extends MaskElement { return this._handlers.undo(e); } - if (!e.isComposing) this._handlers.selectionChange(e); + if (this.ignoreCompositionState || !e.isComposing) this._handlers.selectionChange(e); } _onBeforeinput (e: InputEvent) { @@ -78,7 +80,13 @@ abstract class HTMLMaskElement extends MaskElement { } _onInput (e: InputEvent) { - if (!e.isComposing) this._handlers.input(e); + if (this.ignoreCompositionState || !e.isComposing) this._handlers.input(e); + } + + setIgnoreCompositionState (ignoreCompositionState?: boolean) { + if (typeof ignoreCompositionState !== 'undefined') { + this.ignoreCompositionState = Boolean(ignoreCompositionState); + } } /** Unbinds HTMLElement events to mask internal events */ diff --git a/packages/imask/src/controls/input.ts b/packages/imask/src/controls/input.ts index 7625015f..db274531 100644 --- a/packages/imask/src/controls/input.ts +++ b/packages/imask/src/controls/input.ts @@ -1,6 +1,6 @@ import { DIRECTION, type Selection } from '../core/utils'; import ActionDetails from '../core/action-details'; -import createMask, { type UpdateOpts, maskedClass, type FactoryArg, type FactoryReturnMasked } from '../masked/factory'; +import createMask, { type UpdateOpts, maskedClass, type FactoryArg, type FactoryReturnMasked, type ControlOptions } from '../masked/factory'; import Masked from '../masked/base'; import MaskElement from './mask-element'; import HTMLInputMaskElement, { type InputElement } from './html-input-mask-element'; @@ -63,6 +63,7 @@ class InputMask> { this.alignCursorFriendly = this.alignCursorFriendly.bind(this); this._bindEvents(); + this._setIgnoreCompositionState((opts as Opts & ControlOptions)?.ignoreCompositionState); // refresh this.updateValue(); @@ -164,6 +165,10 @@ class InputMask> { if (this.el) this.el.unbindEvents(); } + _setIgnoreCompositionState (ignoreCompositionState?: boolean) { + this.el.setIgnoreCompositionState?.(ignoreCompositionState); + } + /** Fires custom event */ _fireEvent (ev: string, e?: InputEvent) { const listeners = this._listeners[ev]; @@ -244,7 +249,7 @@ class InputMask> { /** Updates options with deep equal check, recreates {@link Masked} model if mask type changes */ updateOptions(opts: UpdateOpts) { - const { mask, ...restOpts } = opts as any; // TODO types, yes, mask is optional + const { mask, ignoreCompositionState, ...restOpts } = opts as any; // TODO types, yes, mask is optional const updateMask = !this.maskEquals(mask); const updateOpts = this.masked.optionsIsChanged(restOpts); @@ -253,6 +258,7 @@ class InputMask> { if (updateOpts) this.masked.updateOptions(restOpts); // TODO if (updateMask || updateOpts) this.updateControl(); + this._setIgnoreCompositionState(ignoreCompositionState); } /** Updates cursor */ diff --git a/packages/imask/src/controls/mask-element.ts b/packages/imask/src/controls/mask-element.ts index 56913d7b..110bff8a 100644 --- a/packages/imask/src/controls/mask-element.ts +++ b/packages/imask/src/controls/mask-element.ts @@ -68,7 +68,10 @@ abstract class MaskElement { /** */ abstract bindEvents (handlers: EventHandlers): void; /** */ - abstract unbindEvents (): void + abstract unbindEvents (): void; + + /** Sets ignore composition state option */ + setIgnoreCompositionState? (ignoreCompositionState?: boolean): void; } diff --git a/packages/imask/src/index.ts b/packages/imask/src/index.ts index 3b18f610..c9598aaf 100644 --- a/packages/imask/src/index.ts +++ b/packages/imask/src/index.ts @@ -15,6 +15,7 @@ export { default as createMask, normalizeOpts, type AllFactoryStaticOpts, + type ControlOptions, type FactoryArg, type FactoryConstructorOpts, type FactoryConstructorReturnMasked, diff --git a/packages/imask/src/masked/factory.ts b/packages/imask/src/masked/factory.ts index eb7d974d..bebb6190 100644 --- a/packages/imask/src/masked/factory.ts +++ b/packages/imask/src/masked/factory.ts @@ -33,6 +33,14 @@ type FactoryStaticOpts = | MaskedFunctionOptions ; +export +type ControlOptions = { + /** + * When true, ignore IME composition state and process input events anyway. + */ + ignoreCompositionState?: boolean, +}; + export type AllFactoryStaticOpts = & MaskedDateFactoryOptions @@ -43,6 +51,7 @@ type AllFactoryStaticOpts = & MaskedFunctionOptions & MaskedEnumOptions & MaskedRangeOptions + & ControlOptions ; export diff --git a/packages/react-imask/src/mixin.ts b/packages/react-imask/src/mixin.ts index e843acdf..ad9a9900 100644 --- a/packages/react-imask/src/mixin.ts +++ b/packages/react-imask/src/mixin.ts @@ -9,7 +9,7 @@ export type Falsy = false | 0 | "" | null | undefined; export -type ReactMaskOpts = FactoryOpts & { unmask?: 'typed' | boolean }; +type ReactMaskOpts = FactoryOpts & { unmask?: 'typed' | boolean; ignoreCompositionState?: boolean }; export type UnmaskValue = @@ -35,6 +35,7 @@ type ReactMaskProps< value?: UnmaskValue>; inputRef?: React.Ref; ref?: React.Ref>; + ignoreCompositionState?: boolean; } const MASK_PROPS: { [key in keyof (AllFactoryStaticOpts & ReactMaskProps)]: any } = { @@ -65,6 +66,7 @@ const MASK_PROPS: { [key in keyof (AllFactoryStaticOpts & ReactMaskProps['typedValue']; value: InputMask['value']; diff --git a/packages/svelte-imask/src/action.ts b/packages/svelte-imask/src/action.ts index cca46afa..3ee76414 100644 --- a/packages/svelte-imask/src/action.ts +++ b/packages/svelte-imask/src/action.ts @@ -1,13 +1,13 @@ -import IMask, { type FactoryArg, type InputMask, type UpdateOpts } from 'imask'; +import IMask, { type FactoryArg, type InputMask, type UpdateOpts, type ControlOptions } from 'imask'; -function fireEvent (el: HTMLElement, eventName: string, data: InputMask) { +function fireEvent (el: HTMLElement, eventName: string, data: InputMask) { const e = document.createEvent('CustomEvent'); e.initCustomEvent(eventName, true, true, data); el.dispatchEvent(e); } -function initMask (el: HTMLElement, opts: InputMask | Opts): InputMask { +function initMask (el: HTMLElement, opts: InputMask | Opts): InputMask { const maskRef = (opts instanceof IMask.InputMask ? opts : IMask(el, opts as Opts)); return maskRef .on('accept', () => fireEvent(el, 'accept', maskRef)) @@ -16,7 +16,7 @@ function initMask (el: HTMLElement, opts: InputMask (el: HTMLElement, opts: Opts) { +function IMaskAction (el: HTMLElement, opts: Opts) { let maskRef: InputMask | undefined; let created: boolean | undefined; diff --git a/packages/vue-imask/src/props.ts b/packages/vue-imask/src/props.ts index 8a49b59d..0b736ff1 100644 --- a/packages/vue-imask/src/props.ts +++ b/packages/vue-imask/src/props.ts @@ -1,5 +1,5 @@ import { type PropType } from 'vue-demi'; -import { type FactoryOpts, type MaskedDynamicOptions } from 'imask'; +import { type FactoryOpts, type MaskedDynamicOptions, type ControlOptions } from 'imask'; export default { // common @@ -19,6 +19,7 @@ export default { validator: (value: unknown) => ['append', 'remove'].includes(value as string) || typeof value === 'boolean', }, skipInvalid: { type: Boolean, required: false, default: undefined }, + ignoreCompositionState: { type: Boolean, required: false, default: undefined }, // pattern placeholderChar: String,