"use client"; import { Popover } from "@base-ui/react/popover"; import { CheckIcon, ChevronDownIcon, CircleCheckIcon, SearchIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ChangeEvent, type ClipboardEvent, type KeyboardEvent, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface Country { iso: string; name: string; dial: string; format: string; lengths: number[]; trunk?: boolean; example: string; } export const COUNTRIES: Country[] = [ { iso: "US", name: "United States", dial: "1", format: "(###) ###-####", lengths: [10], example: "4155550132" }, { iso: "GB", name: "United Kingdom", dial: "44", format: "#### ######", lengths: [10], trunk: true, example: "7700900123" }, { iso: "CA", name: "Canada", dial: "1", format: "(###) ###-####", lengths: [10], example: "6045550199" }, { iso: "DE", name: "Germany", dial: "49", format: "### ########", lengths: [10, 11], trunk: true, example: "15123456789" }, { iso: "FR", name: "France", dial: "33", format: "# ## ## ## ##", lengths: [9], trunk: true, example: "612345678" }, { iso: "ES", name: "Spain", dial: "34", format: "### ### ###", lengths: [9], example: "612345678" }, { iso: "IT", name: "Italy", dial: "39", format: "### ### ####", lengths: [9, 10], example: "3123456789" }, { iso: "NL", name: "Netherlands", dial: "31", format: "# ## ## ## ##", lengths: [9], trunk: true, example: "612345678" }, { iso: "SE", name: "Sweden", dial: "46", format: "## ### ## ##", lengths: [9], trunk: true, example: "701234567" }, { iso: "IE", name: "Ireland", dial: "353", format: "## ### ####", lengths: [9], trunk: true, example: "851234567" }, { iso: "PT", name: "Portugal", dial: "351", format: "### ### ###", lengths: [9], example: "912345678" }, { iso: "CH", name: "Switzerland", dial: "41", format: "## ### ## ##", lengths: [9], trunk: true, example: "781234567" }, { iso: "IN", name: "India", dial: "91", format: "##### #####", lengths: [10], trunk: true, example: "9812345678" }, { iso: "JP", name: "Japan", dial: "81", format: "##-####-####", lengths: [10], trunk: true, example: "9012345678" }, { iso: "KR", name: "South Korea", dial: "82", format: "##-####-####", lengths: [9, 10], trunk: true, example: "1012345678" }, { iso: "CN", name: "China", dial: "86", format: "### #### ####", lengths: [11], example: "13812345678" }, { iso: "SG", name: "Singapore", dial: "65", format: "#### ####", lengths: [8], example: "81234567" }, { iso: "AU", name: "Australia", dial: "61", format: "### ### ###", lengths: [9], trunk: true, example: "412345678" }, { iso: "NZ", name: "New Zealand", dial: "64", format: "## ### ####", lengths: [8, 9], trunk: true, example: "211234567" }, { iso: "BR", name: "Brazil", dial: "55", format: "(##) #####-####", lengths: [10, 11], example: "11912345678" }, { iso: "MX", name: "Mexico", dial: "52", format: "## #### ####", lengths: [10], example: "5512345678" }, { iso: "ZA", name: "South Africa", dial: "27", format: "## ### ####", lengths: [9], trunk: true, example: "711234567" }, { iso: "NG", name: "Nigeria", dial: "234", format: "### ### ####", lengths: [10], trunk: true, example: "8021234567" }, { iso: "AE", name: "United Arab Emirates", dial: "971", format: "## ### ####", lengths: [9], trunk: true, example: "501234567" }, ]; const THE = new Set(["US", "GB", "NL", "AE"]); const flag = (iso: string) => String.fromCodePoint(...[...iso.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)); function format(digits: string, pattern: string) { let out = ""; let i = 0; for (const ch of pattern) { if (i >= digits.length) break; if (ch === "#") out += digits[i++]; else out += ch; } return out + digits.slice(i); } export interface PhoneValue { country: string; national: string; e164: string | null; } export interface PhoneFieldProps { defaultCountry?: string; onValueChange?: (value: PhoneValue) => void; countries?: Country[]; label?: string; className?: string; } export function PhoneField({ defaultCountry = "GB", onValueChange, countries = COUNTRIES, label = "Phone number", className }: PhoneFieldProps) { const [iso, setIso] = useState(defaultCountry); const [digits, setDigits] = useState(""); const [touched, setTouched] = useState(false); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); const searchRef = useRef(null); const listRef = useRef(null); const caret = useRef(null); const reduce = useReducedMotion(); const id = useId(); const country = countries.find((c) => c.iso === iso) ?? countries[0]; const valid = country.lengths.includes(digits.length); const short = digits.length > 0 && digits.length < Math.min(...country.lengths); const showError = touched && short; const results = useMemo(() => { const q = query.trim().toLowerCase().replace(/^\+/, ""); if (!q) return countries; return countries .filter((c) => c.name.toLowerCase().includes(q) || c.iso.toLowerCase() === q || c.dial.startsWith(q)) .sort((a, b) => Number(!a.name.toLowerCase().startsWith(q)) - Number(!b.name.toLowerCase().startsWith(q))); }, [countries, query]); useLayoutEffect(() => { if (caret.current === null || !inputRef.current) return; inputRef.current.setSelectionRange(caret.current, caret.current); caret.current = null; }); const emit = (nextIso: string, nextDigits: string) => { const c = countries.find((x) => x.iso === nextIso) ?? country; onValueChange?.({ country: nextIso, national: nextDigits, e164: c.lengths.includes(nextDigits.length) ? `+${c.dial}${nextDigits}` : null }); }; const setNumber = (next: string, nextIso = iso) => { const c = countries.find((x) => x.iso === nextIso) ?? country; const clean = (c.trunk ? next.replace(/^0/, "") : next).slice(0, Math.max(...c.lengths)); setDigits(clean); emit(nextIso, clean); return clean; }; const onChange = (event: ChangeEvent) => { const el = event.target; const at = el.selectionStart ?? el.value.length; let before = el.value.slice(0, at).replace(/\D/g, "").length; const raw = el.value.replace(/\D/g, ""); if (country.trunk && raw.startsWith("0")) before = Math.max(0, before - 1); let next = raw; const native = event.nativeEvent as InputEvent; const plain = country.trunk ? raw.replace(/^0/, "") : raw; if (native.inputType === "deleteContentBackward" && plain === digits && before > 0) { next = plain.slice(0, before - 1) + plain.slice(before); before -= 1; } const clean = setNumber(next); const formatted = format(clean, country.format); let pos = 0; let seen = 0; while (pos < formatted.length && seen < Math.min(before, clean.length)) { if (/\d/.test(formatted[pos])) seen++; pos++; } caret.current = pos; }; const onPaste = (event: ClipboardEvent) => { const text = event.clipboardData.getData("text").trim(); const intl = text.replace(/[^\d+]/g, "").replace(/^00/, "+"); if (!intl.startsWith("+")) return; event.preventDefault(); const num = intl.slice(1); const matches = countries.filter((c) => num.startsWith(c.dial)).sort((a, b) => b.dial.length - a.dial.length); const pick = matches.find((c) => c.iso === iso) ?? matches[0]; if (!pick) return; setIso(pick.iso); setNumber(num.slice(pick.dial.length), pick.iso); setTouched(true); }; const choose = (c: Country) => { setIso(c.iso); setOpen(false); setQuery(""); setNumber(digits, c.iso); requestAnimationFrame(() => inputRef.current?.focus()); }; const onSearchKey = (event: KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const next = (active + (event.key === "ArrowDown" ? 1 : -1) + results.length) % Math.max(results.length, 1); setActive(next); listRef.current?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: "nearest" }); } else if (event.key === "Enter" && results[active]) { event.preventDefault(); choose(results[active]); } }; const missing = Math.min(...country.lengths) - digits.length; const place = THE.has(country.iso) ? `the ${country.name}` : country.name; const hint = showError ? `${missing} digit${missing === 1 ? "" : "s"} short for ${place}` : valid ? `+${country.dial} ${format(digits, country.format)}` : digits.length ? `${missing} more to go` : "We'll text a code to this number."; return (
{ setOpen(next); if (next) setActive(Math.max(0, results.findIndex((c) => c.iso === iso))); else setQuery(""); }} open={open} > {flag(country.iso)} +{country.dial}
{ setQuery(event.target.value); setActive(0); }} onKeyDown={onSearchKey} placeholder="Country or code" ref={searchRef} role="combobox" aria-expanded value={query} />
{results.length === 0 &&

No country matches “{query}”

} {results.map((c, i) => (
choose(c)} onMouseMove={() => setActive(i)} role="option" > {flag(c.iso)} {c.name} +{c.dial}
))}
setTouched(true)} onChange={onChange} onFocus={() => setTouched(false)} onPaste={onPaste} placeholder={format(country.example, country.format)} ref={inputRef} type="tel" value={format(digits, country.format)} /> {valid && ( )}

{valid && Valid number: } {hint}

); }