refactor: improve PhoneInput digit normalization and update validation to trigger on blur

This commit is contained in:
estifanos
2026-08-20 08:31:08 +00:00
parent a0560f9cbc
commit 0b03bc45c3
5 changed files with 45 additions and 8 deletions

View File

@@ -153,14 +153,16 @@ export function AddressFormContent({
required
readOnly
value={watch('primaryPhoneNumber') || ''}
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: true })}
onChange={(val) => setValue('primaryPhoneNumber', val, { shouldValidate: !!errors.primaryPhoneNumber })}
onBlur={() => trigger('primaryPhoneNumber')}
error={errors.primaryPhoneNumber?.message}
/>
<PhoneInput
label={t('profileAddress.secondaryPhoneNumber')}
placeholder={t('profileAddress.phonePlaceholder')}
value={watch('secondaryPhoneNumber') || ''}
onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: true })}
onChange={(val) => setValue('secondaryPhoneNumber', val, { shouldValidate: !!errors.secondaryPhoneNumber })}
onBlur={() => trigger('secondaryPhoneNumber')}
error={errors.secondaryPhoneNumber?.message}
/>
<TextInput
@@ -212,7 +214,8 @@ export function AddressFormContent({
label={t('profileAddress.contactPhone')}
placeholder={t('profileAddress.phonePlaceholder')}
value={watch('emergencyContactPhone') || ''}
onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: true })}
onChange={(val) => setValue('emergencyContactPhone', val, { shouldValidate: !!errors.emergencyContactPhone })}
onBlur={() => trigger('emergencyContactPhone')}
error={errors.emergencyContactPhone?.message}
/>
<TextInput

View File

@@ -279,6 +279,7 @@ export function ProfilePage() {
reset: resetPersonal,
watch: watchPersonal,
setValue: setValuePersonal,
trigger: triggerPersonal,
formState: { errors: personalErrors },
} = useForm<PersonalValues>({
resolver: zodResolver(personalSchema),
@@ -673,7 +674,8 @@ export function ProfilePage() {
<PhoneInput
label={t('profile.fields.phone')}
value={watchPersonal('phoneNumber') || ''}
onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: true })}
onChange={(val) => setValuePersonal('phoneNumber', val, { shouldValidate: !!personalErrors.phoneNumber })}
onBlur={() => triggerPersonal('phoneNumber')}
error={personalErrors.phoneNumber?.message}
/>
</SimpleGrid>

View File

@@ -111,6 +111,7 @@ export function SignupPage() {
handleSubmit,
watch,
setValue,
trigger,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -251,7 +252,8 @@ export function SignupPage() {
label={t('signup.phoneLabel', 'Phone number')}
placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')}
value={watch('phoneNumber') || ''}
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: true })}
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
onBlur={() => trigger('phoneNumber')}
error={errors.phoneNumber?.message}
/>

View File

@@ -46,6 +46,8 @@ export interface PhoneInputProps {
/** E.164 (`+14155552671`), or '' when empty. */
value: string;
onChange: (value: string) => void;
/** Fired when the number box loses focus — wire to the form's `trigger`. */
onBlur?: () => void;
label?: React.ReactNode;
placeholder?: string;
description?: React.ReactNode;
@@ -69,6 +71,7 @@ export interface PhoneInputProps {
export function PhoneInput({
value,
onChange,
onBlur,
label,
placeholder,
description,
@@ -119,8 +122,12 @@ export function PhoneInput({
}
function applyDigits(digits: string, forCountry: CountryCode) {
setNational(new AsYouType(forCountry).input(digits));
push(toE164(digits, forCountry));
// Once the digits parse, show the true national number: someone who
// types a trunk prefix (0911111111) or the country code (251911111111)
// shouldn't end up with it doubled beside the "+251" selector.
const parsed = parsePhoneNumberFromString(digits, forCountry);
setNational(new AsYouType(forCountry).input(parsed?.nationalNumber ?? digits));
push(parsed?.number ?? toE164(digits, forCountry));
}
function handleText(text: string) {
@@ -162,6 +169,7 @@ export function PhoneInput({
placeholder={placeholder}
value={national}
onChange={(e) => handleText(e.currentTarget.value)}
onBlur={onBlur}
leftSectionWidth={92}
leftSectionPointerEvents={disabled || readOnly ? 'none' : 'all'}
leftSection={

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
import { AsYouType } from 'libphonenumber-js';
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
describe('phoneNumber', () => {
it('normalizes a legacy Ethiopian national number to E.164', () => {
@@ -69,3 +69,25 @@ describe('typing helpers', () => {
expect(toE164('', 'ET')).toBe('');
});
});
describe('trunk prefix and country code entered into the number box', () => {
// The box holds the national part next to a "+251" selector, so a trunk 0
// or a typed country code must be absorbed, not shown (and doubled) there.
const cases: Array<[string, string]> = [
['0911111111', '911111111'],
['251911111111', '911111111'],
['911111111', '911111111'],
];
it.each(cases)('normalizes %s to the national number %s', (typed, national) => {
const parsed = parsePhoneNumberFromString(typed.replace(/\D/g, ''), 'ET');
expect(parsed?.nationalNumber).toBe(national);
});
it.each(cases)('yields a valid E.164 value for %s', (typed) => {
expect(phoneNumber.parse(typed)).toBe('+251911111111');
});
it('accepts a pasted +251 number', () => {
expect(phoneNumber.parse('+251911666666')).toBe('+251911666666');
});
});