Components › Forms & input › Text field › Masked date input

Masked date input

canon

Date typed straight into a dd.mm.rrrr mask with inline validation; no leading zero required, ISO out only once complete and valid.

Merged to origin/main 15 September 2026 (MR !3540), approved by the product owner as part of the New person dialog work (`.claude/rules/design-system.md` §"Vstupy a mobil", 14 Sep 2026: a structured value is a mask, never free text). Sibling pattern to `MaskedPhoneInput` (input + muted ghost-template) but that component has no card of its own yet in this registry — it is only listed under `record-rail/rail`'s files — so this is the mask pattern's first documented entry, filed under Text field for lack of a dedicated family. Code correction against the design brief: `inputMode` is `"decimal"`, not `"numeric"`. The component's only call site (`NewContactDialog`, the New person dialog) is not merged yet, and it wires no calendar next to the field — "the calendar is a complement" is the rule this component exists to satisfy, not yet an observed behaviour. No stories: lives in `apps/web/src/components/multivalue/`, which `packages/ui` must not import. Registry repaired 17 Oct 2026 (review F1, F10): now exported from @acrm/ui (packages/ui/src/components/MaskedDateInput.tsx, lib/date-mask.ts); the apps/web paths and the "lives in apps/web" caveat are outdated.

Used in 2 files · 2 areas · 3× — components/person, components/relations

A date typed straight into a fixed dd.mm.rrrr mask, with inline validation as the user types. Component MaskedDateInput (apps/web/src/components/multivalue/MaskedDateInput.tsx); the mask and the validation are pure functions in apps/web/src/lib/date-mask.ts — the same split as the phone mask (MaskedPhoneInput / dial-codes.ts). Merged to origin/main 15 September 2026 (MR !3540), following the rule in .claude/rules/design-system.md §"Vstupy a mobil" (approved 14 Sep 2026): a structured value is a mask, never free text.

The component itself has no calendar affordance — it is a text mask and nothing else. Its only call site so far is the New person dialog's date-of-birth row (not yet merged, NewContactDialog, see dialog/formless-dialog.usage.md), which places a PickerMenu for gender right after it — not a calendar. Documenting only what ships: there is currently no calendar complement wired to this field. Treat "the calendar is only a complement" as the product rule this component is built to satisfy, not yet an observed behaviour of the component itself.

When to use

  • A date the user types more often than they pick — date of birth, a document date — where typing is faster than opening a calendar.
  • Anywhere .claude/rules/design-system.md §"Vstupy a mobil" requires a structured mask for a date instead of a free-text field.

When not to use

  • A date the user mostly picks from a calendar (a meeting's day, a due date) — use date-field (DateField), which owns the calendar panel; never a native <input type="date">.
  • A date range — use date-range / date-time-range.
  • Any other structured value (phone, IČO, PSČ) — those need their own mask; this component parses only dd.mm.rrrr.

Anatomy

Measured from date-mask.ts and MaskedDateInput.tsx on origin/main.

  • Wrapper .fl-mask: position: relative; display: inline-block. Width is driven by the ghost template below, not by the input.
  • Input .fl-mask-in: position: absolute; inset: 0, borderless, transparent background, inputMode="decimal" (not "numeric" — verify in code before citing a value elsewhere), autoComplete="bday". Only digits and the recognised separators reach the value; every other character is dropped by partsOf().
  • Ghost .fl-mask-ghost: white-space: pre, sits behind the input and gives the field its width (always the full template, e.g. dd.mm.rrrr) — .typed is the part already written, in color: transparent (reserves space only), .rest is the remaining template in --fg-muted.
  • Invalid state .fl-mask.bad .fl-mask-in: the typed text turns --critical-ink. There is no border or background change — the digits' own colour is the only visual difference between a valid and an invalid value while the field has focus.
  • Segmentation (partsOf): day, then month, then year, tracked as seg (0/1/2). A segment closes on two digits, or on one digit that cannot start a two-digit value (day 4–9, month 2–9) — this is what lets 4. auto-advance while 1 waits to see whether a 2 follows.
  • Separators: a . typed by the user always closes the current segment, including right after an auto-inserted . — it is not swallowed as a duplicate. , (Czech numeric keypad), /, - and space all act exactly like .. Every other non-digit is dropped.
  • No leading zero required: 12.1.2000 parses the same as 12.01.2000 — a segment only needs to be complete (closed by a separator or by the auto-advance rule), not two digits wide.
  • Backspace over an auto-inserted separator removes the digit before it too: the component diffs the shown text against the previous frame in onChange, and if the only thing missing is a trailing auto ., drops the preceding digit as well — otherwise that . could never be deleted.
  • Validation (dateMaskError, run on every keystroke): day 1–31, month 1–12, year inside 1900–current year (checked as the year is typed, not only once it is complete), a real calendar rollover check (new Date(y, m-1, d) compared against getMonth(), so "31.02." is rejected once the year is known) and "not in the future" once the date is complete.
  • Save gate (dateMaskIso): returns an ISO yyyy-mm-dd string only once the date is complete and passes every check above; otherwise ''. NewContactDialog sends birthDate: null unless dateMaskIso produced a value — an incomplete date is simply not saved, it does not block the rest of the dialog as an error.
  • dateMaskText(iso) is the inverse, for pre-filling the field from a stored ISO date.

Best practices

Do
  • Drive invalid from the same dateMaskError the caller uses to gate submission, so the red state and the "cannot save yet" condition can never disagree.
  • Show the string dateMaskError returns directly under the field — it is already a complete sentence-shaped reason ("Den musí být 1–31", "Takové datum neexistuje").
  • Read the value out through dateMaskIso, not by re-parsing the displayed text.
  • Always pass ariaLabel — the input has no visible <label> (see Accessibility).
Don’t
  • Reimplement the segment logic in the caller — partsOf / textOf / restOf are the only source of truth for how the mask splits and re-renders text.
  • Treat an incomplete date as an error. Only a date that is complete and invalid gets the red state; a half-typed date is silently not-yet-saveable.
  • Add a calendar trigger next to this field without checking .claude/rules/design-system.md §8 first — a calendar picker must never live inside .acrm-popover.

Content

  • The template shown by the ghost is the fixed dd.mm.rrrr, never a real example date.
  • Error text stays in the language of dateMaskError's own messages — one clause, no added explanation.
  • date-field — the calendar-first date input; use it when picking, not typing, is the primary path.
  • input — the plain single-line field this mask specialises.
  • formless-dialog — first (and, today, only) call site: the New person dialog's date-of-birth row (not yet merged).
  • No stories yet: the component lives in apps/web/src/components/multivalue/, which packages/ui must not import.

Source: docs/text-field/masked-date-input.usage.md · GitLab