From 98d23acac52fbbb6c8a5c62923eb00977c7fbea5 Mon Sep 17 00:00:00 2001 From: James Acklin Date: Thu, 24 Sep 2026 16:30:12 -0400 Subject: [PATCH] fix: select a phone format that can hold the typed digits NANP country data lists Canada's 7-digit "310-XXXX" service-number format ahead of the 10-digit format for every +1 country. selectFormat returned the first leading-digits match without considering length, and applyFormat then clamped the input to that format's capacity, so any +1 number with area code 310 silently dropped every digit after the seventh. selectFormat now passes over a match whose pattern cannot hold the digits typed so far and continues to the next match, falling back to the widest match so overlong input is still clamped. This mirrors libphonenumber's AsYouTypeFormatter, which re-chooses a format once its template is exhausted. selectFormat moves below getFormatMaxDigits, which it now calls, so the worklet plugin's closure capture never sees a const in its temporal dead zone. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/PhoneNumberTransformer.test.ts | 39 +++++++++++ src/formatters/phone-number.ts | 66 +++++++++++-------- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/formatters/__tests__/PhoneNumberTransformer.test.ts b/src/formatters/__tests__/PhoneNumberTransformer.test.ts index 65f4a85..a9362d2 100644 --- a/src/formatters/__tests__/PhoneNumberTransformer.test.ts +++ b/src/formatters/__tests__/PhoneNumberTransformer.test.ts @@ -369,6 +369,45 @@ describe('PhoneNumberTransformer', () => { }); }); + // NANP data lists Canada's 7-digit "310-XXXX" service-number format ahead of + // the 10-digit format, so selecting a format by leading digits alone clamped + // every +1 310 number to seven digits. + describe('formats sharing a leading-digits prefix (NANP 310)', () => { + const intl = new PhoneNumberTransformer({ international: true }); + + it('formats a full 10-digit 310 number in international mode', () => { + expect(transform(intl, '+13102705123')?.value).toBe('+1 (310) 270-5123'); + }); + + it('formats a full 10-digit 310 number in national mode', () => { + const us = new PhoneNumberTransformer({ country: 'US' }); + expect(transform(us, '3102705123')?.value).toBe('+1 (310) 270-5123'); + }); + + it('still formats a 7-digit 310 service number', () => { + const ca = new PhoneNumberTransformer({ country: 'CA' }); + expect(transform(ca, '3102705')?.value).toBe('+1 310-2705'); + expect(transform(intl, '+13102705')?.value).toBe('+1 310-2705'); + }); + + it('switches from the 7-digit to the 10-digit format on the eighth digit', () => { + expect(transform(intl, '+13102705')?.value).toBe('+1 310-2705'); + const result = transform( + intl, + '+1 310-27051', + { start: 12, end: 12 }, + '+1 310-2705', + { start: 11, end: 11 }, + ); + expect(result?.value).toBe('+1 (310) 270-51'); + expect(result?.selection).toEqual({ start: 15, end: 15 }); + }); + + it('still limits a 310 number to 10 national digits', () => { + expect(transform(intl, '+131027051234')?.value).toBe('+1 (310) 270-5123'); + }); + }); + describe('detectCountry', () => { it('resolves the primary country from the calling code', () => { expect(detectCountry('+1 415 555 2671')).toBe('US'); diff --git a/src/formatters/phone-number.ts b/src/formatters/phone-number.ts index 0a95cd6..02f13b1 100644 --- a/src/formatters/phone-number.ts +++ b/src/formatters/phone-number.ts @@ -82,34 +82,6 @@ const countDigitsBefore = (text: string, pos: number): number => { return count; }; -// Select the best format for a given national number based on leading digits. -// Iterates formats and tests the leadingDigits regex against the start of digits. -// Returns the first match, or the last format as fallback. -const selectFormat = ( - nationalDigits: string, - formats: PhoneFormat[], -): PhoneFormat | null => { - 'worklet'; - if (formats.length === 0) return null; - if (nationalDigits.length === 0) return formats[formats.length - 1]!; - - for (const format of formats) { - if (!format.leadingDigits) { - // No leading digits constraint — matches everything - return format; - } - // Test leading digits regex against the national digits. - // The regex should match from the start of the digits. - const re = new RegExp('^(?:' + format.leadingDigits + ')'); - if (re.test(nationalDigits)) { - return format; - } - } - - // Fallback to last format - return formats[formats.length - 1]!; -}; - // Count the max digits the format pattern can consume by counting \d occurrences // in the capture groups. const getFormatMaxDigits = (pattern: string): number => { @@ -153,6 +125,44 @@ const getFormatMaxDigits = (pattern: string): number => { return count; }; +// Select the best format for a given national number based on leading digits. +// Iterates formats and tests the leadingDigits regex against the start of digits. +// A format whose pattern can't hold every digit typed so far is passed over for +// the next match, so a short format that shares a prefix with a longer one +// (NANP lists the 7-digit "310-XXXX" service numbers ahead of the 10-digit +// "(310) XXX-XXXX") never truncates the longer number. Returns the first match +// that fits, else the widest match, else the last format as fallback. +const selectFormat = ( + nationalDigits: string, + formats: PhoneFormat[], +): PhoneFormat | null => { + 'worklet'; + if (formats.length === 0) return null; + if (nationalDigits.length === 0) return formats[formats.length - 1]!; + + let widest: PhoneFormat | null = null; + let widestMax = -1; + for (const format of formats) { + // Test leading digits regex against the national digits. + // The regex should match from the start of the digits. + if (format.leadingDigits) { + const re = new RegExp('^(?:' + format.leadingDigits + ')'); + if (!re.test(nationalDigits)) continue; + } + const maxDigits = getFormatMaxDigits(format.pattern); + if (nationalDigits.length <= maxDigits) { + return format; + } + if (maxDigits > widestMax) { + widest = format; + widestMax = maxDigits; + } + } + + // Every matching format overflowed: the widest one clamps the input. + return widest !== null ? widest : formats[formats.length - 1]!; +}; + // Build a partial format for when we don't have enough digits to match the full pattern. // We expand digit groups one at a time and fill what we can. const buildPartialFormat = (digits: string, format: PhoneFormat): string => {