mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: implement input length constraints and enhanced validation for international phone numbers
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user