import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core'; import { AsYouType, getCountries, getCountryCallingCode, parsePhoneNumberFromString, type CountryCode, } from 'libphonenumber-js'; import { CountryFlag, getCountryName, resolveLang } from './CountrySelect'; import { nextNationalDigits, toE164, toNationalDigits } from './phone'; // Static list, computed once at module load — same as CountrySelect's dataset. const COUNTRY_CODES = getCountries(); type CountryOption = ComboboxItem & { name: string }; // Search by country name ("united"), dial code ("+1") or ISO prefix ("us") — // the closed control's label alone ("+1") isn't enough to find a country. const filterCountries: SelectProps['filter'] = ({ options, search }) => { const q = search.trim().toLowerCase(); if (!q) return options; return (options as CountryOption[]).filter( (o) => o.name.toLowerCase().includes(q) || o.label.toLowerCase().includes(q) || o.value.toLowerCase().startsWith(q), ); }; const renderCountryOption: SelectProps['renderOption'] = ({ option }) => { const o = option as CountryOption; return ( {o.name} {o.label} ); }; export interface PhoneInputProps { /** E.164 (`+14155552671`), or '' when empty. */ value: string; onChange: (value: string) => void; /** Fired when the number box loses focus — wire to the form's `trigger`. */ onBlur?: () => void; label?: React.ReactNode; placeholder?: string; description?: React.ReactNode; error?: React.ReactNode; required?: boolean; /** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */ withAsterisk?: boolean; disabled?: boolean; readOnly?: boolean; } /** * International phone entry: a searchable country/dial-code select beside a * national-number text box, WhatsApp/Telegram style. Controlled — * `value`/`onChange` carry the E.164 string. * * The typed digits live in local state rather than being re-derived from * `value` on every render: an incomplete number doesn't parse, so deriving * would blank the box between keystrokes. */ export function PhoneInput({ value, onChange, onBlur, label, placeholder, description, error, required, withAsterisk, disabled, readOnly, }: PhoneInputProps) { const { t, i18n } = useTranslation(); const lang = resolveLang(i18n.language); const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET'; const [country, setCountry] = useState(initialCountry); const [national, setNational] = useState(() => new AsYouType(initialCountry).input(toNationalDigits(value, initialCountry)), ); // What this field last pushed upward, so an echo of our own value isn't // mistaken for the parent resetting the form. const emitted = useRef(value); useEffect(() => { if (value === emitted.current) return; const parsedCountry = parsePhoneNumberFromString(value || '')?.country; const next = parsedCountry ?? country; if (parsedCountry) setCountry(parsedCountry); setNational(new AsYouType(next).input(toNationalDigits(value, next))); emitted.current = value; // Adopting an outside change only — `country` is state written here, so // re-running on it would fight the user's typing. // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); const countryOptions = useMemo( () => COUNTRY_CODES.map((code) => ({ value: code, label: `+${getCountryCallingCode(code)}`, name: getCountryName(code, lang), })).sort((a, b) => a.name.localeCompare(b.name, lang)), [lang], ); function push(next: string) { emitted.current = next; onChange(next); } function applyDigits(digits: string, forCountry: CountryCode) { // Once the digits parse, show the true national number: someone who // types a trunk prefix (0911111111) or the country code (251911111111) // shouldn't end up with it doubled beside the "+251" selector. const parsed = parsePhoneNumberFromString(digits, forCountry); setNational(new AsYouType(forCountry).input(parsed?.nationalNumber ?? digits)); push(parsed?.number ?? toE164(digits, forCountry)); } function handleText(text: string) { // A full `+` arriving at once (paste, autofill, or a test // driver's `.fill()`) is parsed standalone and switches the country, // rather than being read under whatever country was already selected. const trimmed = text.trim(); if (trimmed.startsWith('+')) { const parsed = parsePhoneNumberFromString(trimmed); if (parsed?.country) { setCountry(parsed.country); setNational(new AsYouType(parsed.country).input(parsed.nationalNumber)); push(parsed.number); return; } } applyDigits(nextNationalDigits(text, national), country); } function handleCountryChange(next: string | null) { if (!next) return; const code = next as CountryCode; setCountry(code); applyDigits(national.replace(/\D/g, ''), code); } return ( handleText(e.currentTarget.value)} onBlur={onBlur} leftSectionWidth={92} leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'} leftSection={