feat: undo/redo for the masked inputs - #2314
Open
rkaraivanov wants to merge 2 commits into
Open
Conversation
igc-mask-input, igc-date-time-input and igc-date-range-input now handle the
standard shortcuts - Ctrl+Z / Cmd+Z to undo, Ctrl+Y and Ctrl+Shift+Z /
Cmd+Shift+Z to redo - plus the browser's own undo affordances (context menu,
trackpad gesture), which arrive as `historyUndo` / `historyRedo` on
`beforeinput` and are now intercepted rather than left to no-op.
Until now none of that worked. The masked text is rendered through `live()`,
so every keystroke reassigns the native input's value, and each reassignment
clears the browser's undo stack. There was nothing left to undo.
The replacement is a small MaskHistory holding two stacks of
`{ value, start, end }` snapshots taken before each edit. It has no timers: a
run coalesces purely from caret geometry, so consecutive typing collapses
into one step, as does a run of deletions, and a click or an arrow key breaks
the run - the same sequence of edits always produces the same sequence of
steps. Paste, drop, cut, IME composition, spinning a date part and
setRangeText are atomic and each get their own step.
It is self-invalidating rather than instrumented. A programmatic `value`, a
form reset or `clear()` is caught by comparing against the text the history
last observed, and a changed pattern by a signature callback, so no call site
has to announce those changes. The signature is the *source* mask plus the
prompt, not the escaped mask: the date parsers translate a date format into
mask flags, so `MM/dd/yyyy` and `dd/MM/yyyy` both escape to `00/00/0000`
while meaning entirely different things, and signing on the escaped form
would silently keep a stale history alive across an input-format change.
Restoring a step emits `igcInput`, so composite hosts and two-way bindings
follow the undo. For the date editors the restored text stays an uncommitted
draft and `igcChange` still fires on blur only when the committed value
actually moved, matching the behavior introduced in #1346.
Wiring this up pulled the editing pipeline into the mixin: the concrete
components now override `_commitMaskedValue` and `_emitInputEvent` instead of
reimplementing `_updateInput`. That also fixes a latent bug in
igc-mask-input, which committed through the `value` setter - the setter
re-applies the parser, and `apply(parse(x))` left-packs the text, so a mask
with an interior hole such as `1_2-___` collapsed to `12_-___`.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds a custom undo/redo history implementation for the masked editors (mask input, date-time input, and date-range input) to replace the browser undo stack that gets cleared due to live()-driven value assignments. It also refactors the masking pipeline into the shared mask behavior mixin and improves mask/parser internals to support date-format-based parsers and cached derived state.
Changes:
- Introduces
MaskHistory(two-stack snapshot history) and wires it intoMaskBehaviorMixinvia keyboard shortcuts andbeforeinput(historyUndo/historyRedo) interception. - Refactors masked editing commit semantics into
_commitMaskedValue()/_emitInputEvent()overrides to unify behavior and fix the interior-hole left-pack issue inigc-mask-input. - Updates date/time and date-range mask parsers to build positioned parts via a new
DateFormatMaskParserbase and to sign history against the source format + prompt.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/components/mask-input/mask-parser.ts | Exports mask options, adds derived-state invalidation, caches emptyMask, and supports format-to-mask translation hook. |
| src/components/mask-input/mask-input.ts | Moves commit/event responsibilities into mixin overrides and wires beforeinput handler. |
| src/components/mask-input/mask-input.spec.ts | Adds end-to-end undo/redo coverage for mask input, including coalescing and invalidation cases. |
| src/components/mask-input/mask-history.ts | Adds the core undo/redo history implementation with coalescing and self-invalidation. |
| src/components/mask-input/mask-history.spec.ts | Adds focused unit tests for history recording, traversal, coalescing, and invalidation. |
| src/components/date-time-input/datetime-mask-parser.ts | Refactors into DateFormatMaskParser + toMaskFormat(), rebuilds “parts” lazily, and updates parsing helpers. |
| src/components/date-time-input/datetime-mask-parser.spec.ts | Updates tests to use parts APIs and adds an AM/PM-without-hours regression test. |
| src/components/date-time-input/date-time-input.ts | Updates part queries to new parser APIs and resyncs history on focus. |
| src/components/date-time-input/date-time-input.spec.ts | Adds undo/redo behavior tests for draft restore + blur-commit semantics. |
| src/components/date-time-input/date-time-input.base.ts | Records history for atomic programmatic edits (e.g., spinning) and wires beforeinput. |
| src/components/date-time-input/date-part.ts | Adds reusable DATE_PART_TYPES / TIME_PART_TYPES sets. |
| src/components/date-range-picker/date-range-picker-single.spec.ts | Adds an integration test covering undo restore inside the range input. |
| src/components/date-range-picker/date-range-mask-parser.ts | Refactors to extend DateFormatMaskParser and rebuilds range parts via factory recreation + offsetting. |
| src/components/date-range-picker/date-range-mask-parser.spec.ts | Updates tests to use parts and the new cursor-part query behavior. |
| src/components/date-range-picker/date-range-input.ts | Updates navigation/part targeting to new parser APIs and resyncs history on focus. |
| src/components/common/templates/masked-input.ts | Adds beforeinput wiring to the shared masked native input template. |
| src/components/common/mixins/mask-behavior.ts | Implements history recording/restoration, keybindings, and beforeinput interception; refactors commit pipeline hooks. |
| CHANGELOG.md | Documents the new undo/redo behavior and coalescing rules for masked editors. |
Suppressed comments (1)
src/components/date-range-picker/date-range-mask-parser.ts:158
DateRangeMaskParsercomposes twoDateTimeMaskParserinstances, but it doesn't propagate prompt changes to them. If the host component changesprompt,DateRangeMaskParser.emptyMaskwill use the new prompt whileparseDateRange()/formatDateRange()will still treat prompts as the old character, which can break parsing and formatting.
public override set mask(value: string) {
this._startParser.mask = value;
this._endParser.mask = value;
this._separatorStart = this._startParser.mask.length;
this._separatorEnd = this._separatorStart + this._separator.length;
super.mask = `${value}${this._separator}${value}`;
}
public override get mask(): string {
return super.mask;
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
igc-mask-input, igc-date-time-input and igc-date-range-input now handle the standard shortcuts - Ctrl+Z / Cmd+Z to undo, Ctrl+Y and Ctrl+Shift+Z / Cmd+Shift+Z to redo - plus the browser's own undo affordances (context menu, trackpad gesture), which arrive as
historyUndo/historyRedoonbeforeinputand are now intercepted rather than left to no-op.Until now none of that worked. The masked text is rendered through
live(), so every keystroke reassigns the native input's value, and each reassignment clears the browser's undo stack. There was nothing left to undo.The replacement is a small MaskHistory holding two stacks of
{ value, start, end }snapshots taken before each edit. It has no timers: a run coalesces purely from caret geometry, so consecutive typing collapses into one step, as does a run of deletions, and a click or an arrow key breaks the run - the same sequence of edits always produces the same sequence of steps. Paste, drop, cut, IME composition, spinning a date part and setRangeText are atomic and each get their own step.It is self-invalidating rather than instrumented. A programmatic
value, a form reset orclear()is caught by comparing against the text the history last observed, and a changed pattern by a signature callback, so no call site has to announce those changes. The signature is the source mask plus the prompt, not the escaped mask: the date parsers translate a date format into mask flags, soMM/dd/yyyyanddd/MM/yyyyboth escape to00/00/0000while meaning entirely different things, and signing on the escaped form would silently keep a stale history alive across an input-format change.Restoring a step emits
igcInput, so composite hosts and two-way bindings follow the undo. For the date editors the restored text stays an uncommitted draft andigcChangestill fires on blur only when the committed value actually moved, matching the behavior introduced in #1346.Wiring this up pulled the editing pipeline into the mixin: the concrete components now override
_commitMaskedValueand_emitInputEventinstead of reimplementing_updateInput. That also fixes a latent bug in igc-mask-input, which committed through thevaluesetter - the setter re-applies the parser, andapply(parse(x))left-packs the text, so a mask with an interior hole such as1_2-___collapsed to12_-___.Type of Change
Checklist