@maskito/phone
Version:
The optional framework-agnostic Maskito's package with phone masks
399 lines (381 loc) • 14.7 kB
JavaScript
;
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;