UNPKG

@maskito/phone

Version:

The optional framework-agnostic Maskito's package with phone masks

399 lines (381 loc) 14.7 kB
'use strict'; var core$1 = require('@maskito/core'); var core = require('libphonenumber-js/core'); var kit = require('@maskito/kit'); const TEMPLATE_FILLER = 'x'; function extractNumberValue(value, countryIsoCode, metadata) { const formatter = new core.AsYouType(countryIsoCode, metadata); formatter.input(value); const numberValue = formatter.getNumberValue() ?? ''; formatter.reset(); return numberValue; } /** * Converts an international phone value to national format. */ function convertToNationalFormat(value, countryIsoCode, metadata) { const numberValue = extractNumberValue(value, countryIsoCode, metadata); if (!numberValue) { return ''; } try { const phone = core.parsePhoneNumber(numberValue, countryIsoCode, metadata); return core.formatIncompletePhoneNumber(phone.nationalNumber, countryIsoCode, metadata); } catch { return ''; } } function browserAutofillPreprocessorGenerator({ prefix, countryIsoCode, metadata, format = 'INTERNATIONAL', }) { const isNational = format === 'NATIONAL'; return ({ elementState, data }) => { const { selection, value } = elementState; const cleanCode = prefix.trim(); /** * Handle autocomplete: when value doesn't match expected format. * For international: value should start with '+' or country code. * For national: value should not start with '+'. */ if (value && !data) { if (isNational && value.startsWith('+') && countryIsoCode) { /** * For national format, if autocomplete provides international format, * convert it to national format. */ const formattedNational = convertToNationalFormat(value, countryIsoCode, metadata); if (formattedNational) { return { elementState: { value: formattedNational, selection } }; } } else if (!isNational && !value.startsWith(cleanCode)) { /** * International format autocomplete handling. */ const numberValue = extractNumberValue(value, countryIsoCode, metadata); const formatter = new core.AsYouType(countryIsoCode, metadata); return { elementState: { value: formatter.input(numberValue), selection } }; } } return { elementState }; }; } /** * This preprocessor works only once at initialization phase (when `new Maskito(...)` is executed). * This preprocessor helps to avoid conflicts during transition from one mask to another (for the same input). */ function cutInitCountryCodePreprocessor({ countryIsoCode, metadata, format, }) { let isInitializationPhase = true; const code = `+${core.getCountryCallingCode(countryIsoCode, metadata)} `; return ({ elementState, data }) => { if (!isInitializationPhase) { return { elementState, data }; } const { value, selection } = elementState; isInitializationPhase = false; /** * International format: * If the value already starts with the expected prefix (e.g., "+7 "), * don't reformat it. This avoids breaking selection positions when * the input already has a properly formatted value (e.g., an initial * value set on the element before Maskito attaches). * * National format: * If value starts with '+', extract national number. * Otherwise, assume it's already in national format. */ if ((format === 'INTERNATIONAL' && value.startsWith(code)) || (format === 'NATIONAL' && !value.startsWith('+'))) { return { elementState }; } try { const { nationalNumber } = core.parsePhoneNumber(value, countryIsoCode, metadata); return { elementState: { value: format === 'NATIONAL' ? core.formatIncompletePhoneNumber(nationalNumber, countryIsoCode, metadata) : `${code} ${nationalNumber}`, selection, }, }; } catch { return { elementState }; } }; } function parsePhone({ data, prefix, countryIsoCode, metadata, }) { if (!data.startsWith(prefix) && countryIsoCode) { try { return core.parsePhoneNumber(`+${data}`, countryIsoCode, metadata); } catch { return core.parsePhoneNumber(data, countryIsoCode, metadata); } } return core.parsePhoneNumber(data, metadata); } function pasteNonStrictPhonePreprocessorGenerator({ prefix, countryIsoCode, metadata, }) { return ({ elementState, data }) => ({ elementState, data: data.length > 2 && elementState.value === '' ? parsePhone({ data, prefix, countryIsoCode, metadata, }).number : data, }); } function pasteStrictPhonePreprocessorGenerator({ prefix, countryIsoCode, metadata, format = 'INTERNATIONAL', }) { const isNational = format === 'NATIONAL'; return ({ elementState, data }) => { const { selection, value } = elementState; const [from] = selection; const selectionIncludesPrefix = from < prefix.length; // handle paste of a number when input contains only the prefix if (data.length > 2 && value.trim() === prefix.trim()) { // handle paste-event with different code, for example for 8 / +7 const phone = countryIsoCode ? core.parsePhoneNumber(data, countryIsoCode, metadata) : core.parsePhoneNumber(data, metadata); const { nationalNumber, countryCallingCode } = phone; if (isNational && countryIsoCode) { /** * For national format, always return just the national number. * The mask will format it according to the country's national format. */ return { elementState: { selection, value: '', }, data: nationalNumber, }; } return { elementState: { selection, value: selectionIncludesPrefix ? '' : prefix, }, data: selectionIncludesPrefix ? `+${countryCallingCode} ${nationalNumber}` : nationalNumber, }; } return { elementState }; }; } function cutPhoneByValidLength({ phone, metadata, }) { const validationResult = core.validatePhoneNumberLength(phone, metadata); if (validationResult === 'TOO_LONG') { return cutPhoneByValidLength({ phone: phone.slice(0, phone.length - 1), metadata, }); } return phone; } function generatePhoneMask({ value, template, prefix, }) { return [ ...prefix, ...(template ? template .slice(prefix.length) .split('') .map((char) => char === TEMPLATE_FILLER || /\d/.test(char) ? /\d/ : char) : Array.from({ length: Math.max(value.length - prefix.length, prefix.length), }).fill(/\d/)), ]; } function maskitoGetCountryFromNumber(number, metadata) { const formatter = new core.AsYouType({}, metadata); formatter.input(number); return formatter.getCountry(); } function getPhoneTemplate({ formatter, value, separator, countryIsoCode, metadata, format = 'INTERNATIONAL', }) { const isNational = format === 'NATIONAL'; if (isNational && countryIsoCode && metadata) { const normalizedValue = value && !value.startsWith('+') ? `+${value}` : value; formatter.input(normalizedValue.replaceAll(/[^\d+]/g, '')); return getNationalPhoneTemplate({ value: normalizedValue, countryIsoCode, metadata, separator, }); } return getInternationalPhoneTemplate({ formatter, value, separator }); } function getInternationalPhoneTemplate({ formatter, value, separator, }) { const hasDigitsOrPlus = /[\d+]/.test(value); if (!hasDigitsOrPlus) { return ''; } const normalizedValue = value.startsWith('+') ? value : `+${value}`; formatter.input(normalizedValue.replaceAll(/[^\d+]/g, '')); const initialTemplate = formatter.getTemplate(); const split = initialTemplate.split(' '); // Join first two parts with space, remaining parts with custom separator const template = split.length > 1 ? `${split.slice(0, 2).join(' ')} ${split.slice(2).join(separator)}` : initialTemplate; formatter.reset(); return template.trim(); } function getNationalPhoneTemplate({ value, countryIsoCode, metadata, separator, }) { const digitsOnly = value.replaceAll(/\D/g, ''); if (!digitsOnly) { return ''; } const formatted = core.formatIncompletePhoneNumber(digitsOnly, countryIsoCode, metadata); const template = formatted.replaceAll(/\d/g, 'x'); // Parenthesis-based formats (like US): preserve space after ), only replace dashes if (template.includes(')')) { return template.replaceAll('-', separator); } // Space-separated formats (like FR): join groups after first with separator if (!formatted.includes('-')) { const parts = template.split(' '); return parts.length > 1 ? `${parts[0]} ${parts.slice(1).join(separator)}` : template; } // Dash-separated formats (like RU): swap dashes for custom separator return template.replaceAll('-', separator); } function selectTemplate({ currentTemplate, newTemplate, currentPhoneLength, newPhoneLength, }) { return newTemplate.length < currentTemplate.length && newPhoneLength > currentPhoneLength ? currentTemplate : newTemplate; } const MIN_LENGTH = 3; function phoneLengthPostprocessorGenerator(metadata) { return ({ value, selection }) => ({ value: value.length > MIN_LENGTH ? cutPhoneByValidLength({ phone: value, metadata }) : value, selection, }); } const sanitizePreprocessor = ({ elementState, data }) => ({ elementState, data: data.replaceAll(/[^\d+]/g, ''), }); function maskitoPhoneNonStrictOptionsGenerator({ defaultIsoCode, metadata, separator = '-', }) { const formatter = new core.AsYouType(defaultIsoCode, metadata); const prefix = '+'; let currentTemplate = ''; let currentPhoneLength = 0; return { ...core$1.MASKITO_DEFAULT_OPTIONS, mask: ({ value }) => { const newTemplate = getPhoneTemplate({ formatter, value, separator, }); const newPhoneLength = value.replaceAll(/\D/g, '').length; currentTemplate = selectTemplate({ currentTemplate, newTemplate, currentPhoneLength, newPhoneLength, }); currentPhoneLength = newPhoneLength; return currentTemplate.length === 1 ? ['+', /\d/] : generatePhoneMask({ value, template: currentTemplate, prefix }); }, preprocessors: [ sanitizePreprocessor, browserAutofillPreprocessorGenerator({ prefix, countryIsoCode: defaultIsoCode, metadata, }), pasteNonStrictPhonePreprocessorGenerator({ prefix, countryIsoCode: defaultIsoCode, metadata, }), ], postprocessors: [phoneLengthPostprocessorGenerator(metadata)], }; } function maskitoPhoneStrictOptionsGenerator({ countryIsoCode, metadata, separator = '-', format = 'INTERNATIONAL', }) { const isNational = format === 'NATIONAL'; const code = core.getCountryCallingCode(countryIsoCode, metadata); const formatter = new core.AsYouType(countryIsoCode, metadata); const prefix = isNational ? '' : `+${code} `; let currentTemplate = ''; let currentPhoneLength = 0; return { ...core$1.MASKITO_DEFAULT_OPTIONS, mask: ({ value }) => { const newTemplate = getPhoneTemplate({ formatter, value, separator, countryIsoCode, metadata, format, }); const newPhoneLength = value.replaceAll(/\D/g, '').length; currentTemplate = selectTemplate({ currentTemplate, newTemplate, currentPhoneLength, newPhoneLength, }); currentPhoneLength = newPhoneLength; return generatePhoneMask({ value, template: currentTemplate, prefix }); }, plugins: [ kit.maskitoCaretGuard((value, [from, to]) => [ from === to ? prefix.length : 0, value.length, ]), ], preprocessors: [ sanitizePreprocessor, cutInitCountryCodePreprocessor({ countryIsoCode, metadata, format }), browserAutofillPreprocessorGenerator({ prefix, countryIsoCode, metadata, format, }), pasteStrictPhonePreprocessorGenerator({ prefix, countryIsoCode, metadata, format, }), ], postprocessors: isNational ? [phoneLengthPostprocessorGenerator(metadata)] : [ kit.maskitoPrefixPostprocessorGenerator(prefix), phoneLengthPostprocessorGenerator(metadata), ], }; } function maskitoPhoneOptionsGenerator({ countryIsoCode, metadata, strict = true, separator = '-', format = 'INTERNATIONAL', }) { return strict && countryIsoCode ? maskitoPhoneStrictOptionsGenerator({ countryIsoCode, metadata, separator, format, }) : maskitoPhoneNonStrictOptionsGenerator({ defaultIsoCode: countryIsoCode, metadata, separator, }); } exports.maskitoGetCountryFromNumber = maskitoGetCountryFromNumber; exports.maskitoPhoneOptionsGenerator = maskitoPhoneOptionsGenerator;