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"

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
import { formatNational, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
describe('phoneNumber', () => {
@@ -91,3 +91,22 @@ describe('trunk prefix and country code entered into the number box', () => {
expect(phoneNumber.parse('+251911666666')).toBe('+251911666666');
});
});
describe('formatNational', () => {
it('groups digits without the dial code or trunk prefix', () => {
expect(formatNational('911223344', 'ET')).toBe('91 122 3344');
expect(formatNational('4155552671', 'US')).toBe('415 555 2671');
expect(formatNational('91', 'ET')).toBe('91');
expect(formatNational('', 'ET')).toBe('');
});
it('keeps digits intact when the number does not format', () => {
expect(formatNational('0911223344', 'ET').replace(/\D/g, '')).toBe('0911223344');
});
});
describe('legacy stored values', () => {
it('shows a national record without the trunk prefix', () => {
expect(toNationalDigits('0911223344', 'ET')).toBe('911223344');
});
});

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
import { AsYouType, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
/**
* Accepts any international number in E.164 (`+<country><number>`) or a
@@ -33,7 +33,8 @@ export function nextNationalDigits(text: string, prevDisplay: string): string {
/** National digits of a stored value, for display in the number box. */
export function toNationalDigits(value: string, country: CountryCode): string {
if (!value) return '';
const parsed = parsePhoneNumberFromString(value);
// `country` also resolves legacy national records ("0911223344").
const parsed = parsePhoneNumberFromString(value, country);
if (parsed) return parsed.nationalNumber;
if (value.startsWith('+')) {
const prefix = `+${getCountryCallingCode(country)}`;
@@ -54,3 +55,15 @@ export function toE164(digits: string, country: CountryCode): string {
`+${getCountryCallingCode(country)}${digits}`
);
}
/**
* Digits grouped for display ("91 122 3344"). Formatted as an international
* number with the dial code cut off: AsYouType's national mode leaves the
* number ungrouped unless the trunk prefix was typed.
*/
export function formatNational(digits: string, country: CountryCode): string {
if (!digits) return '';
const prefix = `+${getCountryCallingCode(country)}`;
const formatted = new AsYouType().input(prefix + digits);
return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits;
}