mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
refactor: maintain local state for PhoneInput typing to prevent input reset and add helper utilities for E.164 conversion.
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
|||||||
type FormSectionConfig,
|
type FormSectionConfig,
|
||||||
type Vessel,
|
type Vessel,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||||
|
|
||||||
@@ -223,6 +223,12 @@ export function ConfigDrivenSection({
|
|||||||
value={(value as string) ?? ''}
|
value={(value as string) ?? ''}
|
||||||
onChange={(v) => onChange(field.key, v)}
|
onChange={(v) => onChange(field.key, v)}
|
||||||
/>
|
/>
|
||||||
|
) : field.type === 'PHONE' ? (
|
||||||
|
<PhoneInput
|
||||||
|
{...common}
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
onChange={(v) => onChange(field.key, v)}
|
||||||
|
/>
|
||||||
) : field.type === 'TEXTAREA' ? (
|
) : field.type === 'TEXTAREA' ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
{...common}
|
{...common}
|
||||||
|
|||||||
@@ -147,12 +147,13 @@ export function AddressFormContent({
|
|||||||
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
||||||
error={errors.nationality?.message}
|
error={errors.nationality?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PhoneInput
|
||||||
label={t('profileFields.primaryPhoneNumber')}
|
label={t('profileFields.primaryPhoneNumber')}
|
||||||
description={t('profileAddress.accountManagedHint')}
|
description={t('profileAddress.accountManagedHint')}
|
||||||
required
|
required
|
||||||
readOnly
|
readOnly
|
||||||
{...register('primaryPhoneNumber')}
|
value={watch('primaryPhoneNumber') || ''}
|
||||||
|
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: true })}
|
||||||
error={errors.primaryPhoneNumber?.message}
|
error={errors.primaryPhoneNumber?.message}
|
||||||
/>
|
/>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
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';
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -50,6 +51,8 @@ export interface PhoneInputProps {
|
|||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
error?: React.ReactNode;
|
error?: React.ReactNode;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
/** Mantine's asterisk-without-`required` variant, as used by config-driven forms. */
|
||||||
|
withAsterisk?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
@@ -57,7 +60,11 @@ export interface PhoneInputProps {
|
|||||||
/**
|
/**
|
||||||
* International phone entry: a searchable country/dial-code select beside a
|
* International phone entry: a searchable country/dial-code select beside a
|
||||||
* national-number text box, WhatsApp/Telegram style. Controlled —
|
* 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({
|
export function PhoneInput({
|
||||||
value,
|
value,
|
||||||
@@ -67,21 +74,33 @@ export function PhoneInput({
|
|||||||
description,
|
description,
|
||||||
error,
|
error,
|
||||||
required,
|
required,
|
||||||
|
withAsterisk,
|
||||||
disabled,
|
disabled,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: PhoneInputProps) {
|
}: PhoneInputProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = resolveLang(i18n.language);
|
const lang = resolveLang(i18n.language);
|
||||||
|
|
||||||
// The picked country. Re-derived from `value` when it carries a
|
const initialCountry = parsePhoneNumberFromString(value || '')?.country ?? 'ET';
|
||||||
// recognizable dial code (e.g. the form was reset from outside); otherwise
|
const [country, setCountry] = useState<CountryCode>(initialCountry);
|
||||||
// left alone so picking a country before typing digits isn't clobbered.
|
const [national, setNational] = useState(() =>
|
||||||
const [country, setCountry] = useState<CountryCode>(
|
new AsYouType(initialCountry).input(toNationalDigits(value, initialCountry)),
|
||||||
() => parsePhoneNumberFromString(value || '')?.country ?? 'ET',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const parsed = value ? parsePhoneNumberFromString(value)?.country : undefined;
|
if (value === emitted.current) return;
|
||||||
if (parsed) setCountry(parsed);
|
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]);
|
}, [value]);
|
||||||
|
|
||||||
const countryOptions = useMemo<CountryOption[]>(
|
const countryOptions = useMemo<CountryOption[]>(
|
||||||
@@ -94,45 +113,38 @@ export function PhoneInput({
|
|||||||
[lang],
|
[lang],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Digits currently typed, formatted for display under the selected
|
function push(next: string) {
|
||||||
// country's convention.
|
emitted.current = next;
|
||||||
const national = useMemo(() => {
|
onChange(next);
|
||||||
const digits = value.startsWith('+')
|
}
|
||||||
? (parsePhoneNumberFromString(value)?.nationalNumber ?? '')
|
|
||||||
: value;
|
|
||||||
return new AsYouType(country).input(digits);
|
|
||||||
}, [value, country]);
|
|
||||||
|
|
||||||
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();
|
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('+')) {
|
if (trimmed.startsWith('+')) {
|
||||||
const parsed = parsePhoneNumberFromString(trimmed);
|
const parsed = parsePhoneNumberFromString(trimmed);
|
||||||
if (parsed?.country) {
|
if (parsed?.country) {
|
||||||
setCountry(parsed.country);
|
setCountry(parsed.country);
|
||||||
onChange(parsed.number);
|
setNational(new AsYouType(parsed.country).input(parsed.nationalNumber));
|
||||||
|
push(parsed.number);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const digits = trimmed.replace(/\D/g, '');
|
applyDigits(nextNationalDigits(text, national), country);
|
||||||
if (!digits) {
|
|
||||||
onChange('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onChange(
|
|
||||||
parsePhoneNumberFromString(digits, forCountry)?.number ??
|
|
||||||
`+${getCountryCallingCode(forCountry)}${digits}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCountryChange(next: string | null) {
|
function handleCountryChange(next: string | null) {
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
const code = next as CountryCode;
|
const code = next as CountryCode;
|
||||||
setCountry(code);
|
setCountry(code);
|
||||||
emit(national, code);
|
applyDigits(national.replace(/\D/g, ''), code);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -144,11 +156,12 @@ export function PhoneInput({
|
|||||||
description={description}
|
description={description}
|
||||||
error={error}
|
error={error}
|
||||||
required={required}
|
required={required}
|
||||||
|
withAsterisk={withAsterisk}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={national}
|
value={national}
|
||||||
onChange={(e) => emit(e.currentTarget.value, country)}
|
onChange={(e) => handleText(e.currentTarget.value)}
|
||||||
leftSectionWidth={92}
|
leftSectionWidth={92}
|
||||||
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
|
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
|
||||||
leftSection={
|
leftSection={
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { optionalPhoneNumber, phoneNumber } from './phone';
|
import { nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
|
||||||
|
import { AsYouType } from 'libphonenumber-js';
|
||||||
|
|
||||||
describe('phoneNumber', () => {
|
describe('phoneNumber', () => {
|
||||||
it('normalizes a legacy Ethiopian national number to E.164', () => {
|
it('normalizes a legacy Ethiopian national number to E.164', () => {
|
||||||
@@ -32,3 +33,39 @@ describe('optionalPhoneNumber', () => {
|
|||||||
expect(() => optionalPhoneNumber.parse('abc')).toThrow();
|
expect(() => optionalPhoneNumber.parse('abc')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('typing helpers', () => {
|
||||||
|
// Regression: an incomplete number doesn't parse, and an earlier version
|
||||||
|
// collapsed it to '' — the box emptied on every keystroke.
|
||||||
|
it('keeps partial digits as the number is typed one character at a time', () => {
|
||||||
|
let display = '';
|
||||||
|
let value = '';
|
||||||
|
for (const ch of '911223344') {
|
||||||
|
const digits = nextNationalDigits(display + ch, display);
|
||||||
|
display = new AsYouType('ET').input(digits);
|
||||||
|
value = toE164(digits, 'ET');
|
||||||
|
}
|
||||||
|
expect(display).toBe('911223344');
|
||||||
|
expect(value).toBe('+251911223344');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a digit when a keystroke only removed a formatting character', () => {
|
||||||
|
// "(415)" backspaced to "(415" leaves the digits unchanged.
|
||||||
|
expect(nextNationalDigits('(415', '(415)')).toBe('41');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the deleted digit count when a real digit is removed', () => {
|
||||||
|
expect(nextNationalDigits('91122334', '911223344')).toBe('91122334');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the national part back out of a stored E.164 value', () => {
|
||||||
|
expect(toNationalDigits('+251911223344', 'ET')).toBe('911223344');
|
||||||
|
expect(toNationalDigits('+14155552671', 'US')).toBe('4155552671');
|
||||||
|
expect(toNationalDigits('', 'ET')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a dial-code concatenation while the number is incomplete', () => {
|
||||||
|
expect(toE164('9', 'ET')).toBe('+2519');
|
||||||
|
expect(toE164('', 'ET')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
import { 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
|
||||||
@@ -16,3 +16,41 @@ export const phoneNumber = z
|
|||||||
|
|
||||||
/** Same rules, but blank is allowed. */
|
/** Same rules, but blank is allowed. */
|
||||||
export const optionalPhoneNumber = z.union([z.literal(''), phoneNumber]).optional();
|
export const optionalPhoneNumber = z.union([z.literal(''), phoneNumber]).optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digits the field should hold after an edit. A keystroke that only removed
|
||||||
|
* a formatting character (backspacing the ')' out of "(415)") leaves the
|
||||||
|
* digits unchanged, which would otherwise make the caret stick — drop a real
|
||||||
|
* digit in that case.
|
||||||
|
*/
|
||||||
|
export function nextNationalDigits(text: string, prevDisplay: string): string {
|
||||||
|
const digits = text.replace(/\D/g, '');
|
||||||
|
const deleting = text.length < prevDisplay.length;
|
||||||
|
if (deleting && digits === prevDisplay.replace(/\D/g, '')) return digits.slice(0, -1);
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
if (parsed) return parsed.nationalNumber;
|
||||||
|
if (value.startsWith('+')) {
|
||||||
|
const prefix = `+${getCountryCallingCode(country)}`;
|
||||||
|
if (value.startsWith(prefix)) return value.slice(prefix.length).replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
return value.replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E.164 for the digits typed so far. Incomplete numbers don't parse, so they
|
||||||
|
* fall back to a plain dial-code concatenation rather than collapsing to ''
|
||||||
|
* — the value has to survive mid-typing for the field to be usable.
|
||||||
|
*/
|
||||||
|
export function toE164(digits: string, country: CountryCode): string {
|
||||||
|
if (!digits) return '';
|
||||||
|
return (
|
||||||
|
parsePhoneNumberFromString(digits, country)?.number ??
|
||||||
|
`+${getCountryCallingCode(country)}${digits}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user