feat: implement input length constraints and enhanced validation for international phone numbers

This commit is contained in:
estifanos
2026-08-20 08:58:07 +00:00
parent caa209306f
commit bc31cec0b2
5 changed files with 66 additions and 13 deletions

View File

@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
@@ -126,9 +126,9 @@ export function ProfilePage() {
.string()
.min(1, { message: t('profile.validation.usernameRequired') }),
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
phoneNumber: z
.string()
.min(1, { message: t('profile.validation.phoneRequired') }),
// Shared international rule: bare 09xxxxxxxx normalizes to +251, any
// other E.164 number is accepted as typed.
phoneNumber,
});
type ProfileValues = z.infer<typeof profileSchema>;

View File

@@ -1,3 +1,4 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
@@ -526,6 +527,13 @@ export function validateSections(
}
if (empty) continue;
// PhoneInput emits E.164 while typing, so a half-typed "+2519" is a
// non-empty string that still has to be caught here.
if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) {
errors[`${section.key}.${field.key}`] = 'Enter a valid phone number';
continue;
}
const numeric = Number(value);
if (!Number.isNaN(numeric)) {
if (field.min !== undefined && numeric < field.min) {

View File

@@ -8,7 +8,7 @@ import {
type CountryCode,
} from 'libphonenumber-js';
import { CountryFlag, getCountryName, resolveLang } from './CountrySelect';
import { formatNational, nextNationalDigits, toE164, toNationalDigits } from './phone';
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, toE164, toNationalDigits } from './phone';
// Static list, computed once at module load — same as CountrySelect's dataset.
const COUNTRY_CODES = getCountries();
@@ -45,7 +45,11 @@ 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`. */
/**
* Fired when focus leaves a field that has digits in it — wire to the
* form's `trigger`. A blank field is left to submit-time validation, like
* the form's other inputs, so tabbing past it doesn't raise an error.
*/
onBlur?: () => void;
label?: React.ReactNode;
placeholder?: string;
@@ -124,6 +128,8 @@ export function PhoneInput({
}
function applyDigits(digits: string, forCountry: CountryCode) {
// Keystroke past the longest possible number is ignored, not stored.
if (exceedsMaxLength(digits, forCountry)) return;
// 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. Not
@@ -155,7 +161,8 @@ export function PhoneInput({
if (!next) return;
const code = next as CountryCode;
setCountry(code);
applyDigits(national.replace(/\D/g, ''), code);
// A number carried over from a longer plan is cut to fit the new one.
applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code);
}
return (
@@ -177,7 +184,7 @@ export function PhoneInput({
// country select mustn't validate a half-typed number.
wrapperProps={{
onBlur: (e: React.FocusEvent<HTMLDivElement>) => {
if (!e.currentTarget.contains(e.relatedTarget)) onBlur?.();
if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.();
},
}}
leftSectionWidth={92}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatNational, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone';
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
describe('phoneNumber', () => {
@@ -20,7 +20,11 @@ describe('phoneNumber', () => {
});
it('rejects non-numeric input', () => {
expect(() => phoneNumber.parse('abc')).toThrow();
expect(() => phoneNumber.parse('abc')).toThrow('Enter a valid phone number');
});
it('reports a blank value as missing, not invalid', () => {
expect(() => phoneNumber.parse('')).toThrow('Phone number is required');
});
});
@@ -110,3 +114,18 @@ describe('legacy stored values', () => {
expect(toNationalDigits('0911223344', 'ET')).toBe('911223344');
});
});
describe('length limit', () => {
it('reports the longest national number per country', () => {
expect(maxNationalLength('ET')).toBe(9);
expect(maxNationalLength('US')).toBe(10);
});
it('flags digits past the limit, counting the national part only', () => {
expect(exceedsMaxLength('911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('9112233445', 'ET')).toBe(true);
expect(exceedsMaxLength('0911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('251911223344', 'ET')).toBe(false);
expect(exceedsMaxLength('09112233445', 'ET')).toBe(true);
});
});

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
import { AsYouType, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
import { AsYouType, Metadata, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
/**
* Accepts any international number in E.164 (`+<country><number>`) or a
@@ -10,8 +10,9 @@ export const phoneNumber = z
.string()
.trim()
.transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v)
.refine((v) => isValidPhoneNumber(v), {
message: 'Enter a valid phone number',
.superRefine((v, ctx) => {
if (!v) ctx.addIssue({ code: 'custom', message: 'Phone number is required' });
else if (!isValidPhoneNumber(v)) ctx.addIssue({ code: 'custom', message: 'Enter a valid phone number' });
});
/** Same rules, but blank is allowed. */
@@ -67,3 +68,21 @@ export function formatNational(digits: string, country: CountryCode): string {
const formatted = new AsYouType().input(prefix + digits);
return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits;
}
const metadata = new Metadata();
/** Longest national number the country's numbering plan allows. */
export function maxNationalLength(country: CountryCode): number {
metadata.selectNumberingPlan(country);
return Math.max(...(metadata.numberingPlan?.possibleLengths() ?? [15]));
}
/**
* True when the typed digits exceed the country's longest number. Judged on
* the national part once it parses, so a trunk prefix (0911223344) or typed
* country code (251911223344) isn't counted against the limit.
*/
export function exceedsMaxLength(digits: string, country: CountryCode): boolean {
const national = parsePhoneNumberFromString(digits, country)?.nationalNumber ?? digits;
return national.length > maxNationalLength(country);
}