diff --git a/libs/ui/src/lib/input/PhoneInput.tsx b/libs/ui/src/lib/input/PhoneInput.tsx index 0454f166d..1493a2d0c 100644 --- a/libs/ui/src/lib/input/PhoneInput.tsx +++ b/libs/ui/src/lib/input/PhoneInput.tsx @@ -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(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) => { + 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" diff --git a/libs/ui/src/lib/input/phone.spec.ts b/libs/ui/src/lib/input/phone.spec.ts index f46f39712..e1c2a0f11 100644 --- a/libs/ui/src/lib/input/phone.spec.ts +++ b/libs/ui/src/lib/input/phone.spec.ts @@ -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'); + }); +}); diff --git a/libs/ui/src/lib/input/phone.ts b/libs/ui/src/lib/input/phone.ts index a7b877dbc..be38b6238 100644 --- a/libs/ui/src/lib/input/phone.ts +++ b/libs/ui/src/lib/input/phone.ts @@ -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 (`+`) 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; +}