refactor: maintain local state for PhoneInput typing to prevent input reset and add helper utilities for E.164 conversion.

This commit is contained in:
estifanos
2026-08-20 08:21:51 +00:00
parent 366ddd23a7
commit a0560f9cbc
5 changed files with 134 additions and 39 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
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 {
@@ -9,6 +9,7 @@ import {
type CountryCode,
} from 'libphonenumber-js';
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
import { nextNationalDigits, toE164, toNationalDigits } from './phone';
// Static list, computed once at module load — same as CountrySelect's dataset.
const COUNTRY_CODES = getCountries();
@@ -50,6 +51,8 @@ export interface PhoneInputProps {
description?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
/** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */
withAsterisk?: boolean;
disabled?: boolean;
readOnly?: boolean;
}
@@ -57,7 +60,11 @@ export interface PhoneInputProps {
/**
* International phone entry: a searchable country/dial-code select beside a
* national-number text box, WhatsApp/Telegram style. Controlled —
* `value`/`onChange` carry the E.164 string; everything else is display.
* `value`/`onChange` carry the E.164 string.
*
* The typed digits live in local state rather than being re-derived from
* `value` on every render: an incomplete number doesn't parse, so deriving
* would blank the box between keystrokes.
*/
export function PhoneInput({
value,
@@ -67,21 +74,33 @@ export function PhoneInput({
description,
error,
required,
withAsterisk,
disabled,
readOnly,
}: PhoneInputProps) {
const { t, i18n } = useTranslation();
const lang = resolveLang(i18n.language);
// The picked country. Re-derived from `value` when it carries a
// recognizable dial code (e.g. the form was reset from outside); otherwise
// left alone so picking a country before typing digits isn't clobbered.
const [country, setCountry] = useState<CountryCode>(
() => parsePhoneNumberFromString(value || '')?.country ?? 'ET',
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
const [country, setCountry] = useState<CountryCode>(initialCountry);
const [national, setNational] = useState(() =>
new AsYouType(initialCountry).input(toNationalDigits(value, initialCountry)),
);
// What this field last pushed upward, so an echo of our own value isn't
// mistaken for the parent resetting the form.
const emitted = useRef(value);
useEffect(() => {
const parsed = value ? parsePhoneNumberFromString(value)?.country : undefined;
if (parsed) setCountry(parsed);
if (value === emitted.current) return;
const parsedCountry = parsePhoneNumberFromString(value || '')?.country;
const next = parsedCountry ?? country;
if (parsedCountry) setCountry(parsedCountry);
setNational(new AsYouType(next).input(toNationalDigits(value, 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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const countryOptions = useMemo<CountryOption[]>(
@@ -94,45 +113,38 @@ export function PhoneInput({
[lang],
);
// Digits currently typed, formatted for display under the selected
// country's convention.
const national = useMemo(() => {
const digits = value.startsWith('+')
? (parsePhoneNumberFromString(value)?.nationalNumber ?? '')
: value;
return new AsYouType(country).input(digits);
}, [value, country]);
function push(next: string) {
emitted.current = next;
onChange(next);
}
function emit(text: string, forCountry: CountryCode) {
function applyDigits(digits: string, forCountry: CountryCode) {
setNational(new AsYouType(forCountry).input(digits));
push(toE164(digits, forCountry));
}
function handleText(text: string) {
// A full `+<code><number>` arriving at once (paste, autofill, or a test
// driver's `.fill()`) is parsed standalone and switches the country,
// rather than being read under whatever country was already selected.
const trimmed = text.trim();
// A full `+<code><number>` landing in the box directly (paste,
// autofill, or a test driver's `.fill()`) is parsed standalone and
// switches the country, rather than being digit-stripped and read
// under whatever country was already selected.
if (trimmed.startsWith('+')) {
const parsed = parsePhoneNumberFromString(trimmed);
if (parsed?.country) {
setCountry(parsed.country);
onChange(parsed.number);
setNational(new AsYouType(parsed.country).input(parsed.nationalNumber));
push(parsed.number);
return;
}
}
const digits = trimmed.replace(/\D/g, '');
if (!digits) {
onChange('');
return;
}
onChange(
parsePhoneNumberFromString(digits, forCountry)?.number ??
`+${getCountryCallingCode(forCountry)}${digits}`,
);
applyDigits(nextNationalDigits(text, national), country);
}
function handleCountryChange(next: string | null) {
if (!next) return;
const code = next as CountryCode;
setCountry(code);
emit(national, code);
applyDigits(national.replace(/\D/g, ''), code);
}
return (
@@ -144,11 +156,12 @@ export function PhoneInput({
description={description}
error={error}
required={required}
withAsterisk={withAsterisk}
disabled={disabled}
readOnly={readOnly}
placeholder={placeholder}
value={national}
onChange={(e) => emit(e.currentTarget.value, country)}
onChange={(e) => handleText(e.currentTarget.value)}
leftSectionWidth={92}
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
leftSection={