Masked date input
canonDate 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 bypartsOf(). - 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) —.typedis the part already written, incolor: transparent(reserves space only),.restis 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 asseg(0/1/2). A segment closes on two digits, or on one digit that cannot start a two-digit value (day4–9, month2–9) — this is what lets4.auto-advance while1waits to see whether a2follows. - 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.2000parses the same as12.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 againstgetMonth(), 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 ISOyyyy-mm-ddstring only once the date is complete and passes every check above; otherwise''.NewContactDialogsendsbirthDate: nullunlessdateMaskIsoproduced 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
- Drive
invalidfrom the samedateMaskErrorthe caller uses to gate submission, so the red state and the "cannot save yet" condition can never disagree. - Show the string
dateMaskErrorreturns 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).
- Reimplement the segment logic in the caller —
partsOf/textOf/restOfare 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.
Related
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/, whichpackages/uimust not import.