mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 07:08:20 +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 { useTranslation } from 'react-i18next';
|
||||||
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
|
import { Group, Select, Text, TextInput, type ComboboxItem, type SelectProps } from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
AsYouType,
|
|
||||||
getCountries,
|
getCountries,
|
||||||
getCountryCallingCode,
|
getCountryCallingCode,
|
||||||
parsePhoneNumberFromString,
|
parsePhoneNumberFromString,
|
||||||
type CountryCode,
|
type CountryCode,
|
||||||
} from 'libphonenumber-js';
|
} from 'libphonenumber-js';
|
||||||
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
|
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.
|
// Static list, computed once at module load — same as CountrySelect's dataset.
|
||||||
const COUNTRY_CODES = getCountries();
|
const COUNTRY_CODES = getCountries();
|
||||||
@@ -87,8 +86,11 @@ export function PhoneInput({
|
|||||||
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
|
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
|
||||||
const [country, setCountry] = useState<CountryCode>(initialCountry);
|
const [country, setCountry] = useState<CountryCode>(initialCountry);
|
||||||
const [national, setNational] = useState(() =>
|
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
|
// What this field last pushed upward, so an echo of our own value isn't
|
||||||
// mistaken for the parent resetting the form.
|
// mistaken for the parent resetting the form.
|
||||||
@@ -99,7 +101,7 @@ export function PhoneInput({
|
|||||||
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
|
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
|
||||||
const next = parsedCountry ?? country;
|
const next = parsedCountry ?? country;
|
||||||
if (parsedCountry) setCountry(parsedCountry);
|
if (parsedCountry) setCountry(parsedCountry);
|
||||||
setNational(new AsYouType(next).input(toNationalDigits(value, next)));
|
setNational(formatNational(toNationalDigits(value, next), next));
|
||||||
emitted.current = value;
|
emitted.current = value;
|
||||||
// Adopting an outside change only — `country` is state written here, so
|
// Adopting an outside change only — `country` is state written here, so
|
||||||
// re-running on it would fight the user's typing.
|
// re-running on it would fight the user's typing.
|
||||||
@@ -122,12 +124,14 @@ export function PhoneInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyDigits(digits: string, forCountry: CountryCode) {
|
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)
|
// 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);
|
const parsed = parsePhoneNumberFromString(digits, forCountry);
|
||||||
setNational(new AsYouType(forCountry).input(parsed?.nationalNumber ?? digits));
|
setNational(formatNational(parsed?.isValid() ? parsed.nationalNumber : digits, forCountry));
|
||||||
push(parsed?.number ?? toE164(digits, forCountry));
|
push(toE164(digits, forCountry));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleText(text: string) {
|
function handleText(text: string) {
|
||||||
@@ -139,7 +143,7 @@ export function PhoneInput({
|
|||||||
const parsed = parsePhoneNumberFromString(trimmed);
|
const parsed = parsePhoneNumberFromString(trimmed);
|
||||||
if (parsed?.country) {
|
if (parsed?.country) {
|
||||||
setCountry(parsed.country);
|
setCountry(parsed.country);
|
||||||
setNational(new AsYouType(parsed.country).input(parsed.nationalNumber));
|
setNational(formatNational(parsed.nationalNumber, parsed.country));
|
||||||
push(parsed.number);
|
push(parsed.number);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,7 +173,13 @@ export function PhoneInput({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={national}
|
value={national}
|
||||||
onChange={(e) => handleText(e.currentTarget.value)}
|
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}
|
leftSectionWidth={92}
|
||||||
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
|
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
|
||||||
leftSection={
|
leftSection={
|
||||||
@@ -181,6 +191,12 @@ export function PhoneInput({
|
|||||||
renderOption={renderCountryOption}
|
renderOption={renderCountryOption}
|
||||||
filter={filterCountries}
|
filter={filterCountries}
|
||||||
searchable
|
searchable
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
onDropdownOpen={() => setSearch('')}
|
||||||
|
onDropdownClose={() => setSearch(`+${getCountryCallingCode(country)}`)}
|
||||||
|
nothingFoundMessage={t('phone.noCountry', 'No matching country')}
|
||||||
|
allowDeselect={false}
|
||||||
disabled={disabled || readOnly}
|
disabled={disabled || readOnly}
|
||||||
variant="unstyled"
|
variant="unstyled"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||||
|
|
||||||
describe('phoneNumber', () => {
|
describe('phoneNumber', () => {
|
||||||
@@ -91,3 +91,22 @@ describe('trunk prefix and country code entered into the number box', () => {
|
|||||||
expect(phoneNumber.parse('+251911666666')).toBe('+251911666666');
|
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 { 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
|
* 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. */
|
/** National digits of a stored value, for display in the number box. */
|
||||||
export function toNationalDigits(value: string, country: CountryCode): string {
|
export function toNationalDigits(value: string, country: CountryCode): string {
|
||||||
if (!value) return '';
|
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 (parsed) return parsed.nationalNumber;
|
||||||
if (value.startsWith('+')) {
|
if (value.startsWith('+')) {
|
||||||
const prefix = `+${getCountryCallingCode(country)}`;
|
const prefix = `+${getCountryCallingCode(country)}`;
|
||||||
@@ -54,3 +55,15 @@ export function toE164(digits: string, country: CountryCode): string {
|
|||||||
`+${getCountryCallingCode(country)}${digits}`
|
`+${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