Phone input (country selector + i18n format)
components specs/components/phone-input.kmd
Country-aware phone input — country selector + locale-aware mask + ISO E.164 normalization on storage. Common need in signup / profile / SMS-verification flows across Koder products. Modeled after Base Web PhoneInput.
When this spec applies
Primary triggers
- Pick a phone input component for a Koder product form
All triggers
- Add a phone field to a form
- Replace a plain <input type='tel'> with a validated, i18n-aware control
Specification body
Component — Phone input
Status: v0.1.0 — Draft.
R1 — Anatomy
- Country selector (combobox-style; see
specs/components/combobox.kmd) showing flag glyph + dial-code (e.g. 🇧🇷 +55). - Inline divider.
- Phone-number input with locale-aware mask.
- Trailing validation icon (✓ on valid E.164, ✗ on invalid).
R2 — Default country
- Derived from user's locale per
specs/i18n/contract.kmd(en-US → US, pt-BR → BR, es-ES → ES). - Fallback: device locale via
navigator.language(web) /Locale.getDefault()(Flutter / native) when product locale doesn't map to a country (e.g. en-US is fine; en-Latn is not). - Manual override always allowed — user picks any country in the selector.
R3 — Format (mask + storage)
- Display: locale-aware mask via libphonenumber-equivalent (e.g.
+55 (11) 91234-5678for BR;+1 (415) 555-2671for US). - Storage: ISO E.164 (
+5511912345678). Always strip mask before emitting onChange value. - Paste handling: if pasted value parses as E.164 in a different country than currently selected, auto-switch the country selector (and announce via live region — see R5).
R4 — Validation
- Inline: validate per the selected country's libphonenumber rules.
- Validity states:
- Empty: neutral (no icon)
- Partial / invalid format: muted (no error icon — too noisy mid-typing)
- Complete + valid: ✓ icon
- Complete + invalid: ✗ icon + error message below
- Error messages from
specs/errors/user-facing-messages.kmd.
R5 — Keyboard navigation
| Key | Action |
|---|---|
| Tab into selector | Open dropdown / focus current selection |
| ↑ / ↓ in selector | Navigate countries |
| Type letters in selector | Jump to country starting with those letters |
| Tab out of selector | Focus phone input |
| Type / paste in input | Mask applies live |
| Tab out of input | Validate + show error if invalid |
| Esc in selector | Close dropdown without changing selection |
Live-region announce on country auto-switch (R3 paste): "Country changed to {country name} based on pasted number."
R6 — Accessibility
- Both selector and input have associated labels.
- Selector:
role="combobox"perspecs/components/combobox.kmdR3. - Phone input:
<input type="tel" inputmode="tel">so mobile keyboards show the dial pad. - Validation icon + error message linked via
aria-describedby.
R7 — i18n
- Country names translated per locale (per
specs/i18n/contract.kmd). - Mask uses the country's local convention regardless of UI locale
(a BR phone number is always masked
+55 (XX) XXXXX-XXXXeven when the UI is in English). - Dial codes are universal (no translation needed).
R8 — OUIA
Per specs/testing/ouia-test-hooks.kmd:
data-ouia-component-type="PhoneInput"data-ouia-component-id="<input-id>"data-ouia-safe="true"always (input doesn't have async ready states beyond the country list load).
Não-escopo
- SMS verification flow (consumer concern; phone-input emits valid E.164 only, downstream handles the OTP loop).
- libphonenumber library binding (impl detail — Google libphonenumber-js for web, libphonenumber-dart for Flutter).
- Phone-extension support ("ext. 1234") — out of v0; add if a product needs it.
Requirements (testable)
Requirement: Default country from locale {#req-phone-input-default-country}
The phone input SHALL derive its initial country from the product locale per specs/i18n/contract.kmd, SHALL fall back to the device locale when the product locale does not map to a country, and SHALL always allow the user to override the selection manually.
Scenario: Product locale maps to a country
- GIVEN the product locale is pt-BR
- WHEN the phone input mounts
- THEN the country selector shows BR (🇧🇷 +55) as the selected country
Scenario: Product locale does not map, device locale used
- GIVEN the product locale is en-Latn which does not map to a country
- AND the device locale (
navigator.language/Locale.getDefault()) is en-US - WHEN the phone input mounts
- THEN the country selector shows US (+1) as the selected country
Scenario: Manual override
- GIVEN the country selector shows BR
- WHEN the user opens the selector and picks ES
- THEN the selected country becomes ES (+34)
- AND the input mask switches to the ES convention
Requirement: E.164 storage normalization {#req-phone-input-e164-storage}
The phone input SHALL display the number using the selected country's locale-aware mask, and SHALL emit the onChange value as ISO E.164 with the mask stripped.
Scenario: Masked display, E.164 emitted
- GIVEN the selected country is BR
- WHEN the user types
11912345678 - THEN the visible field reads
+55 (11) 91234-5678 - AND the emitted onChange value is
+5511912345678
Scenario: Paste auto-switches country
- GIVEN the selected country is BR
- WHEN the user pastes
+14155552671 - THEN the country selector auto-switches to US
- AND a live region announces "Country changed to United States based on pasted number."
Requirement: Validation state feedback {#req-phone-input-validation-states}
The phone input SHALL validate against the selected country's rules and SHALL reflect validity through the trailing icon and error message: neutral when empty, muted (no error icon) while partial or mid-typing, ✓ when complete and valid, and ✗ plus an error message when complete and invalid.
Scenario: Complete and valid shows check icon
- GIVEN the selected country is US
- WHEN the user enters the complete valid number
+1 (415) 555-2671 - THEN the trailing validation icon shows ✓
- AND no error message is displayed
Scenario: Complete and invalid shows error
- GIVEN the selected country is US
- WHEN the user enters a complete but invalid number
+1 (000) 000-0000 - THEN the trailing validation icon shows ✗
- AND an error message from
specs/errors/user-facing-messages.kmdis displayed below the input
Scenario: Partial input stays muted
- GIVEN the input is empty
- WHEN the user types a partial number that is not yet complete
- THEN no error icon is shown
- AND no error message is displayed
Requirement: Keyboard navigation and Escape {#req-phone-input-keyboard-nav}
The phone input SHALL support keyboard operation of the country selector (arrow navigation, type-to-jump, Tab to move focus to the input) and Esc SHALL close the selector dropdown without changing the current selection.
Scenario: Escape closes selector without change
- GIVEN the selected country is BR and the selector dropdown is open
- WHEN the user presses Esc
- THEN the dropdown closes
- AND the selected country remains BR
Scenario: Tab moves focus from selector to input
- GIVEN focus is on the country selector
- WHEN the user presses Tab
- THEN focus moves to the phone-number input
Requirement: Accessibility semantics {#req-phone-input-accessibility}
The phone input SHALL expose an accessible name for both the selector and the input, SHALL render the number field as <input type="tel" inputmode="tel">, and SHALL link its validation icon and error message to the input via aria-describedby.
Scenario: Input type and inputmode for mobile dial pad
- GIVEN the phone input is rendered
- WHEN the input element is inspected
- THEN it has
type="tel"andinputmode="tel" - AND it has an associated accessible label
Scenario: Error linked via aria-describedby
- GIVEN the selected country is US and a complete invalid number is entered
- WHEN the ✗ error message is displayed
- THEN the input's
aria-describedbyreferences the element containing that error message
References
specs/components/combobox.kmdspecs/i18n/contract.kmdspecs/testing/ouia-test-hooks.kmd