"use client";
import * as React from "react";
/* --------------------------------- dataset --------------------------------- */
export interface PhoneCountry {
/** ISO 3166-1 alpha-2 region code, e.g. "MY". */
code: string;
/** Country calling code without the "+", e.g. "60". */
dial: string;
/**
* National display formats where "#" is a digit. The first pattern with room for the typed digits
* is used, so list shorter formats first. Digits beyond the longest pattern are appended as typed.
*/
patterns: string[];
/** National trunk prefix dropped when typed in front of a number (e.g. the "0" in "07700 900123"). */
trunk?: string;
}
/**
* About thirty common regions with simplified national formats. This is a display aid, NOT a
* numbering-plan database or a validation library: real plans vary by area and number type. Pass
* your own `countries` (or use a library such as libphonenumber) when correctness matters.
* Regions sharing a calling code are listed in priority order (US before CA for +1).
*/
export const PHONE_COUNTRIES: PhoneCountry[] = [
{ code: "US", dial: "1", patterns: ["(###) ###-####"] },
{ code: "CA", dial: "1", patterns: ["(###) ###-####"] },
{ code: "MX", dial: "52", patterns: ["## #### ####"] },
{ code: "BR", dial: "55", patterns: ["## ####-####", "## #####-####"] },
{ code: "AR", dial: "54", patterns: ["## ####-####"] },
{ code: "GB", dial: "44", patterns: ["#### ######"], trunk: "0" },
{ code: "IE", dial: "353", patterns: ["## ### ####"], trunk: "0" },
{ code: "FR", dial: "33", patterns: ["# ## ## ## ##"], trunk: "0" },
{ code: "DE", dial: "49", patterns: ["### #######", "#### #######"], trunk: "0" },
{ code: "NL", dial: "31", patterns: ["# ########"], trunk: "0" },
{ code: "BE", dial: "32", patterns: ["### ## ## ##"], trunk: "0" },
{ code: "CH", dial: "41", patterns: ["## ### ## ##"], trunk: "0" },
{ code: "ES", dial: "34", patterns: ["### ### ###"] },
{ code: "PT", dial: "351", patterns: ["### ### ###"] },
{ code: "IT", dial: "39", patterns: ["### ### ####"] },
{ code: "SE", dial: "46", patterns: ["## ### ## ##"], trunk: "0" },
{ code: "NO", dial: "47", patterns: ["### ## ###"] },
{ code: "DK", dial: "45", patterns: ["## ## ## ##"] },
{ code: "PL", dial: "48", patterns: ["### ### ###"] },
{ code: "AE", dial: "971", patterns: ["## ### ####"], trunk: "0" },
{ code: "SA", dial: "966", patterns: ["## ### ####"], trunk: "0" },
{ code: "ZA", dial: "27", patterns: ["## ### ####"], trunk: "0" },
{ code: "NG", dial: "234", patterns: ["### ### ####"], trunk: "0" },
{ code: "IN", dial: "91", patterns: ["##### #####"], trunk: "0" },
{ code: "MY", dial: "60", patterns: ["##-### ####", "##-#### ####"], trunk: "0" },
{ code: "SG", dial: "65", patterns: ["#### ####"] },
{ code: "ID", dial: "62", patterns: ["###-###-####", "###-####-####"], trunk: "0" },
{ code: "TH", dial: "66", patterns: ["## ### ####"], trunk: "0" },
{ code: "PH", dial: "63", patterns: ["### ### ####"], trunk: "0" },
{ code: "VN", dial: "84", patterns: ["## ### ## ##"], trunk: "0" },
{ code: "HK", dial: "852", patterns: ["#### ####"] },
{ code: "CN", dial: "86", patterns: ["### #### ####"], trunk: "0" },
{ code: "JP", dial: "81", patterns: ["## #### ####"], trunk: "0" },
{ code: "KR", dial: "82", patterns: ["## #### ####"], trunk: "0" },
{ code: "AU", dial: "61", patterns: ["### ### ###"], trunk: "0" },
{ code: "NZ", dial: "64", patterns: ["## ### ####"], trunk: "0" },
];
/* ---------------------------------- types ---------------------------------- */
export interface PhoneInputValue {
/** ISO region code of the selected country. */
country: string;
/** The national number as displayed (formatted when `formatAsYouType` is on). */
national: string;
/** "+<dial><digits>", or "" while no digits are entered. */
e164: string;
/** True once the digits reach the length of the country's shortest pattern. */
complete: boolean;
}
export interface PhoneInputProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/** Controlled number in E.164 ("+60123456789"). */
value?: string;
/** Initial number in E.164 when uncontrolled. */
defaultValue?: string;
/** Region selected initially when the number does not imply one. */
defaultCountry?: string;
/** Called after every edit with the country, formatted national number and E.164 string. */
onChange?: (value: PhoneInputValue) => void;
/** The region list. Defaults to `PHONE_COUNTRIES` (≈30 common regions, not exhaustive). */
countries?: PhoneCountry[];
/** Visible label, rendered as a real <label>. */
label?: string;
/** Helper text below the field (linked with aria-describedby). */
hint?: React.ReactNode;
/** External error message; overrides the built-in checks. */
error?: string;
/** BCP 47 locale for region names (Intl.DisplayNames) and sorting. */
locale?: string;
/** Insert spaces, dashes and brackets while typing. */
formatAsYouType?: boolean;
/** Show the calling code next to the region badge. */
showDialCode?: boolean;
/** Focus ring, caret and selection colour. */
accent?: string;
disabled?: boolean;
required?: boolean;
/** When set, a hidden input submits the E.164 value under this name. */
name?: string;
/** id of the text input (generated when omitted). */
inputId?: string;
}
interface PhoneState {
country: string;
digits: string;
}
/* --------------------------------- helpers --------------------------------- */
const MAX_E164_DIGITS = 15;
const onlyDigits = (s: string) => s.replace(/\D/g, "");
const countHashes = (pattern: string) => pattern.split("#").length - 1;
function findCountry(countries: PhoneCountry[], code: string) {
return countries.find((c) => c.code === code) ?? countries[0];
}
function maxDigits(country: PhoneCountry) {
return MAX_E164_DIGITS - country.dial.length;
}
function minDigits(country: PhoneCountry) {
return Math.min(...country.patterns.map(countHashes));
}
/** Formats national digits with the first pattern that fits; overflow digits are appended. */
function formatNational(digits: string, country: PhoneCountry, enabled: boolean) {
if (!enabled || !digits) return digits;
const pattern = country.patterns.find((p) => countHashes(p) >= digits.length) ?? country.patterns[country.patterns.length - 1];
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);
}
/** A believable placeholder from the first pattern: "##-### ####" → "12-345 6789". */
function examplePlaceholder(country: PhoneCountry, enabled: boolean) {
let n = 1;
const digits = country.patterns[0].replace(/#/g, () => String(n++ % 10));
return enabled ? digits : onlyDigits(digits);
}
/** Strips one trunk prefix ("0") from the start of national digits. */
function dropTrunk(digits: string, country: PhoneCountry) {
return country.trunk && digits.startsWith(country.trunk) ? digits.slice(country.trunk.length) : digits;
}
type IntlParse = { kind: "match"; state: PhoneState } | { kind: "pending" } | { kind: "unknown" };
/**
* Resolves "+<digits>" against the calling codes. Codes are prefix-free (ITU E.164), so at most one
* matches; regions sharing it prefer `preferred`, then list order.
*/
function parseInternational(text: string, countries: PhoneCountry[], preferred: string): IntlParse {
const digits = onlyDigits(text);
const matches = countries.filter((c) => digits.startsWith(c.dial));
if (matches.length) {
const best = matches.reduce((a, b) => (b.dial.length > a.dial.length ? b : a));
const sameCode = matches.filter((c) => c.dial === best.dial);
const country = sameCode.find((c) => c.code === preferred) ?? sameCode[0];
const rest = dropTrunk(digits.slice(country.dial.length), country).slice(0, maxDigits(country));
return { kind: "match", state: { country: country.code, digits: rest } };
}
return countries.some((c) => c.dial.startsWith(digits)) ? { kind: "pending" } : { kind: "unknown" };
}
function initialState(e164: string | undefined, countries: PhoneCountry[], defaultCountry: string): PhoneState {
const fallback = findCountry(countries, defaultCountry).code;
if (e164) {
const parsed = parseInternational(e164, countries, fallback);
if (parsed.kind === "match") return parsed.state;
}
return { country: fallback, digits: "" };
}
function toE164(state: PhoneState, countries: PhoneCountry[]) {
return state.digits ? `+${findCountry(countries, state.country).dial}${state.digits}` : "";
}
/** Caret index in `display` just after the `n`th digit. */
function caretAfterDigits(display: string, n: number) {
if (n <= 0) {
const first = display.search(/\d/);
return first === -1 ? display.length : Math.min(first, display.length);
}
let seen = 0;
for (let i = 0; i < display.length; i++) {
if (/\d/.test(display[i]) && ++seen === n) return i + 1;
}
return display.length;
}
function regionNamer(locale: string) {
let names: Intl.DisplayNames | null = null;
try {
names = new Intl.DisplayNames([locale], { type: "region" });
} catch {
names = null;
}
// Collapse exotic spaces so server and browser ICU builds render the same text.
return (code: string) => (names?.of(code) ?? code).replace(/[ ]/g, " ");
}
/** Region badge: the ISO code in a small mono frame (no emoji flags, no images). */
function RegionBadge({ code, active = false }: { code: string; active?: boolean }) {
return (
<span
aria-hidden="true"
className={`inline-flex h-[18px] min-w-[26px] shrink-0 items-center justify-center rounded-[4px] border px-1 font-mono text-[10px] font-semibold leading-none tracking-[0.06em] ${
active
? "border-[var(--pi-accent)] bg-[var(--pi-accent)] text-zinc-950"
: "border-zinc-900/15 bg-zinc-900/[0.03] text-zinc-700 dark:border-white/15 dark:bg-white/[0.05] dark:text-zinc-200"
}`}
>
{code}
</span>
);
}
/* -------------------------------- component -------------------------------- */
/**
* An international phone field: a searchable country listbox (ARIA combobox) beside a tel input
* that formats as you type while keeping the caret on the same digit. Typing, pasting or
* autofilling a "+" number selects its country. Reports { country, national, e164 } on change.
*/
export function PhoneInput({
value,
defaultValue,
defaultCountry = "MY",
onChange,
countries = PHONE_COUNTRIES,
label = "Phone number",
hint,
error,
locale = "en",
formatAsYouType = true,
showDialCode = true,
accent = "#FF7448",
disabled = false,
required = false,
name,
inputId: inputIdProp,
className = "",
style,
...props
}: PhoneInputProps) {
const uid = React.useId();
const inputId = inputIdProp ?? `pi-${uid}`;
const hintId = `${inputId}-hint`;
const errorId = `${inputId}-error`;
const popoverId = `${inputId}-countries`;
const listboxId = `${inputId}-listbox`;
const optionId = (code: string) => `${inputId}-opt-${code}`;
const [state, setState] = React.useState<PhoneState>(() => initialState(value ?? defaultValue, countries, defaultCountry));
/** Raw text while a "+" number is being typed and its calling code is not resolved yet. */
const [draft, setDraft] = React.useState<string | null>(null);
const [draftError, setDraftError] = React.useState<string | null>(null);
const [touched, setTouched] = React.useState(false);
// Controlled mode: adopt an outside value that differs from what this field last produced.
const [syncedValue, setSyncedValue] = React.useState(value);
if (value !== undefined && value !== syncedValue) {
setSyncedValue(value);
if (value !== toE164(state, countries)) {
setState(initialState(value, countries, state.country));
setDraft(null);
setDraftError(null);
}
}
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [activeCode, setActiveCode] = React.useState<string | null>(null);
const [announcement, setAnnouncement] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const fieldRef = React.useRef<HTMLDivElement>(null);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const popoverRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
/** Digits that should sit before the caret after the next render (set by edit handlers). */
const pendingCaret = React.useRef<number | null>(null);
/** Where focus goes when the country popover closes. */
const focusAfterClose = React.useRef<"trigger" | "input">("trigger");
const country = findCountry(countries, state.country);
const nameOf = regionNamer(locale);
const countryName = nameOf(country.code);
const national = formatNational(state.digits, country, formatAsYouType);
const display = draft ?? national;
const e164 = toE164(state, countries);
const complete = state.digits.length >= minDigits(country);
const builtInError =
draftError ?? (touched && state.digits.length > 0 && !complete ? `Enter a complete number for ${countryName}.` : null);
const errorText = error ?? builtInError;
const describedBy = [hint ? hintId : null, errorText ? errorId : null].filter(Boolean).join(" ") || undefined;
/* --------------------------------- commits -------------------------------- */
const commit = (next: PhoneState) => {
setState(next);
setDraft(null);
setDraftError(null);
const c = findCountry(countries, next.country);
onChange?.({
country: c.code,
national: formatNational(next.digits, c, formatAsYouType),
e164: toE164(next, countries),
complete: next.digits.length >= minDigits(c),
});
};
const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));
// Restore the caret onto the same digit after formatting re-renders the text.
React.useLayoutEffect(() => {
const input = inputRef.current;
const n = pendingCaret.current;
if (!input || n === null) return;
pendingCaret.current = null;
if (document.activeElement !== input) return;
const at = caretAfterDigits(input.value, n);
input.setSelectionRange(at, at);
});
const handleInput = (event: React.ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
const caret = event.target.selectionStart ?? raw.length;
// International entry: "+…" typed, pasted or autofilled (autocomplete="tel" gives "+60 12-…").
if (raw.trimStart().startsWith("+")) {
const parsed = parseInternational(raw, countries, state.country);
if (parsed.kind === "match") {
const next = parsed.state;
const c = findCountry(countries, next.country);
const before = onlyDigits(raw.slice(0, caret)).length - c.dial.length;
pendingCaret.current = Math.max(0, Math.min(next.digits.length, before));
if (next.country !== state.country) announce(`${nameOf(c.code)}, +${c.dial}, selected from the number.`);
commit(next);
} else {
setDraft(raw.replace(/[^\d+\s()-]/g, ""));
setDraftError(parsed.kind === "unknown" ? "Unknown country calling code." : null);
}
return;
}
let digits = onlyDigits(raw);
let before = onlyDigits(raw.slice(0, caret)).length;
const trimmed = dropTrunk(digits, country);
if (trimmed.length !== digits.length) {
before = Math.max(0, before - (digits.length - trimmed.length));
digits = trimmed;
}
digits = digits.slice(0, maxDigits(country));
pendingCaret.current = Math.min(before, digits.length);
commit({ country: state.country, digits });
};
const removeDigitAt = (index: number) => {
const digits = state.digits.slice(0, index) + state.digits.slice(index + 1);
pendingCaret.current = index;
commit({ country: state.country, digits });
};
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (draft !== null || !formatAsYouType) return;
const input = event.currentTarget;
const start = input.selectionStart ?? 0;
if (start !== input.selectionEnd) return;
// Deleting a separator would be undone by formatting, so delete the neighbouring digit instead.
if (event.key === "Backspace" && start > 0 && !/\d/.test(input.value[start - 1])) {
const n = onlyDigits(input.value.slice(0, start)).length;
if (n > 0) {
event.preventDefault();
removeDigitAt(n - 1);
}
} else if (event.key === "Delete" && start < input.value.length && !/\d/.test(input.value[start])) {
const n = onlyDigits(input.value.slice(0, start)).length;
if (n < state.digits.length) {
event.preventDefault();
removeDigitAt(n);
}
} else if (event.key === "ArrowDown" && event.altKey) {
event.preventDefault();
popoverRef.current?.showPopover();
}
};
/* ------------------------------ country list ------------------------------ */
const collator = new Intl.Collator(locale);
const q = query.trim().toLowerCase().replace(/^\+/, "");
const sorted = [...countries].sort((a, b) => collator.compare(nameOf(a.code), nameOf(b.code)));
const filtered = q
? sorted.filter((c) => nameOf(c.code).toLowerCase().includes(q) || c.code.toLowerCase() === q || c.dial.startsWith(q))
: sorted;
const active = filtered.find((c) => c.code === activeCode) ?? filtered[0] ?? null;
const scrollToOption = (code: string) => {
const list = listRef.current;
const el = list?.querySelector<HTMLElement>(`#${CSS.escape(optionId(code))}`);
if (!list || !el) return;
// Scroll the listbox only — scrollIntoView would move the page too.
if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop - 4;
else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight)
list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 4;
};
const setActive = (code: string) => {
setActiveCode(code);
requestAnimationFrame(() => scrollToOption(code));
};
const closePopover = () => {
const pop = popoverRef.current;
if (pop?.matches(":popover-open")) pop.hidePopover();
};
const chooseCountry = (code: string) => {
const c = findCountry(countries, code);
const digits = dropTrunk(state.digits, c).slice(0, maxDigits(c));
pendingCaret.current = digits.length;
commit({ country: c.code, digits });
announce(`${nameOf(c.code)}, +${c.dial}, selected.`);
focusAfterClose.current = "input";
closePopover();
};
const position = React.useCallback(() => {
const pop = popoverRef.current;
const field = fieldRef.current;
if (!pop || !field) return;
const rect = field.getBoundingClientRect();
const height = pop.offsetHeight;
const width = pop.offsetWidth;
const below = rect.bottom + 6;
const top = below + height > window.innerHeight - 8 && rect.top - height - 6 > 8 ? rect.top - height - 6 : below;
pop.style.top = `${Math.round(top)}px`;
pop.style.left = `${Math.round(Math.min(Math.max(rect.left, 8), Math.max(window.innerWidth - width - 8, 8)))}px`;
}, []);
React.useEffect(() => {
const pop = popoverRef.current;
if (!pop) return;
const onToggle = (event: Event) => {
const next = (event as ToggleEvent).newState === "open";
setOpen(next);
if (next) {
focusAfterClose.current = "trigger";
setQuery("");
setActiveCode(state.country);
requestAnimationFrame(() => {
searchRef.current?.focus({ preventScroll: true });
scrollToOption(state.country);
});
} else if (focusAfterClose.current === "input") {
inputRef.current?.focus({ preventScroll: true });
} else if (pop.contains(document.activeElement) || document.activeElement === document.body) {
triggerRef.current?.focus({ preventScroll: true });
}
};
pop.addEventListener("toggle", onToggle);
return () => pop.removeEventListener("toggle", onToggle);
});
// Keep the popover attached to the field while it is open.
React.useEffect(() => {
if (!open) return;
position();
window.addEventListener("scroll", position, { capture: true, passive: true });
window.addEventListener("resize", position);
return () => {
window.removeEventListener("scroll", position, { capture: true });
window.removeEventListener("resize", position);
};
}, [open, position]);
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const index = active ? filtered.indexOf(active) : -1;
const move = (to: number) => {
const c = filtered[Math.max(0, Math.min(filtered.length - 1, to))];
if (c) setActive(c.code);
};
switch (event.key) {
case "ArrowDown":
event.preventDefault();
move(index + 1);
break;
case "ArrowUp":
event.preventDefault();
move(index - 1);
break;
case "PageDown":
event.preventDefault();
move(index + 6);
break;
case "PageUp":
event.preventDefault();
move(index - 6);
break;
case "Home":
event.preventDefault();
move(0);
break;
case "End":
event.preventDefault();
move(filtered.length - 1);
break;
case "Enter":
event.preventDefault();
if (active) chooseCountry(active.code);
break;
case "Escape":
// Close only this layer; keep the page (and any surrounding dialog) untouched.
event.preventDefault();
event.stopPropagation();
closePopover();
break;
case "Tab":
closePopover();
break;
}
};
const rootStyle = { ["--pi-accent" as string]: accent, ...style };
return (
<div className={`flex w-full max-w-sm flex-col gap-1.5 ${className}`} style={rootStyle} {...props}>
<label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
<div
ref={fieldRef}
role="group"
aria-label={label}
data-invalid={errorText ? "" : undefined}
className={`group/pi flex h-11 items-stretch rounded-lg border bg-white transition-[border-color,box-shadow] duration-150 dark:bg-white/[0.04] ${
errorText
? "border-red-500/70 dark:border-red-400/70"
: "border-zinc-900/15 focus-within:border-[var(--pi-accent)] dark:border-white/15 dark:focus-within:border-[var(--pi-accent)]"
} focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--pi-accent)_22%,transparent)] ${disabled ? "opacity-50" : ""}`}
>
<button
ref={triggerRef}
type="button"
disabled={disabled}
popoverTarget={popoverId}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={popoverId}
aria-label={`Country: ${countryName}, +${country.dial}. Change country`}
className="flex shrink-0 items-center gap-1.5 rounded-l-[7px] border-r border-zinc-900/10 pl-2.5 pr-2 text-zinc-800 outline-hidden transition-colors hover:bg-zinc-900/[0.03] focus-visible:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--pi-accent)] disabled:cursor-not-allowed dark:border-white/10 dark:text-zinc-100 dark:hover:bg-white/[0.04] dark:focus-visible:bg-white/[0.06]"
>
<RegionBadge code={country.code} />
{showDialCode ? <span className="font-mono text-[13px] tabular-nums tracking-[-0.01em]">+{country.dial}</span> : null}
<svg
viewBox="0 0 12 12"
aria-hidden="true"
className={`size-3 text-zinc-400 transition-transform duration-150 motion-reduce:transition-none dark:text-zinc-500 ${open ? "rotate-180" : ""}`}
>
<path d="M3 4.5 6 7.5l3-3" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<input
ref={inputRef}
id={inputId}
type="tel"
inputMode="tel"
autoComplete="tel"
spellCheck={false}
disabled={disabled}
required={required}
placeholder={examplePlaceholder(country, formatAsYouType)}
value={display}
aria-invalid={errorText ? true : undefined}
aria-describedby={describedBy}
onChange={handleInput}
onKeyDown={handleInputKeyDown}
onBlur={() => setTouched(true)}
className="min-w-0 flex-1 bg-transparent px-3 font-mono text-[14px] tabular-nums tracking-[0.01em] text-zinc-900 caret-[var(--pi-accent)] outline-hidden selection:bg-[color-mix(in_oklab,var(--pi-accent)_30%,transparent)] placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-500"
/>
<span
aria-hidden="true"
className={`flex items-center pr-3 transition-opacity duration-150 motion-reduce:transition-none ${complete && !errorText ? "opacity-100" : "opacity-0"}`}
>
<svg viewBox="0 0 16 16" className="size-4 text-[var(--pi-accent)]">
<path d="m3.5 8.5 3 3 6-7" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
<div className="flex min-h-4 flex-col font-mono text-[10px] tracking-[0.04em]">
{hint ? (
<p id={hintId} className={errorText ? "sr-only" : "text-zinc-500 dark:text-zinc-400"}>
{hint}
</p>
) : null}
<p id={errorId} aria-live="polite" className="text-red-600 dark:text-red-400">
{errorText ?? ""}
</p>
</div>
<div
ref={popoverRef}
id={popoverId}
popover="auto"
className="fixed inset-auto m-0 w-[17.5rem] overflow-hidden rounded-xl border border-zinc-900/10 bg-white p-0 text-zinc-900 shadow-[0_24px_48px_-24px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100"
style={rootStyle}
>
{open ? (
<>
<div className="flex items-center gap-2 border-b border-zinc-900/10 px-3 dark:border-white/10">
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-3.5 shrink-0 text-zinc-400">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
<path d="m10.5 10.5 3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
<input
ref={searchRef}
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={active ? optionId(active.code) : undefined}
aria-label="Search countries"
placeholder="Search country or +code"
autoComplete="off"
spellCheck={false}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActiveCode(null);
if (listRef.current) listRef.current.scrollTop = 0;
}}
onKeyDown={handleSearchKeyDown}
className="h-10 min-w-0 flex-1 bg-transparent text-[13px] text-zinc-900 outline-hidden placeholder:text-zinc-400 dark:text-zinc-50 dark:placeholder:text-zinc-500"
/>
</div>
<div
ref={listRef}
id={listboxId}
role="listbox"
aria-label="Countries"
className="max-h-64 overflow-y-auto overscroll-contain p-1"
>
{filtered.map((c) => {
const selected = c.code === state.country;
const isActive = c.code === active?.code;
return (
<div
key={c.code}
id={optionId(c.code)}
role="option"
aria-selected={selected}
onPointerDown={(event) => event.preventDefault()}
onPointerEnter={() => setActiveCode(c.code)}
onClick={() => chooseCountry(c.code)}
className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-[13px] ${
isActive ? "bg-zinc-900/[0.05] dark:bg-white/[0.07]" : ""
}`}
>
<RegionBadge code={c.code} active={selected} />
<span className="min-w-0 flex-1 truncate">{nameOf(c.code)}</span>
<span className="font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">+{c.dial}</span>
</div>
);
})}
{filtered.length === 0 ? (
<p className="px-2 py-6 text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
No matching country
</p>
) : null}
</div>
</>
) : null}
</div>
{name ? <input type="hidden" name={name} value={e164} /> : null}
<span className="sr-only" aria-live="polite">
{announcement}
</span>
</div>
);
}