mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement formatNational utility and improve PhoneInput formatting and interaction logic
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user