Phone input
A country's calling code and a phone number as one international value, laid out the way that country writes its numbers.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add phone-inputpnpm dlx ermineui add phone-inputyarn dlx ermineui add phone-inputbunx ermineui add phone-inputThat copies 3 files into src/components/ui/phone-input/. Files you already have are kept.
Use
import { PhoneInput, formatNational, parsePhone, phoneCountries, toE164 } from '@/components/ui/phone-input'<PhoneInput v-model="phone" default-country="GB" /> <!-- "+447700900123" -->
<PasswordStrength :password="password" />
<Toolbar label="Text alignment">
<ToolbarButton label="Align left" :pressed="align === 'left'" @click="align = 'left'"><Icon name="menu" /></ToolbarButton>
<ToolbarSeparator />
<ToolbarButton @click="copy">Copy</ToolbarButton>
</Toolbar>
<Meter label="Storage used" :value="42" :max="64" :low="32" :high="56" optimum="low" :format="(value, max) => `${value} GB of ${max} GB`" />In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
PhoneInput.vue | src/components/ui/phone-input/PhoneInput.vue |
index.ts | src/components/ui/phone-input/index.ts |
phone.ts | src/components/ui/phone-input/phone.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
PhoneInput.vue 162 lines
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { formatNational, parsePhone, phoneCountries, toE164, type PhoneCountry } from './phone'
// The wrapper is only a frame; id, name, aria-invalid and the rest belong on the number field.
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
countries?: readonly PhoneCountry[]
/** The country to start on, as a two-letter code, when the value does not already say. */
defaultCountry?: string
/** Accessible name of the country menu. */
countryLabel?: string
disabled?: boolean
}>(),
{ countries: () => phoneCountries, defaultCountry: 'US', countryLabel: 'Country calling code' },
)
/** The whole number in international form: "+15550100199". Empty until a digit is typed. */
const model = defineModel<string>({ default: '' })
const byCode = (code: string) => props.countries.find((country) => country.code === code)
const country = ref<PhoneCountry>(parsePhone(model.value, props.countries, props.defaultCountry).country ?? byCode(props.defaultCountry) ?? props.countries[0]!)
const national = ref(parsePhone(model.value, props.countries).national)
// A value set from outside (a saved number arriving) is taken apart again; one that came from typing here already matches.
watch(model, (value) => {
if (value === toE164(country.value, national.value)) return
const parsed = parsePhone(value, props.countries, country.value.code)
if (parsed.country) country.value = parsed.country
national.value = parsed.national
})
const shown = computed(() => formatNational(national.value, country.value.format))
function onInput(event: Event) {
const input = event.target as HTMLInputElement
// A whole international number pasted in: take the country from it.
const pasted = input.value.trim().startsWith('+') ? parsePhone(input.value, props.countries, country.value.code) : undefined
if (pasted?.country) country.value = pasted.country
national.value = pasted?.country ? pasted.national : input.value.replace(/\D/g, '')
model.value = toE164(country.value, national.value)
input.value = shown.value
}
function onCountry(event: Event) {
country.value = byCode((event.target as HTMLSelectElement).value) ?? country.value
model.value = toE164(country.value, national.value)
}
</script>
<template>
<div class="er-phone-input" :data-disabled="disabled ? '' : undefined">
<!-- A real select laid invisibly over the code, so the phone's own picker opens and nothing has to be rebuilt. -->
<label class="er-phone-input-country">
<span aria-hidden="true">{{ country.code }} +{{ country.dial }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
<select :value="country.code" :aria-label="countryLabel" :disabled="disabled" autocomplete="tel-country-code" @change="onCountry">
<option v-for="item in countries" :key="item.code" :value="item.code">{{ item.name }} (+{{ item.dial }})</option>
</select>
</label>
<input v-bind="$attrs" :value="shown" type="tel" inputmode="tel" autocomplete="tel-national" :disabled="disabled" @input="onInput" />
</div>
</template>
<style>
@layer components {
.er-phone-input {
display: flex;
width: 100%;
height: var(--er-control-md);
border: 1px solid var(--er-input);
border-radius: var(--er-radius-control);
background: var(--er-input-background);
box-shadow: var(--er-shadow-xs);
transition:
border-color var(--er-duration),
outline-color var(--er-duration);
}
.er-phone-input:has(input:focus-visible) {
border-color: var(--er-ring);
outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
outline-offset: 0;
}
.er-phone-input:has(input[aria-invalid='true']) {
border-color: var(--er-destructive);
}
.er-phone-input[data-disabled] {
opacity: 0.5;
}
.er-phone-input-country {
position: relative;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.25rem;
padding: 0 0.5rem 0 0.75rem;
border-right: 1px solid var(--er-input);
border-radius: calc(var(--er-radius-control) - 1px) 0 0 calc(var(--er-radius-control) - 1px);
color: var(--er-foreground);
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.er-phone-input-country:hover {
background: var(--er-accent);
}
.er-phone-input-country:has(select:focus-visible) {
outline: 2px solid var(--er-ring);
outline-offset: -2px;
}
.er-phone-input-country svg {
width: 0.875rem;
height: 0.875rem;
color: var(--er-muted-foreground);
}
.er-phone-input-country select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
/* 16px stops iOS from zooming the page when the menu is opened. */
font-size: 1rem;
opacity: 0;
cursor: pointer;
}
.er-phone-input input {
flex: 1;
min-width: 0;
padding: 0 0.75rem;
border: 0;
background: transparent;
color: var(--er-foreground);
font-family: inherit;
font-size: 1rem;
font-variant-numeric: tabular-nums;
outline: none;
}
@media (min-width: 48rem) {
.er-phone-input input {
font-size: 0.875rem;
}
}
.er-phone-input input::placeholder {
color: var(--er-muted-foreground);
}
}
</style>index.ts 4 lines
/** Phone input — a country's calling code and a phone number as one international value, laid out the way that country writes its numbers. */
export { default as PhoneInput } from './PhoneInput.vue'
export { formatNational, parsePhone, phoneCountries, toE164, type PhoneCountry } from './phone'phone.ts 79 lines
export interface PhoneCountry {
/** Two-letter country code: "US", "GB". */
code: string
name: string
/** The calling code, without the plus: "1", "44". */
dial: string
/** How a national number is laid out, `#` for each digit. Numbers are left as typed when there is none. */
format?: string
}
/** A starting list. Add the countries you sell to, or replace it with your own. */
export const phoneCountries: readonly PhoneCountry[] = [
{ code: 'AU', name: 'Australia', dial: '61', format: '### ### ###' },
{ code: 'AT', name: 'Austria', dial: '43' },
{ code: 'BE', name: 'Belgium', dial: '32', format: '### ## ## ##' },
{ code: 'BR', name: 'Brazil', dial: '55', format: '## #####-####' },
{ code: 'CA', name: 'Canada', dial: '1', format: '(###) ###-####' },
{ code: 'CN', name: 'China', dial: '86', format: '### #### ####' },
{ code: 'DK', name: 'Denmark', dial: '45', format: '## ## ## ##' },
{ code: 'FI', name: 'Finland', dial: '358' },
{ code: 'FR', name: 'France', dial: '33', format: '# ## ## ## ##' },
{ code: 'DE', name: 'Germany', dial: '49' },
{ code: 'IN', name: 'India', dial: '91', format: '##### #####' },
{ code: 'IE', name: 'Ireland', dial: '353', format: '## ### ####' },
{ code: 'IT', name: 'Italy', dial: '39', format: '### ### ####' },
{ code: 'JP', name: 'Japan', dial: '81', format: '##-####-####' },
{ code: 'MX', name: 'Mexico', dial: '52', format: '## #### ####' },
{ code: 'NL', name: 'Netherlands', dial: '31', format: '# ########' },
{ code: 'NZ', name: 'New Zealand', dial: '64' },
{ code: 'NO', name: 'Norway', dial: '47', format: '### ## ###' },
{ code: 'PL', name: 'Poland', dial: '48', format: '### ### ###' },
{ code: 'PT', name: 'Portugal', dial: '351', format: '### ### ###' },
{ code: 'SG', name: 'Singapore', dial: '65', format: '#### ####' },
{ code: 'ZA', name: 'South Africa', dial: '27', format: '## ### ####' },
{ code: 'KR', name: 'South Korea', dial: '82', format: '##-####-####' },
{ code: 'ES', name: 'Spain', dial: '34', format: '### ### ###' },
{ code: 'SE', name: 'Sweden', dial: '46', format: '##-### ## ##' },
{ code: 'CH', name: 'Switzerland', dial: '41', format: '## ### ## ##' },
{ code: 'GB', name: 'United Kingdom', dial: '44', format: '#### ######' },
{ code: 'US', name: 'United States', dial: '1', format: '(###) ###-####' },
]
const digits = (text: string) => text.replace(/\D/g, '')
/** Lays digits out in a format, adding each separator only once something follows it. */
export function formatNational(national: string, format?: string): string {
const raw = digits(national)
if (!format) return raw
let out = ''
let used = 0
for (const character of format) {
if (used >= raw.length) break
out += character === '#' ? raw[used++] : character
}
// More digits than the format has room for are kept, not dropped: formats here are a guide, not a rule.
return out + raw.slice(used)
}
/** A country and a national number as one international number: "+15550100199". Empty while there are no digits. */
export function toE164(country: PhoneCountry, national: string): string {
// The leading zero some countries write in front of a national number is not dialled from abroad.
const raw = digits(national).replace(/^0+/, '')
return raw ? `+${country.dial}${raw}` : ''
}
/**
* Splits an international number into its country and the rest. Several countries share a calling code
* (the US and Canada both have +1), so say which one you would rather have with `prefer`.
*/
export function parsePhone(value: string, countries: readonly PhoneCountry[] = phoneCountries, prefer?: string): { country?: PhoneCountry; national: string } {
const raw = digits(value)
if (!value.trim().startsWith('+')) return { national: raw }
// The longest calling code that fits wins: +353 is Ireland, not a +3 country followed by 53.
const matches = countries.filter((country) => raw.startsWith(country.dial)).sort((a, b) => b.dial.length - a.dial.length)
const longest = matches.filter((country) => country.dial.length === matches[0]?.dial.length)
const country = longest.find((item) => item.code === prefer) ?? longest[0]
return country ? { country, national: raw.slice(country.dial.length) } : { national: raw }
}