Phone input
canonPhone number typed as a country-code prefix plus a national-number mask; an old value the mask cannot read is shown editable as-is.
Merged to origin/main 28 September 2026 (acrm MR !4085, "fix(web): telefon jako maska s předvolbou (PhoneInput); vzor telefonu do @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. AD 28 Sep 2026 moved the whole phone pattern into @acrm/ui (source=package): `lib/dial-codes.ts` (pure functions — `DIAL_CODES`, `DIAL_MASKS` per country, `maskNational`, `maskTemplate`, `splitDial`, `phoneDigitsFor`, `phoneComplete`, `searchDialCodes`), `DialCodePicker` (country-code chip + search) and two inputs sharing that mask: `PhoneInput` (this card's primary component — a FORM FIELD, `.acrm-inputwrap.acrm-maskwrap.acrm-phonewrap`) and `MaskedPhoneInput` (the row input of the Multi-value editor, `.phe-*`, no `.acrm-inputwrap` frame of its own). Both were previously app-only (`apps/web/src/components/multivalue/`); `masked-input`'s registry entry called this move out as pending — it is done as of !4085, and this is that promised card. Anatomy: the country code is a prefix inside the field (`DialCodePicker`, chip + search), the national number is masked per country from `DIAL_MASKS` (CZ/SK 3·3·3, DE/AT 3·4·4, fallback 3·3·3 for an unlisted code), with the same visible ghost template pattern as `masked-input` (`.acrm-mask-ghost`, typed part transparent, rest muted underscores). Below 720px `DialCodePicker` renders the canonical `BottomSheet` ("Předvolba" title, grab handle, search, full-width Hotovo, backdrop closes, portais into the modal's own dialog when opened from one) instead of the desktop `Popover` — rule 14 Sep 2026 item 5/6 for every mobile overlay; `useSheetMode()` picks the mode. The selected country is kept even with an empty number (a picked country used to snap back to +420 once the digits were cleared — fixed same MR). Value is the display string "+420 601 123 456" (identical to the app's `formatPhone`), so the stored data format is unchanged. An old value the mask cannot read without loss (several numbers "a; b", letters) is shown as-is via the `legacy` branch and stays editable, never rejected or blanked. `phoneComplete(value)` gates saving — an invalid or incomplete phone never blocks the rest of the form; the host simply does not send it (edit keeps the previous stored value). `aria-describedby` now comes from the enclosing `<Field>`/`<DrawerField>` automatically via `useFieldDescribedBy()` unless the caller passes its own `describedBy` (see `field` sub-entry). Used in: Nová firma (`FirmyPage.tsx`), Nová objednávka (`NewOrderForm.tsx`), Nastavení › Profil ("Telefon" and "Telefon 2", `NastaveniPage.tsx`), and SIM record evidence (`TelefonyPage.tsx`) — there the number is the record's key, so the host saves only a valid number or the unchanged old one, never a half-typed new one.
Used in 7 files · 7 areas · 9× — components/multivalue, components/orders, components/org, pages/NastaveniPage, pages/TelefonyPage, pages/komunikace …
@acrm/ui <PhoneInput> <DialCodePicker> <MaskedPhoneInput>.acrm-inputwrap .acrm-maskwrap .acrm-maskwrap.is-error .acrm-phonewrap +12SourceStoryKeyboard
- National-number
<input>: native text editing, Tab moves focus in and out.inputMode="tel"andautoComplete="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'smaskInput), 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-expandedreflects open state. Opening moves focus into the search input (an effect, notautoFocus— the panel is hidden viavisibilityuntil its position is computed, and a hidden element cannot receive focus, same recipe asRecordSelect). - Desktop popover:
Escapecloses it (Popover's own handling) and returns focus to the chip;Enterin the search field picks the first match. - Mobile BottomSheet (below 720px): same search/
Enter-picks-first-match behaviour;Escapeinside 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 nativearia-labelon the national-number<input>; the dial-code chip gets its own accessible name vialabel/dialLabel(defaults to "Předvolba"). Every current call site (FirmyPage,NewOrderForm,NastaveniPage,TelefonyPage) suppliesariaLabel. aria-invalidis set only wheninvalidis true (aria-invalid={invalid || undefined}): a valid or empty field carries noaria-invalidattribute at all — same convention asmasked-input/masked-date-input.- Error linking is automatic as of acrm MR !4085:
PhoneInput(likeMaskedInput) readsuseFieldDescribedBy()from the enclosing<Field>/<DrawerField>and falls back to it foraria-describedbywhenever the caller does not pass its owndescribedBy(aria-describedby={describedBy ?? fieldDescribedBy}).<Field>now gives the rendered error (or, absent an error, the hint) span an automaticid(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 passesdescribedByexplicitly. Seefield.a11y.mdfor the mechanism andmasked-input.a11y.md, whose "inconsistent across call sites" gap this same change closed. - The dial-code panel (
Popover/BottomSheet) usesrole="dialog"withariaLabel— it is a search-and-pick surface, not a command menu, so it deliberately does not use the Popover's defaultrole="menu". - The ghost span (
.acrm-mask-ghost) isaria-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 asinput/masked-input—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). - Dial-code chip: standard button focus ring; opening the picker does not trap focus in the
Popovercase (it is a portalled dialog-role panel, closed by outside click/Escape). TheBottomSheetcase follows this DS's mobile-overlay focus contract (handled byBottomSheetitself, 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)
--fgon--surface-100— 17.59:1 light / 13.09:1 dark, same asmasked-input'sfieldvariant (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