Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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 the usePhoneInput hook 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. PhoneInputLabels also gained a required placeholder key, 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.

tsx
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"

CountryTypedResultWhy
Russia8 912 345 67 89+79123456789field shows 8 912 345 67 89Trunk prefix 8, dropped
Russia812 123 45 67+78121234567field shows 812 123 45 67Ten digits: this 8 is St Petersburg’s code, and stays
Hungary06 1 234 5678+3612345678field shows 06 1 234 5678Trunk prefix 06, dropped
Italy06 1234 5678+390612345678field shows 06 1234 5678No trunk prefix: the 0 is part of the number
United States1 212 555 0123+12125550123field shows 1 212 555 0123Trunk prefix 1, dropped
United States011 44 20 7946 0958+442079460958→ United Kingdomfield shows +44 207 946 095 8International prefix 011, read as +
Jamaica876 555 1234+18765551234field shows 876 555 1234Ten digits already include the area code
Australia0011 44 20 7946 0958+442079460958→ United Kingdomfield shows +44 207 946 095 8International 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 0 shows +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 67 and 8 812 123 45 67 are both +78121234567. While 8 912… is being typed, onValueChange reports the 8 until the eleventh digit arrives.
  • North America. Ten digits already include the area code. Jamaica is +1876 in the country list, and 876 555 1234 becomes +18765551234 — not +1876 followed 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.0 only a leading 0 was dropped and 00 stayed digits. An onChange on PhoneInputField that rewrote 00 as + 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

TypeddefaultCountry="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.

FormData → press Save without touching the field

What the port guarantees

  • Domestic numbers become E.164. With a country selected, 0912 345 678 posts +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 678 stored +840912345678. Before 3.1.0 this 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 — +44 was Guernsey in English and the Isle of Man in Vietnamese, +7 Kazakhstan, +358 the Å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 1234 stays the United States instead of being read as +1 340, the US Virgin Islands.
  • Country names are the reader’s. They come from Intl.DisplayNames in 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 a DisplayNames per country, some 240 of them, on every render — every keystroke.
  • Search ignores diacritics. duc finds Đức, viet nam finds 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. readOnly on PhoneInput keeps 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, with aria-disabled. Upstream’s button still opened, and so did this one before 3.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 from invalid or from a form library’s aria-invalid on the root. A disabled field is dimmed once, not twice (upstream: 25%).
  • Small things that bit upstream users. A placeholder on PhoneInputField is 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 a countries list, 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 labels prop (and placeholder, on the root or on the field), then the provider’s phoneInput namespace, then English. DEFAULT_PHONE_INPUT_LABELS is the English table since 3.0.0 and is not read by the component; the Vietnamese one is comitorMessagesVi.phoneInput.
  • countrySelect is 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.
KeyEnglish (default)comitorMessagesViUsed 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 countryChọn quốc giaThe same button with no country selected (a globe icon)
searchPlaceholderSearch country…Tìm quốc gia…Search box at the top of the country list
emptyNo country found.Không tìm thấy quốc gia.Shown when no country matches the search
placeholderEnter phone numberNhập số điện thoạiPlaceholder of the number field — new required key in 3.0.0
tsx
'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 678 with 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 with 001 or 008, Taiwan with 002. The field reads the 00 and takes the rest for the calling code, so Korea plus 001 44 20 7946 0958 becomes +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, so FormLabel’s htmlFor points at the frame div. Pass aria-label to PhoneInput and to PhoneInputField. The package’s own FormField does not have the problem when its control props go on the field, as in the form demo.
  • A countries list is not translated for you — build it from the provider’s locale, as the markets demo does.

Props

PhoneInput

PropKiểuMặc địnhMô tả
valuestring—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.
defaultValuestring—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.
defaultCountrystringnoneInitial 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.
countrystring—Controlled country code.
onCountryChange(country: string) => void—Fires when the user picks a country and when a typed calling code selects one.
countriesPhoneInputCountry[]all 239Restricts or reorders the list. Used exactly as given: names are not re-localised and the order is yours.
localestringprovider localeLanguage 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.
labelsPartial<PhoneInputLabels>phoneInput namespaceOverrides single keys; the rest follow the provider, then English. countrySelect is a function.
placeholderstringlabels.placeholderBeats labels.placeholder. A placeholder on PhoneInputField beats both.
namestring—Name of the hidden input that posts the E.164 value when the component sits inside a form. The visible field has no name.
disabledbooleanfalseDisables the field and the country button. A disabled value is not submitted.
readOnlybooleanfalseLocks 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.
requiredbooleanfalseNative required and aria-required on the visible field, so an empty field blocks submission.
invalidbooleanfalsearia-invalid on the field and a destructive-ink border on the frame.
showFlagbooleantrueDraws the emoji flags in the button and in the list.
aria-labelstring—Name of the role=group frame, which has none of its own. Or use aria-labelledby.
asChildbooleanfalseRender the frame as your own element (Radix Slot).

PhoneInputCountrySelect

PropKiểuMặc địnhMô tả
disabledboolean—Disables the country button alone. A read-only field does not need it: since 3.0.1, readOnly on PhoneInput locks the button too.
classNamestring—Merged onto the trigger button.
onOpenChange(open: boolean) => void—Called after the component's own open-state handling.
...restPopover props—modal and the other Radix Popover root props. children are ignored — the button draws its own.

PhoneInputField

PropKiểuMặc địnhMô tả
idstring—Put it here, not on PhoneInput, and point your label's htmlFor at it.
placeholderstringroot placeholderWins over the root placeholder and labels.placeholder. Upstream ignored it.
onChangeChangeEventHandler<HTMLInputElement>—Runs before the component reads the text. preventDefault() skips the built-in handling; changing event.currentTarget.value changes what it reads.
disabled · readOnly · requiredboolean—Combined with the flags on PhoneInput.
...restComponentProps<'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

PropKiểuMặc địnhMô tả
codebắt buộcstring—ISO 3166-1 alpha-2, upper case — VN.
namebắt buộcstring—Shown in the list and in the button's accessible name — Vietnam, or Việt Nam.
dialCodebắt buộcstring—With the plus — +84.
flagstring—Emoji flag. Leave it out and no flag is drawn.

usePhoneInput

tsx
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 with aria-labelledby or aria-label. The number field is a real <input type="tel"> with inputMode="tel", so phones show the dial pad; label it through an id on PhoneInputField.
  • 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.
  • required and aria-required land on the visible field, so an empty required field blocks submission natively. invalid sets aria-invalid on it and the frame shows a destructive-ink border; point aria-describedby on 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 on PhoneInputField.
  • Mask Input — its phone pattern 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.