"use client"; import { ChevronDown, Search } from "lucide-react"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { type ClipboardEvent, type KeyboardEvent, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useDismiss } from "@/lib/hooks/use-dismiss"; import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type PhoneCountry = { /** ISO 3166-1 alpha-2 code, e.g. "ID". */ code: string; name: string; /** Dial code digits without "+", e.g. "62". */ dial: string; /** Grouping for the national number: `#` is a digit, anything else a separator. */ pattern: string; /** Valid national (significant) number length, `[min, max]` digits. */ length: [number, number]; /** Keep a leading 0 instead of treating it as the trunk prefix (Italy). */ keepLeadingZero?: boolean; }; export type PhoneValueMeta = { country: PhoneCountry; /** National significant number, digits only. */ national: string; valid: boolean; }; /** Compact built-in list. Patterns cover the common mobile format per country. */ export const PHONE_COUNTRIES: PhoneCountry[] = [ { code: "ID", name: "Indonesia", dial: "62", pattern: "### #### #####", length: [9, 12] }, { code: "US", name: "United States", dial: "1", pattern: "(###) ###-####", length: [10, 10] }, { code: "GB", name: "United Kingdom", dial: "44", pattern: "#### ######", length: [9, 10] }, { code: "IN", name: "India", dial: "91", pattern: "##### #####", length: [10, 10] }, { code: "SG", name: "Singapore", dial: "65", pattern: "#### ####", length: [8, 8] }, { code: "MY", name: "Malaysia", dial: "60", pattern: "##-#### ####", length: [9, 10] }, { code: "JP", name: "Japan", dial: "81", pattern: "##-####-####", length: [9, 10] }, { code: "DE", name: "Germany", dial: "49", pattern: "### ########", length: [7, 12] }, { code: "BR", name: "Brazil", dial: "55", pattern: "(##) #####-####", length: [10, 11] }, { code: "AU", name: "Australia", dial: "61", pattern: "### ### ###", length: [9, 9] }, { code: "CA", name: "Canada", dial: "1", pattern: "(###) ###-####", length: [10, 10] }, { code: "MX", name: "Mexico", dial: "52", pattern: "## #### ####", length: [10, 10] }, { code: "FR", name: "France", dial: "33", pattern: "# ## ## ## ##", length: [9, 9] }, { code: "IT", name: "Italy", dial: "39", pattern: "### ### ####", length: [6, 11], keepLeadingZero: true }, { code: "ES", name: "Spain", dial: "34", pattern: "### ## ## ##", length: [9, 9] }, { code: "NL", name: "Netherlands", dial: "31", pattern: "# ########", length: [9, 9] }, { code: "BE", name: "Belgium", dial: "32", pattern: "### ## ## ##", length: [8, 9] }, { code: "CH", name: "Switzerland", dial: "41", pattern: "## ### ## ##", length: [9, 9] }, { code: "SE", name: "Sweden", dial: "46", pattern: "## ### ## ##", length: [7, 9] }, { code: "NO", name: "Norway", dial: "47", pattern: "### ## ###", length: [8, 8] }, { code: "DK", name: "Denmark", dial: "45", pattern: "## ## ## ##", length: [8, 8] }, { code: "IE", name: "Ireland", dial: "353", pattern: "## ### ####", length: [7, 9] }, { code: "PT", name: "Portugal", dial: "351", pattern: "### ### ###", length: [9, 9] }, { code: "PL", name: "Poland", dial: "48", pattern: "### ### ###", length: [9, 9] }, { code: "RU", name: "Russia", dial: "7", pattern: "### ###-##-##", length: [10, 10] }, { code: "TR", name: "Türkiye", dial: "90", pattern: "### ### ## ##", length: [10, 10] }, { code: "CN", name: "China", dial: "86", pattern: "### #### ####", length: [11, 11] }, { code: "HK", name: "Hong Kong", dial: "852", pattern: "#### ####", length: [8, 8] }, { code: "TW", name: "Taiwan", dial: "886", pattern: "### ### ###", length: [9, 9] }, { code: "KR", name: "South Korea", dial: "82", pattern: "##-####-####", length: [9, 10] }, { code: "TH", name: "Thailand", dial: "66", pattern: "## ### ####", length: [8, 9] }, { code: "VN", name: "Vietnam", dial: "84", pattern: "## ### ## ##", length: [9, 10] }, { code: "PH", name: "Philippines", dial: "63", pattern: "### ### ####", length: [10, 10] }, { code: "PK", name: "Pakistan", dial: "92", pattern: "### #######", length: [10, 10] }, { code: "BD", name: "Bangladesh", dial: "880", pattern: "####-######", length: [10, 10] }, { code: "AE", name: "United Arab Emirates", dial: "971", pattern: "## ### ####", length: [8, 9] }, { code: "SA", name: "Saudi Arabia", dial: "966", pattern: "## ### ####", length: [9, 9] }, { code: "EG", name: "Egypt", dial: "20", pattern: "### ### ####", length: [10, 10] }, { code: "NG", name: "Nigeria", dial: "234", pattern: "### ### ####", length: [10, 10] }, { code: "ZA", name: "South Africa", dial: "27", pattern: "## ### ####", length: [9, 9] }, { code: "KE", name: "Kenya", dial: "254", pattern: "### ######", length: [9, 9] }, { code: "AR", name: "Argentina", dial: "54", pattern: "## ####-####", length: [10, 11] }, { code: "CO", name: "Colombia", dial: "57", pattern: "### ### ####", length: [10, 10] }, { code: "CL", name: "Chile", dial: "56", pattern: "# #### ####", length: [9, 9] }, { code: "NZ", name: "New Zealand", dial: "64", pattern: "## ### ####", length: [8, 10] }, ]; /** Emoji flag from an ISO alpha-2 code via regional indicator symbols. */ export function countryFlag(code: string) { const upper = code.toUpperCase(); if (!/^[A-Z]{2}$/.test(upper)) return ""; return String.fromCodePoint( ...[...upper].map((char) => 0x1f1e6 + char.charCodeAt(0) - 65), ); } /** Groups national digits by the country pattern; overflow digits trail ungrouped. */ export function formatNational(digits: string, pattern: string) { let out = ""; let index = 0; for (const char of pattern) { if (index >= digits.length) break; if (char === "#") { out += digits[index]; index += 1; } else { out += char; } } return out + digits.slice(index); } function normalizeNational(digits: string, country: PhoneCountry) { const trimmed = !country.keepLeadingZero && digits.startsWith("0") ? digits.slice(1) : digits; return trimmed.slice(0, country.length[1]); } /** Finds the country for a full international number (longest dial code wins). */ export function detectCountry( international: string, countries: PhoneCountry[], prefer?: string, ): { country: PhoneCountry; national: string } | null { const digits = international.replace(/\D/g, ""); let best: PhoneCountry | null = null; for (const country of countries) { if (!digits.startsWith(country.dial)) continue; if ( !best || country.dial.length > best.dial.length || (country.dial === best.dial && country.code === prefer) ) best = country; } if (!best) return null; return { country: best, national: normalizeNational(digits.slice(best.dial.length), best), }; } function isValidNational(national: string, country: PhoneCountry) { return ( national.length >= country.length[0] && national.length <= country.length[1] ); } function toE164(national: string, country: PhoneCountry) { return national ? `+${country.dial}${national}` : ""; } const SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] }; export interface PhoneInputProps { /** Controlled E.164 value, e.g. "+6281234567890". Empty string for none. */ value?: string; defaultValue?: string; /** E.164 string (empty when there are no digits) plus parsed details. */ onChange?: (e164: string, meta: PhoneValueMeta) => void; /** Controlled ISO country code. */ country?: string; /** Initial ISO country code. Default "ID". */ defaultCountry?: string; onCountryChange?: (country: PhoneCountry) => void; /** Replace the built-in country list. */ countries?: PhoneCountry[]; label?: string; placeholder?: string; /** Override the built-in "invalid number" message, or force an error. */ error?: string | boolean; hint?: string; disabled?: boolean; name?: string; id?: string; "aria-label"?: string; onBlur?: () => void; className?: string; } /** * International phone field: searchable country picker with emoji flags, a * national number formatted as you type (caret preserved), E.164 output, and a * blur-time length check that shakes the field when the number is invalid. */ export function PhoneInput({ value: valueProp, defaultValue, onChange, country: countryProp, defaultCountry = "ID", onCountryChange, countries = PHONE_COUNTRIES, label, placeholder, error: errorProp, hint, disabled = false, name, id: idProp, "aria-label": ariaLabel, onBlur, className, }: PhoneInputProps) { const reduce = useReducedMotion(); const uid = useId(); const inputId = idProp ?? `${uid}-input`; const messageId = `${uid}-message`; const fallback = countries[0] ?? PHONE_COUNTRIES[0]; const findCountry = useCallback( (code: string | undefined) => countries.find((c) => c.code === code?.toUpperCase()), [countries], ); const [internalCountry, setInternalCountry] = useState(() => { const initial = defaultValue ? detectCountry(defaultValue, countries, defaultCountry)?.country.code : undefined; return initial ?? defaultCountry; }); const countryCode = countryProp ?? internalCountry; const country = findCountry(countryCode) ?? fallback; const [internalNational, setInternalNational] = useState(() => defaultValue ? (detectCountry(defaultValue, countries, defaultCountry)?.national ?? "") : "", ); const controlled = valueProp !== undefined; const national = controlled ? valueProp ? (detectCountry(valueProp, [country], country.code)?.national ?? detectCountry(valueProp, countries, country.code)?.national ?? "") : "" : internalNational; const formatted = formatNational(national, country.pattern); const valid = isValidNational(national, country); const [touched, setTouched] = useState(false); const builtInError = touched && national.length > 0 && !valid ? `Enter a valid ${country.name} number` : null; const errorMessage = typeof errorProp === "string" ? errorProp : builtInError; const hasError = Boolean(errorProp) || Boolean(builtInError); const fieldRef = useRef(null); const inputRef = useRef(null); const pendingCaret = useRef(null); // Restore the caret after the formatted value re-renders: the caret sits // after the same count of digits it was after before formatting. useLayoutEffect(() => { const digitsBefore = pendingCaret.current; const input = inputRef.current; if (digitsBefore === null || !input) return; pendingCaret.current = null; let position = 0; let seen = 0; while (position < formatted.length && seen < digitsBefore) { if (/\d/.test(formatted[position])) seen += 1; position += 1; } if (document.activeElement === input) input.setSelectionRange(position, position); }); const shake = useCallback(() => { if (fieldRef.current && !reduce) animate(fieldRef.current, SHAKE, { duration: 0.4, ease: EASE_OUT }); }, [reduce]); const emit = (nextNational: string, nextCountry: PhoneCountry) => { if (!controlled) setInternalNational(nextNational); onChange?.(toE164(nextNational, nextCountry), { country: nextCountry, national: nextNational, valid: isValidNational(nextNational, nextCountry), }); }; const changeCountry = (next: PhoneCountry) => { if (countryProp === undefined) setInternalCountry(next.code); onCountryChange?.(next); }; const commitDigits = ( rawDigits: string, digitsBeforeCaret: number, target: PhoneCountry, ) => { const next = normalizeNational(rawDigits, target); const stripped = !target.keepLeadingZero && rawDigits.startsWith("0") ? 1 : 0; pendingCaret.current = Math.min( Math.max(0, digitsBeforeCaret - stripped), next.length, ); emit(next, target); }; const applyInternational = (text: string) => { const detected = detectCountry(text, countries, country.code); if (!detected) { shake(); return; } if (detected.country.code !== country.code) changeCountry(detected.country); pendingCaret.current = detected.national.length; emit(detected.national, detected.country); }; const onInputChange = (raw: string, caret: number) => { if (raw.trim().startsWith("+")) { applyInternational(raw); return; } const digitsBefore = raw.slice(0, caret).replace(/\D/g, "").length; commitDigits(raw.replace(/\D/g, ""), digitsBefore, country); }; const onPaste = (event: ClipboardEvent) => { const text = event.clipboardData.getData("text").trim(); if (!text.startsWith("+") && !text.startsWith("00")) return; event.preventDefault(); applyInternational(text.startsWith("00") ? `+${text.slice(2)}` : text); }; // Backspace/Delete next to a separator removes the neighbouring digit // instead of the separator (which formatting would just put back). const onInputKeyDown = (event: KeyboardEvent) => { const input = event.currentTarget; const start = input.selectionStart ?? 0; const end = input.selectionEnd ?? 0; if (start !== end) return; if (event.key === "Backspace" && start > 0 && /\D/.test(formatted[start - 1])) { event.preventDefault(); const digitsBefore = formatted.slice(0, start).replace(/\D/g, "").length; if (digitsBefore === 0) return; const next = national.slice(0, digitsBefore - 1) + national.slice(digitsBefore); pendingCaret.current = digitsBefore - 1; emit(next, country); } else if ( event.key === "Delete" && start < formatted.length && /\D/.test(formatted[start]) ) { event.preventDefault(); const digitsBefore = formatted.slice(0, start).replace(/\D/g, "").length; const next = national.slice(0, digitsBefore) + national.slice(digitsBefore + 1); pendingCaret.current = digitsBefore; emit(next, country); } }; const message = errorMessage ?? hint; const examplePlaceholder = placeholder ?? formatNational("1".repeat(country.length[1]), country.pattern).replace(/1/g, "0"); return (
{label ? ( ) : null}
{ changeCountry(next); const renormalized = normalizeNational(national, next); if (renormalized !== national || next.code !== country.code) emit(renormalized, next); requestAnimationFrame(() => inputRef.current?.focus()); }} /> +{country.dial} onInputChange( event.target.value, event.target.selectionStart ?? event.target.value.length, ) } onKeyDown={onInputKeyDown} onPaste={onPaste} onBlur={() => { setTouched(true); if (national.length > 0 && !valid) shake(); onBlur?.(); }} className="h-full min-w-0 flex-1 bg-transparent pr-4 pl-1.5 text-base tabular-nums text-foreground caret-foreground outline-none placeholder:text-muted-foreground/50 disabled:cursor-not-allowed" />
{message ? ( {message} ) : null}
); } function CountryPicker({ countries, country, disabled, reduce, onSelect, }: { countries: PhoneCountry[]; country: PhoneCountry; disabled: boolean; reduce: boolean; onSelect: (country: PhoneCountry) => void; }) { const uid = useId(); const listId = `${uid}-list`; const rootRef = useRef(null); const buttonRef = useRef(null); const searchRef = useRef(null); const listRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const filtered = useMemo(() => { const q = query.trim().toLowerCase().replace(/^\+/, ""); if (!q) return countries; return countries.filter( (c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase() === q || c.dial.startsWith(q), ); }, [countries, query]); const close = useCallback((restoreFocus = false) => { setOpen(false); setQuery(""); if (restoreFocus) buttonRef.current?.focus(); }, []); const dismiss = useCallback(() => close(false), [close]); useDismiss(open, dismiss, rootRef, { escape: false }); const openList = () => { if (disabled) return; const index = countries.findIndex((c) => c.code === country.code); setActive(Math.max(0, index)); setOpen(true); }; useEffect(() => { if (!open) return; requestAnimationFrame(() => searchRef.current?.focus()); }, [open]); const activeIndex = Math.min(active, filtered.length - 1); const activeCountry = filtered[activeIndex]; useEffect(() => { if (!open || !activeCountry) return; const list = listRef.current; const item = list?.querySelector( `[data-code="${activeCountry.code}"]`, ); if (!list || !item) return; if (item.offsetTop < list.scrollTop) list.scrollTop = item.offsetTop - 4; else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight + 4; }, [activeCountry, open]); const pick = (next: PhoneCountry) => { close(false); onSelect(next); }; const onSearchKeyDown = (event: KeyboardEvent) => { switch (event.key) { case "ArrowDown": event.preventDefault(); setActive((a) => (filtered.length ? (Math.min(a, filtered.length - 1) + 1) % filtered.length : 0)); return; case "ArrowUp": event.preventDefault(); setActive((a) => filtered.length ? (Math.min(a, filtered.length - 1) - 1 + filtered.length) % filtered.length : 0, ); return; case "Home": event.preventDefault(); setActive(0); return; case "End": event.preventDefault(); setActive(Math.max(0, filtered.length - 1)); return; case "Enter": event.preventDefault(); if (activeCountry) pick(activeCountry); return; case "Escape": event.preventDefault(); event.stopPropagation(); close(true); return; case "Tab": close(false); return; } }; return (
{open ? (
{ setQuery(event.target.value); setActive(0); }} onKeyDown={onSearchKeyDown} className="h-10 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" />
{filtered.length === 0 ? (

No countries found.

) : ( filtered.map((item, index) => { const isActive = index === activeIndex; const selected = item.code === country.code; return ( ); }) )}
) : null}
); }