feat: implement formatNational utility and improve PhoneInput formatting and interaction logic

This commit is contained in:
estifanos
2026-08-20 08:47:28 +00:00
parent 0b03bc45c3
commit caa209306f
3 changed files with 61 additions and 13 deletions

View File

@@ -2,14 +2,13 @@ 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';
import { formatNational, nextNationalDigits, toE164, toNationalDigits } from './phone';
// Static list, computed once at module load — same as CountrySelect's dataset.
const COUNTRY_CODES = getCountries();
@@ -87,8 +86,11 @@ export function PhoneInput({
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
const [country, setCountry] = useState<CountryCode>(initialCountry);
const [national, setNational] = useState(() =>
new AsYouType(initialCountry).input(toNationalDigits(value, initialCountry)),
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.
@@ -99,7 +101,7 @@ export function PhoneInput({
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
const next = parsedCountry ?? country;
if (parsedCountry) setCountry(parsedCountry);
setNational(new AsYouType(next).input(toNationalDigits(value, next)));
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.
@@ -122,12 +124,14 @@ export function PhoneInput({
}
function applyDigits(digits: string, forCountry: CountryCode) {
// Once the digits parse, show the true national number: someone who
// 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.
// 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(new AsYouType(forCountry).input(parsed?.nationalNumber ?? digits));
push(parsed?.number ?? toE164(digits, forCountry));
setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry));
push(toE164(digits, forCountry));
}
function handleText(text: string) {
@@ -139,7 +143,7 @@ export function PhoneInput({
const parsed = parsePhoneNumberFromString(trimmed);
if (parsed?.country) {
setCountry(parsed.country);
setNational(new AsYouType(parsed.country).input(parsed.nationalNumber));
setNational(formatNational(parsed.nationalNumber, parsed.country));
push(parsed.number);
return;
}
@@ -169,7 +173,13 @@ export function PhoneInput({
placeholder={placeholder}
value={national}
onChange={(e) => handleText(e.currentTarget.value)}
onBlur={onBlur}
// 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<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget)) onBlur?.();
},
}}
leftSectionWidth={92}
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
leftSection={
@@ -181,6 +191,12 @@ export function PhoneInput({
renderOption={renderCountryOption}
filter={filterCountries}
searchable
searchValue={search}
onSearchChange={setSearch}
onDropdownOpen={() => setSearch('')}
onDropdownClose={() => setSearch(`+${getCountryCallingCode(country)}`)}
nothingFoundMessage={t('phone.noCountry', 'No matching country')}
allowDeselect={false}
disabled={disabled || readOnly}
variant="unstyled"
size="xs"