From 90b4c96cb2e7035cb73ca57daba75ad3ba2afc6c Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 18 Jun 2026 13:44:10 -0400 Subject: [PATCH] feat: add international auto-detect mode to PhoneNumberTransformer Adds an `international` option where the calling code is part of the editable text and the country is detected from it as the user types (e.g. typing +44 switches formatting to the UK). The worklet matches the longest calling-code prefix against a generated COUNTRY_CALLING_CODES index and formats the national part for that country, so detection + formatting stay on the UI thread. Also exports detectCountry(value) (JS thread) to drive a flag/country indicator from the value. Ambiguous codes (e.g. +1 NANP) resolve to the primary country; area-code disambiguation is left for later. Tests added for international formatting and detectCountry. --- scripts/generate-phone-data.ts | 11 + .../__tests__/PhoneNumberTransformer.test.ts | 48 +++- src/formatters/phone-data.ts | 235 ++++++++++++++++++ src/formatters/phone-number.ts | 169 ++++++++++++- 4 files changed, 461 insertions(+), 2 deletions(-) diff --git a/scripts/generate-phone-data.ts b/scripts/generate-phone-data.ts index 2344fa2..64331b6 100644 --- a/scripts/generate-phone-data.ts +++ b/scripts/generate-phone-data.ts @@ -199,6 +199,17 @@ function generate(): string { lines.push('];'); lines.push(''); + // Calling code -> ISO country codes (primary country first). Used to detect + // the country from a typed international calling code. + lines.push( + 'export const COUNTRY_CALLING_CODES: Record = {', + ); + for (const [code, isos] of Object.entries(callingCodes)) { + lines.push(` ${JSON.stringify(code)}: ${JSON.stringify(isos)},`); + } + lines.push('};'); + lines.push(''); + return lines.join('\n'); } diff --git a/src/formatters/__tests__/PhoneNumberTransformer.test.ts b/src/formatters/__tests__/PhoneNumberTransformer.test.ts index f4f8f78..65f4a85 100644 --- a/src/formatters/__tests__/PhoneNumberTransformer.test.ts +++ b/src/formatters/__tests__/PhoneNumberTransformer.test.ts @@ -1,4 +1,4 @@ -import { PhoneNumberTransformer } from '../phone-number'; +import { PhoneNumberTransformer, detectCountry } from '../phone-number'; // Helper to call the transformer worklet with simpler API const transform = ( @@ -334,4 +334,50 @@ describe('PhoneNumberTransformer', () => { expect(deResult?.value).toBe('+49 30 1234567'); }); }); + + describe('international mode', () => { + const intl = new PhoneNumberTransformer({ international: true }); + + it('formats a US number typed with +1', () => { + expect(transform(intl, '+14155552671')?.value).toBe('+1 (415) 555-2671'); + }); + + it('switches to French formatting when +33 is typed', () => { + expect(transform(intl, '+33612345678')?.value).toBe('+33 6 12 34 56 78'); + }); + + it('keeps a French trunk 0 when typed in full', () => { + expect(transform(intl, '+330612345678')?.value).toBe( + '+33 06 12 34 56 78', + ); + }); + + it('switches to UK formatting when +44 is typed', () => { + expect(transform(intl, '+447911123456')?.value).toBe('+44 7911 123456'); + }); + + it('shows just the calling code while it is the only thing typed', () => { + expect(transform(intl, '+44')?.value).toBe('+44'); + }); + + it('ignores the `country` option and follows the typed code', () => { + const intlUs = new PhoneNumberTransformer({ + international: true, + country: 'US', + }); + expect(transform(intlUs, '+447911123456')?.value).toBe('+44 7911 123456'); + }); + }); + + describe('detectCountry', () => { + it('resolves the primary country from the calling code', () => { + expect(detectCountry('+1 415 555 2671')).toBe('US'); + expect(detectCountry('+44 7911 123456')).toBe('GB'); + expect(detectCountry('+33 6 12 34 56 78')).toBe('FR'); + }); + + it('returns undefined when no calling code is recognized', () => { + expect(detectCountry('')).toBeUndefined(); + }); + }); }); diff --git a/src/formatters/phone-data.ts b/src/formatters/phone-data.ts index 8887fd8..871a7d7 100644 --- a/src/formatters/phone-data.ts +++ b/src/formatters/phone-data.ts @@ -5793,3 +5793,238 @@ export const COUNTRY_LIST: CountryPhoneData[] = [ COUNTRY_PHONE_DATA['ZM']!, COUNTRY_PHONE_DATA['ZW']!, ]; + +export const COUNTRY_CALLING_CODES: Record = { + '1': [ + 'US', + 'AG', + 'AI', + 'AS', + 'BB', + 'BM', + 'BS', + 'CA', + 'DM', + 'DO', + 'GD', + 'GU', + 'JM', + 'KN', + 'KY', + 'LC', + 'MP', + 'MS', + 'PR', + 'SX', + 'TC', + 'TT', + 'VC', + 'VG', + 'VI', + ], + '7': ['RU', 'KZ'], + '20': ['EG'], + '27': ['ZA'], + '30': ['GR'], + '31': ['NL'], + '32': ['BE'], + '33': ['FR'], + '34': ['ES'], + '36': ['HU'], + '39': ['IT', 'VA'], + '40': ['RO'], + '41': ['CH'], + '43': ['AT'], + '44': ['GB', 'GG', 'IM', 'JE'], + '45': ['DK'], + '46': ['SE'], + '47': ['NO', 'SJ'], + '48': ['PL'], + '49': ['DE'], + '51': ['PE'], + '52': ['MX'], + '53': ['CU'], + '54': ['AR'], + '55': ['BR'], + '56': ['CL'], + '57': ['CO'], + '58': ['VE'], + '60': ['MY'], + '61': ['AU', 'CC', 'CX'], + '62': ['ID'], + '63': ['PH'], + '64': ['NZ'], + '65': ['SG'], + '66': ['TH'], + '81': ['JP'], + '82': ['KR'], + '84': ['VN'], + '86': ['CN'], + '90': ['TR'], + '91': ['IN'], + '92': ['PK'], + '93': ['AF'], + '94': ['LK'], + '95': ['MM'], + '98': ['IR'], + '211': ['SS'], + '212': ['MA', 'EH'], + '213': ['DZ'], + '216': ['TN'], + '218': ['LY'], + '220': ['GM'], + '221': ['SN'], + '222': ['MR'], + '223': ['ML'], + '224': ['GN'], + '225': ['CI'], + '226': ['BF'], + '227': ['NE'], + '228': ['TG'], + '229': ['BJ'], + '230': ['MU'], + '231': ['LR'], + '232': ['SL'], + '233': ['GH'], + '234': ['NG'], + '235': ['TD'], + '236': ['CF'], + '237': ['CM'], + '238': ['CV'], + '239': ['ST'], + '240': ['GQ'], + '241': ['GA'], + '242': ['CG'], + '243': ['CD'], + '244': ['AO'], + '245': ['GW'], + '246': ['IO'], + '247': ['AC'], + '248': ['SC'], + '249': ['SD'], + '250': ['RW'], + '251': ['ET'], + '252': ['SO'], + '253': ['DJ'], + '254': ['KE'], + '255': ['TZ'], + '256': ['UG'], + '257': ['BI'], + '258': ['MZ'], + '260': ['ZM'], + '261': ['MG'], + '262': ['RE', 'YT'], + '263': ['ZW'], + '264': ['NA'], + '265': ['MW'], + '266': ['LS'], + '267': ['BW'], + '268': ['SZ'], + '269': ['KM'], + '290': ['SH', 'TA'], + '291': ['ER'], + '297': ['AW'], + '298': ['FO'], + '299': ['GL'], + '350': ['GI'], + '351': ['PT'], + '352': ['LU'], + '353': ['IE'], + '354': ['IS'], + '355': ['AL'], + '356': ['MT'], + '357': ['CY'], + '358': ['FI', 'AX'], + '359': ['BG'], + '370': ['LT'], + '371': ['LV'], + '372': ['EE'], + '373': ['MD'], + '374': ['AM'], + '375': ['BY'], + '376': ['AD'], + '377': ['MC'], + '378': ['SM'], + '380': ['UA'], + '381': ['RS'], + '382': ['ME'], + '383': ['XK'], + '385': ['HR'], + '386': ['SI'], + '387': ['BA'], + '389': ['MK'], + '420': ['CZ'], + '421': ['SK'], + '423': ['LI'], + '500': ['FK'], + '501': ['BZ'], + '502': ['GT'], + '503': ['SV'], + '504': ['HN'], + '505': ['NI'], + '506': ['CR'], + '507': ['PA'], + '508': ['PM'], + '509': ['HT'], + '590': ['GP', 'BL', 'MF'], + '591': ['BO'], + '592': ['GY'], + '593': ['EC'], + '594': ['GF'], + '595': ['PY'], + '596': ['MQ'], + '597': ['SR'], + '598': ['UY'], + '599': ['CW', 'BQ'], + '670': ['TL'], + '672': ['NF'], + '673': ['BN'], + '674': ['NR'], + '675': ['PG'], + '676': ['TO'], + '677': ['SB'], + '678': ['VU'], + '679': ['FJ'], + '680': ['PW'], + '681': ['WF'], + '682': ['CK'], + '683': ['NU'], + '685': ['WS'], + '686': ['KI'], + '687': ['NC'], + '688': ['TV'], + '689': ['PF'], + '690': ['TK'], + '691': ['FM'], + '692': ['MH'], + '850': ['KP'], + '852': ['HK'], + '853': ['MO'], + '855': ['KH'], + '856': ['LA'], + '880': ['BD'], + '886': ['TW'], + '960': ['MV'], + '961': ['LB'], + '962': ['JO'], + '963': ['SY'], + '964': ['IQ'], + '965': ['KW'], + '966': ['SA'], + '967': ['YE'], + '968': ['OM'], + '970': ['PS'], + '971': ['AE'], + '972': ['IL'], + '973': ['BH'], + '974': ['QA'], + '975': ['BT'], + '976': ['MN'], + '977': ['NP'], + '992': ['TJ'], + '993': ['TM'], + '994': ['AZ'], + '995': ['GE'], + '996': ['KG'], + '998': ['UZ'], +}; diff --git a/src/formatters/phone-number.ts b/src/formatters/phone-number.ts index 91eb35d..0a95cd6 100644 --- a/src/formatters/phone-number.ts +++ b/src/formatters/phone-number.ts @@ -1,5 +1,9 @@ import { Transformer, type Selection } from '../Transformer'; -import { COUNTRY_PHONE_DATA, type PhoneFormat } from './phone-data'; +import { + COUNTRY_CALLING_CODES, + COUNTRY_PHONE_DATA, + type PhoneFormat, +} from './phone-data'; export type PhoneNumberTransformerOptions = { /** @@ -14,6 +18,14 @@ export type PhoneNumberTransformerOptions = { * @default true */ includeCallingCode?: boolean; + /** + * International mode: the calling code is part of the editable text and the + * country is detected from it as you type (e.g. typing "+44" switches + * formatting to the UK). `country` is ignored in this mode. Use + * {@link detectCountry} to drive a flag/country indicator from the value. + * @default false + */ + international?: boolean; /** * Enable debug logging for transformer operations. * @default false @@ -21,6 +33,36 @@ export type PhoneNumberTransformerOptions = { debug?: boolean; }; +// Longest calling-code prefix (1–3 digits) of `digits` that is a key of +// `codes`. `codes` can be any calling-code-keyed record (the lookup table or +// the per-code format index), so the worklet reuses its captured index. +const matchCallingCode = ( + digits: string, + codes: Record, +): string => { + 'worklet'; + const max = Math.min(3, digits.length); + for (let len = max; len >= 1; len--) { + if (codes[digits.slice(0, len)] !== undefined) { + return digits.slice(0, len); + } + } + return ''; +}; + +/** + * Detect the ISO 3166-1 alpha-2 country for a phone value by its leading + * international calling code. Returns the primary country for the code (e.g. + * "US" for "+1"), or undefined if no calling code is recognized yet. Runs on + * the JS thread — use it to drive a flag/country indicator alongside an + * `international` PhoneNumberTransformer. + */ +export function detectCountry(value: string): string | undefined { + const digits = value.replace(/\D/g, ''); + const code = matchCallingCode(digits, COUNTRY_CALLING_CODES); + return code ? COUNTRY_CALLING_CODES[code]?.[0] : undefined; +} + // Extract all digits from text const extractDigits = (text: string): string => { 'worklet'; @@ -254,8 +296,133 @@ export class PhoneNumberTransformer extends Transformer { constructor({ country = 'US', includeCallingCode = true, + international = false, debug = false, }: PhoneNumberTransformerOptions = {}) { + if (international) { + // Per-calling-code formatting data (primary country for each code), + // captured once so the worklet can detect + format any country. + const callingCodeData: Record< + string, + { formats: PhoneFormat[]; nationalPrefix: string } + > = {}; + for (const code in COUNTRY_CALLING_CODES) { + const primary = COUNTRY_CALLING_CODES[code]![0]!; + const d = COUNTRY_PHONE_DATA[primary]; + if (d) { + callingCodeData[code] = { + formats: d.formats, + nationalPrefix: d.nationalPrefix ?? '', + }; + } + } + + const intlWorklet = (input: { + value: string; + previousValue: string; + selection: Selection; + previousSelection: Selection; + }) => { + 'worklet'; + + const { value, selection, previousValue, previousSelection } = input; + + const allDigits = extractDigits(value); + const prevAllDigits = extractDigits(previousValue); + + if (allDigits.length === 0) { + return { value: '', selection: { start: 0, end: 0 } }; + } + + const digitsBeforeStart = countDigitsBefore(value, selection.start); + const digitsBeforeEnd = countDigitsBefore(value, selection.end); + + // Backspacing a separator removes the digit before the caret instead, + // so deletion makes progress. + const isCaret = selection.start === selection.end; + const deletedFormattingChar = + isCaret && + value.length < previousValue.length && + allDigits.length === prevAllDigits.length && + allDigits.length > 0; + + let workingDigits = allDigits; + let finalStart = digitsBeforeStart; + let finalEnd = digitsBeforeEnd; + if (deletedFormattingChar && digitsBeforeStart > 0) { + workingDigits = + allDigits.slice(0, digitsBeforeStart - 1) + + allDigits.slice(digitsBeforeStart); + finalStart = digitsBeforeStart - 1; + finalEnd = finalStart; + } + + const cursorAtEnd = + isCaret && + selection.end >= value.length && + previousSelection.end >= previousValue.length; + + const callingCode = matchCallingCode(workingDigits, callingCodeData); + + // No recognized calling code yet — show "+digits" as typed. + if (callingCode === '') { + const result = '+' + workingDigits; + if (cursorAtEnd) { + return { + value: result, + selection: { start: result.length, end: result.length }, + }; + } + // One leading "+" before the digits. + return { + value: result, + selection: { start: 1 + finalStart, end: 1 + finalEnd }, + }; + } + + const data = callingCodeData[callingCode]!; + const nationalPrefix = data.nationalPrefix; + const nationalDigits = workingDigits.slice(callingCode.length); + + let result: string; + if (nationalDigits.length === 0) { + result = '+' + callingCode; + } else { + let significantDigits = nationalDigits; + let trunkPrefix = ''; + if ( + nationalPrefix.length > 0 && + nationalDigits.length > nationalPrefix.length && + nationalDigits.startsWith(nationalPrefix) + ) { + significantDigits = nationalDigits.slice(nationalPrefix.length); + trunkPrefix = nationalPrefix; + } + const format = selectFormat(significantDigits, data.formats); + const formattedNational = format + ? trunkPrefix + applyFormat(significantDigits, format) + : nationalDigits; + result = '+' + callingCode + ' ' + formattedNational; + } + + if (cursorAtEnd) { + return { + value: result, + selection: { start: result.length, end: result.length }, + }; + } + + // result digits are [callingCode..., national...] in input order, so + // map the input digit counts straight onto the formatted output. + const newStart = mapCursorToFormatted(result, finalStart); + const newEnd = mapCursorToFormatted(result, finalEnd); + return { value: result, selection: { start: newStart, end: newEnd } }; + }; + + super(intlWorklet); + return; + } + const countryData = COUNTRY_PHONE_DATA[country]; if (!countryData) { throw new Error(