Masked input
canonStructured 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
@acrm/ui <MaskedInput>.acrm-maskwrap .acrm-maskwrap.is-error .acrm-mask .acrm-mask-ghost +4SourceStoryKeyboard
- Native
<input>: typing edits the value in place, Tab moves focus in and out;disabledremoves it from the tab order (same asinput). 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 asmasked-date-input, covered byfield-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.
inputModeis set per mask (numericfor IČO/PSČ/rodné číslo,textfor DIČ), so mobile keyboards match the expected characters.
ARIA
- Accessible name: pass
ariaLabel, rendered as a nativearia-labelon the<input>— the same limitation asinputapplies when the component is used inside<Field>/<DrawerField>, whose<label>is not linked to its control viahtmlFor/id(seetext-field/input.a11y.md).MaskedInputdoes not add that link either; callers must supplyariaLabelthemselves, and every current call site does. aria-invalidis set only wheninvalidis true (aria-invalid={invalid || undefined}): a valid or empty field carries noaria-invalidattribute at all rather than"false"— same convention asmasked-date-input.- Error linking, unlike
masked-date-input(which has nodescribedByprop at all):MaskedInputacceptsdescribedByand wires it toaria-describedbyon the input (aria-describedby={describedBy ?? fieldDescribedBy}). - The
formlesscaller (NewContactDialog, rodné číslo) passesdescribedBy={rcErrId}and gives the rendered<span class="fl-err">that sameid— explicitly linked, as before. - The
fieldcallers (FirmyPage, IČO/DIČ/PSČ inside<DrawerField>) still do not passdescribedByexplicitly, 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:MaskedInputreadsuseFieldDescribedBy()and falls back to it, and<Field>/<DrawerField>now give the rendered error (or hint) span an automaticidand 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 — seetext-field/field.a11y.md's ARIA section for the mechanism. - The ghost span (
.acrm-mask-ghost/.fl-mask-ghost) isaria-hidden="true"in both variants — it visually echoes the typed value plus the remaining template; exposing it would read the value twice.
Focus
fieldvariant: focus is drawn by the surrounding.acrm-inputwrap(:focus-within), same ring asinput—border-color: var(--accent)+ 3px ring at 16%--accent..is-errorkeeps the same geometry with--critical; a focused error field shows the accent ring (focus rule wins by source order, same asinput).formlessvariant: the input itself has no border (.fl-mask-inis 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 asmasked-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)
--fgon--surface-100— 17.59:1 light / 13.09:1 dark (fieldvariant,--surface-50in dark); 17.59:1 light / 14.53:1 dark (formlessvariant, dialog body surface). - Typed value (invalid)
--critical-ink— 5.37:1 / 5.80:1, passes AA in both variants (same token asmasked-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-mutedlimit 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(fieldvariant only) — 3.91:1 light / 4.35:1 dark, passes 3:1. - Resting hairline
--border-strong(fieldvariant only) — 1.38:1 light / 1.55:1 dark, hairline, below the 3:1 non-text minimum — same as every.acrm-inputwrapat rest.
Known limits
- ~~
describedBy/aria-describedbylinking 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) viauseFieldDescribedBy(): every call site now getsaria-describedbyfor free from the enclosing<Field>/<DrawerField>unless it passes its owndescribedBy— see ARIA above. <Field>/<DrawerField>do not link label and control — same gap asinput; the accessible name must be supplied by the screen viaariaLabel.- 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