Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Composites
Phone Input
A country picker and a number field that together produce one E.164 string — the only phone format a CRM or HR record should store — while letting people type their number the way they write it at home.
- Import
import { PhoneInput, PhoneInputCountrySelect, PhoneInputField } from '@comitor/ui'— the main entry, with no optional peer to install. All three parts and theusePhoneInputhook are client-side.- Origin
- Added in
2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Popover, Command, Input and tokens. Where it behaves differently from upstream is listed under What the port guarantees. - Changed in 3.0.0
- There is no default country (2.1.0: Vietnam) and country names follow the provider (2.1.0: always Vietnamese). Pass
defaultCountry="VN"from the workspace’s settings — without a country,0912…is not converted to E.164.PhoneInputLabelsalso gained a requiredplaceholderkey, which only matters to code that builds a whole labels object. Details in the changelog.
Anatomy
The root draws only the frame — you compose both parts inside it; there is no root-only mode. The country button is optional: a field for one country only can leave it out and keep defaultCountry. However the number is typed, the value has one shape — a + and digits, E.164. Whether those digits are the right number depends on the country, as the next three sections show.
import { PhoneInput, PhoneInputCountrySelect, PhoneInputField } from '@comitor/ui'
<PhoneInput // role="group" frame · store · hidden form input (name)
defaultCountry="VN" // region: from workspace settings — there is no default
value={phone} // E.164: '+84912345678'
onValueChange={setPhone}
aria-labelledby="phone-label" // the frame has no name of its own
>
<PhoneInputCountrySelect /> {/* flag button → searchable country list */}
<PhoneInputField id="phone" /> {/* <input type="tel"> — the id your label points at */}
</PhoneInput>A contact’s mobile number
People write their own numbers the domestic way. With a country selected, a number that does not start with + is read as domestic: the country’s trunk prefix — the 0 in front of 912 — is dropped and the calling code is added. The field keeps showing exactly what was typed; only the value is E.164. Type 0912 345 678, then try +44 20 7946 0958 or 0044 20 7946 0958: a leading +, or the 00 dialled in its place, switches to international mode and the flag follows the calling code.
value = "" · country = "VN"
Change the country after typing a domestic number and the same digits are read again as a number of the new country — its trunk prefix dropped, its calling code added; the text in the field stays as typed. Not every country’s trunk prefix is a 0; the next section has the rules.
Prefixes follow the country
Two prefixes are dialled but never stored, and each country has its own. The trunk prefix goes in front of a domestic number — 0 in most countries, 8 in Russia and Kazakhstan, 06 in Hungary, 1 in North America — and is dropped from the value, also when habit puts it after the calling code: +84 0912 345 678 is +84912345678. Italy, San Marino, the Vatican, Côte d’Ivoire and Benin have none; there the leading 0 belongs to the number and stays. The international prefix stands in for the + and is read as one: 00 in most of the world, 011 as well in North America, 0011 in Australia and 010 in Japan instead. Pick a country above the field — or any other from its flag button — and type a number from the table; then switch: a domestic number is read again under each country’s rules, an international one keeps the country of its calling code.
value = "" · country = "VN"
| Country | Typed | Result | Why |
|---|---|---|---|
| Russia | 8 912 345 67 89 | +79123456789field shows 8 912 345 67 89 | Trunk prefix 8, dropped |
| Russia | 812 123 45 67 | +78121234567field shows 812 123 45 67 | Ten digits: this 8 is St Petersburg’s code, and stays |
| Hungary | 06 1 234 5678 | +3612345678field shows 06 1 234 5678 | Trunk prefix 06, dropped |
| Italy | 06 1234 5678 | +390612345678field shows 06 1234 5678 | No trunk prefix: the 0 is part of the number |
| United States | 1 212 555 0123 | +12125550123field shows 1 212 555 0123 | Trunk prefix 1, dropped |
| United States | 011 44 20 7946 0958 | +442079460958→ United Kingdomfield shows +44 207 946 095 8 | International prefix 011, read as + |
| Jamaica | 876 555 1234 | +18765551234field shows 876 555 1234 | Ten digits already include the area code |
| Australia | 0011 44 20 7946 0958 | +442079460958→ United Kingdomfield shows +44 207 946 095 8 | International prefix 0011, in place of 00 |
- What the field shows. A domestic number stays as typed. An international prefix turns into
+as it is typed, and a trunk prefix typed right after the calling code disappears —+84 0shows+84. - Russia and Kazakhstan. A number there can itself begin with 8 — St Petersburg is 812, toll-free numbers 800 — so the 8 is dropped only when the number has a digit too many:
812 123 45 67and8 812 123 45 67are both+78121234567. While8 912…is being typed,onValueChangereports the 8 until the eleventh digit arrives. - North America. Ten digits already include the area code. Jamaica is
+1876in the country list, and876 555 1234becomes+18765551234— not+1876followed by the area code a second time. - At most 15 digits — the E.164 maximum, calling code included. A digit past it does not enter the field.
- Before
3.1.0only a leading 0 was dropped and00stayed digits. AnonChangeonPhoneInputFieldthat rewrote00as+can go — it does no harm if it stays.
Why the country has to be passed
Since 3.0.0 there is no default country, as upstream. Without one the domestic rule cannot run, and the first digits typed are taken for the calling code — so the same 0912 345 678 turns into +091 234 567 8 on screen and +0912345678 in your database, a number that cannot exist. The package does not guess the country from the language, and neither should you: a Vietnamese-speaking user can work for a Singapore office. The country belongs in workspace settings.
value = "" · country = none
| Typed | defaultCountry="VN" | No country |
|---|---|---|
| 0912 345 678 | +84912345678field shows 0912 345 678 | +0912345678not a real numberfield shows +091 234 567 8 |
| 028 3822 1234 | +842838221234field shows 028 3822 1234 | +02838221234not a real numberfield shows +028 382 212 34 |
| +84 912 345 678 | +84912345678field shows +84 912 345 678 | +84912345678→ Vietnamfield shows +84 912 345 678 |
| +84 0912 345 678 | +84912345678field shows +84 912 345 678 | +84912345678→ Vietnamfield shows +84 912 345 678 |
| 0084 912 345 678 | +84912345678field shows +84 912 345 678 | +84912345678→ Vietnamfield shows +84 912 345 678 |
| 0044 20 7946 0958 | +442079460958→ United Kingdomfield shows +44 207 946 095 8 | +442079460958→ United Kingdomfield shows +44 207 946 095 8 |
| 84 912 345 678 | +8484912345678not a real numberfield shows 84 912 345 678 | +84912345678→ Vietnamfield shows +84 912 345 678 |
A +, or the 00 dialled in its place, makes a number international with or without a country, and the 0 typed after +84 is dropped either way. The last row is the flip side of the domestic rule: once a country is selected, a calling code typed without its + is taken for part of a domestic number. International numbers need the plus, or the 00.
The markets a workspace sells into
countries narrows the list to the markets that matter. It is used exactly as given — names, flags and order — so the package cannot translate it for you: build it from useComitorLocale() with the same two Intl calls the package uses for its own list. The demo also shows the calling-code rule: Canada is selected, so typing +1 604 555 0199 keeps the Canadian flag. With no country selected, or the United States, +1 resolves to the United States.
value = "" · country = "CA"
In a form
With a name, the value is posted as E.164 through a hidden input — the visible field has no name, so the formatted text never reaches the server. Press Save without touching the field: workPhone=+84903123456 arrives. Before 2.1.0 an untouched field posted an empty string, because the hidden input only wrote a value when it changed. FormField’s control props go on PhoneInputField, not on the root, so its label points at the real input.
What the port guarantees
- Domestic numbers become E.164. With a country selected,
0912 345 678posts+84912345678; upstream posted+0912345678, broken data for any CRM. The text stays as typed and the caret does not jump; characters other than digits, spaces and( ) . -are removed where they were typed. - Prefixes are dialled, not stored. The selected country’s trunk prefix is dropped wherever it is typed — in front of a domestic number or right after the calling code — and its international prefix is read as
+; the value never passes 15 digits. Upstream kept every digit:+84 0912 345 678stored+840912345678. Before3.1.0this port dropped only a leading 0. - Shared calling codes resolve the same way in every language. Eight codes belong to more than one country. Upstream took whichever came first alphabetically in the display language —
+44was Guernsey in English and the Isle of Man in Vietnamese,+7Kazakhstan,+358the Åland Islands. Now the country already selected wins if it shares the code, then the main one: +1 United States, +7 Russia, +39 Italy, +44 United Kingdom, +212 Morocco, +358 Finland, +590 Guadeloupe, +599 Curaçao. Canada plus+1 604…stays Canada; upstream flipped it at the first digit. - A domestic number never re-detects the country. While digits are typed the domestic way, the selected country is the source of truth: the United States plus
340 555 1234stays the United States instead of being read as+1 340, the US Virgin Islands. - Country names are the reader’s. They come from
Intl.DisplayNamesin the provider’s locale (upstream: English only), sorted with a collator for the same locale — “Đan Mạch” after “Dominica”, as a Vietnamese dictionary has it — so server and browser agree. The list is built once per locale; upstream built aDisplayNamesper country, some 240 of them, on every render — every keystroke. - Search ignores diacritics.
ducfinds Đức,viet namfinds Việt Nam; the calling code and the ISO code are searchable too. - The country button says its value. Its name is “Country: Vietnam (+84)”, or “Select country” with a globe icon when nothing is chosen. Upstream’s button had no name, and its empty state was a grey box that measured 1.00:1 against its own hover colour.
- A read-only field keeps its country.
readOnlyonPhoneInputkeeps the country list shut, so the calling code — part of the value — cannot change either. The button stays in the tab order and still says its country, witharia-disabled. Upstream’s button still opened, and so did this one before3.0.1. - The frame behaves like an Input.
h-9, the package’s border roles, a focus ring when the number field has focus — upstream watched a slot that does not exist, so its frame never lit up — and an error border frominvalidor from a form library’saria-invalidon the root. A disabled field is dimmed once, not twice (upstream: 25%). - Small things that bit upstream users. A
placeholderonPhoneInputFieldis honoured (upstream overwrote it); spaces before a pasted+are ignored; the initial value is posted with the form.
Language and region
The provider’s language decides the country names, their order, the button’s accessible name, the search box and the placeholder. Switch to vi below, open the list and search for duc. en-GB barely registers here — 8 of the 239 names lose a full stop (St. Lucia becomes St Lucia, U.S. Virgin Islands becomes US Virgin Islands) — which is why the toggle stops at two. What the language never decides is the country the field assumes.
value = "" · country = "VN"
- The country is region.
defaultCountry— and acountrieslist, if you pass one — come from workspace settings. The language comes from the user, once, at the root: Set the language. - Precedence, per string: the
labelsprop (andplaceholder, on the root or on the field), then the provider’sphoneInputnamespace, then English.DEFAULT_PHONE_INPUT_LABELSis the English table since 3.0.0 and is not read by the component; the Vietnamese one iscomitorMessagesVi.phoneInput. countrySelectis a function, because the country sits in the middle of the sentence and word order differs by language. Functions cannot be passed from a Server Component, so an override of it lives in a client component.
| Key | English (default) | comitorMessagesVi | Used for |
|---|---|---|---|
countrySelect(Vietnam) | Country: Vietnam (+84) | Quốc gia: Việt Nam (+84) | Name of the country button — the button shows only a flag, so its name carries the value |
countrySelect(undefined) | Select country | Chọn quốc gia | The same button with no country selected (a globe icon) |
searchPlaceholder | Search country… | Tìm quốc gia… | Search box at the top of the country list |
empty | No country found. | Không tìm thấy quốc gia. | Shown when no country matches the search |
placeholder | Enter phone number | Nhập số điện thoại | Placeholder of the number field — new required key in 3.0.0 |
'use client' // countrySelect is a function — it cannot cross from a Server Component
import { ComitorLocaleProvider, type PhoneInputLabels } from '@comitor/ui'
// Partial overrides are untouched by 3.0.0 — labels is Partial<PhoneInputLabels>.
<PhoneInput labels={{ placeholder: 'Mobile number' }} defaultCountry="VN">…</PhoneInput>
// Code that builds a WHOLE object has to add placeholder: it became a required key in 3.0.0,
// so a 2.x object without it no longer typechecks. Module constant = stable reference.
const REGION_WORDING: PhoneInputLabels = {
countrySelect: (country) => (country ? `Region: ${country.name} (${country.dialCode})` : 'Select region'),
searchPlaceholder: 'Search regions…',
empty: 'No region found.',
placeholder: 'Enter phone number',
}
<ComitorLocaleProvider locale="en" messages={{ phoneInput: REGION_WORDING }}>{children}</ComitorLocaleProvider>Known limitations
- Nothing validates the number. A calling code typed without its plus after a country is selected is read as part of a domestic number —
84 912 345 678with Vietnam gives+8484912345678— and no length is checked beyond the 15-digit maximum. Validate before storing — on the server, a library such as libphonenumber is the reliable check. - International prefixes with a carrier code are not understood. Korea dials abroad with
001, Singapore with001or008, Taiwan with002. The field reads the00and takes the rest for the calling code, so Korea plus001 44 20 7946 0958becomes+1442079460958, a United States number. Type+there. - Grouping is not the country’s format. After the calling code the digits are grouped in threes: a Ho Chi Minh City landline shows as
+84 283 822 123 4. Display only — the value is unaffected. - Inside react-hook-form’s
FormControl, the Slot hands the id to the root, soFormLabel’shtmlForpoints at the frame div. Passaria-labeltoPhoneInputand toPhoneInputField. The package’s ownFormFielddoes not have the problem when its control props go on the field, as in the form demo. - A
countrieslist is not translated for you — build it from the provider’s locale, as the markets demo does.
Props
PhoneInput
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
value | string | — | Controlled value in E.164 — a plus and digits only, +84912345678. A value set from outside always shows in international form, and as it is: a stored +840912345678 is not corrected until the user edits the field, and that first edit normalises the whole number. |
defaultValue | string | — | Uncontrolled initial value, same format. |
onValueChange | (value: string) => void | — | Fires on every keystroke with the E.164 string: an empty string when the field is empty, + while only the plus (or 00) is typed. Never more than 15 digits; nothing else checks length or validity. |
defaultCountry | string | none | Initial country, ISO 3166-1 alpha-2 in upper case. None by default since 3.0.0 (2.1.0: VN) — and without a country, domestic input is not converted. Pass the workspace's country. |
country | string | — | Controlled country code. |
onCountryChange | (country: string) => void | — | Fires when the user picks a country and when a typed calling code selects one. |
countries | PhoneInputCountry[] | all 239 | Restricts or reorders the list. Used exactly as given: names are not re-localised and the order is yours. |
locale | string | provider locale | Language of the country names (Intl.DisplayNames) and of the list order. Ignored when countries is passed. Without a provider, en; 2.1.0 hard-coded vi. |
labels | Partial<PhoneInputLabels> | phoneInput namespace | Overrides single keys; the rest follow the provider, then English. countrySelect is a function. |
placeholder | string | labels.placeholder | Beats labels.placeholder. A placeholder on PhoneInputField beats both. |
name | string | — | Name of the hidden input that posts the E.164 value when the component sits inside a form. The visible field has no name. |
disabled | boolean | false | Disables the field and the country button. A disabled value is not submitted. |
readOnly | boolean | false | Locks the field and the country button: the button stays focusable and still says its country, but carries aria-disabled and does not open. Before 3.0.1 it still opened, so the calling code of a read-only value could change. readOnly on PhoneInputField alone locks only the field. |
required | boolean | false | Native required and aria-required on the visible field, so an empty field blocks submission. |
invalid | boolean | false | aria-invalid on the field and a destructive-ink border on the frame. |
showFlag | boolean | true | Draws the emoji flags in the button and in the list. |
aria-label | string | — | Name of the role=group frame, which has none of its own. Or use aria-labelledby. |
asChild | boolean | false | Render the frame as your own element (Radix Slot). |
PhoneInputCountrySelect
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
disabled | boolean | — | Disables the country button alone. A read-only field does not need it: since 3.0.1, readOnly on PhoneInput locks the button too. |
className | string | — | Merged onto the trigger button. |
onOpenChange | (open: boolean) => void | — | Called after the component's own open-state handling. |
...rest | Popover props | — | modal and the other Radix Popover root props. children are ignored — the button draws its own. |
PhoneInputField
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
id | string | — | Put it here, not on PhoneInput, and point your label's htmlFor at it. |
placeholder | string | root placeholder | Wins over the root placeholder and labels.placeholder. Upstream ignored it. |
onChange | ChangeEventHandler<HTMLInputElement> | — | Runs before the component reads the text. preventDefault() skips the built-in handling; changing event.currentTarget.value changes what it reads. |
disabled · readOnly · required | boolean | — | Combined with the flags on PhoneInput. |
...rest | ComponentProps<'input'> | — | aria-* and handlers. The field is type=tel with inputMode=tel. Do not give it a name — it would post the text on screen. |
PhoneInputCountry
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
codebắt buộc | string | — | ISO 3166-1 alpha-2, upper case — VN. |
namebắt buộc | string | — | Shown in the list and in the button's accessible name — Vietnam, or Việt Nam. |
dialCodebắt buộc | string | — | With the plus — +84. |
flag | string | — | Emoji flag. Leave it out and no flag is drawn. |
usePhoneInput
import { usePhoneInput } from '@comitor/ui'
// Reads the store from a component rendered INSIDE <PhoneInput>; anywhere else it throws.
// State: value · country · open · startsWithPlus · nationalInput (the typed domestic text, or null)
const country = usePhoneInput((state) => state.country) // 'VN'Accessibility
- The frame is a
role="group"without a name of its own — give it one witharia-labelledbyoraria-label. The number field is a real<input type="tel">withinputMode="tel", so phones show the dial pad; label it through anidonPhoneInputField. - The country button’s name carries the current value — “Country: Vietnam (+84)” — because the button itself shows only a flag. Flags, the globe and the chevron are
aria-hidden; in the list the country name sits beside the flag, so nothing is read twice. - The list is the package’s
Command: type to filter, ↑ / ↓ to move, Enter to pick, Escape to close. After a pick, focus moves on to the number field so typing can continue. requiredandaria-requiredland on the visible field, so an empty required field blocks submission natively.invalidsetsaria-invalidon it and the frame shows a destructive-ink border; pointaria-describedbyon the field at your error text.- Forms: the E.164 value travels in a hidden input named by
name, rendered only inside a<form>. Its initial value is posted (since 2.1.0); a disabled field posts nothing. - The focus ring appears on the frame whenever the number field has focus; the country button draws its own outline inset, so it does not spill over the field beside it.
- Still the app’s job: naming the group, choosing the country from workspace settings, validating the number before it is stored, and explaining errors next to the field.
Related
- CRM — the contact record prints numbers like +84 236 555 0111. Store what this field emits, E.164, and format it for display there.
- HR — the employee record form, where a work phone and an emergency contact belong.
- Commerce — a delivery contact at checkout, for a shop that ships to more than one country.
- Form Section —
FormField’s control props go onPhoneInputField. - Mask Input — its
phonepattern is a US format with no country; phone numbers belong here. - Time Picker and Date Picker — the other region-sensitive fields: clock and date order follow the locale.