diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c0e3ece..f5f2daf83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- #### Mask input, Date time input, Date range picker + - The masked editors now support the standard undo/redo shortcuts - `Ctrl + Z` / `Cmd + Z` to undo and `Ctrl + Y`, `Ctrl + Shift + Z` / `Cmd + Shift + Z` to redo. Previously these did nothing, because rendering the masked text reassigns the native input's value and that clears the browser's own undo stack. + - A run of consecutive typed characters, or of consecutive deletions, collapses into a single undo step. Moving the caret, pasting, dropping, cutting, spinning a date part, or composing with an IME each start a step of their own. + - Restoring a step emits `igcInput`, so composite hosts and two-way bindings follow the undo. For the date editors the restored text remains an uncommitted draft, and `igcChange` is emitted on blur only if the committed value actually changed. + - The history is kept across focus changes and is discarded when the value is set programmatically, when the control is reset by its form, or when the `mask`/`prompt`/`input-format` pattern changes. - #### Icon - `registerIcon` and `registerIconFromText` now accept a `RegisterIconOptions` object as their third argument in addition to the existing plain collection string. Setting `stripMeta: true` removes `` and `<desc>` elements from the stored SVG, preventing the browser from displaying a native tooltip on hover. The title text is still captured and exposed as the `aria-label` of the host `<igc-icon>` element. Any `aria-labelledby` / `aria-describedby` references on the root `<svg>` that pointed to the stripped elements' IDs are cleaned up automatically. [#1822](https://github.com/IgniteUI/igniteui-webcomponents/issues/1822) - #### QR Code diff --git a/src/components/common/mixins/mask-behavior.ts b/src/components/common/mixins/mask-behavior.ts index 652d0a8ce..f07e41d46 100644 --- a/src/components/common/mixins/mask-behavior.ts +++ b/src/components/common/mixins/mask-behavior.ts @@ -1,10 +1,22 @@ import type { LitElement } from 'lit'; import { property, state } from 'lit/decorators.js'; +import { + createMaskHistory, + type MaskEditKind, + type MaskHistory, + type MaskHistoryState, +} from '../../mask-input/mask-history.js'; import type { MaskParser } from '../../mask-input/mask-parser.js'; import type { RangeTextSelectMode, SelectionRangeDirection, } from '../../types.js'; +import { + addKeybindings, + ctrlKey, + metaKey, + shiftKey, +} from '../controllers/key-bindings.js'; import type { AbstractConstructor } from './constructor.js'; export type MaskSelection = { @@ -12,6 +24,22 @@ export type MaskSelection = { end: number; }; +/** + * The `inputType` values the mask editor models, mapped to their undo granularity. + * + * Anything absent is a native mutation we do not model - auto-fill is the empty-string + * entry, `deleteWordBackward` and friends are simply not handled. + */ +const MaskEditKinds = new Map<string, MaskEditKind>([ + ['insertText', 'insert'], + ['deleteContentBackward', 'delete-backward'], + ['deleteContentForward', 'delete-forward'], + ['deleteByCut', 'atomic'], + ['insertFromPaste', 'atomic'], + ['insertFromDrop', 'atomic'], + ['', 'atomic'], +]); + /** * Public + protected interface contributed by {@link MaskBehaviorMixin}. * Declared as a `declare class` so consumers can see protected members through @@ -37,9 +65,11 @@ export declare class MaskBehaviorElementInterface { protected _compositionStart: number; protected _focused: boolean; protected _maskedValue: string; + protected readonly _history: MaskHistory; protected get _inputSelection(): MaskSelection; protected get _isEmptyMask(): boolean; + protected get _historyText(): string; //#endregion @@ -72,7 +102,9 @@ export declare class MaskBehaviorElementInterface { //#region Event handlers protected _handleInput(event: InputEvent): Promise<void>; + protected _handleBeforeInput(event: InputEvent): void; protected _updateInput(text: string, range: MaskSelection): Promise<void>; + protected _commitMaskedValue(value: string): void; protected _emitInputEvent(): void; protected _setMaskSelection(event: Event): void; protected _handleCompositionStart(): void; @@ -81,6 +113,20 @@ export declare class MaskBehaviorElementInterface { //#endregion + //#region Undo/redo + + protected _recordHistory( + kind: MaskEditKind, + next: string, + caret: number, + selection?: MaskSelection + ): void; + + protected _historyStep(direction: 'undo' | 'redo'): Promise<void>; + protected _historyResync(): void; + + //#endregion + //#region Public methods /** Sets the text selection range of the control. */ @@ -114,7 +160,7 @@ export declare class MaskBehaviorElementInterface { * * The host class must implement `_syncValueFromMask` to bridge the masked * text back into its public `value`. It is called by the default - * `_updateInput` implementation and by `setRangeText`. + * `_commitMaskedValue` implementation. */ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( superClass: T @@ -138,6 +184,15 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( protected _maskSelection: MaskSelection = { start: 0, end: 0 }; protected _compositionStart = 0; + /** + * The signature is the source pattern rather than the escaped one: the date parsers + * convert a date format into mask flags, so `MM/dd/yyyy` and `dd/MM/yyyy` share an + * escaped mask of `00/00/0000` while meaning entirely different things. + */ + protected readonly _history = createMaskHistory( + () => `${this._parser.mask} ${this._parser.prompt}` + ); + @state() protected _focused = false; @@ -156,6 +211,44 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( return this._maskedValue === this._parser.emptyMask; } + /** + * The masked text as the undo history sees it. `igc-mask-input` blanks it on blur and + * restores the empty mask on focus; both spell the same empty document, so without + * this normalization every focus of an empty editor would look like a foreign change. + */ + protected get _historyText(): string { + return this._maskedValue || this._parser.emptyMask; + } + + //#endregion + + //#region Lifecycle + + constructor(...args: any[]) { + super(...args); + + // Assigning `input.value` - which every masked edit does - clears the browser's + // own undo stack, so the standard shortcuts have to be served from `_history`. + // The IME owns the text until `compositionend`, hence the `isComposing` guard. + const step = + (direction: 'undo' | 'redo') => + (event: KeyboardEvent): void => { + if (!event.isComposing) { + this._historyStep(direction); + } + }; + + addKeybindings(this, { + skip: () => this.readOnly, + bindingDefaults: { repeat: true }, + }) + .set([ctrlKey, 'z'], step('undo')) + .set([metaKey, 'z'], step('undo')) + .set([ctrlKey, 'y'], step('redo')) + .set([ctrlKey, shiftKey, 'z'], step('redo')) + .set([metaKey, shiftKey, 'z'], step('redo')); + } + //#endregion //#region Public attributes and properties @@ -209,30 +302,63 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( const value = this._input?.value ?? ''; const { start, end } = this._maskSelection; const deletePosition = this._parser.getNextNonLiteralPosition(end) + 1; + + // Reachable only where `beforeinput` is not cancelable - normally the browser's + // history commands are intercepted before they ever mutate the input. + if (inputType === 'historyUndo' || inputType === 'historyRedo') { + return this._historyStep(inputType === 'historyUndo' ? 'undo' : 'redo'); + } + + // A composing backspace is handled by the composition events instead. + if (inputType === 'deleteContentBackward' && isComposing) { + return; + } + + const kind = MaskEditKinds.get(inputType ?? ''); + + if (kind === undefined) { + // A non-modeled mutation has already changed the input's DOM value, so re-render + // to let the `live()` binding roll it back to the masked text. Never mid-IME + // though - `insertCompositionText` fires repeatedly there and resetting the value + // underneath the browser breaks composition outright. + if (!isComposing) { + this.requestUpdate(); + } + return; + } + this._setTouchedState(); switch (inputType) { - case 'deleteContentForward': - this._updateInput('', { start, end: deletePosition }); - await this.updateComplete; - return this._input?.setSelectionRange(deletePosition, deletePosition); + case 'deleteContentForward': { + await this._updateInput('', { start, end: deletePosition }, kind); + this._input?.setSelectionRange(deletePosition, deletePosition); + // `_updateInput` settled on the parser's cursor, but the caret actually ends up + // past the deleted character - record that so a run of deletes coalesces. + this._history.settle(this._historyText, deletePosition); + return; + } case 'deleteContentBackward': - if (isComposing) return; - return this._updateInput('', { - start: this._parser.getPreviousNonLiteralPosition( - this._inputSelection.start + 1 - ), - end, - }); + return this._updateInput( + '', + { + start: this._parser.getPreviousNonLiteralPosition( + this._inputSelection.start + 1 + ), + end, + }, + kind + ); case 'deleteByCut': - return this._updateInput('', this._maskSelection); + return this._updateInput('', this._maskSelection, kind); case 'insertText': return this._updateInput( value.substring(start, this._inputSelection.end), - this._maskSelection + this._maskSelection, + kind ); case 'insertFromPaste': @@ -241,16 +367,23 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( { start, end: this._inputSelection.start, - } + }, + kind ); case 'insertFromDrop': + // An external drop is preceded by no `dragstart`, so `_maskSelection` is stale. return this._updateInput( value.substring( this._inputSelection.start, this._inputSelection.end ), - { ...this._inputSelection } + { ...this._inputSelection }, + kind, + { + start: this._inputSelection.start, + end: this._inputSelection.start, + } ); // Potential browser auto-fill behavior @@ -263,21 +396,23 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( { start, end: this._inputSelection.end, - } + }, + kind, + { start, end: start } ); } } /** - * Default mask-update routine. Re-applies the parser, syncs the leaf's - * value via {@link MaskBehaviorElementInterface._syncValueFromMask} and - * emits an input event when the edit is not at the trailing mask boundary. - * - * Leaves with bespoke commit semantics (e.g. `igc-mask-input`) override this. + * Default mask-update routine. Re-applies the parser, commits the result through + * {@link MaskBehaviorElementInterface._commitMaskedValue} and emits an input event + * when the edit is not at the trailing mask boundary. */ protected async _updateInput( text: string, - range: MaskSelection + range: MaskSelection, + kind: MaskEditKind = 'atomic', + caretBefore?: MaskSelection ): Promise<void> { const { value, end } = this._parser.replace( this._maskedValue, @@ -286,8 +421,8 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( range.end ); - this._maskedValue = value; - this._syncValueFromMask(); + this._recordHistory(kind, value, end, caretBefore ?? this._maskSelection); + this._commitMaskedValue(value); this.requestUpdate(); if (range.start !== this._parser.mask.length) { @@ -298,6 +433,18 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( this._input?.setSelectionRange(end, end); } + /** + * Writes a fully-formed masked text into the component's value pipeline. + * + * This is the one step where the leaves genuinely differ - `igc-mask-input` commits + * straight to its form value, while the date editors keep the text as a draft until + * blur - so it is also the only thing undo/redo has to delegate. + */ + protected _commitMaskedValue(value: string): void { + this._maskedValue = value; + this._syncValueFromMask(); + } + /** * Emits an `igcInput` event with the current masked value as detail. * Override to emit a different payload (e.g. the parsed value). @@ -319,10 +466,13 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( } protected _handleCompositionEnd({ data }: CompositionEvent): void { - this._updateInput(data, { - start: this._compositionStart, - end: this._inputSelection.end, - }); + // The whole composed sequence is one undo step, anchored where it began. + this._updateInput( + data, + { start: this._compositionStart, end: this._inputSelection.end }, + 'atomic', + { start: this._compositionStart, end: this._compositionStart } + ); } protected _handleClick(): void { @@ -337,6 +487,93 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( } } + /** + * Intercepts the browser's own history commands - the Edit and context menus, and + * the software keyboard on mobile - which never reach the key bindings. The native + * stack is empty anyway, so letting one through would only de-sync the input's DOM + * value from the masked text. + */ + protected _handleBeforeInput(event: InputEvent): void { + const { inputType } = event; + + if (inputType !== 'historyUndo' && inputType !== 'historyRedo') { + return; + } + + event.preventDefault(); + + if (!this.readOnly) { + this._historyStep(inputType === 'historyUndo' ? 'undo' : 'redo'); + } + } + + //#endregion + + //#region Undo/redo + + /** + * Snapshots the current masked text before `next` replaces it, then reports where the + * caret ends up. Must be called *before* the text is committed. + * + * Only a real change earns an undo step: a character the mask rejects, a backspace at + * position zero or a spin that hit a boundary would otherwise leave behind a step + * that appears to do nothing when undone. + */ + protected _recordHistory( + kind: MaskEditKind, + next: string, + caret: number, + selection: MaskSelection = this._inputSelection + ): void { + const previous = this._historyText; + + if (next !== previous) { + this._history.record(kind, { value: previous, ...selection }); + } + + this._history.settle(next, caret); + } + + /** Reconciles the history with the current masked text. */ + protected _historyResync(): void { + this._history.resync(this._historyText); + } + + /** Restores the neighboring history state in the given direction. */ + protected async _historyStep(direction: 'undo' | 'redo'): Promise<void> { + if (this.readOnly) { + return; + } + + const current: MaskHistoryState = { + value: this._historyText, + ...this._inputSelection, + }; + + const state = + direction === 'undo' + ? this._history.undo(current) + : this._history.redo(current); + + if (!state) { + return; + } + + this._setTouchedState(); + this._commitMaskedValue(state.value); + this.requestUpdate(); + + // Native inputs announce an undo as an `input` event, and the composite hosts + // (`igc-date-picker`, `igc-date-range-picker`) read the draft value from ours. + this._emitInputEvent(); + + await this.updateComplete; + + // Through the mixin method, since the input's own keydown handler has already + // overwritten `_maskSelection` with the caret as it was before the restore. + this.setSelectionRange(state.start, state.end); + } + //#endregion //#region Public methods @@ -370,8 +607,10 @@ export function MaskBehaviorMixin<T extends AbstractConstructor<LitElement>>( _start, _end ); - this._maskedValue = this._parser.apply(this._parser.parse(result.value)); - this._syncValueFromMask(); + const next = this._parser.apply(this._parser.parse(result.value)); + + this._recordHistory('atomic', next, _start, current); + this._commitMaskedValue(next); this.updateComplete.then(() => { switch (selectMode) { diff --git a/src/components/common/templates/masked-input.ts b/src/components/common/templates/masked-input.ts index 14bdb8b1b..4b63bf703 100644 --- a/src/components/common/templates/masked-input.ts +++ b/src/components/common/templates/masked-input.ts @@ -28,6 +28,8 @@ export interface MaskedInputOptions { // Required mask handlers onInput: (event: InputEvent) => void; + /** Wired to `beforeinput` so the component owns `historyUndo` / `historyRedo`. */ + onBeforeInput: (event: InputEvent) => void; onFocus: (event: FocusEvent) => void; onBlur: (event: FocusEvent) => void; onClick: () => void; @@ -67,6 +69,7 @@ export function renderMaskedNativeInput( aria-describedby=${bindIf(!!opts.ariaDescribedBy, opts.ariaDescribedBy)} .ariaLabelledByElements=${opts.ariaLabelledByElements ?? null} @input=${opts.onInput} + @beforeinput=${opts.onBeforeInput} @focus=${opts.onFocus} @blur=${opts.onBlur} @click=${opts.onClick} diff --git a/src/components/date-range-picker/date-range-input.ts b/src/components/date-range-picker/date-range-input.ts index 5d99d0faa..01dff7194 100644 --- a/src/components/date-range-picker/date-range-input.ts +++ b/src/components/date-range-picker/date-range-input.ts @@ -14,7 +14,6 @@ import { DatePartType, } from '../date-time-input/date-part.js'; import { IgcDateTimeInputBaseComponent } from '../date-time-input/date-time-input.base.js'; -import { DateParts } from '../date-time-input/datetime-mask-parser.js'; import { styles } from '../input/themes/input.base.css.js'; import { styles as shared } from '../input/themes/shared/input.common.css.js'; import { all } from '../input/themes/themes.js'; @@ -118,11 +117,17 @@ export default class IgcDateRangeInputComponent extends EventEmitterMixin< if (!this.value || (!this.value.start && !this.value.end)) { this._maskedValue = this._parser.emptyMask; + this._historyResync(); await this.updateComplete; this.select(); - } else if (this.displayFormat !== this.inputFormat) { + return; + } + + if (this.displayFormat !== this.inputFormat) { this._updateMaskDisplay(); } + + this._historyResync(); } // #endregion @@ -140,14 +145,9 @@ export default class IgcDateRangeInputComponent extends EventEmitterMixin< direction: number ): number { const cursorPos = this._maskSelection.start; - const rangeParts = this._parser.rangeParts; + const rangeParts = this._parser.parts; - const currentPart = rangeParts.find( - (p) => - p.type !== DateParts.Literal && - cursorPos >= p.start && - cursorPos <= p.end - ); + const currentPart = this._parser.getPartForCursor(cursorPos); const isStartOrEndPart = currentPart && @@ -159,9 +159,9 @@ export default class IgcDateRangeInputComponent extends EventEmitterMixin< if (isStartOrEndPart && cursorPos !== currentPart.start) { return currentPart.start; } - const prevPart = [...rangeParts] - .reverse() - .find((p) => p.type !== DateParts.Literal && p.end < cursorPos); + const prevPart = rangeParts.findLast( + (p) => p.type !== DatePartType.Literal && p.end < cursorPos + ); return prevPart?.start ?? 0; } @@ -170,7 +170,7 @@ export default class IgcDateRangeInputComponent extends EventEmitterMixin< return currentPart.end; } const nextPart = rangeParts.find( - (p) => p.type !== DateParts.Literal && p.start > cursorPos + (p) => p.type !== DatePartType.Literal && p.start > cursorPos ); return nextPart?.end ?? inputValue.length; } @@ -257,35 +257,14 @@ export default class IgcDateRangeInputComponent extends EventEmitterMixin< /** * Gets the date range part at the current cursor position. - * If the cursor is at a literal, finds the nearest non-literal part. - * Returns undefined if no valid part is found. + * Returns undefined if the cursor sits outside any part - in the separator, say. */ protected override _getDatePartAtCursor(): DateRangePart | undefined { - const cursorPos = this._inputSelection.start; - let part = this._parser.getDateRangePartForCursor(cursorPos); + const part = this._parser.getPartForCursor(this._inputSelection.start); - // If cursor is at a literal, find the nearest non-literal part - if (part?.type === DatePartType.Literal) { - const nextPart = this._parser.rangeParts.find( - (p) => p.start >= cursorPos && p.type !== DatePartType.Literal - ); - if (nextPart) { - part = nextPart; - } else { - part = this._parser.rangeParts.findLast( - (p) => p.end <= cursorPos && p.type !== DatePartType.Literal - ); - } - } - - if (part && part.type !== DatePartType.Literal) { - return { - part: part.type as DatePart, - position: part.position, - }; - } - - return undefined; + return part + ? { part: part.type as DatePart, position: part.position } + : undefined; } /** @@ -345,21 +324,11 @@ export default class IgcDateRangeInputComponent extends EventEmitterMixin< } public override hasDateParts(): boolean { - return this._parser.rangeParts.some( - (p) => - p.type === DatePartType.Date || - p.type === DatePartType.Month || - p.type === DatePartType.Year - ); + return this._parser.hasDateParts(); } public override hasTimeParts(): boolean { - return this._parser.rangeParts.some( - (p) => - p.type === DatePartType.Hours || - p.type === DatePartType.Minutes || - p.type === DatePartType.Seconds - ); + return this._parser.hasTimeParts(); } // #endregion diff --git a/src/components/date-range-picker/date-range-mask-parser.spec.ts b/src/components/date-range-picker/date-range-mask-parser.spec.ts index 59f6ce7ab..d6844ee02 100644 --- a/src/components/date-range-picker/date-range-mask-parser.spec.ts +++ b/src/components/date-range-picker/date-range-mask-parser.spec.ts @@ -46,7 +46,7 @@ describe('DateRangeMaskParser', () => { it('builds range parts with position information', () => { const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); - const parts = parser.rangeParts; + const parts = parser.parts; const startParts = parts.filter( (p) => p.position === DateRangePosition.Start @@ -146,23 +146,19 @@ describe('DateRangeMaskParser', () => { }); describe('Part Queries', () => { - it('gets date range part at position', () => { + it('gets part for cursor position', () => { const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); - const startMonthPart = parser.getDateRangePartAtPosition(0); - expect(startMonthPart?.type).to.equal(DatePartType.Month); - expect(startMonthPart?.position).to.equal(DateRangePosition.Start); + const startMonth = parser.getPartForCursor(0); + expect(startMonth?.type).to.equal(DatePartType.Month); + expect(startMonth?.position).to.equal(DateRangePosition.Start); - const endMonthPart = parser.getDateRangePartAtPosition(13); - expect(endMonthPart?.type).to.equal(DatePartType.Month); - expect(endMonthPart?.position).to.equal(DateRangePosition.End); - }); + const endMonth = parser.getPartForCursor(13); + expect(endMonth?.type).to.equal(DatePartType.Month); + expect(endMonth?.position).to.equal(DateRangePosition.End); - it('gets part for cursor position', () => { - const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); - - const part = parser.getDateRangePartForCursor(2); - expect(part?.type).to.equal(DatePartType.Month); + // Inside the separator there is nothing to target. + expect(parser.getPartForCursor(12)).to.be.undefined; }); it('gets part by type and position', () => { @@ -253,15 +249,59 @@ describe('DateRangeMaskParser', () => { }); }); + describe('Prompt Updates', () => { + it('formats both dates with a changed prompt', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + parser.prompt = '*'; + + expect(parser.emptyMask).to.equal('**/**/**** - **/**/****'); + expect(parser.formatDateRange(null)).to.equal(parser.emptyMask); + expect( + parser.formatDateRange({ start: new Date(2026, 11, 25), end: null }) + ).to.equal('12/25/2026 - **/**/****'); + }); + + it('parses both dates against a changed prompt', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + parser.prompt = '*'; + + // An untouched range is empty, not a pair of default dates. + expect(parser.parseDateRange(parser.formatDateRange(null))).to.be.null; + + const range = parser.parseDateRange('12/25/2026 - **/**/****'); + expect(range!.start!.getDate()).to.equal(25); + expect(range!.end!.getFullYear()).to.equal(2000); + }); + + it('ignores a prompt that collides with a mask flag', () => { + const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); + parser.prompt = '0'; + + // The base setter rejects it, so the sub-parsers must not drift off on their own. + expect(parser.prompt).to.equal('_'); + expect(parser.formatDateRange(null)).to.equal(parser.emptyMask); + }); + + it('applies the same prompt rules from the constructor', () => { + const parser = new DateRangeMaskParser({ + format: 'MM/dd/yyyy', + promptCharacter: '0', + }); + + expect(parser.prompt).to.equal('_'); + expect(parser.formatDateRange(null)).to.equal(parser.emptyMask); + }); + }); + describe('Mask Updates', () => { it('updates mask and rebuilds parts', () => { const parser = new DateRangeMaskParser({ format: 'MM/dd/yyyy' }); - const initialParts = parser.rangeParts.length; + const initialParts = parser.parts.length; parser.mask = 'M/d/yy'; expect(parser.mask).to.equal('M/d/yy - M/d/yy'); - expect(parser.rangeParts.length).to.equal(initialParts); + expect(parser.parts.length).to.equal(initialParts); expect(parser.emptyMask).to.equal('_/_/__ - _/_/__'); }); }); diff --git a/src/components/date-range-picker/date-range-mask-parser.ts b/src/components/date-range-picker/date-range-mask-parser.ts index a2f7b2a88..81feb6f9c 100644 --- a/src/components/date-range-picker/date-range-mask-parser.ts +++ b/src/components/date-range-picker/date-range-mask-parser.ts @@ -1,15 +1,16 @@ import { + createDatePart, type DatePart, DatePartType, type IDatePart, type SpinOptions, } from '../date-time-input/date-part.js'; import { - type DateTimeMaskOptions, + DateFormatMaskParser, DateTimeMaskParser, DEFAULT_DATETIME_FORMAT, } from '../date-time-input/datetime-mask-parser.js'; -import { MaskParser } from '../mask-input/mask-parser.js'; +import type { MaskOptions } from '../mask-input/mask-parser.js'; import type { DateRangeValue } from '../types.js'; //#region Types and Enums @@ -41,7 +42,7 @@ export interface IDateRangePart extends IDatePart { } /** Options for the DateRangeMaskParser */ -export interface DateRangeMaskOptions extends DateTimeMaskOptions { +export interface DateRangeMaskOptions extends MaskOptions { /** Separator (defaults to ' - ') */ separator?: string; } @@ -55,6 +56,28 @@ const DEFAULT_SEPARATOR = ' - '; //#endregion +/** + * Re-creates one date's parts at their position within the range. Going back through the + * factory keeps them real part instances - spreading would copy the data and lose the + * prototype along with it. + */ +function offsetParts( + parts: ReadonlyArray<IDatePart>, + offset: number, + position: DateRangePosition +): IDateRangePart[] { + return parts.map((part) => + Object.assign( + createDatePart(part.type, { + start: part.start + offset, + end: part.end + offset, + format: part.format, + }), + { position } + ) + ); +} + /** * A specialized mask parser for date range input fields. * Uses composition with two DateTimeMaskParser instances to handle start and end dates. @@ -69,13 +92,10 @@ const DEFAULT_SEPARATOR = ' - '; * parser.formatDateRange({ start: date1, end: date2 }); // Returns formatted string * ``` */ -export class DateRangeMaskParser extends MaskParser { +export class DateRangeMaskParser extends DateFormatMaskParser<IDateRangePart> { private _startParser: DateTimeMaskParser; private _endParser: DateTimeMaskParser; - /** Cached date range parts with position information */ - private _rangeParts: IDateRangePart[] = []; - /** The separator between start and end dates */ private _separator: string; @@ -85,13 +105,6 @@ export class DateRangeMaskParser extends MaskParser { /** End position of the separator in the mask */ private _separatorEnd: number; - /** - * Gets the parsed date range parts with position information. - */ - public get rangeParts(): ReadonlyArray<IDateRangePart> { - return this._rangeParts; - } - /** * Gets the separator string used between start and end dates. */ @@ -104,83 +117,31 @@ export class DateRangeMaskParser extends MaskParser { const separator = options?.separator || DEFAULT_SEPARATOR; const promptCharacter = options?.promptCharacter; - // Build the combined range format for the parent MaskParser - const rangeFormat = `${format}${separator}${format}`; - - super( - options?.promptCharacter - ? { format: rangeFormat, promptCharacter: options.promptCharacter } - : { format: rangeFormat } - ); + super({ format: `${format}${separator}${format}`, promptCharacter }); - // Create two parsers for start and end dates this._startParser = new DateTimeMaskParser({ format, promptCharacter }); this._endParser = new DateTimeMaskParser({ format, promptCharacter }); this._separator = separator; - this._separatorStart = this._startParser.mask.length; this._separatorEnd = this._separatorStart + separator.length; - - this._buildRangeParts(); } - //#region Mask Format Conversion - - /** - * Overrides base class to convert date format to mask format - * before parsing literals. This ensures date format characters - * (M, d, y, etc.) are properly converted to mask characters (0, L). - */ - protected override _parseMaskLiterals(): void { - // Convert the range format to mask format - // e.g., "M/d/yyyy - M/d/yyyy" → "0/0/0000 - 0/0/0000" - const dateFormat = this._options.format; - const maskFormat = - DateTimeMaskParser.convertDateFormatToMaskFormat(dateFormat); - - // Temporarily set the converted format for base class parsing - const originalFormat = this._options.format; - this._options.format = maskFormat; - - super._parseMaskLiterals(); - - // Restore the original date format - this._options.format = originalFormat; - } - - //#endregion - - /** - * Builds the range parts array by combining parts from start and end parsers - * and adding position information. - */ - private _buildRangeParts(): void { - const startParts = this._startParser.dateParts.map( - (part): IDateRangePart => ({ - ...part, - position: DateRangePosition.Start, - getValue: part.getValue.bind(part), - validate: part.validate.bind(part), - spin: part.spin.bind(part), - }) - ); - - const endParts = this._endParser.dateParts.map((part): IDateRangePart => ({ - ...part, - // Adjust positions for end date (offset by separator) - start: part.start + this._separatorEnd, - end: part.end + this._separatorEnd, - position: DateRangePosition.End, - getValue: part.getValue.bind(part), - validate: part.validate.bind(part), - spin: part.spin.bind(part), - })); - - this._rangeParts = [...startParts, ...endParts]; + protected override _buildParts(): IDateRangePart[] { + return [ + ...offsetParts(this._startParser.parts, 0, DateRangePosition.Start), + ...offsetParts( + this._endParser.parts, + this._separatorEnd, + DateRangePosition.End + ), + ]; } /** * Sets a new date format and updates both parsers. + * + * @remarks + * Takes the format of a *single* date; the getter returns the combined range format. */ public override set mask(value: string) { this._startParser.mask = value; @@ -189,16 +150,33 @@ export class DateRangeMaskParser extends MaskParser { this._separatorStart = this._startParser.mask.length; this._separatorEnd = this._separatorStart + this._separator.length; - const rangeFormat = `${value}${this._separator}${value}`; - super.mask = rangeFormat; - - this._buildRangeParts(); + super.mask = `${value}${this._separator}${value}`; } public override get mask(): string { return super.mask; } + /** + * Sets the prompt character and updates both parsers. + * + * @remarks + * Each half of the range is parsed and formatted by its own sub-parser, so all three + * have to agree on the prompt. Read back through `super` rather than propagating the + * argument, since the base setter normalizes it to a single character and rejects one + * that collides with a mask flag. + */ + public override set prompt(value: string) { + super.prompt = value; + + this._startParser.prompt = this.prompt; + this._endParser.prompt = this.prompt; + } + + public override get prompt(): string { + return super.prompt; + } + //#region Date Range Parsing /** @@ -210,11 +188,10 @@ export class DateRangeMaskParser extends MaskParser { return null; } - const startString = masked.substring(0, this._separatorStart); - const endString = masked.substring(this._separatorEnd); - - const start = this._startParser.parseDate(startString); - const end = this._endParser.parseDate(endString); + const start = this._startParser.parseDate( + masked.substring(0, this._separatorStart) + ); + const end = this._endParser.parseDate(masked.substring(this._separatorEnd)); return { start, end }; } @@ -227,45 +204,16 @@ export class DateRangeMaskParser extends MaskParser { * Formats a DateRangeValue into a masked string using the two internal parsers. */ public formatDateRange(range: DateRangeValue | null): string { - const startString = range?.start - ? this._startParser.formatDate(range.start) - : this._startParser.emptyMask; + const start = this._startParser.formatDate(range?.start ?? null); + const end = this._endParser.formatDate(range?.end ?? null); - const endString = range?.end - ? this._endParser.formatDate(range.end) - : this._endParser.emptyMask; - - return startString + this._separator + endString; + return start + this._separator + end; } //#endregion //#region Part Queries - /** - * Gets the date range part at a cursor position. - * Uses exclusive end for precise character targeting. - */ - public getDateRangePartAtPosition( - position: number - ): IDateRangePart | undefined { - return this._rangeParts.find( - (part) => position >= part.start && position < part.end - ); - } - - /** - * Gets the date range part for cursor (inclusive end). - * Handles the edge case where cursor is at the end of the last part. - */ - public getDateRangePartForCursor( - position: number - ): IDateRangePart | undefined { - return this._rangeParts.find( - (part) => position >= part.start && position <= part.end - ); - } - /** * Gets a specific part type for a position. */ @@ -273,9 +221,7 @@ export class DateRangeMaskParser extends MaskParser { type: DatePartType, position: DateRangePosition ): IDateRangePart | undefined { - return this._rangeParts.find( - (p) => p.type === type && p.position === position - ); + return this.parts.find((p) => p.type === type && p.position === position); } /** @@ -284,7 +230,7 @@ export class DateRangeMaskParser extends MaskParser { public getFirstDatePartForPosition( position: DateRangePosition ): IDateRangePart | undefined { - return this._rangeParts.find( + return this.parts.find( (p) => p.position === position && p.type !== DatePartType.Literal ); } @@ -305,29 +251,22 @@ export class DateRangeMaskParser extends MaskParser { amPmValue?: string ): DateRangeValue { const value = currentValue || { start: null, end: null }; + const isStart = part.position === DateRangePosition.Start; - const targetDate = - part.position === DateRangePosition.Start ? value.start : value.end; - - // If no date exists, create one with today's date - const dateToSpin = targetDate || new Date(); - - // Create a new date instance to spin - const newDate = new Date(dateToSpin.getTime()); + // Spin a copy of the targeted date, defaulting to today when the range has no value. + const originalDate = (isStart ? value.start : value.end) ?? new Date(); + const date = new Date(originalDate.getTime()); - // Spin using the part's built-in spin method const spinOptions: SpinOptions = { - date: newDate, + date, spinLoop, - originalDate: dateToSpin, + originalDate, amPmValue, }; part.spin(delta, spinOptions); - return part.position === DateRangePosition.Start - ? { ...value, start: newDate } - : { ...value, end: newDate }; + return isStart ? { ...value, start: date } : { ...value, end: date }; } //#endregion diff --git a/src/components/date-range-picker/date-range-picker-single.spec.ts b/src/components/date-range-picker/date-range-picker-single.spec.ts index 71aa330a3..fd0cfd60d 100644 --- a/src/components/date-range-picker/date-range-picker-single.spec.ts +++ b/src/components/date-range-picker/date-range-picker-single.spec.ts @@ -1145,6 +1145,29 @@ describe('Date range picker - single input', () => { expect(eventSpy).not.called; }); }); + + describe('Undo / redo', () => { + it('restores a typed draft in the range input', async () => { + rangeInput.focus(); + await elementUpdated(picker); + + const initial = input.value; + + rangeInput.setSelectionRange(0, input.value.length); + simulateInput(input, { + value: '10/10/2020 - 11/11/2020', + inputType: 'insertText', + }); + await elementUpdated(picker); + + expect(input.value).to.equal('10/10/2020 - 11/11/2020'); + + simulateKeyboard(input, [ctrlKey, 'z']); + await elementUpdated(picker); + + expect(input.value).to.equal(initial); + }); + }); }); describe('Slots', () => { it('should render slotted elements', async () => { diff --git a/src/components/date-time-input/date-part.ts b/src/components/date-time-input/date-part.ts index 991017a61..055863333 100644 --- a/src/components/date-time-input/date-part.ts +++ b/src/components/date-time-input/date-part.ts @@ -33,6 +33,20 @@ export const DatePartType = { export type DatePartType = (typeof DatePartType)[keyof typeof DatePartType]; +/** The part types that make up a calendar date. */ +export const DATE_PART_TYPES = new Set<DatePartType>([ + DatePartType.Date, + DatePartType.Month, + DatePartType.Year, +]); + +/** The part types that make up a time of day. */ +export const TIME_PART_TYPES = new Set<DatePartType>([ + DatePartType.Hours, + DatePartType.Minutes, + DatePartType.Seconds, +]); + // Spin delta defaults export const DEFAULT_DATE_PARTS_SPIN_DELTAS = Object.freeze<DatePartDeltas>({ date: 1, diff --git a/src/components/date-time-input/date-time-input.base.ts b/src/components/date-time-input/date-time-input.base.ts index 479cf9285..67f14b108 100644 --- a/src/components/date-time-input/date-time-input.base.ts +++ b/src/components/date-time-input/date-time-input.base.ts @@ -410,8 +410,12 @@ export abstract class IgcDateTimeInputBaseComponent< return; } + const next = this._formatValue(value); + + this._recordHistory('atomic', next, this._inputSelection.start); + this._isEditing = true; - this._maskedValue = this._formatValue(value); + this._maskedValue = next; this.requestUpdate(); } @@ -604,6 +608,7 @@ export abstract class IgcDateTimeInputBaseComponent< ariaDescribedBy: hasHelperText ? 'helper-text' : undefined, ariaLabelledByElements: this._resolvedLabelElements, onInput: this._handleInput, + onBeforeInput: this._handleBeforeInput, onFocus: this._handleFocus, onBlur: this._handleBlur, onClick: this._handleClick, diff --git a/src/components/date-time-input/date-time-input.spec.ts b/src/components/date-time-input/date-time-input.spec.ts index cc399c06b..cb2697c06 100644 --- a/src/components/date-time-input/date-time-input.spec.ts +++ b/src/components/date-time-input/date-time-input.spec.ts @@ -344,6 +344,139 @@ describe('Date Time Input component', () => { }); }); + describe('Undo / redo', () => { + /** Replaces the whole mask with the given digits. */ + async function type(digits: string): Promise<void> { + element.setSelectionRange(0, input.value.length); + simulateInput(input, { value: digits, inputType: 'insertText' }); + await elementUpdated(element); + } + + async function press(...keys: string[]): Promise<void> { + simulateKeyboard(input, keys); + await elementUpdated(element); + } + + const undo = () => press(ctrlKey, 'z'); + const redo = () => press(ctrlKey, 'y'); + + beforeEach(async () => { + element.inputFormat = 'MM/dd/yyyy'; + element.displayFormat = 'MM/dd/yyyy'; + element.value = null; + await elementUpdated(element); + + element.focus(); + await elementUpdated(element); + }); + + it('restores the mask without committing the value', async () => { + await type('10102020'); + expect(input.value).to.equal('10/10/2020'); + expect(element.value).to.be.null; + + await undo(); + + expect(input.value).to.equal('__/__/____'); + // Still an uncommitted draft - `value` only moves on blur. + expect(element.value).to.be.null; + }); + + it('commits the restored draft on blur', async () => { + await type('10102020'); + await type('01012021'); + expect(input.value).to.equal('01/01/2021'); + + await undo(); + expect(input.value).to.equal('10/10/2020'); + + element.blur(); + await elementUpdated(element); + + expect(element.value?.getTime()).to.equal( + new Date(2020, 9, 10).getTime() + ); + }); + + it('redoes a restored draft', async () => { + await type('10102020'); + + await undo(); + expect(input.value).to.equal('__/__/____'); + + await redo(); + expect(input.value).to.equal('10/10/2020'); + }); + + it('emits no igcChange when undone back to the focused value', async () => { + const initial = new Date(2020, 2, 3); + element.value = initial; + element.blur(); + await elementUpdated(element); + + element.focus(); + await elementUpdated(element); + + await type('10102020'); + + const eventSpy = spy(element, 'emitEvent'); + + await undo(); + expect(input.value).to.equal('03/03/2020'); + + element.blur(); + await elementUpdated(element); + + expect(eventSpy).not.calledWith('igcChange'); + expect(element.value?.getTime()).to.equal(initial.getTime()); + }); + + it('keeps consecutive spins as separate steps', async () => { + element.value = new Date(2020, 2, 3); + await elementUpdated(element); + + element.setSelectionRange(0, 0); + await press(arrowUp); + await press(arrowUp); + expect(input.value).to.equal('05/03/2020'); + + await undo(); + expect(input.value).to.equal('04/03/2020'); + + await undo(); + expect(input.value).to.equal('03/03/2020'); + }); + + it('emits igcInput when a step is restored', async () => { + await type('10102020'); + + const eventSpy = spy(element, 'emitEvent'); + await undo(); + + expect(eventSpy).calledWith('igcInput'); + }); + + it('does nothing while readonly', async () => { + await type('10102020'); + + element.readOnly = true; + await elementUpdated(element); + + await undo(); + expect(input.value).to.equal('10/10/2020'); + }); + + it('drops the history when the input format changes', async () => { + await type('10102020'); + + element.inputFormat = 'dd/MM/yyyy'; + await elementUpdated(element); + + await undo(); + expect(input.value).to.equal('10/10/2020'); + }); + }); + it('should correctly switch between different pre-defined date formats', async () => { const targetDate = new Date(2020, 2, 3, 0, 0, 0, 0); diff --git a/src/components/date-time-input/date-time-input.ts b/src/components/date-time-input/date-time-input.ts index 819b27ecc..26fab97b7 100644 --- a/src/components/date-time-input/date-time-input.ts +++ b/src/components/date-time-input/date-time-input.ts @@ -13,16 +13,14 @@ import { styles as shared } from '../input/themes/shared/input.common.css.js'; import { all } from '../input/themes/themes.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import { + createDatePart, DatePart, type DatePartDeltas, + DatePartType, DEFAULT_DATE_PARTS_SPIN_DELTAS, } from './date-part.js'; import { IgcDateTimeInputBaseComponent } from './date-time-input.base.js'; -import { - createDatePart, - DateParts, - DateTimeMaskParser, -} from './datetime-mask-parser.js'; +import { DateTimeMaskParser } from './datetime-mask-parser.js'; import { dateTimeInputValidators } from './validators.js'; export interface IgcDateTimeInputComponentEventMap { @@ -142,11 +140,17 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< if (!this.value) { this._maskedValue = this._parser.emptyMask; + this._historyResync(); await this.updateComplete; this.select(); - } else if (this.displayFormat !== this.inputFormat) { + return; + } + + if (this.displayFormat !== this.inputFormat) { this._updateMaskDisplay(); } + + this._historyResync(); } //#endregion @@ -163,19 +167,19 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< direction: number ): number { const cursorPos = this._maskSelection.start; - const dateParts = this._parser.dateParts; + const dateParts = this._parser.parts; if (direction === 0) { // Navigate backwards: find last literal before cursor const part = dateParts.findLast( - (part) => part.type === DateParts.Literal && part.end < cursorPos + (part) => part.type === DatePartType.Literal && part.end < cursorPos ); return part?.end ?? 0; } // Navigate forwards: find first literal after cursor const part = dateParts.find( - (part) => part.type === DateParts.Literal && part.start > cursorPos + (part) => part.type === DatePartType.Literal && part.start > cursorPos ); return part?.start ?? inputValue.length; } @@ -190,8 +194,8 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< * Returns undefined if cursor is not within a valid date part. */ protected override _getDatePartAtCursor(): DatePart | undefined { - return this._parser.getDatePartForCursor(this._inputSelection.start) - ?.type as DatePart | undefined; + return this._parser.getPartForCursor(this._inputSelection.start)?.type as + DatePart | undefined; } /** @@ -199,9 +203,9 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< * Prioritizes: Date > Hours > First available part */ protected override _getDefaultDatePart(): DatePart | undefined { - return (this._parser.getPartByType(DateParts.Date)?.type ?? - this._parser.getPartByType(DateParts.Hours)?.type ?? - this._parser.getFirstDatePart()?.type) as DatePart | undefined; + return (this._parser.getPartByType(DatePartType.Date)?.type ?? + this._parser.getPartByType(DatePartType.Hours)?.type ?? + this._parser.getFirstPart()?.type) as DatePart | undefined; } protected override _parseMask(strict: boolean): Date | null { @@ -275,7 +279,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< } const newDate = new Date(current.getTime()); - const partType = datePart as unknown as DateParts; + const partType = datePart as unknown as DatePartType; // Get the part instance from the parser, or create one for explicit spin operations let part = this._parser.getPartByType(partType); @@ -288,7 +292,7 @@ export default class IgcDateTimeInputComponent extends EventEmitterMixin< // For AM/PM, we need to extract the current AM/PM value from the mask let amPmValue: string | undefined; if (datePart === DatePart.AmPm) { - const formatPart = this._parser.getPartByType(DateParts.AmPm); + const formatPart = this._parser.getPartByType(DatePartType.AmPm); if (formatPart) { amPmValue = this._maskedValue.substring( formatPart.start, diff --git a/src/components/date-time-input/datetime-mask-parser.spec.ts b/src/components/date-time-input/datetime-mask-parser.spec.ts index d57bd99d1..868287642 100644 --- a/src/components/date-time-input/datetime-mask-parser.spec.ts +++ b/src/components/date-time-input/datetime-mask-parser.spec.ts @@ -1,61 +1,54 @@ import { expect } from '@open-wc/testing'; -import { DateParts, DateTimeMaskParser } from './datetime-mask-parser.js'; +import { DatePartType } from './date-part.js'; +import { DateTimeMaskParser } from './datetime-mask-parser.js'; describe('DateTimeMaskParser', () => { describe('Format Parsing', () => { it('parses MM/dd/yyyy format correctly', () => { const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); - expect(parser.dateParts).to.have.lengthOf(5); // MM, /, dd, /, yyyy + expect(parser.parts).to.have.lengthOf(5); // MM, /, dd, /, yyyy - const parts = parser.dateParts.filter( - (p) => p.type !== DateParts.Literal - ); + const parts = parser.parts.filter((p) => p.type !== DatePartType.Literal); expect(parts).to.have.lengthOf(3); - expect(parts[0].type).to.equal(DateParts.Month); - expect(parts[1].type).to.equal(DateParts.Date); - expect(parts[2].type).to.equal(DateParts.Year); + expect(parts[0].type).to.equal(DatePartType.Month); + expect(parts[1].type).to.equal(DatePartType.Date); + expect(parts[2].type).to.equal(DatePartType.Year); }); it('parses HH:mm:ss format correctly', () => { const parser = new DateTimeMaskParser({ format: 'HH:mm:ss' }); - const parts = parser.dateParts.filter( - (p) => p.type !== DateParts.Literal - ); + const parts = parser.parts.filter((p) => p.type !== DatePartType.Literal); expect(parts).to.have.lengthOf(3); - expect(parts[0].type).to.equal(DateParts.Hours); - expect(parts[1].type).to.equal(DateParts.Minutes); - expect(parts[2].type).to.equal(DateParts.Seconds); + expect(parts[0].type).to.equal(DatePartType.Hours); + expect(parts[1].type).to.equal(DatePartType.Minutes); + expect(parts[2].type).to.equal(DatePartType.Seconds); }); it('parses format with AM/PM correctly', () => { const parser = new DateTimeMaskParser({ format: 'hh:mm tt' }); - const parts = parser.dateParts.filter( - (p) => p.type !== DateParts.Literal - ); + const parts = parser.parts.filter((p) => p.type !== DatePartType.Literal); expect(parts).to.have.lengthOf(3); - expect(parts[0].type).to.equal(DateParts.Hours); - expect(parts[1].type).to.equal(DateParts.Minutes); - expect(parts[2].type).to.equal(DateParts.AmPm); + expect(parts[0].type).to.equal(DatePartType.Hours); + expect(parts[1].type).to.equal(DatePartType.Minutes); + expect(parts[2].type).to.equal(DatePartType.AmPm); }); it('identifies date part positions correctly', () => { const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); - const monthPart = parser.dateParts.find( - (p) => p.type === DateParts.Month - ); + const monthPart = parser.parts.find((p) => p.type === DatePartType.Month); expect(monthPart!.start).to.equal(0); expect(monthPart!.end).to.equal(2); expect(monthPart!.format).to.equal('MM'); - const datePart = parser.dateParts.find((p) => p.type === DateParts.Date); + const datePart = parser.parts.find((p) => p.type === DatePartType.Date); expect(datePart!.start).to.equal(3); expect(datePart!.end).to.equal(5); - const yearPart = parser.dateParts.find((p) => p.type === DateParts.Year); + const yearPart = parser.parts.find((p) => p.type === DatePartType.Year); expect(yearPart!.start).to.equal(6); expect(yearPart!.end).to.equal(10); }); @@ -151,6 +144,15 @@ describe('DateTimeMaskParser', () => { expect(amDate!.getHours()).to.equal(9); }); + it('handles an AM/PM marker in a format without hours', () => { + const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy tt' }); + const date = parser.parseDate('12/25/2023 PM'); + + expect(date).to.not.be.null; + expect(date!.getDate()).to.equal(25); + expect(date!.getHours()).to.equal(0); + }); + it('returns null for invalid month', () => { const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); expect(parser.parseDate('13/25/2023')).to.be.null; @@ -176,11 +178,16 @@ describe('DateTimeMaskParser', () => { it('gets date part at cursor position', () => { const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); - expect(parser.getDatePartAtPosition(0)?.type).to.equal(DateParts.Month); - expect(parser.getDatePartAtPosition(1)?.type).to.equal(DateParts.Month); - expect(parser.getDatePartAtPosition(2)).to.be.undefined; // Literal / - expect(parser.getDatePartAtPosition(3)?.type).to.equal(DateParts.Date); - expect(parser.getDatePartAtPosition(6)?.type).to.equal(DateParts.Year); + expect(parser.getPartForCursor(0)?.type).to.equal(DatePartType.Month); + expect(parser.getPartForCursor(1)?.type).to.equal(DatePartType.Month); + + // The end of a part is inclusive - a caret there still belongs to it. + expect(parser.getPartForCursor(2)?.type).to.equal(DatePartType.Month); + + expect(parser.getPartForCursor(3)?.type).to.equal(DatePartType.Date); + expect(parser.getPartForCursor(6)?.type).to.equal(DatePartType.Year); + expect(parser.getPartForCursor(10)?.type).to.equal(DatePartType.Year); + expect(parser.getPartForCursor(11)).to.be.undefined; }); it('identifies date vs time parts', () => { @@ -199,17 +206,17 @@ describe('DateTimeMaskParser', () => { it('gets first date part', () => { const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); - const first = parser.getFirstDatePart(); + const first = parser.getFirstPart(); expect(first).to.not.be.undefined; - expect(first!.type).to.equal(DateParts.Month); + expect(first!.type).to.equal(DatePartType.Month); }); it('gets part by type', () => { const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); - expect(parser.getPartByType(DateParts.Year)?.format).to.equal('yyyy'); - expect(parser.getPartByType(DateParts.AmPm)).to.be.undefined; + expect(parser.getPartByType(DatePartType.Year)?.format).to.equal('yyyy'); + expect(parser.getPartByType(DatePartType.AmPm)).to.be.undefined; }); }); diff --git a/src/components/date-time-input/datetime-mask-parser.ts b/src/components/date-time-input/datetime-mask-parser.ts index 4a883b6dd..817a56aee 100644 --- a/src/components/date-time-input/datetime-mask-parser.ts +++ b/src/components/date-time-input/datetime-mask-parser.ts @@ -1,38 +1,18 @@ import { asNumber, clamp } from '../common/util.js'; -import { MaskParser } from '../mask-input/mask-parser.js'; -import { createDatePart, DatePartType, type IDatePart } from './date-part.js'; - -//#region Types and Enums - -/** - * Types of date/time parts that can appear in a format string. - * Re-exported from date-part.ts for backward compatibility. - */ -/** - * Re-export createDatePart factory for creating standalone parts. - */ -export { createDatePart, DatePartType as DateParts }; - -/** - * Information about a parsed date part within a format string. - * This is a type alias for IDatePart for backward compatibility. - */ -export type DatePartInfo = IDatePart; - -/** Options for the DateTimeMaskParser */ -export interface DateTimeMaskOptions { - /** The date/time format string (e.g., 'MM/dd/yyyy', 'HH:mm:ss') */ - format?: string; - /** The prompt character for unfilled positions */ - promptCharacter?: string; -} - -//#endregion +import { type MaskOptions, MaskParser } from '../mask-input/mask-parser.js'; +import { + createDatePart, + DATE_PART_TYPES, + type DatePartOptions, + DatePartType, + type IDatePart, + TIME_PART_TYPES, +} from './date-part.js'; //#region Constants /** Maps format characters to their corresponding DatePartType */ -export const FORMAT_CHAR_TO_DATE_PART = new Map<string, DatePartType>([ +const FORMAT_CHAR_TO_DATE_PART = new Map<string, DatePartType>([ ['d', DatePartType.Date], ['D', DatePartType.Date], ['M', DatePartType.Month], @@ -47,9 +27,6 @@ export const FORMAT_CHAR_TO_DATE_PART = new Map<string, DatePartType>([ ['T', DatePartType.AmPm], ]); -/** Set of valid date/time format characters */ -export const DATE_FORMAT_CHARS = new Set(FORMAT_CHAR_TO_DATE_PART.keys()); - /** Century threshold for two-digit year interpretation */ const CENTURY_THRESHOLD = 50; const CENTURY_BASE = 2000; @@ -69,137 +46,176 @@ export const DEFAULT_DATETIME_FORMAT = 'MM/dd/yyyy'; //#endregion +//#region Format conversion + +/** A mutable date part under construction, before it is handed to `createDatePart`. */ +type PartBuilder = DatePartOptions & { type: DatePartType }; + /** - * A specialized mask parser for date/time input fields. - * Extends MaskParser to handle date-specific format patterns and validation. + * Converts a date format string into a mask pattern. Date characters become `0`, or `L` + * for the alphabetic AM/PM marker; everything else is carried over as a literal. * * @example * ```ts - * const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); - * parser.apply('12252023'); // Returns '12/25/2023' - * parser.parseDate('12/25/2023'); // Returns Date object + * toMaskFormat('MM/dd/yyyy'); // '00/00/0000' * ``` */ -export class DateTimeMaskParser extends MaskParser { - /** Parsed date parts from the format string */ - private _dateParts!: IDatePart[]; +export function toMaskFormat(dateFormat: string): string { + let result = ''; + + for (const char of dateFormat) { + const type = FORMAT_CHAR_TO_DATE_PART.get(char); + result += type ? (type === DatePartType.AmPm ? 'L' : '0') : char; + } + + return result; +} + +/** + * Widens a short year format to `yyyy` for editing purposes, `yy` excluded - a two digit + * year is edited as two digits. + */ +function normalizeYearFormat(builders: PartBuilder[]): void { + const year = builders.find((part) => part.type === DatePartType.Year); + + if (year && year.format.length !== 2) { + year.end += 4 - year.format.length; + year.format = 'yyyy'; + } +} + +//#endregion +/** + * Base for parsers whose public format is a date/time format string rather than a mask + * pattern. Owns the translation between the two and the list of positioned parts. + */ +export abstract class DateFormatMaskParser< + T extends IDatePart = IDatePart, +> extends MaskParser { /** - * Gets the parsed date parts from the format string. - * Each part contains type, position, and format information. + * Built on first read, because {@link MaskParser} parses the mask from its constructor - + * before any subclass field exists. Declared without an initializer so that it survives + * the `_invalidate` call made from there. */ - public get dateParts(): ReadonlyArray<IDatePart> { - return this._dateParts; + private _parts?: T[]; + + /** The positioned parts of the current format, literals included. */ + public get parts(): ReadonlyArray<T> { + this._parts ??= this._buildParts(); + return this._parts; } - constructor(options?: DateTimeMaskOptions) { - const format = options?.format || DEFAULT_DATETIME_FORMAT; + protected abstract _buildParts(): T[]; - super( - options?.promptCharacter - ? { format, promptCharacter: options.promptCharacter } - : { format } - ); + protected override _toMaskFormat(format: string): string { + return toMaskFormat(format); + } + + protected override _invalidate(): void { + super._invalidate(); + this._parts = undefined; } /** - * Sets a new date/time format and re-parses the date parts. + * The part at a cursor position. The end is inclusive, so a caret resting at the end of + * a part still resolves to it. */ - public override set mask(value: string) { - super.mask = value; - this._parseDateFormat(); + public getPartForCursor(position: number): T | undefined { + return this.parts.find( + (part) => + part.type !== DatePartType.Literal && + position >= part.start && + position <= part.end + ); } - public override get mask(): string { - return super.mask; + /** The first part of the given type, if the format contains one. */ + public getPartByType(type: DatePartType): T | undefined { + return this.parts.find((part) => part.type === type); + } + + /** The first non-literal part - the default target when nothing is focused. */ + public getFirstPart(): T | undefined { + return this.parts.find((part) => part.type !== DatePartType.Literal); + } + + /** Whether the format contains a day, month or year part. */ + public hasDateParts(): boolean { + return this.parts.some((part) => DATE_PART_TYPES.has(part.type)); + } + + /** Whether the format contains an hours, minutes or seconds part. */ + public hasTimeParts(): boolean { + return this.parts.some((part) => TIME_PART_TYPES.has(part.type)); + } +} + +/** + * A mask parser for date/time input fields. + * + * @example + * ```ts + * const parser = new DateTimeMaskParser({ format: 'MM/dd/yyyy' }); + * parser.apply('12252023'); // '12/25/2023' + * parser.parseDate('12/25/2023'); // Date + * ``` + */ +export class DateTimeMaskParser extends DateFormatMaskParser { + constructor(options?: MaskOptions) { + super({ ...options, format: options?.format || DEFAULT_DATETIME_FORMAT }); } //#region Date Format Parsing - /** - * Parses the format string into IDatePart objects. - * This identifies each date/time component and its position. - */ - private _parseDateFormat(): void { - const format = this.mask; - const builders: Array<{ - type: DatePartType; - start: number; - end: number; - format: string; - }> = []; - const chars = Array.from(format); - const length = chars.length; - - let currentBuilder: (typeof builders)[0] | null = null; + protected override _buildParts(): IDatePart[] { + const builders: PartBuilder[] = []; + + let run: PartBuilder | null = null; let position = 0; - for (let i = 0; i < length; i++, position++) { - const char = chars[i]; - const partType = FORMAT_CHAR_TO_DATE_PART.get(char); + for (const char of this.mask) { + const type = FORMAT_CHAR_TO_DATE_PART.get(char); - if (partType) { - // Date/time format character - if (currentBuilder?.format.includes(char)) { - // Continue building the same part - currentBuilder.end = position + 1; - currentBuilder.format += char; - } else { - // Start a new part - if (currentBuilder) { - builders.push(currentBuilder); - } - currentBuilder = { - type: partType, - start: position, - end: position + 1, - format: char, - }; - } + // A part runs only while the same format character repeats - 'MM' is one part, + // 'Mm' is two - and any literal closes it. + if (run && !(type && run.format.includes(char))) { + builders.push(run); + run = null; + } + + if (run) { + run.end = position + 1; + run.format += char; } else { - // Literal character - if (currentBuilder) { - builders.push(currentBuilder); - currentBuilder = null; - } - builders.push({ - type: DatePartType.Literal, + const builder: PartBuilder = { + type: type ?? DatePartType.Literal, start: position, end: position + 1, format: char, - }); + }; + + if (type) { + run = builder; + } else { + builders.push(builder); + } } + + position++; } - // Don't forget the last part - if (currentBuilder) { - builders.push(currentBuilder); + if (run) { + builders.push(run); } - // Normalize year format for editing (except 'yy') - this._normalizeYearFormatBuilder(builders); + normalizeYearFormat(builders); - // Create immutable date parts from builders using factory - this._dateParts = builders.map((b) => - createDatePart(b.type, { start: b.start, end: b.end, format: b.format }) + return builders.map(({ type, ...options }) => + createDatePart(type, options) ); } - /** - * Normalizes year format to 'yyyy' for editing (except for 'yy'). - * Also updates the end position to account for the expanded format. - */ - private _normalizeYearFormatBuilder( - builders: Array<{ type: DatePartType; end: number; format: string }> - ): void { - const yearBuilder = builders.find((b) => b.type === DatePartType.Year); - if (yearBuilder && yearBuilder.format.length !== 2) { - const expansion = 4 - yearBuilder.format.length; - yearBuilder.end += expansion; - yearBuilder.format = 'yyyy'; - } - } - //#endregion //#region Date Parsing @@ -207,9 +223,6 @@ export class DateTimeMaskParser extends MaskParser { /** * Parses a masked string into a Date object. * Returns null if the string cannot be parsed into a valid date. - * - * @param masked - The masked input string to parse - * @returns A Date object or null if parsing fails */ public parseDate(masked: string): Date | null { const parts = this._extractDateValues(masked); @@ -231,7 +244,6 @@ export class DateTimeMaskParser extends MaskParser { return null; } - // Handle AM/PM conversion this._applyAmPmConversion(parts, masked); return this._createDateFromParts(parts); @@ -246,7 +258,7 @@ export class DateTimeMaskParser extends MaskParser { const parts: Partial<Record<DatePartType, number>> = {}; const prompt = this.prompt; - for (const datePart of this._dateParts) { + for (const datePart of this.parts) { if (datePart.type === DatePartType.Literal) continue; const isMonthOrDate = @@ -270,45 +282,23 @@ export class DateTimeMaskParser extends MaskParser { /** * Validates that parsed date parts are within valid ranges. * Only validates parts that are present in the format. - * Uses the validate() method on each date part instance. */ private _validateDateParts( parts: Partial<Record<DatePartType, number>> ): boolean { - // Build validation context for date-dependent validation + // Day-of-month validation needs both, so the context is built up front. const context = { year: parts[DatePartType.Year], month: parts[DatePartType.Month], }; - // Validate each parsed value using its corresponding part instance - for (const datePart of this._dateParts) { - if (datePart.type === DatePartType.Literal) continue; - + return this.parts.every((datePart) => { const value = parts[datePart.type]; - if (value === undefined) continue; - - if (!datePart.validate(value, context)) { - return false; - } - } - - // Additional check: validate date against month/year context - // (the part's validate method needs both year and month for proper validation) - if ( - parts[DatePartType.Date] !== undefined && - parts[DatePartType.Month] !== undefined && - parts[DatePartType.Year] !== undefined - ) { - if ( - parts[DatePartType.Date]! > - this._daysInMonth(parts[DatePartType.Year]!, parts[DatePartType.Month]!) - ) { - return false; - } - } - return true; + return datePart.type === DatePartType.Literal || value === undefined + ? true + : datePart.validate(value, context); + }); } /** @@ -318,18 +308,20 @@ export class DateTimeMaskParser extends MaskParser { parts: Partial<Record<DatePartType, number>>, masked: string ): void { - const amPmPart = this._dateParts.find((p) => p.type === DatePartType.AmPm); - if (!amPmPart) return; + const amPm = this.getPartByType(DatePartType.AmPm); + const hours = parts[DatePartType.Hours]; - parts[DatePartType.Hours]! %= 12; + // A format can carry an AM/PM marker without an hours part; there is nothing to shift. + if (!amPm || hours === undefined) { + return; + } - const amPmValue = masked - .substring(amPmPart.start, amPmPart.end) + const marker = masked + .substring(amPm.start, amPm.end) .replaceAll(this.prompt, ''); - if (amPmValue.toLowerCase() === 'pm') { - parts[DatePartType.Hours]! += 12; - } + parts[DatePartType.Hours] = + (hours % 12) + (marker.toLowerCase() === 'pm' ? 12 : 0); } /** @@ -349,156 +341,18 @@ export class DateTimeMaskParser extends MaskParser { ); } - /** - * Gets the number of days in a specific month/year. - */ - private _daysInMonth(year: number, month: number): number { - return new Date(year, month + 1, 0).getDate(); - } - //#endregion //#region Date Formatting /** * Formats a Date object into a masked string according to the current format. - * - * @param date - The date to format - * @returns The formatted masked string */ public formatDate(date: Date | null): string { return date - ? this._dateParts.map((part) => part.getValue(date)).join('') + ? this.parts.map((part) => part.getValue(date)).join('') : this.emptyMask; } //#endregion - - //#region Part Queries - - /** - * Finds the date part at a given cursor position. - * Uses exclusive end (position < end) for precise character targeting. - * - * @param position - The cursor position to check - * @returns The DatePartInfo at that position, or undefined if not found - */ - public getDatePartAtPosition(position: number): DatePartInfo | undefined { - return this._dateParts.find( - (p) => - p.type !== DatePartType.Literal && - position >= p.start && - position < p.end - ); - } - - /** - * Finds the date part for a cursor position, using inclusive end. - * This handles the edge case where cursor is at the end of the mask - * (position equals the end of the last part). - * - * @param position - The cursor position to check - * @returns The DatePartInfo at that position, or undefined if not found - */ - public getDatePartForCursor(position: number): DatePartInfo | undefined { - return this._dateParts.find( - (p) => - p.type !== DatePartType.Literal && - position >= p.start && - position <= p.end - ); - } - - /** - * Checks if the format includes any date parts (day, month, year). - */ - public hasDateParts(): boolean { - return this._dateParts.some( - (p) => - p.type === DatePartType.Date || - p.type === DatePartType.Month || - p.type === DatePartType.Year - ); - } - - /** - * Checks if the format includes any time parts (hours, minutes, seconds). - */ - public hasTimeParts(): boolean { - return this._dateParts.some( - (p) => - p.type === DatePartType.Hours || - p.type === DatePartType.Minutes || - p.type === DatePartType.Seconds - ); - } - - /** - * Gets the first non-literal date part (useful for default selection). - */ - public getFirstDatePart(): DatePartInfo | undefined { - return this._dateParts.find((p) => p.type !== DatePartType.Literal); - } - - /** - * Gets a specific type of date part. - */ - public getPartByType(type: DatePartType): DatePartInfo | undefined { - return this._dateParts.find((p) => p.type === type); - } - - //#endregion - - //#region Override for Date-Specific Mask - - /** - * Builds the internal mask pattern from the date format. - * Converts date format characters to mask pattern characters. - */ - protected override _parseMaskLiterals(): void { - // First, convert date format to mask format - const dateFormat = this._options.format; - const maskFormat = - DateTimeMaskParser.convertDateFormatToMaskFormat(dateFormat); - - // Temporarily set the converted format for the base class parsing - const originalFormat = this._options.format; - this._options.format = maskFormat; - - super._parseMaskLiterals(); - - // Restore the original date format - this._options.format = originalFormat; - - // Parse date-specific format structure - this._parseDateFormat(); - } - //#endregion - - //#region Static Utilities - - /** - * Converts a date format string to a mask format string. - * Date format chars become '0' (numeric) or 'L' (alpha for AM/PM). - * This is a static utility that can be reused by other parsers. - * - * @param dateFormat - The date format string to convert (e.g., 'MM/dd/yyyy') - * @returns The mask format string (e.g., '00/00/0000') - */ - public static convertDateFormatToMaskFormat(dateFormat: string): string { - let result = ''; - - for (const char of dateFormat) { - if (DATE_FORMAT_CHARS.has(char)) { - // AM/PM markers are alphabetic, others are numeric - result += char === 't' || char === 'T' ? 'L' : '0'; - } else { - result += char; - } - } - - return result; - } - - //#endregion } diff --git a/src/components/mask-input/mask-history.spec.ts b/src/components/mask-input/mask-history.spec.ts new file mode 100644 index 000000000..332e34d07 --- /dev/null +++ b/src/components/mask-input/mask-history.spec.ts @@ -0,0 +1,259 @@ +import { expect } from '@open-wc/testing'; + +import { + createMaskHistory, + type MaskHistory, + type MaskHistoryState, +} from './mask-history.js'; + +/** Shorthand for a collapsed-caret state. */ +function at(value: string, caret: number): MaskHistoryState { + return { value, start: caret, end: caret }; +} + +describe('Mask history', () => { + let history: MaskHistory; + let signature: string; + + beforeEach(() => { + signature = 'mask'; + history = createMaskHistory(() => signature); + }); + + describe('Recording', () => { + it('starts out empty', () => { + expect(history.canUndo).to.be.false; + expect(history.canRedo).to.be.false; + expect(history.undo(at('___', 0))).to.be.null; + expect(history.redo(at('___', 0))).to.be.null; + }); + + it('records a single step', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + + expect(history.canUndo).to.be.true; + expect(history.undo(at('a__', 1))).to.eql(at('___', 0)); + }); + + it('drops the oldest entry past the cap', () => { + for (let i = 0; i < 120; i++) { + history.record('atomic', at(`${i}`, 0)); + history.settle(`${i + 1}`, 0); + } + + let steps = 0; + let state = history.undo(at('120', 0)); + + while (state) { + steps++; + state = history.undo(state); + } + + expect(steps).to.equal(100); + }); + }); + + describe('Coalescing', () => { + it('merges a contiguous run of insertions', () => { + history.record('insert', at('___', 0)); + history.settle('a__', 1); + history.record('insert', at('a__', 1)); + history.settle('ab_', 2); + history.record('insert', at('ab_', 2)); + history.settle('abc', 3); + + // One step back to the state before the whole run. + expect(history.undo(at('abc', 3))).to.eql(at('___', 0)); + expect(history.canUndo).to.be.false; + }); + + it('merges a contiguous run of backspaces', () => { + history.record('delete-backward', at('abc', 3)); + history.settle('ab_', 2); + history.record('delete-backward', at('ab_', 2)); + history.settle('a__', 1); + + expect(history.undo(at('a__', 1))).to.eql(at('abc', 3)); + expect(history.canUndo).to.be.false; + }); + + it('breaks the run when the kind changes', () => { + history.record('insert', at('___', 0)); + history.settle('a__', 1); + history.record('delete-backward', at('a__', 1)); + history.settle('___', 0); + + expect(history.undo(at('___', 0))).to.eql(at('a__', 1)); + expect(history.undo(at('a__', 1))).to.eql(at('___', 0)); + }); + + it('breaks the run when the caret is not contiguous', () => { + history.record('insert', at('___', 0)); + history.settle('a__', 1); + + // The caret moved - e.g. an arrow key or a click. + history.record('insert', at('a__', 2)); + history.settle('a_c', 3); + + expect(history.undo(at('a_c', 3))).to.eql(at('a__', 2)); + expect(history.undo(at('a__', 2))).to.eql(at('___', 0)); + }); + + it('never merges into a replaced selection', () => { + history.record('insert', at('___', 0)); + history.settle('a__', 1); + history.record('insert', { value: 'a__', start: 1, end: 3 }); + history.settle('ab_', 2); + + expect(history.undo(at('ab_', 2))).to.eql({ + value: 'a__', + start: 1, + end: 3, + }); + expect(history.undo(at('a__', 1))).to.eql(at('___', 0)); + }); + + it('never merges atomic edits', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + history.record('atomic', at('a__', 1)); + history.settle('ab_', 2); + + expect(history.undo(at('ab_', 2))).to.eql(at('a__', 1)); + expect(history.undo(at('a__', 1))).to.eql(at('___', 0)); + }); + + it('does not merge across an atomic edit', () => { + history.record('insert', at('___', 0)); + history.settle('a__', 1); + history.record('atomic', at('a__', 1)); + history.settle('ab_', 2); + history.record('insert', at('ab_', 2)); + history.settle('abc', 3); + + expect(history.undo(at('abc', 3))).to.eql(at('ab_', 2)); + expect(history.undo(at('ab_', 2))).to.eql(at('a__', 1)); + expect(history.undo(at('a__', 1))).to.eql(at('___', 0)); + }); + }); + + describe('Traversal', () => { + it('round-trips undo and redo', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + + const undone = history.undo(at('a__', 1))!; + expect(undone).to.eql(at('___', 0)); + expect(history.canRedo).to.be.true; + + expect(history.redo(undone)).to.eql(at('a__', 1)); + expect(history.canUndo).to.be.true; + expect(history.canRedo).to.be.false; + }); + + it('keeps the redo caret correct across a fast repeat', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + history.record('atomic', at('a__', 1)); + history.settle('ab_', 2); + + // Two undos in a row. The masked text is written synchronously, but the caret is + // only applied after the render - a key repeat outruns it and reports a stale one. + const first = history.undo(at('ab_', 2))!; + history.undo({ value: 'a__', start: 99, end: 99 }); + + // The redo entry must carry the caret the first undo restored, not the stale 99. + expect(history.redo(at('___', 0))).to.eql(first); + }); + + it('records after an undo and drops the redo stack', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + history.undo(at('a__', 1)); + + expect(history.canRedo).to.be.true; + + history.record('atomic', at('___', 0)); + history.settle('z__', 1); + + expect(history.canRedo).to.be.false; + }); + + it('does not merge a new edit into the step it just restored', () => { + history.record('insert', at('___', 0)); + history.settle('a__', 1); + history.undo(at('a__', 1)); + + history.record('insert', at('___', 0)); + history.settle('z__', 1); + + expect(history.undo(at('z__', 1))).to.eql(at('___', 0)); + }); + }); + + describe('Invalidation', () => { + it('drops the earlier history when the text moved out of band', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + + // Something assigned `value` behind the history's back, and then the user edited. + history.record('atomic', at('xyz', 0)); + history.settle('xyzq', 1); + + // The new edit is undoable, but only back to the assigned value - the steps that + // preceded it describe a document that no longer exists. + expect(history.undo(at('xyzq', 1))).to.eql(at('xyz', 0)); + expect(history.canUndo).to.be.false; + }); + + it('refuses to traverse a history whose document moved on', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + + expect(history.undo(at('xyz', 0))).to.be.null; + expect(history.canUndo).to.be.false; + }); + + it('drops the history when the mask pattern changes', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + + signature = 'other-mask'; + + expect(history.undo(at('a__', 1))).to.be.null; + expect(history.canUndo).to.be.false; + }); + + it('resync keeps an unchanged document', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + + history.resync('a__'); + + expect(history.undo(at('a__', 1))).to.eql(at('___', 0)); + }); + + it('resync drops a changed document', () => { + history.record('atomic', at('___', 0)); + history.settle('a__', 1); + + history.resync('zzz'); + + expect(history.canUndo).to.be.false; + }); + + it('resync breaks the current run', () => { + history.record('insert', at('___', 0)); + history.settle('a__', 1); + + history.resync('a__'); + + history.record('insert', at('a__', 1)); + history.settle('ab_', 2); + + expect(history.undo(at('ab_', 2))).to.eql(at('a__', 1)); + expect(history.undo(at('a__', 1))).to.eql(at('___', 0)); + }); + }); +}); diff --git a/src/components/mask-input/mask-history.ts b/src/components/mask-input/mask-history.ts new file mode 100644 index 000000000..a1937c0da --- /dev/null +++ b/src/components/mask-input/mask-history.ts @@ -0,0 +1,216 @@ +/** A snapshot of the editor state as it was *before* an edit was applied. */ +type MaskHistoryState = { + value: string; + start: number; + end: number; +}; + +/** + * The granularity an edit contributes to the history. + * + * The three granular kinds coalesce, so a run of typed characters or of deletions + * collapses into a single step. Everything else - paste, drop, cut, composition, + * auto-fill, spinning a date part, `setRangeText` - is `atomic` and gets its own step. + */ +type MaskEditKind = 'insert' | 'delete-backward' | 'delete-forward' | 'atomic'; + +/** Resolves the identity of the mask pattern the snapshots were taken against. */ +type MaskSignature = () => string; + +const MAX_HISTORY_SIZE = 100; + +/** + * The undo/redo history of a masked editor. + * + * Masked text is rendered through `.value=${live(...)}`, so every edit reassigns the + * native input's value - which wipes the browser's own undo stack. This replaces it. + * + * It holds no timers: a run is coalesced purely from the caret geometry, so the same + * sequence of edits always produces the same sequence of steps. + * + * The history is self-invalidating rather than instrumented. Anything that moves the + * masked text without going through it - a programmatic `value`, `clear()`, a form reset - + * is caught by comparing against the text it last observed, and a changed mask pattern is + * caught by the signature. That is why no call site has to announce those changes. + * + * @hidden + */ +class MaskHistory { + private readonly _signature: MaskSignature; + private readonly _undoStack: MaskHistoryState[] = []; + private readonly _redoStack: MaskHistoryState[] = []; + + private _lastKind: MaskEditKind | null = null; + private _lastCaret = -1; + private _lastValue = ''; + private _pattern = ''; + + /** + * The state the most recent traversal restored. + * + * Holding `Ctrl + Z` fires faster than the caret can be written back to the DOM, so the + * live selection is not a trustworthy counterpart entry for the opposite stack. The + * state we know we just restored is. + */ + private _lastRestored: MaskHistoryState | null = null; + + constructor(signature: MaskSignature) { + this._signature = signature; + } + + public get canUndo(): boolean { + return this._undoStack.length > 0; + } + + public get canRedo(): boolean { + return this._redoStack.length > 0; + } + + /** + * A run continues only while the caret stays where the previous edit left it, which is + * what makes a click or an arrow key break it without any extra hook. Replacing a + * selection is always a deliberate edit of its own. + */ + private _shouldCoalesce( + kind: MaskEditKind, + state: MaskHistoryState + ): boolean { + if ( + kind === 'atomic' || + kind !== this._lastKind || + !this.canUndo || + state.start !== state.end + ) { + return false; + } + + return kind === 'insert' + ? state.start === this._lastCaret + : state.end === this._lastCaret; + } + + private _clear(): void { + this._undoStack.length = 0; + this._redoStack.length = 0; + this._lastKind = null; + this._lastCaret = -1; + this._lastRestored = null; + } + + /** + * Drops everything the history holds when the mask pattern changed or the text moved + * behind our back. Returns whether the snapshots are still usable. + */ + private _validate(value: string): boolean { + const pattern = this._signature(); + const stale = + pattern !== this._pattern || (this.canUndo && value !== this._lastValue); + + if (stale) { + this._pattern = pattern; + this._clear(); + this._lastValue = value; + } + + return !stale; + } + + /** + * Records the state an edit is about to overwrite. Must be called *before* the mask is + * mutated, and only once the edit is known to change something. + */ + public record(kind: MaskEditKind, state: MaskHistoryState): void { + this._validate(state.value); + this._redoStack.length = 0; + this._lastRestored = null; + + if (!this._shouldCoalesce(kind, state)) { + this._undoStack.push({ ...state }); + + if (this._undoStack.length > MAX_HISTORY_SIZE) { + this._undoStack.shift(); + } + } + + this._lastKind = kind; + } + + /** + * Reports the state an edit settled on, so that the next {@link record} can tell + * whether it continues the run. + */ + public settle(value: string, caret: number): void { + this._lastValue = value; + this._lastCaret = caret; + } + + /** + * Reconciles the history with the editor's current text, typically on focus where a + * date editor swaps the display format back for the input format. An unchanged + * document keeps its history, a changed one drops it, and either way the run ends. + */ + public resync(value: string): void { + this._validate(value); + + this._lastValue = value; + this._lastKind = null; + this._lastCaret = -1; + this._lastRestored = null; + } + + /** Steps one edit back, handing `current` to the redo stack. */ + public undo(current: MaskHistoryState): MaskHistoryState | null { + return this._step(this._undoStack, this._redoStack, current); + } + + /** Steps one edit forward, handing `current` back to the undo stack. */ + public redo(current: MaskHistoryState): MaskHistoryState | null { + return this._step(this._redoStack, this._undoStack, current); + } + + private _step( + from: MaskHistoryState[], + to: MaskHistoryState[], + current: MaskHistoryState + ): MaskHistoryState | null { + // Traversing a history whose document has moved on would restore text belonging to a + // value the component no longer holds. + if (!this._validate(current.value)) { + return null; + } + + const state = from.pop(); + + if (!state) { + return null; + } + + to.push(this._lastRestored ?? { ...current }); + + // Typing after an undo must not extend the step that was just restored. + this._lastKind = null; + this._lastValue = state.value; + this._lastCaret = state.start; + this._lastRestored = { ...state }; + + return state; + } +} + +/** + * Creates a {@link MaskHistory} for a masked editor. + * + * @param signature - resolves the identity of the mask pattern the snapshots are taken + * against. A callback rather than a value because the history is created by the mask + * behavior mixin, whose fields initialize *before* the parser of the concrete component. + * + * @example + * ```ts + * const history = createMaskHistory(() => `${parser.mask} ${parser.prompt}`); + * ``` + */ +export function createMaskHistory(signature: MaskSignature): MaskHistory { + return new MaskHistory(signature); +} + +export type { MaskEditKind, MaskHistory, MaskHistoryState, MaskSignature }; diff --git a/src/components/mask-input/mask-input.spec.ts b/src/components/mask-input/mask-input.spec.ts index 4810c6678..26a312119 100644 --- a/src/components/mask-input/mask-input.spec.ts +++ b/src/components/mask-input/mask-input.spec.ts @@ -1,6 +1,11 @@ import { elementUpdated, expect, fixture } from '@open-wc/testing'; import { html } from 'lit'; import { spy } from 'sinon'; +import { + ctrlKey, + metaKey, + shiftKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { createFormAssociatedTestBed, @@ -267,6 +272,41 @@ describe('Masked input', () => { checkSelectionRange(5, 5); }); + it('setRangeText() clearing a focused input keeps the mask visible', async () => { + element.mask = '(CC) (CC)'; + element.value = '1111'; + + await elementUpdated(element); + syncParser(); + + element.focus(); + await elementUpdated(element); + + element.setRangeText('', 0, 9); + await elementUpdated(element); + + // A focused editor shows the prompts it is being typed into, exactly as it does + // after deleting the same text by hand. + expect(element.value).to.equal(''); + expect(input.value).to.equal(parser.emptyMask); + }); + + it('setRangeText() clearing an unfocused input empties it', async () => { + element.mask = '(CC) (CC)'; + element.value = '1111'; + + await elementUpdated(element); + syncParser(); + + element.setRangeText('', 0, 9); + await elementUpdated(element); + + // Unfocused there is nothing to edit, so an empty value renders as an empty + // document and lets the placeholder through. + expect(element.value).to.equal(''); + expect(input.value).to.be.empty; + }); + it('igcChange event', async () => { syncParser(); @@ -642,6 +682,273 @@ describe('Masked input', () => { }); }); + describe('Undo / redo', () => { + /** Types characters at the current caret, the way a browser would. */ + async function type(text: string): Promise<void> { + for (const char of text) { + const caret = input.selectionStart ?? 0; + + simulateKeyboard(input, char); + input.value = `${input.value.substring(0, caret)}${char}${input.value.substring(caret)}`; + input.setSelectionRange(caret + 1, caret + 1); + simulateInput(input, { + inputType: 'insertText', + skipValueProperty: true, + }); + await elementUpdated(element); + } + } + + async function backspace(times = 1): Promise<void> { + for (let i = 0; i < times; i++) { + // The browser has already removed the character by the time `input` fires, so the + // caret sits one position back. + const caret = Math.max((input.selectionStart ?? 0) - 1, 0); + + simulateKeyboard(input, 'Backspace'); + input.setSelectionRange(caret, caret); + simulateInput(input, { + inputType: 'deleteContentBackward', + skipValueProperty: true, + }); + await elementUpdated(element); + } + } + + async function press(...keys: string[]): Promise<void> { + simulateKeyboard(input, keys); + await elementUpdated(element); + } + + const undo = () => press(ctrlKey, 'z'); + const redo = () => press(ctrlKey, 'y'); + + beforeEach(async () => { + element = await fixture<IgcMaskInputComponent>( + html`<igc-mask-input></igc-mask-input>` + ); + input = element.renderRoot.querySelector('input')!; + + element.focus(); + await elementUpdated(element); + element.setSelectionRange(0, 0); + }); + + it('collapses a run of typed characters into a single step', async () => { + await type('123'); + expect(element.value).to.equal('123'); + + await undo(); + + expect(element.value).to.equal(''); + expect(input.value).to.equal('__________'); + }); + + it('redoes the restored run', async () => { + await type('12'); + + await undo(); + expect(element.value).to.equal(''); + + await redo(); + expect(element.value).to.equal('12'); + expect(input.value).to.equal('12________'); + }); + + it('supports the alternate shortcuts', async () => { + await type('1'); + + await press(metaKey, 'z'); + expect(element.value).to.equal(''); + + await press(ctrlKey, shiftKey, 'z'); + expect(element.value).to.equal('1'); + + await press(metaKey, 'z'); + await press(metaKey, shiftKey, 'z'); + expect(element.value).to.equal('1'); + }); + + it('preserves interior holes in the mask', async () => { + // Refocus so the empty mask is rebuilt against the new pattern. + element.blur(); + element.mask = 'CCC-CCC'; + await elementUpdated(element); + element.focus(); + await elementUpdated(element); + + element.setSelectionRange(0, 0); + await type('1'); + + element.setSelectionRange(2, 2); + await type('2'); + expect(input.value).to.equal('1_2-___'); + + element.setSelectionRange(6, 6); + await type('3'); + expect(input.value).to.equal('1_2-__3'); + + await undo(); + + // A restore that round-tripped through the value setter would left-pack this + // to '12_-___'. + expect(input.value).to.equal('1_2-___'); + }); + + it('starts a new step when the caret moves', async () => { + await type('12'); + + element.setSelectionRange(5, 5); + await type('3'); + expect(element.value).to.equal('123'); + + await undo(); + expect(element.value).to.equal('12'); + + await undo(); + expect(element.value).to.equal(''); + }); + + it('collapses a run of backspaces into a single step', async () => { + element.value = '1234'; + await elementUpdated(element); + element.setSelectionRange(4, 4); + + await backspace(2); + expect(element.value).to.equal('12'); + + await undo(); + expect(element.value).to.equal('1234'); + }); + + it('keeps typing and deleting as separate steps', async () => { + await type('12'); + await backspace(); + expect(element.value).to.equal('1'); + + await undo(); + expect(element.value).to.equal('12'); + + await undo(); + expect(element.value).to.equal(''); + }); + + it('records a paste as its own step', async () => { + await type('1'); + + input.value = '1999______'; + input.setSelectionRange(1, 4); + simulateInput(input, { + inputType: 'insertFromPaste', + skipValueProperty: true, + }); + await elementUpdated(element); + + expect(element.value).to.equal('1999'); + + await undo(); + expect(element.value).to.equal('1'); + }); + + it('does not record an edit the mask rejects', async () => { + element.mask = '000'; + await elementUpdated(element); + element.focus(); + await elementUpdated(element); + element.setSelectionRange(0, 0); + + // A letter is not valid for a numeric position - nothing changes. + await type('1a'); + expect(element.value).to.equal('1'); + + // A single undo must reach the empty mask, not sit on a step that does nothing. + await undo(); + expect(element.value).to.equal(''); + }); + + it('emits igcInput when a step is restored', async () => { + await type('1'); + + const eventSpy = spy(element, 'emitEvent'); + await undo(); + + expect(eventSpy).calledWith('igcInput', { detail: '' }); + }); + + it('places the caret where the undone edit began', async () => { + element.setSelectionRange(3, 3); + await type('9'); + + await undo(); + + expect(input.selectionStart).to.equal(3); + expect(input.selectionEnd).to.equal(3); + + // Typing after the undo must land at the restored caret. + await type('7'); + expect(input.value).to.equal('___7______'); + }); + + it('is a no-op with nothing to undo', async () => { + await undo(); + + expect(element.value).to.equal(''); + expect(input.value).to.equal('__________'); + }); + + it('does nothing while readonly', async () => { + await type('1'); + + element.readOnly = true; + await elementUpdated(element); + + await undo(); + expect(element.value).to.equal('1'); + }); + + it('drops the history on a programmatic value assignment', async () => { + await type('1'); + + element.value = 'abc'; + await elementUpdated(element); + + await undo(); + expect(element.value).to.equal('abc'); + }); + + it('drops the history when the mask changes', async () => { + await type('1'); + + element.mask = 'CCCCC'; + await elementUpdated(element); + + await undo(); + expect(element.value).to.equal('1'); + }); + + it('drops the history when the prompt changes', async () => { + await type('1'); + + element.prompt = '*'; + await elementUpdated(element); + + await undo(); + expect(element.value).to.equal('1'); + }); + + it('survives a blur and refocus', async () => { + await type('12'); + + element.blur(); + await elementUpdated(element); + element.focus(); + await elementUpdated(element); + + await undo(); + expect(element.value).to.equal(''); + }); + }); + describe('Form integration', () => { const spec = createFormAssociatedTestBed<IgcMaskInputComponent>( html`<igc-mask-input name="mask"></igc-mask-input>` @@ -844,6 +1151,32 @@ describe('Masked input', () => { expect(spec.element.value).to.equal('abc'); }); + + it('drops the undo history on reset', async () => { + const nativeInput = spec.element.renderRoot.querySelector('input')!; + + spec.setProperties({ value: '123' }); + spec.element.focus(); + await elementUpdated(spec.element); + + spec.element.setSelectionRange(3, 3); + nativeInput.value = '123z______'; + nativeInput.setSelectionRange(4, 4); + simulateInput(nativeInput, { + inputType: 'insertText', + skipValueProperty: true, + }); + await elementUpdated(spec.element); + expect(spec.element.value).to.equal('123z'); + + spec.reset(); + await elementUpdated(spec.element); + + simulateKeyboard(nativeInput, [ctrlKey, 'z']); + await elementUpdated(spec.element); + + expect(spec.element.value).to.equal('abc'); + }); }); describe('Validation', () => { diff --git a/src/components/mask-input/mask-input.ts b/src/components/mask-input/mask-input.ts index 0a7e0f065..66baad85b 100644 --- a/src/components/mask-input/mask-input.ts +++ b/src/components/mask-input/mask-input.ts @@ -197,10 +197,14 @@ export default class IgcMaskInputComponent extends MaskBehaviorMixin( if (!this._formValue.value) { // In case of empty value, select the whole mask this._maskedValue = this._parser.emptyMask; + this._historyResync(); await this.updateComplete; this.select(); + return; } + + this._historyResync(); } protected override _handleBlur(): void { @@ -226,22 +230,25 @@ export default class IgcMaskInputComponent extends MaskBehaviorMixin( this._formValue.setValueAndFormState(value); } - protected override async _updateInput( - text: string, - { start, end }: MaskSelection - ): Promise<void> { - const result = this._parser.replace(this._maskedValue, text, start, end); - - this._maskedValue = result.value; - this._formValue.setValueAndFormState(this._parser.parse(this._maskedValue)); - this.requestUpdate(); + /** + * Commits straight to the form value instead of going through the `value` setter: the + * setter re-applies the parser, and `apply(parse(x))` left-packs the text - a mask with + * an interior hole such as `1_2-___` would collapse to `12_-___`. + */ + protected override _commitMaskedValue(value: string): void { + this._maskedValue = value; + this._formValue.setValueAndFormState(this._parser.parse(value)); - if (start !== this.mask.length) { - this.emitEvent('igcInput', { detail: this.value }); + // Reachable unfocused only through `setRangeText`, where an emptied mask must read + // as an empty document - as it does after a blur - rather than a row of prompts. + if (!this._focused) { + this._updateMaskedValue(); } + } - await this.updateComplete; - this._input?.setSelectionRange(result.end, result.end); + protected override _emitInputEvent(): void { + this._setTouchedState(); + this.emitEvent('igcInput', { detail: this.value }); } protected override _syncValueFromMask(): void { @@ -284,6 +291,7 @@ export default class IgcMaskInputComponent extends MaskBehaviorMixin( ariaDescribedBy: hasHelperText ? 'helper-text' : undefined, ariaLabelledByElements: this._resolvedLabelElements, onInput: this._handleInput, + onBeforeInput: this._handleBeforeInput, onFocus: this._handleFocus, onBlur: this._handleBlur, onClick: this._handleClick, diff --git a/src/components/mask-input/mask-parser.spec.ts b/src/components/mask-input/mask-parser.spec.ts index ac20b2309..cf9cbec76 100644 --- a/src/components/mask-input/mask-parser.spec.ts +++ b/src/components/mask-input/mask-parser.spec.ts @@ -572,6 +572,28 @@ describe('Mask parser', () => { expect(customParser.prompt).to.equal('_'); }); + it('constructor rejects a prompt conflicting with a mask flag', () => { + // Same rule the `prompt` accessor applies - a flag standing in for an unfilled + // position could not be told apart from one the user typed. + const customParser = new MaskParser({ + format: '0000', + promptCharacter: '0', + }); + + expect(customParser.prompt).to.equal('_'); + expect(customParser.apply()).to.equal('____'); + }); + + it('constructor keeps only the first character of the prompt', () => { + const customParser = new MaskParser({ + format: '000', + promptCharacter: '$$', + }); + + expect(customParser.prompt).to.equal('$'); + expect(customParser.apply()).to.equal('$$$'); + }); + it('C flag accepts any character including special chars', () => { parser.mask = 'CCCC'; expect(parser.apply('!@#$')).to.equal('!@#$'); diff --git a/src/components/mask-input/mask-parser.ts b/src/components/mask-input/mask-parser.ts index ec061733e..3aa516d17 100644 --- a/src/components/mask-input/mask-parser.ts +++ b/src/components/mask-input/mask-parser.ts @@ -1,5 +1,5 @@ /** Options for the {@link MaskParser} */ -interface MaskOptions { +export interface MaskOptions { /** * The mask format string (e.g., '00/00/0000' for dates, 'AAA-000' for custom IDs). * @@ -12,7 +12,9 @@ interface MaskOptions { /** * The character used to prompt for input in unfilled mask positions. - * Must be a single character. + * + * Only the first character is used, and a mask flag is rejected in favor of the + * default - the same rules the `prompt` accessor applies. * @default '_' */ promptCharacter?: string; @@ -75,6 +77,17 @@ const UNICODE_DIGIT_TO_ASCII = new Map<number, string>( ) ); +/** + * Narrows a prompt down to the single character the parser will actually use. + * + * Falls back to `current` when the prompt is empty or collides with a mask flag - a flag + * standing in for an unfilled position could not be told apart from one the user typed. + */ +function normalizePrompt(value: string | undefined, current: string): string { + const char = value ? value.substring(0, 1) : current; + return MASK_FLAGS.has(char) ? current : char; +} + function replaceUnicodeNumbers(text: string): string { const matcher = /\p{Nd}/gu; @@ -99,12 +112,6 @@ function validate(char: string, flag: string): boolean { return MASK_PATTERNS.get(flag)?.test(char) ?? false; } -/** Default mask parser options */ -const MaskDefaultOptions: MaskOptionsInternal = { - format: DEFAULT_FORMAT, - promptCharacter: DEFAULT_PROMPT, -}; - /** * A class for parsing and applying masks to strings, typically for input fields. * It handles mask definitions, literals, character validation, and cursor positioning. @@ -124,6 +131,13 @@ export class MaskParser { /** Cached array of required non-literal positions for validation */ protected _requiredPositions: number[] = []; + /** + * Declared without an initializer on purpose. With `useDefineForClassFields: false` + * nothing is emitted for it, so {@link _invalidate} can safely run from the + * constructor - before subclass fields exist. + */ + private _emptyMask?: string; + /** * Returns a set of the all the literal positions in the mask. * These positions are fixed characters that are not part of the input. @@ -144,7 +158,8 @@ export class MaskParser { * Returns the result of applying an empty string over the mask pattern. */ public get emptyMask(): string { - return this.apply(); + this._emptyMask ??= this.apply(); + return this._emptyMask; } /** @@ -176,18 +191,21 @@ export class MaskParser { * @remarks The prompt character cannot be a mask flag character. */ public set prompt(value: string) { - const char = value ? value.substring(0, 1) : this._options.promptCharacter; - - // Silently ignore if prompt character conflicts with mask flags - if (MASK_FLAGS.has(char)) { - return; - } - - this._options.promptCharacter = char; + this._options.promptCharacter = normalizePrompt( + value, + this._options.promptCharacter + ); + this._invalidate(); } constructor(options?: MaskOptions) { - this._options = { ...MaskDefaultOptions, ...options }; + this._options = { + format: options?.format || DEFAULT_FORMAT, + promptCharacter: normalizePrompt( + options?.promptCharacter, + DEFAULT_PROMPT + ), + }; this._parseMaskLiterals(); } @@ -195,12 +213,28 @@ export class MaskParser { return char === ESCAPE_CHAR && MASK_FLAGS.has(nextChar); } + /** + * The pattern the literal parser consumes. Subclasses whose public format is not itself + * a mask pattern - date formats, for instance - translate it here. + */ + protected _toMaskFormat(format: string): string { + return format; + } + + /** + * Drops everything derived from the mask. Runs on every mask *and* prompt change, and + * from the constructor, so overrides must not touch fields with initializers. + */ + protected _invalidate(): void { + this._emptyMask = undefined; + } + /** * Parses the mask format string to identify literal characters and * create the escaped mask. This method is called whenever the mask format changes. */ protected _parseMaskLiterals(): void { - const mask = this.mask; + const mask = this._toMaskFormat(this._options.format); const length = mask.length; const escapedMaskChars: string[] = []; @@ -231,6 +265,7 @@ export class MaskParser { this._escapedMask = escapedMaskChars.join(''); this._literalPositions = new Set(this._literals.keys()); this._requiredPositions = this._computeRequiredPositions(); + this._invalidate(); } /** @@ -319,7 +354,7 @@ export class MaskParser { const endBoundary = Math.min(end, length); // Initialize the array for the masked string or get a fresh mask with prompts and/or literals - const maskedChars = maskString ? [...maskString] : [...this.apply('')]; + const maskedChars = maskString ? [...maskString] : [...this.emptyMask]; const inputChars = Array.from(replaceUnicodeNumbers(value)); const inputLength = inputChars.length;