Components › Forms & input › Text field › Masked input

Masked input

canon

Structured value typed straight into a visible mask (IČO, DIČ, PSČ, rodné číslo) with inline check-digit validation; input holds the typed text, a muted ghost draws the rest of the template.

Merged to origin/main 28 September 2026 (acrm MR !4082, "fix(web): IČO, DIČ, PSČ a rodné číslo jako maska (MaskedInput v @acrm/ui)"), under the owner's binding rule of 14 Sep 2026 (".claude/rules/design-system.md" §"Vstupy a mobil"): a structured value is a mask, never free text — only valid characters, separators added automatically, the template stays visible, the error is reported while typing. Unlike "masked-date-input", this component IS exported from @acrm/ui (source=package) and ships four masks as pure functions in `field-masks.ts`: IČO (8 digits, mod-11 check digit), DIČ (digits-first = Czech, "CZ" is added automatically; letters-first = the typed country code, CZ is never forced; mod-11 check only for CZ), PSČ ("### ##", Czech/Slovak only — a foreign address falls back to a plain Input in the host), rodné číslo ("######/####" or 9 digits before 1953, date part validated per segment, +50 on the month for women, 10-digit form checked mod 11 with the pre-1985 exception). Two host shapes, no new look: `variant="field"` (default) wraps `.acrm-inputwrap` (`.acrm-mask`/`.acrm-mask-ghost` inside, `.acrm-maskwrap.is-error` colours the typed text `--critical-ink` in addition to the wrapper's usual red border+ring) — e.g. IČO/DIČ in the "Nová firma" drawer (`FirmyPage.tsx`); `variant="formless"` reuses the exact `.fl-mask`/`.fl-mask-in`/`.fl-mask-ghost`/`.fl-mask.bad` classes `masked-date-input` already established in `dialog-formless.css` — e.g. rodné číslo in the "Nová osoba" dialog (`NewContactDialog.tsx`). Save gate: the host reads the value out only when `mask.complete(text)` is true (`ico: mask.complete(f.ico) ? f.ico : null`); an incomplete or invalid field is simply not saved, it never blocks the rest of the form (AD 28 Sep 2026) — same rule as `masked-date-input`'s ISO gate. An old invalid stored value (recorded before the mask existed) is loaded through `applyMask()` and shown as-is, editable, not rejected. `describedBy` links the error text via `aria-describedby` when the caller passes an id — the formless caller (`NewContactDialog`, rodné číslo) wires it; the field-variant callers in `FirmyPage` (IČO, DIČ, PSČ) do not, leaving the same label/error linking gap `text-field/input.a11y.md` documents for `<Field>`. Sibling pattern `MaskedPhoneInput` (phone, dial codes) moved into `packages/ui` 28 September 2026 (acrm MR !4085) alongside the new `PhoneInput` form field and `DialCodePicker`; it now has its own registry card, `phone-input`.

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

Keyboard

  • Native <input>: typing edits the value in place, Tab moves focus in and out; disabled removes it from the tab order (same as input). There is no keyboard shortcut and nothing to escape out of — the component renders no popover or calendar.
  • Backspace over an auto-inserted separator deletes the digit before it too (maskInput), so deleting never gets stuck on a character the user did not type — same behaviour as masked-date-input, covered by field-masks.test.ts.
  • No arrow-key segment navigation; the caret moves through the mask like an ordinary text field, only separator characters are special-cased by the template.
  • inputMode is set per mask (numeric for IČO/PSČ/rodné číslo, text for DIČ), so mobile keyboards match the expected characters.

ARIA

  • Accessible name: pass ariaLabel, rendered as a native aria-label on the <input> — the same limitation as input applies when the component is used inside <Field>/<DrawerField>, whose <label> is not linked to its control via htmlFor/id (see text-field/input.a11y.md). MaskedInput does not add that link either; callers must supply ariaLabel themselves, and every current call site does.
  • 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" — same convention as masked-date-input.
  • Error linking, unlike masked-date-input (which has no describedBy prop at all): MaskedInput accepts describedBy and wires it to aria-describedby on the input (aria-describedby={describedBy ?? fieldDescribedBy}).
  • The formless caller (NewContactDialog, rodné číslo) passes describedBy={rcErrId} and gives the rendered <span class="fl-err"> that same id — explicitly linked, as before.
  • The field callers (FirmyPage, IČO/DIČ/PSČ inside <DrawerField>) still do not pass describedBy explicitly, but as of acrm MR !4085 (28 Sep 2026, "fix(ui): chyba pod DS Field se čtečce provází sama") they no longer need to: MaskedInput reads useFieldDescribedBy() and falls back to it, and <Field>/<DrawerField> now give the rendered error (or hint) span an automatic id and pass it down through that context. A screen reader user is now told both that the field is invalid (aria-invalid) and why (the linked error text), with no per-call-site wiring — see text-field/field.a11y.md's ARIA section for the mechanism.
  • The ghost span (.acrm-mask-ghost / .fl-mask-ghost) is aria-hidden="true" in both variants — it visually echoes the typed value plus the remaining template; exposing it would read the value twice.

Focus

  • field variant: focus is drawn by the surrounding .acrm-inputwrap (:focus-within), same ring as input — border-color: var(--accent) + 3px ring at 16% --accent. .is-error keeps the same geometry with --critical; a focused error field shows the accent ring (focus rule wins by source order, same as input).
  • formless variant: the input itself has no border (.fl-mask-in is transparent and absolutely positioned over the ghost), so it relies on the row's own focus treatment (.fl-row:has(.fl-mask-in:focus) tints the row) rather than drawing a dedicated ring — same as masked-date-input.

Contrast

Computed from packages/ui/src/tokens/tokens.css (WCAG 2.x relative luminance) — the same token set already measured in text-field/input.a11y.md and masked-date-input.a11y.md:

  • Typed value (valid) --fg on --surface-100 — 17.59:1 light / 13.09:1 dark (field variant, --surface-50 in dark); 17.59:1 light / 14.53:1 dark (formless variant, dialog body surface).
  • Typed value (invalid) --critical-ink — 5.37:1 / 5.80:1, passes AA in both variants (same token as masked-date-input's invalid state).
  • Ghost .rest --fg-muted — 2.85:1 light / 3.27–3.63:1 dark, below AA 4.5:1 for text; it is a template echo, not content, same system-wide --fg-muted limit as every other muted placeholder/ghost in this family.
  • Focus border --accent — 4.44:1 light / 3.83:1 dark, passes the 3:1 non-text minimum.
  • Error border --critical (field variant only) — 3.91:1 light / 4.35:1 dark, passes 3:1.
  • Resting hairline --border-strong (field variant only) — 1.38:1 light / 1.55:1 dark, hairline, below the 3:1 non-text minimum — same as every .acrm-inputwrap at rest.

Known limits

  • ~~describedBy/aria-describedby linking depends on the caller wiring it — one call site does (NewContactDialog), the drawer call sites do not (FirmyPage).~~ Fixed in acrm MR !4085 (28 Sep 2026) via useFieldDescribedBy(): every call site now gets aria-describedby for free from the enclosing <Field>/<DrawerField> unless it passes its own describedBy — see ARIA above.
  • <Field>/<DrawerField> do not link label and control — same gap as input; the accessible name must be supplied by the screen via ariaLabel.
  • No live region and no "character rejected" feedback: a dropped keystroke (a letter typed into a numeric-only slot) simply does not appear, with nothing announced to a screen reader user — same limit as masked-date-input.
  • The ghost's ARIA-hidden echo means the visual template ("________" for an empty IČO) is not itself announced; a screen reader user only hears the label and, once typed, the value — intended, but worth knowing if a future caller wants the template read aloud.

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