Masked input

A structured value typed straight into a visible mask, with check-digit validation reported while the user types. Component MaskedInput (packages/ui/src/components/MaskedInput.tsx); the masks themselves are pure functions in packages/ui/src/lib/field-masks.ts — the component only imports maskInput/maskRest and renders. 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 — the field accepts only valid characters, separators are added automatically, the template stays visible, and the error is reported while typing, not only on blur.

Unlike masked-date-input (app-only, apps/web), this component is exported from @acrm/ui (source: package) and ships six masks out of the box: IČO, DIČ, PSČ, rodné číslo, SPZ and VIN (SPZ and VIN since acrm !4534, 3 Oct 2026).

When to use

  • IČO (company ID) — 8 digits, mod-11 check digit validated once all 8 are typed.
  • DIČ (VAT ID) — digits typed first are treated as Czech and CZ is prefixed automatically; letters typed first are the country code as-is (ATU12345678, SK…, DE…) — CZ is never forced. The mod-11 check only runs for CZ (Czech VAT ID = CZ + a valid IČO).
  • PSČ (postal code) — ### ##, Czech/Slovak addresses only. A foreign address is free text — render a plain Input in the host, not this mask.
  • SPZ / RZ (vehicle registration plate) — capitals, digits and one space between groups, at most 10 characters (1ab 2345! → 1AB 2345). No fixed template: Czech and foreign plates differ in shape, so the mask only keeps valid characters. Complete from 2 characters.
  • VIN — 17 capitals and digits without I, O and Q (ISO 3779 — they read as 1 and 0); the rest shows as _. The check digit is not validated (European VINs do not use it). An unfinished VIN is not an error while typing — the host reports it after the field is left.
  • Rodné číslo (birth number) — ######/#### (9 digits before 1953, otherwise 10), date part validated per segment as it is typed, +50 added to the month for women, 10-digit form checked mod 11 with the pre-1985 zero-remainder exception.
  • Any other short structured value with a fixed character template and a mechanically checkable digit — add a new FieldMask to field-masks.ts rather than inventing a new component.

When not to use

  • A date — use masked-date-input (dd.mm.rrrr, its own segment/rollover logic); this component's masks are IČO/DIČ/PSČ/RČ only.
  • A phone number — use phone-input (PhoneInput as a form field, MaskedPhoneInput as the Multi-value editor row); both moved into packages/ui and got their own registry card in acrm MR !4085 (28 Sep 2026).
  • Free text of any length — use input.
  • A value picked from a closed list — use select / combobox, not a mask.

Anatomy

Measured from MaskedInput.tsx and field-masks.ts on origin/main.

  • Two host shapes, no new look:
  • variant="field" (default) — the input sits inside .acrm-inputwrap.acrm-maskwrap, the same frame, focus ring and .is-error treatment as input. Example: IČO/DIČ in the "Nová firma" drawer (FirmyPage.tsx).
  • variant="formless" — a form-less dialog row, reusing the exact .fl-mask/.fl-mask-in/ .fl-mask-ghost/.fl-mask.bad classes masked-date-input already established in dialog-formless.css. Example: rodné číslo in the "Nová osoba" dialog (NewContactDialog.tsx).
  • Input holds the typed text (value); a muted "ghost" span sits behind/under it and draws the rest of the template (maskRest) in --fg-muted, plus the already-typed part in color: transparent so it reserves space and the two lines up character for character — same ghost pattern as masked-date-input. An empty field shows placeholder (or, if none given, the template itself as underscores) as the ghost.
  • field variant error state: .acrm-maskwrap.is-error draws the usual red border + 16% ring on the wrapper (.acrm-inputwrap.is-error) and colours the typed text --critical-ink via .acrm-maskwrap.is-error .acrm-mask > input.
  • formless variant error state: .fl-mask.bad .fl-mask-in colours the typed text --critical-ink only — no border, same as masked-date-input's formless row (the row itself has no frame to redraw).
  • Backspace across an automatic separator deletes the digit before it too (maskInput): the shown text is diffed against the raw input, and if only a separator is missing, the preceding character is dropped as well — otherwise that separator could never be deleted by the user.
  • Template syntax in field-masks.ts: # = digit, A = letter (upper-cased), anything else is a separator the mask inserts and swallows if the user typed it themselves. DIČ has a variable template (apply/rest instead of a fixed template) because its shape depends on the detected country code.

Best practices

Do

  • Read the mask's own error(text) for the inline message and complete(text) to gate Save/lookup actions — do not re-derive validity in the host.
  • Only send the value to the server once mask.complete(text) is true (ico: mask.complete(f.ico) ? f.ico : null — see FirmyPage.tsx / NewContactDialog.tsx); an incomplete or invalid masked 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 save gate.
  • Load a previously stored value through applyMask(value, mask) so an old value that predates the mask (or was entered with different separators) re-renders correctly; if it is invalid, show it as-is and let the user edit it — do not reject or blank it on load.
  • Pass describedBy pointing at the rendered error element's id so the error is linked with aria-describedby, not just shown visually next to the field (see Accessibility).
  • Use variant="field" inside a labelled <Field>/<DrawerField>; use variant="formless" only inside a form-less dialog row, matching masked-date-input's own row pattern.

Don't

  • Show the incomplete-value message before the field has been touched/blurred — error(text) itself only reports a genuinely wrong value (e.g. a bad check digit once all digits are typed); "not finished yet" is a host-level message shown only after blur, same pattern as masked-date-input.
  • Force CZ onto a DIČ the user is typing with a different country's letters — the mask only adds CZ when the user's first character is a digit.
  • Reimplement the mask's segmenting or check-digit logic in the host — field-masks.ts is the only source of truth.
  • Block saving the rest of the form because one masked field is incomplete — see the save-gate rule above.

Content

  • The ghost template shows underscores for undecided digits, never a fake example value.
  • placeholder on the field variant is the field's own label text (e.g. "IČO"), shown as the ghost of an empty field — not a hint sentence.
  • Error text is one sentence naming what's wrong: "Neplatné IČO (kontrolní číslice nesedí)", "Neplatné rodné číslo (měsíc)". The "not yet complete" message is a separate host string ("IČO má 8 číslic") shown only once the field has been left.
  • input — the plain single-line field this mask specialises; shares .acrm-inputwrap.
  • masked-date-input — the date-mask sibling (dd.mm.rrrr), same ghost/formless pattern, no @acrm/ui export yet.
  • field — the label/hint/error wrapper used around the field variant.
  • date-field — when the value is a date the user mostly picks, not types.
  • Stories: components--masked-input--default, components--masked-input--variants, components--masked-input--states, components--masked-input--composition.

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