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 +12SourceStoryA phone number typed as a country-code prefix plus a masked national number, with the ghost template of masked-input and inline validation reported while typing. Primary component PhoneInput (packages/ui/src/components/PhoneInput.tsx) — a FORM FIELD in the canonical .acrm-inputwrap frame; sibling MaskedPhoneInput (packages/ui/src/components/MaskedPhoneInput.tsx) is the row input of the Multi-value editor. Both share the country-code picker DialCodePicker (packages/ui/src/components/DialCodePicker.tsx) and the pure mask functions in packages/ui/src/lib/dial-codes.ts. 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 pattern — previously app-only under apps/web/src/components/multivalue/ — into @acrm/ui (source: package).
When to use
- Any phone number field in a form: company phone (Nová firma), order contact phone (Nová objednávka), a person's own phone numbers (Nastavení › Profil — "Telefon" and "Telefon 2"), and the SIM record evidence (
TelefonyPage.tsx), where the number is the record's key. - A phone number that may legitimately vary by country — the national mask follows the picked dial code (
DIAL_MASKS), not a single hardcoded Czech shape.
When not to use
- The row of the Multi-value editor (
.phe-*card) — useMaskedPhoneInput+DialCodePickerdirectly, notPhoneInput's.acrm-inputwrapframe. - IČO, DIČ, PSČ, rodné číslo — use
masked-input; its masks are structured non-phone values. - A value picked from a closed list — use
select/combobox.
Anatomy
Measured from PhoneInput.tsx, DialCodePicker.tsx and dial-codes.ts on origin/main.
PhoneInputrenders.acrm-inputwrap.acrm-maskwrap.acrm-phonewrap(is-erroradded wheninvalid): the same frame, focus ring and error treatment asinput/masked-input, with.acrm-phonewraptrimming the wrapper's left padding so the dial-code chip does not sit further from the edge than typed text does in other fields.- Dial code as prefix:
DialCodePickerrenders a chip (.phe-cc, e.g. "🇨🇿 +420") inside the field, opening a search-over-countries panel (searchDialCodes, diacritics-insensitive, by name or code). - Desktop: the canonical
Popover(.acrm-popover,role="dialog"), portalled to<body>. - Phone, below 720px: the canonical
BottomSheet— grab handle, title "Předvolba", the same search input and country list, a full-width "Hotovo" close affordance, backdrop tap closes; when opened from inside a modal dialog it portals into that dialog's own container (rule 14 Sep 2026 item 5/6).useSheetMode()decides which mode renders. - The picked country is kept even once the national number is cleared — an earlier version snapped back to
+420on an empty value, fixed in the same MR. - National number: masked per country from
DIAL_MASKS(groups of digits, e.g. CZ/SK3·3·3, DE/AT3·4·4; an unlisted code falls back to3·3·3). Same ghost-template pattern asmasked-input: the input holds the typed text, a muted.acrm-mask-ghostspan draws the already-typed part transparently and the remaining template as underscores (maskTemplate/maskNational), so the two line up character for character. - Backspace across the mask's automatic space deletes the preceding digit too, same recipe as
masked-input'smaskInput. - Value format: the display string
"+420 601 123 456"— identical to the app'sformatPhone, so the stored data format does not change.phoneComplete(value)reports whether a value has a known dial code and exactly as many digits as that country's mask expects. - Legacy value: an old value the mask cannot read without loss (several numbers separated by
;/,, or containing letters) is detected byreadable()and shown as-is, fully editable — not rejected, not auto-reformatted. aria-describedbyis now wired automatically from the enclosing<Field>/<DrawerField>viauseFieldDescribedBy()(acrm MR !4085, seefieldsub-entry) unless the caller passes its owndescribedBy.
Best practices
- Gate saving with
phoneComplete(value): send the phone only when it is a known dial code plus a complete national number; an incomplete/invalid value is simply not saved — it never blocks the rest of the form (AD 28 Sep 2026). - On edit, when the field wasn't touched, keep the previously stored value rather than saving a half-typed replacement — see
NastaveniPage.tsx(phone: !phone || phoneComplete(phone) ? phone : (user?.phone ?? '')) andTelefonyPage.tsx, where the SIM number is the record's key and the host accepts only a valid number or the unchanged old one. - Show the "not yet complete" message only after the field has been left (blur), same as
masked-inputandmasked-date-input— not while the user is still typing. - Let an old, mask-unreadable value render as-is; do not attempt to auto-normalise it on load.
- Reimplement per-country digit grouping in the host —
dial-codes.tsis the only source of truth forDIAL_MASKS/maskNational/maskTemplate. - Put
DialCodePicker's country list inside.acrm-popoverunscoped from the field on mobile — below 720px it must be the canonicalBottomSheet, never a shrunk popover (rule 14 Sep 2026 item 7). - Block the rest of the form because the phone is incomplete.
Content
- The ghost template shows underscores for undecided digits, grouped per the selected country — never a fake example number.
- Search placeholder: "Hledat zemi…"; empty result: "Nic nenalezeno".
- Error text is a single sentence ("Telefon není celý"), shown by the host only after blur.
Related
masked-input— the sibling mask pattern (IČO/DIČ/PSČ/RČ), same ghost-template anatomy and.acrm-maskwrapframe.input— the plain single-line fieldPhoneInputspecialises; shares.acrm-inputwrap.field— the label/hint/error wrapper; source of the automaticaria-describedbyviauseFieldDescribedBy.- Stories:
components--phone-input--default,components--phone-input--variants,components--phone-input--states,components--phone-input--composition.
Source: docs/text-field/phone-input.usage.md · GitLab