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 { getCountries, getCountryCallingCode, parsePhoneNumberFromString, type CountryCode, } from 'libphonenumber-js'; import { CountryFlag, getCountryName, resolveLang } from './CountrySelect'; import { exceedsMaxLength, formatNational, maxNationalLength, 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 focus leaves a field that has digits in it — wire to the * form's `trigger`. A blank field is left to submit-time validation, like * the form's other inputs, so tabbing past it doesn't raise an error. */ 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(() => formatNational(toNationalDigits(value, initialCountry), initialCountry), ); // Mantine keeps the selected label ("+251") as the search text, so typing // would search for "+251u". Cleared while the dropdown is open instead. const [search, setSearch] = useState(''); // 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(formatNational(toNationalDigits(value, next), 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) { // Keystroke past the longest possible number is ignored, not stored. if (exceedsMaxLength(digits, forCountry)) return; // Once the number is valid, show the true national part: someone who // types a trunk prefix (0911111111) or the country code (251911111111) // shouldn't end up with it doubled beside the "+251" selector. Not // before: a partial number can parse too, and rewriting it mid-typing // makes digits vanish under the caret. const parsed = parsePhoneNumberFromString(digits, forCountry); setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry)); push(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(formatNational(parsed.nationalNumber, parsed.country)); push(parsed.number); return; } } applyDigits(nextNationalDigits(text, national), country); } function handleCountryChange(next: string | null) { if (!next) return; const code = next as CountryCode; setCountry(code); // A number carried over from a longer plan is cut to fit the new one. applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code); } return ( handleText(e.currentTarget.value)} // Blur of the whole field, not of the number box: moving into the // country select mustn't validate a half-typed number. wrapperProps={{ onBlur: (e: React.FocusEvent) => { if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.(); }, }} leftSectionWidth={92} leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'} leftSection={