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 `` 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 `` element. Any `aria-labelledby` / `aria-describedby` references on the root `` 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([
+ ['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;
+ protected _handleBeforeInput(event: InputEvent): void;
protected _updateInput(text: string, range: MaskSelection): Promise;
+ 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;
+ 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>(
superClass: T
@@ -138,6 +184,15 @@ export function MaskBehaviorMixin>(
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>(
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>(
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>(
{
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>(
{
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 {
const { value, end } = this._parser.replace(
this._maskedValue,
@@ -286,8 +421,8 @@ export function MaskBehaviorMixin>(
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>(
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>(
}
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>(
}
}
+ /**
+ * 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 {
+ 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>(
_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,
+ 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 {
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 {
- 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.Date,
+ DatePartType.Month,
+ DatePartType.Year,
+]);
+
+/** The part types that make up a time of day. */
+export const TIME_PART_TYPES = new Set([
+ DatePartType.Hours,
+ DatePartType.Minutes,
+ DatePartType.Seconds,
+]);
+
// Spin delta defaults
export const DEFAULT_DATE_PARTS_SPIN_DELTAS = Object.freeze({
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 {
+ element.setSelectionRange(0, input.value.length);
+ simulateInput(input, { value: digits, inputType: 'insertText' });
+ await elementUpdated(element);
+ }
+
+ async function press(...keys: string[]): Promise {
+ 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([
+const FORMAT_CHAR_TO_DATE_PART = new Map([
['d', DatePartType.Date],
['D', DatePartType.Date],
['M', DatePartType.Month],
@@ -47,9 +27,6 @@ export const FORMAT_CHAR_TO_DATE_PART = new Map([
['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 {
- return this._dateParts;
+ private _parts?: T[];
+
+ /** The positioned parts of the current format, literals included. */
+ public get parts(): ReadonlyArray {
+ 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> = {};
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>
): 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>,
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 {
+ 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 {
+ 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 {
+ simulateKeyboard(input, keys);
+ await elementUpdated(element);
+ }
+
+ const undo = () => press(ctrlKey, 'z');
+ const redo = () => press(ctrlKey, 'y');
+
+ beforeEach(async () => {
+ element = await fixture(
+ html` `
+ );
+ 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(
html` `
@@ -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 {
- 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(
)
);
+/**
+ * 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;