Skip to content

Commit 69d8b3c

Browse files
authored
feat: adds strictValidation props for stricter validation (#522)
* feat: adds strictValidation props for stricter validation * typo: adjust strictValidation description with correct trade offs * removes strictValidation from demo
1 parent 060334d commit 69d8b3c

File tree

4 files changed

+222
-12
lines changed

4 files changed

+222
-12
lines changed

src/components/vue-tel-input.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,114 @@ describe('Props', () => {
343343
describe(':validCharactersOnly', () => {
344344
// TODO
345345
});
346+
describe(':strictValidation', () => {
347+
it('defaults to false', () => {
348+
const wrapper = shallowMount(VueTelInput);
349+
expect(wrapper.props('strictValidation')).toBe(false);
350+
});
351+
352+
describe('with strictValidation: false (min metadata)', () => {
353+
it('accepts Brazilian mobile with 8 digits (old format)', async () => {
354+
const wrapper = shallowMount(VueTelInput, {
355+
props: {
356+
defaultCountry: 'BR',
357+
autoDefaultCountry: false,
358+
strictValidation: false,
359+
},
360+
});
361+
362+
await wrapper.vm.$nextTick();
363+
// +55 (DDI) + 75 (DDD) + 99980948 (8 digits - old format, missing the leading 9)
364+
wrapper.vm.data.phone = '+557599980948';
365+
await wrapper.vm.$nextTick();
366+
367+
// Min metadata only checks length, so this passes as valid
368+
expect(wrapper.vm.phoneObject.valid).toBe(true);
369+
});
370+
371+
it('accepts valid Brazilian mobile with 9 digits', async () => {
372+
const wrapper = shallowMount(VueTelInput, {
373+
props: {
374+
defaultCountry: 'BR',
375+
autoDefaultCountry: false,
376+
strictValidation: false,
377+
},
378+
});
379+
380+
await wrapper.vm.$nextTick();
381+
// +55 (DDI) + 75 (DDD) + 999980948 (9 digits - current format)
382+
wrapper.vm.data.phone = '+5575999980948';
383+
await wrapper.vm.$nextTick();
384+
385+
expect(wrapper.vm.phoneObject.valid).toBe(true);
386+
});
387+
});
388+
389+
describe('with strictValidation: true (max metadata)', () => {
390+
it('rejects Brazilian mobile with 8 digits (old format)', async () => {
391+
const wrapper = shallowMount(VueTelInput, {
392+
props: {
393+
defaultCountry: 'BR',
394+
autoDefaultCountry: false,
395+
strictValidation: true,
396+
},
397+
});
398+
399+
await wrapper.vm.$nextTick();
400+
401+
// Wait for strict parser to load (async dynamic import)
402+
await new Promise(resolve => setTimeout(resolve, 100));
403+
404+
// +55 (DDI) + 75 (DDD) + 99980948 (8 digits - old format)
405+
// This is the problematic format that max metadata should reject
406+
wrapper.vm.data.phone = '+557599980948';
407+
await wrapper.vm.$nextTick();
408+
409+
// Max metadata validates digit patterns, so this should be invalid
410+
expect(wrapper.vm.phoneObject.valid).toBe(false);
411+
});
412+
413+
it('accepts valid Brazilian mobile with 9 digits', async () => {
414+
const wrapper = shallowMount(VueTelInput, {
415+
props: {
416+
defaultCountry: 'BR',
417+
autoDefaultCountry: false,
418+
strictValidation: true,
419+
},
420+
});
421+
422+
await wrapper.vm.$nextTick();
423+
424+
await new Promise(resolve => setTimeout(resolve, 100));
425+
426+
// +55 (DDI) + 75 (DDD) + 999980948 (9 digits - current format)
427+
wrapper.vm.data.phone = '+5575999980948';
428+
await wrapper.vm.$nextTick();
429+
430+
expect(wrapper.vm.phoneObject.valid).toBe(true);
431+
});
432+
433+
it('accepts valid US phone number', async () => {
434+
const wrapper = shallowMount(VueTelInput, {
435+
props: {
436+
defaultCountry: 'US',
437+
autoDefaultCountry: false,
438+
strictValidation: true,
439+
},
440+
});
441+
442+
await wrapper.vm.$nextTick();
443+
444+
await new Promise(resolve => setTimeout(resolve, 100));
445+
446+
wrapper.vm.data.phone = '+12015550123';
447+
await wrapper.vm.$nextTick();
448+
449+
expect(wrapper.vm.phoneObject.valid).toBe(true);
450+
expect(wrapper.vm.phoneObject.country).toBe('US');
451+
});
452+
});
453+
});
346454
describe(':styleClasses', () => {
347455
it('sets classes along side with .vue-tel-input', () => {
348456
const wrapper = shallowMount(VueTelInput, {

src/components/vue-tel-input.vue

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@
8686
import type { CountryCode, NumberFormat } from 'libphonenumber-js';
8787
import type { CountryObject, DropdownOptions, InputOptions, PhoneMeta } from '../types';
8888
89-
import { parsePhoneNumberFromString } from 'libphonenumber-js';
89+
import { parsePhoneNumber, loadStrictParser, isStrictParserReady, parseMin } from '../phone-parser';
9090
import { getDefault, setCaretPosition, getCountry, toLowerCase, toUpperCase } from '../utils';
9191
import clickOutside from '../directives/click-outside';
92-
import { computed, nextTick, onMounted, reactive, shallowRef, watch } from 'vue';
92+
import { computed, nextTick, onMounted, reactive, shallowRef, watch, ref } from 'vue';
9393
9494
const refRoot = shallowRef<HTMLDivElement>()
9595
const refList = shallowRef<HTMLUListElement>()
@@ -189,8 +189,15 @@
189189
type: [String, Array, Object],
190190
default: () => getDefault('styleClasses') as string,
191191
},
192+
strictValidation: {
193+
type: Boolean,
194+
default: () => getDefault('strictValidation') as boolean,
195+
},
192196
})
193197
198+
// Track when strict parser is loaded for re-validation
199+
const strictParserReady = ref(isStrictParserReady())
200+
194201
const modelValue = defineModel({ type: String })
195202
watch(modelValue, (value, oldValue) => {
196203
if (!testCharacters()) {
@@ -289,9 +296,12 @@
289296
})
290297
291298
const phoneObject = computed(() => {
299+
// Reactive dependency: triggers re-computation when strict parser loads
300+
void strictParserReady.value;
301+
292302
const result = data.phone.startsWith('+')
293-
? parsePhoneNumberFromString(data.phone)
294-
: parsePhoneNumberFromString(data.phone, data.activeCountryCode);
303+
? parsePhoneNumber(data.phone, undefined, props.strictValidation)
304+
: parsePhoneNumber(data.phone, data.activeCountryCode, props.strictValidation);
295305
296306
const meta: PhoneMeta = {
297307
country: result?.country,
@@ -309,12 +319,12 @@
309319
if (result?.country
310320
&& (props.ignoredCountries.length || props.onlyCountries.length)
311321
&& !findCountry(result.country)) {
312-
meta.valid = false;
313-
meta.possible = false;
314-
result.country = null;
322+
meta.valid = false;
323+
meta.possible = false;
324+
result.country = null;
315325
}
316326
317-
if(!result) {
327+
if (!result) {
318328
return meta
319329
}
320330
@@ -324,7 +334,7 @@
324334
}
325335
})
326336
watch(() => phoneObject.value.countryCode, (value) => {
327-
if(value) {
337+
if (value) {
328338
data.activeCountryCode = value;
329339
}
330340
})
@@ -352,6 +362,13 @@
352362
watch(() => props.inputOptions.placeholder, resetPlaceholder)
353363
354364
onMounted(() => {
365+
if (props.strictValidation && !isStrictParserReady()) {
366+
loadStrictParser().then(() => {
367+
strictParserReady.value = true;
368+
emit('validate', phoneObject.value);
369+
});
370+
}
371+
355372
if (modelValue.value) {
356373
data.phone = modelValue.value.trim();
357374
}
@@ -483,7 +500,7 @@
483500
&& phoneObject.value.nationalNumber) {
484501
data.activeCountryCode = parsedCountry.iso2;
485502
// Attach the current phone number with the newly selected country
486-
data.phone = parsePhoneNumberFromString(
503+
data.phone = parseMin(
487504
phoneObject.value.nationalNumber,
488505
parsedCountry.iso2,
489506
)

src/phone-parser.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Phone Parser with dynamic import for code splitting
3+
*
4+
* This module provides phone number parsing with optional strict validation.
5+
* When strictValidation is false (default), it uses libphonenumber-js (min metadata).
6+
* When strictValidation is true, it dynamically loads libphonenumber-js/max for
7+
* more precise validation, but only when needed (code splitting).
8+
*/
9+
10+
import type { CountryCode, PhoneNumber } from 'libphonenumber-js';
11+
import { parsePhoneNumberFromString as parseMin } from 'libphonenumber-js';
12+
13+
// Cache for the dynamically loaded max parser
14+
let parseMaxCached: typeof parseMin | null = null;
15+
let parseMaxLoading: Promise<typeof parseMin> | null = null;
16+
17+
/**
18+
* Dynamically loads libphonenumber-js/max for strict validation.
19+
* Uses caching to avoid multiple loads.
20+
*
21+
* @returns Promise that resolves to the parsePhoneNumberFromString function from /max
22+
*/
23+
export async function loadStrictParser(): Promise<typeof parseMin> {
24+
if (parseMaxCached) {
25+
return parseMaxCached;
26+
}
27+
28+
if (parseMaxLoading) {
29+
return parseMaxLoading;
30+
}
31+
32+
parseMaxLoading = import('libphonenumber-js/max')
33+
.then((module) => {
34+
parseMaxCached = module.parsePhoneNumberFromString;
35+
return parseMaxCached;
36+
});
37+
38+
return parseMaxLoading;
39+
}
40+
41+
/**
42+
* Parses a phone number using the appropriate parser based on strictValidation flag.
43+
*
44+
* When strictValidation is false: Uses min metadata (synchronous, smaller bundle)
45+
* When strictValidation is true: Uses max metadata (async load, precise validation)
46+
*
47+
* @param phoneNumber - The phone number string to parse
48+
* @param countryCode - Optional default country code
49+
* @param strictValidation - Whether to use strict validation (max metadata)
50+
* @returns The parsed PhoneNumber object or undefined
51+
*/
52+
export function parsePhoneNumber(
53+
phoneNumber: string,
54+
countryCode?: CountryCode,
55+
strictValidation: boolean = false
56+
): PhoneNumber | undefined {
57+
if (!strictValidation) {
58+
return countryCode
59+
? parseMin(phoneNumber, countryCode)
60+
: parseMin(phoneNumber);
61+
}
62+
63+
if (parseMaxCached) {
64+
return countryCode
65+
? parseMaxCached(phoneNumber, countryCode)
66+
: parseMaxCached(phoneNumber);
67+
}
68+
69+
return countryCode
70+
? parseMin(phoneNumber, countryCode)
71+
: parseMin(phoneNumber);
72+
}
73+
74+
export function isStrictParserReady(): boolean {
75+
return parseMaxCached !== null;
76+
}
77+
78+
export { parseMin };

src/utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ export const allProps = [
290290
description: 'Only allow valid characters in a phone number (will also verify in <code>mounted</code>, so phone number with invalid characters will be shown as an empty string)',
291291
inDemo: false,
292292
},
293+
{
294+
name: 'strictValidation',
295+
default: false,
296+
type: Boolean,
297+
description: 'Use strict phone number validation with full metadata (libphonenumber-js/max). When false (default), uses minimal metadata for smaller bundle size. When true, validates phone number patterns more precisely but increases bundle size by ~145KB.',
298+
inDemo: false,
299+
},
293300
];
294301

295302
export const defaultOptions = [...allProps]
@@ -320,10 +327,10 @@ export function getDefault(key: string) {
320327
return value;
321328
}
322329

323-
export function toLowerCase<T extends string> (str: T) {
330+
export function toLowerCase<T extends string>(str: T) {
324331
return str?.toLowerCase() as Lowercase<T>;
325332
}
326333

327-
export function toUpperCase<T extends string> (str: T) {
334+
export function toUpperCase<T extends string>(str: T) {
328335
return str?.toUpperCase() as Uppercase<T>;
329336
}

0 commit comments

Comments
 (0)