Skip to content

feat: undo/redo for the masked inputs - #2314

Open
rkaraivanov wants to merge 2 commits into
masterfrom
rkaraivanov/masked-inputs-undo-redo
Open

feat: undo/redo for the masked inputs#2314
rkaraivanov wants to merge 2 commits into
masterfrom
rkaraivanov/masked-inputs-undo-redo

Conversation

@rkaraivanov

Copy link
Copy Markdown
Member

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 / 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_-___.

Type of Change

  • New feature (non-breaking change that adds functionality)

Checklist

  • My code follows the project's coding standards
  • I have tested my changes locally
  • I have updated documentation if needed

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_-___`.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 into MaskBehaviorMixin via keyboard shortcuts and beforeinput (historyUndo/historyRedo) interception.
  • Refactors masked editing commit semantics into _commitMaskedValue() / _emitInputEvent() overrides to unify behavior and fix the interior-hole left-pack issue in igc-mask-input.
  • Updates date/time and date-range mask parsers to build positioned parts via a new DateFormatMaskParser base 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

  • DateRangeMaskParser composes two DateTimeMaskParser instances, but it doesn't propagate prompt changes to them. If the host component changes prompt, DateRangeMaskParser.emptyMask will use the new prompt while parseDateRange() / 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;
  }

Comment thread src/components/common/mixins/mask-behavior.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants