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 => {