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

Keyboard

  • Native <input>: typing edits the value in place, Tab moves focus in and out; there is no keyboard shortcut and no calendar to tab into or escape from (the component renders no calendar).
  • Backspace over an auto-inserted separator deletes the digit before it too, so deleting never gets stuck on a character the user did not type — covered by components/multivalue/__tests__/masked-date-input.test.tsx.
  • No arrow-key segment navigation (day → month → year): the caret moves through the mask like an ordinary text field; only the separator characters are special-cased.

ARIA

  • The accessible name comes straight from ariaLabel, rendered as a native aria-label on the <input> itself — not through <Field>, whose <label> is not linked to its control (see text-field/input.a11y.md). The component's own test finds the field with screen.getByLabelText(...), confirming the name resolves correctly.
  • aria-invalid is set only when invalid is true (aria-invalid={invalid || undefined}): a valid or empty field carries no aria-invalid attribute at all, rather than "false".
  • Gap — the error text is not linked to the input. The only current caller (NewContactDialog) renders dateMaskError's message as a plain <span class="fl-err"> sibling of the row, with no id / aria-describedby pair to the input. A screen reader user hears that the field is invalid but not why, unless they navigate to the error text separately.
  • The ghost template (.fl-mask-ghost) is aria-hidden="true" — it visually echoes the input's own value plus the remaining template, and exposing it too would read the date twice.

Focus

  • The input has no visible border or box of its own — .fl-mask-in is transparent and absolutely positioned over the ghost — so it relies on the surrounding row's focus treatment (formless-dialog's .fl-row:has(input:focus) row tint) rather than drawing a dedicated ring. dialog-formless.css has no separate :focus rule for .fl-mask-in.

Contrast

Computed from packages/ui/src/tokens/tokens.css — the same tokens already measured in dialog/formless-dialog.a11y.md, reused here because the mask sits inside that same dialog body:

  • Typed text (valid) --fg on --surface-100 — 17.59:1 light / 14.53:1 dark.
  • Typed text (invalid) and the error line both use --critical-ink — 5.37:1 / 5.80:1, passes AA.
  • Ghost template .rest --fg-muted — 2.85:1 / 3.63:1, below AA for text; it is a placeholder echo of the template, not content — the same system-wide --fg-muted limit as every other muted placeholder in this family.

Known limits

  • The error message has no aria-describedby link to the input (see ARIA).
  • No calendar complement exists in the shipped code, so there is no alternative input method for someone who cannot type the mask — .claude/rules/design-system.md treats the calendar as a required complement, not an optional one; this field currently has none at all.
  • No live region and no "character rejected" feedback: a dropped keystroke (e.g. a letter) simply does not appear, with nothing announced to a screen reader user.

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