Phone input — accessibility

Keyboard

  • National-number <input>: native text editing, Tab moves focus in and out. inputMode="tel" and autoComplete="tel-national" match the expected characters and let a browser/password manager offer a saved phone number for the field.
  • Backspace over an auto-inserted separator deletes the digit before it too (same recipe as masked-input's maskInput), so deleting never gets stuck on a character the user did not type.
  • Dial-code picker (DialCodePicker): the chip is a native <button> (Tab stop, Enter/Space open it); aria-expanded reflects open state. Opening moves focus into the search input (an effect, not autoFocus — the panel is hidden via visibility until its position is computed, and a hidden element cannot receive focus, same recipe as RecordSelect).
  • Desktop popover: Escape closes it (Popover's own handling) and returns focus to the chip; Enter in the search field picks the first match.
  • Mobile BottomSheet (below 720px): same search/Enter-picks-first-match behaviour; Escape inside the sheet's search input also closes it. The sheet itself carries the touch-overlay contract every mobile overlay in this DS follows (grab handle, title, a full-width "Hotovo" affordance, backdrop tap closes) — rule 14 Sep 2026 item 5.
  • No arrow-key segment navigation inside the national-number mask; the caret moves through it like an ordinary text field, only separator characters are special-cased by the template.

ARIA

  • Accessible name: pass ariaLabel, rendered as a native aria-label on the national-number <input>; the dial-code chip gets its own accessible name via label/dialLabel (defaults to "Předvolba"). Every current call site (FirmyPage, NewOrderForm, NastaveniPage, TelefonyPage) supplies ariaLabel.
  • 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 — same convention as masked-input/masked-date-input.
  • Error linking is automatic as of acrm MR !4085: PhoneInput (like MaskedInput) reads useFieldDescribedBy() from the enclosing <Field>/<DrawerField> and falls back to it for aria-describedby whenever the caller does not pass its own describedBy (aria-describedby={describedBy ?? fieldDescribedBy}). <Field> now gives the rendered error (or, absent an error, the hint) span an automatic id (useId()) and provides it through context, so a screen reader reaches the error text without any per-call-site wiring — every current call site (all four) gets this for free, none passes describedBy explicitly. See field.a11y.md for the mechanism and masked-input.a11y.md, whose "inconsistent across call sites" gap this same change closed.
  • The dial-code panel (Popover/BottomSheet) uses role="dialog" with ariaLabel — it is a search-and-pick surface, not a command menu, so it deliberately does not use the Popover's default role="menu".
  • The ghost span (.acrm-mask-ghost) is aria-hidden="true" — it visually echoes the typed value plus the remaining template; exposing it would read the number twice.

Focus

  • Field frame: focus is drawn by the surrounding .acrm-inputwrap (:focus-within), same ring as input/masked-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).
  • Dial-code chip: standard button focus ring; opening the picker does not trap focus in the Popover case (it is a portalled dialog-role panel, closed by outside click/Escape). The BottomSheet case follows this DS's mobile-overlay focus contract (handled by BottomSheet itself, shared with every other mobile picker).

Contrast

Computed from packages/ui/src/tokens/tokens.css (WCAG 2.x relative luminance) — the same token set already measured in masked-input.a11y.md, which this component's .acrm-inputwrap frame and .acrm-mask/.acrm-mask-ghost typography reuse unchanged:

  • Typed value (valid) --fg on --surface-100 — 17.59:1 light / 13.09:1 dark, same as masked-input's field variant (identical frame).
  • Typed value (invalid) --critical-ink — 5.37:1 / 5.80:1, passes AA.
  • Ghost .rest --fg-muted — 2.85:1 light / 3.27–3.63:1 dark, below AA 4.5:1 for text; a template echo, not content, same system-wide limit as every other muted 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 — 3.91:1 light / 4.35:1 dark, passes 3:1.

Known limits

  • No live region and no "character rejected" feedback: a dropped keystroke (a non-digit typed into the national-number field) simply does not appear, with nothing announced — same limit as masked-input/masked-date-input.
  • The ghost's ARIA-hidden echo means the visual template is not itself announced; a screen reader user only hears the label and, once typed, the value.
  • A legacy (mask-unreadable) value is shown and can be edited, but nothing tells a screen reader user that this field currently holds an unusual, non-masked value rather than the normal masked form — it reads exactly like any other typed text.

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