UNPKG

react-phone-number-input

Version:

Telephone number input React component

730 lines (681 loc) 28.8 kB
import parsePhoneNumber_, { getCountryCallingCode, AsYouType, Metadata } from 'libphonenumber-js/core' import getInternationalPhoneNumberPrefix from './getInternationalPhoneNumberPrefix.js' /** * Decides which country should be pre-selected * when the phone number input component is first mounted. * @param {object?} phoneNumber - An instance of `PhoneNumber` class. * @param {string?} country - Pre-defined country (two-letter code). * @param {string[]?} countries - A list of countries available. * @param {object} metadata - `libphonenumber-js` metadata * @return {string?} */ export function getPreSelectedCountry({ value, phoneNumber, defaultCountry, getAnyCountry, countries, required, metadata }) { let country // If can get country from E.164 phone number // then it overrides the `country` passed (or not passed). if (phoneNumber && phoneNumber.country) { // `country` will be left `undefined` in case of non-detection. country = phoneNumber.country } else if (defaultCountry) { if (!value || couldNumberBelongToCountry(value, defaultCountry, metadata)) { country = defaultCountry } } // Only pre-select a country if it's in the available `countries` list. if (countries && countries.indexOf(country) < 0) { country = undefined } // If there will be no "International" option // then some `country` must be selected. // It will still be the wrong country though. // But still country `<select/>` can't be left in a broken state. if (!country && required && countries && countries.length > 0) { country = getAnyCountry() // noCountryMatchesTheNumber = true } return country } /** * Generates a sorted list of country `<select/>` options. * @param {string[]} countries - A list of two-letter ("ISO 3166-1 alpha-2") country codes. * @param {object} labels - Custom country labels. E.g. `{ RU: 'Россия', US: 'США', ... }`. * @param {boolean} addInternationalOption - Whether should include "International" option at the top of the list. * @return {object[]} A list of objects having shape `{ value : string, label : string }`. */ export function getCountrySelectOptions({ countries, countryNames, addInternationalOption, // `locales` are only used in country name comparator: // depending on locale, string sorting order could be different. compareStringsLocales, compareStrings: _compareStrings }) { // Default country name comparator uses `String.localeCompare()`. if (!_compareStrings) { _compareStrings = compareStrings } // Generates a `<Select/>` option for each country. const countrySelectOptions = countries.map((country) => ({ value: country, // All `locale` country names included in this library // include all countries (this is checked at build time). // The only case when a country name might be missing // is when a developer supplies their own `labels` property. // To guard against such cases, a missing country name // is substituted by country code. label: countryNames[country] || country })) // Sort the list of countries alphabetically. countrySelectOptions.sort((a, b) => _compareStrings(a.label, b.label, compareStringsLocales)) // Add the "International" option to the country list (if suitable) if (addInternationalOption) { countrySelectOptions.unshift({ label: countryNames.ZZ }) } return countrySelectOptions } /** * Parses a E.164 phone number to an instance of `PhoneNumber` class. * @param {string?} value = E.164 phone number. * @param {object} metadata - `libphonenumber-js` metadata * @return {object} Object having shape `{ country: string?, countryCallingCode: string, number: string }`. `PhoneNumber`: https://gitlab.com/catamphetamine/libphonenumber-js#phonenumber. * @example * parsePhoneNumber('+78005553535') */ export function parsePhoneNumber(value, metadata) { return parsePhoneNumber_(value || '', metadata) } /** * Generates national number digits for a parsed phone. * May prepend national prefix. * The phone number must be a complete and valid phone number. * @param {object} phoneNumber - An instance of `PhoneNumber` class. * @param {object} metadata - `libphonenumber-js` metadata * @return {string} * @example * getNationalNumberDigits({ country: 'RU', phone: '8005553535' }) * // returns '88005553535' */ export function generateNationalNumberDigits(phoneNumber) { return phoneNumber.formatNational().replace(/\D/g, '') } /** * Migrates parsed `<input/>` `value` for the newly selected `country`. * @param {string?} phoneDigits - Phone number digits (and `+`) parsed from phone number `<input/>` (it's not the same as the `value` property). * @param {string?} prevCountry - Previously selected country. * @param {string?} newCountry - Newly selected country. Can't be same as previously selected country. * @param {object} metadata - `libphonenumber-js` metadata. * @param {boolean} useNationalFormat - whether should attempt to convert from international to national number for the new country. * @return {string?} */ export function getPhoneDigitsForNewCountry(phoneDigits, { prevCountry, newCountry, metadata, useNationalFormat }) { if (prevCountry === newCountry) { return phoneDigits } // If `parsed_input` is empty // then no need to migrate anything. if (!phoneDigits) { if (useNationalFormat) { return '' } else { // If `phoneDigits` is empty then set `phoneDigits` to // `+{getCountryCallingCode(newCountry)}`. return getInternationalPhoneNumberPrefix(newCountry, metadata) } } // If switching to some country. // (from "International" or another country) // If switching from "International" then `phoneDigits` starts with a `+`. // Otherwise it may or may not start with a `+`. if (newCountry) { // If the phone number was entered in international format // then migrate it to the newly selected country. // The phone number may be incomplete. // The phone number entered not necessarily starts with // the previously selected country phone prefix. if (phoneDigits[0] === '+') { // If the international phone number is for the new country // then convert it to local if required. if (useNationalFormat) { // // If a phone number is being input in international form // // and the country can already be derived from it, // // and if it is the new country, then format as a national number. // const derived_country = getCountryFromPossiblyIncompleteInternationalPhoneNumber(phoneDigits, metadata) // if (derived_country === newCountry) { // return stripCountryCallingCode(phoneDigits, derived_country, metadata) // } // Actually, the two countries don't necessarily need to match: // the condition could be looser here, because several countries // might share the same international phone number format // (for example, "NANPA" countries like US, Canada, etc). // The looser condition would be just "same nternational phone number format" // which would mean "same country calling code" in the context of `libphonenumber-js`. if (phoneDigits.indexOf('+' + getCountryCallingCode(newCountry, metadata)) === 0) { return stripCountryCallingCode(phoneDigits, newCountry, metadata) } // Simply discard the previously entered international phone number, // because otherwise any "smart" transformation like getting the // "national (significant) number" part and then prepending the // newly selected country's "country calling code" to it // would just be confusing for a user without being actually useful. return '' // // Simply strip the leading `+` character // // therefore simply converting all digits into a "local" phone number. // // https://github.com/catamphetamine/react-phone-number-input/issues/287 // return phoneDigits.slice(1) } if (prevCountry) { const newCountryPrefix = getInternationalPhoneNumberPrefix(newCountry, metadata) if (phoneDigits.indexOf(newCountryPrefix) === 0) { return phoneDigits } else { return newCountryPrefix } } else { const defaultValue = getInternationalPhoneNumberPrefix(newCountry, metadata) // If `phoneDigits`'s country calling code part is the same // as for the new `country`, then leave `phoneDigits` as is. if (phoneDigits.indexOf(defaultValue) === 0) { return phoneDigits } // If `phoneDigits`'s country calling code part is not the same // as for the new `country`, then set `phoneDigits` to // `+{getCountryCallingCode(newCountry)}`. return defaultValue } // // If the international phone number already contains // // any country calling code then trim the country calling code part. // // (that could also be the newly selected country phone code prefix as well) // // `phoneDigits` doesn't neccessarily belong to `prevCountry`. // // (e.g. if a user enters an international number // // not belonging to any of the reduced `countries` list). // phoneDigits = stripCountryCallingCode(phoneDigits, prevCountry, metadata) // // Prepend country calling code prefix // // for the newly selected country. // return e164(phoneDigits, newCountry, metadata) || `+${getCountryCallingCode(newCountry, metadata)}` } } // If switching to "International" from a country. else { // If the phone number was entered in national format. if (phoneDigits[0] !== '+') { // Format the national phone number as an international one. // The phone number entered not necessarily even starts with // the previously selected country phone prefix. // Even if the phone number belongs to whole another country // it will still be parsed into some national phone number. // // Ignore the now-uncovered `|| ''` code branch: // previously `e164()` function could return an empty string // even when `phoneDigits` were not empty. // Now it always returns some `value` when there're any `phoneDigits`. // Still, didn't remove the `|| ''` code branch just in case // that logic changes somehow in some future, so there're no // possible bugs related to that. // // (ignore the `|| ''` code branch) /* istanbul ignore next */ return e164(phoneDigits, prevCountry, metadata) || '' } } return phoneDigits } /** * Converts phone number digits to a (possibly incomplete) E.164 phone number. * @param {string?} number - A possibly incomplete phone number digits string. Can be a possibly incomplete E.164 phone number. * @param {string?} country * @param {object} metadata - `libphonenumber-js` metadata. * @return {string?} */ export function e164(number, country, metadata) { if (!number) { return } // If the phone number is being input in international format. if (number[0] === '+') { // If it's just the `+` sign then return nothing. if (number === '+') { return } // Return a E.164 phone number. // // Could return `number` "as is" here, but there's a possibility // that some user might incorrectly input an international number // with a "national prefix". Such numbers aren't considered valid, // but `libphonenumber-js` is "forgiving" when it comes to parsing // user's input, and this input component follows that behavior. // const asYouType = new AsYouType(country, metadata) asYouType.input(number) // This function would return `undefined` only when `number` is `"+"`, // but at this point it is known that `number` is not `"+"`. return asYouType.getNumberValue() } // For non-international phone numbers // an accompanying country code is required. // The situation when `country` is `undefined` // and a non-international phone number is passed // to this function shouldn't happen. if (!country) { return } const partial_national_significant_number = getNationalSignificantNumberDigits(number, country, metadata) // // Even if no "national (significant) number" digits have been input, // still return a non-`undefined` value. // https://gitlab.com/catamphetamine/react-phone-number-input/-/issues/113 // // For example, if the user has selected country `US` and entered `"1"` // then that `"1"` is just a "national prefix" and no "national (significant) number" // digits have been input yet. Still, return `"+1"` as `value` in such cases, // because otherwise the app would think that the input is empty and mark it as such // while in reality it isn't empty, which might be thought of as a "bug", or just // a "weird" behavior. // // if (partial_national_significant_number) { return `+${getCountryCallingCode(country, metadata)}${partial_national_significant_number || ''}` // } } /** * Trims phone number digits if they exceed the maximum possible length * for a national (significant) number for the country. * @param {string} number - A possibly incomplete phone number digits string. Can be a possibly incomplete E.164 phone number. * @param {string} country * @param {object} metadata - `libphonenumber-js` metadata. * @return {string} Can be empty. */ export function trimNumber(number, country, metadata) { const nationalSignificantNumberPart = getNationalSignificantNumberDigits(number, country, metadata) if (nationalSignificantNumberPart) { const overflowDigitsCount = nationalSignificantNumberPart.length - getMaxNumberLength(country, metadata) if (overflowDigitsCount > 0) { return number.slice(0, number.length - overflowDigitsCount) } } return number } function getMaxNumberLength(country, metadata) { // Get "possible lengths" for a phone number of the country. metadata = new Metadata(metadata) metadata.selectNumberingPlan(country) // Return the last "possible length". return metadata.numberingPlan.possibleLengths()[metadata.numberingPlan.possibleLengths().length - 1] } // If the phone number being input is an international one // then tries to derive the country from the phone number. // (regardless of whether there's any country currently selected) /** * @param {string} partialE164Number - A possibly incomplete E.164 phone number. * @param {string?} country - Currently selected country. * @param {string[]?} countries - A list of available countries. If not passed then "all countries" are assumed. * @param {object} metadata - `libphonenumber-js` metadata. * @return {string?} */ export function getCountryForPartialE164Number(partialE164Number, { country, countries, required, metadata }) { if (partialE164Number === '+') { // Don't change the currently selected country yet. return country } const derived_country = getCountryFromPossiblyIncompleteInternationalPhoneNumber(partialE164Number, metadata) // If a phone number is being input in international form // and the country can already be derived from it, // then select that country. if (derived_country && (!countries || (countries.indexOf(derived_country) >= 0))) { return derived_country } // If "International" country option has not been disabled // and the international phone number entered doesn't correspond // to the currently selected country then reset the currently selected country. else if (country && !required && !couldNumberBelongToCountry(partialE164Number, country, metadata)) { return undefined } // Don't change the currently selected country. return country } /** * Parses `<input/>` value. Derives `country` from `input`. Derives an E.164 `value`. * @param {string?} phoneDigits — Parsed `<input/>` value. Examples: `""`, `"+"`, `"+123"`, `"123"`. * @param {string?} prevPhoneDigits — Previous parsed `<input/>` value. Examples: `""`, `"+"`, `"+123"`, `"123"`. * @param {string?} country - Currently selected country. * @param {boolean} countryRequired - Is selecting some country required. * @param {function} getAnyCountry - Can be used to get any country when selecting some country required. * @param {string[]?} countries - A list of available countries. If not passed then "all countries" are assumed. * @param {boolean} international - Set to `true` to force international phone number format (leading `+`). Set to `false` to force "national" phone number format. Is `undefined` by default. * @param {boolean} limitMaxLength — Whether to enable limiting phone number max length. * @param {object} metadata - `libphonenumber-js` metadata. * @return {object} An object of shape `{ input, country, value }`. */ export function onPhoneDigitsChange(phoneDigits, { prevPhoneDigits, country, defaultCountry, countryRequired, getAnyCountry, countries, international, limitMaxLength, countryCallingCodeEditable, metadata }) { if (international && countryCallingCodeEditable === false) { const prefix = getInternationalPhoneNumberPrefix(country, metadata) // The `<input/>` value must start with the country calling code. if (phoneDigits.indexOf(prefix) !== 0) { let value // If a phone number input is declared as // `international` and `withCountryCallingCode`, // then it's gonna be non-empty even before the user // has input anything in it. // This will result in its contents (the country calling code part) // being selected when the user tabs into such field. // If the user then starts inputting the national part digits, // then `<input/>` value changes from `+xxx` to `y` // because inputting anything while having the `<input/>` value // selected results in erasing the `<input/>` value. // So, the component handles such case by restoring // the intended `<input/>` value: `+xxxy`. // https://gitlab.com/catamphetamine/react-phone-number-input/-/issues/43 if (phoneDigits && phoneDigits[0] !== '+') { phoneDigits = prefix + phoneDigits value = e164(phoneDigits, country, metadata) } else { phoneDigits = prefix } return { phoneDigits, value, country } } } // If `international` property is `false`, then it means // "enforce national-only format during input", // so, if that's the case, then remove all `+` characters, // but only if some country is currently selected. // (not if "International" country is selected). if (international === false && country && phoneDigits && phoneDigits[0] === '+') { phoneDigits = convertInternationalPhoneDigitsToNational(phoneDigits, country, metadata) } // Trim the input to not exceed the maximum possible number length. if (phoneDigits && country && limitMaxLength) { phoneDigits = trimNumber(phoneDigits, country, metadata) } // If this `onChange()` event was triggered // as a result of selecting "International" country, // then force-prepend a `+` sign if the phone number // `<input/>` value isn't in international format. // Also, force-prepend a `+` sign if international // phone number input format is set. if (phoneDigits && phoneDigits[0] !== '+' && (!country || international)) { phoneDigits = '+' + phoneDigits } // If the previously entered phone number // has been entered in international format // and the user decides to erase it, // then also reset the `country` // because it was most likely automatically selected // while the user was typing in the phone number // in international format. // This fixes the issue when a user is presented // with a phone number input with no country selected // and then types in their local phone number // then discovers that the input's messed up // (a `+` has been prepended at the start of their input // and a random country has been selected), // decides to undo it all by erasing everything // and then types in their local phone number again // resulting in a seemingly correct phone number // but in reality that phone number has incorrect country. // https://github.com/catamphetamine/react-phone-number-input/issues/273 if (!phoneDigits && prevPhoneDigits && prevPhoneDigits[0] === '+') { if (international) { country = undefined } else { country = defaultCountry } } // Also resets such "randomly" selected country // as soon as the user erases the number // digit-by-digit up to the leading `+` sign. if (phoneDigits === '+' && prevPhoneDigits && prevPhoneDigits[0] === '+' && prevPhoneDigits.length > '+'.length) { country = undefined } // Generate the new `value` property. let value if (phoneDigits) { if (phoneDigits[0] === '+') { if (phoneDigits === '+') { value = undefined } else if (country && getInternationalPhoneNumberPrefix(country, metadata).indexOf(phoneDigits) !== 0) { // Selected a `country` but started inputting an // international phone number for another country. // Even though the input value is non-empty, // the `value` is assumed `undefined` in such case. // The `country` will be reset (or re-selected) // immediately after such mismatch has been detected // by the phone number input component, and `value` // will be set to the currently entered international prefix. // // For example, if selected `country` `"US"` // and started inputting phone number `"+2"` // then `value` `undefined` will be returned from this function, // and then, immediately after that, `country` will be reset // and the `value` will be set to `"+2"`. // value = undefined } else { value = e164(phoneDigits, country, metadata) } } else { value = e164(phoneDigits, country, metadata) } } // Derive the country from the phone number. // (regardless of whether there's any country currently selected, // because there could be several countries corresponding to one country calling code) if (value) { country = getCountryForPartialE164Number(value, { country, countries, metadata }) // If `international` property is `false`, then it means // "enforce national-only format during input", // so, if that's the case, then remove all `+` characters, // but only if some country is currently selected. // (not if "International" country is selected). if (international === false && country && phoneDigits && phoneDigits[0] === '+') { phoneDigits = convertInternationalPhoneDigitsToNational(phoneDigits, country, metadata) // Re-calculate `value` because `phoneDigits` has changed. value = e164(phoneDigits, country, metadata) } } if (!country && countryRequired) { country = defaultCountry || getAnyCountry() } return { phoneDigits, country, value } } function convertInternationalPhoneDigitsToNational(input, country, metadata) { // Handle the case when a user might have pasted // a phone number in international format. if (input.indexOf(getInternationalPhoneNumberPrefix(country, metadata)) === 0) { // Create "as you type" formatter. const formatter = new AsYouType(country, metadata) // Input partial national phone number. formatter.input(input) // Return the parsed partial national phone number. const phoneNumber = formatter.getNumber() if (phoneNumber) { // Transform the number to a national one, // and remove all non-digits. return phoneNumber.formatNational().replace(/\D/g, '') } else { return '' } } else { // Just remove the `+` sign. return input.replace(/\D/g, '') } } /** * Determines the country for a given (possibly incomplete) E.164 phone number. * @param {string} number - A possibly incomplete E.164 phone number. * @param {object} metadata - `libphonenumber-js` metadata. * @return {string?} */ export function getCountryFromPossiblyIncompleteInternationalPhoneNumber(number, metadata) { const formatter = new AsYouType(null, metadata) formatter.input(number) // // `001` is a special "non-geograpical entity" code // // in Google's `libphonenumber` library. // if (formatter.getCountry() === '001') { // return // } return formatter.getCountry() } /** * Compares two strings. * A helper for `Array.sort()`. * @param {string} a — First string. * @param {string} b — Second string. * @param {(string[]|string)} [locales] — The `locales` argument of `String.localeCompare`. */ export function compareStrings(a, b, locales) { // Use `String.localeCompare` if it's available. // https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare // Which means everyone except IE <= 10 and Safari <= 10. // `localeCompare()` is available in latest Node.js versions. /* istanbul ignore else */ if (String.prototype.localeCompare) { return a.localeCompare(b, locales); } /* istanbul ignore next */ return a < b ? -1 : (a > b ? 1 : 0); } /** * Strips `+${countryCallingCode}` prefix from an E.164 phone number. * @param {string} number - (possibly incomplete) E.164 phone number. * @param {string?} country - A possible country for this phone number. * @param {object} metadata - `libphonenumber-js` metadata. * @return {string} */ export function stripCountryCallingCode(number, country, metadata) { // Just an optimization, so that it // doesn't have to iterate through all country calling codes. if (country) { const countryCallingCodePrefix = '+' + getCountryCallingCode(country, metadata) // If `country` fits the actual `number`. if (number.length < countryCallingCodePrefix.length) { if (countryCallingCodePrefix.indexOf(number) === 0) { return '' } } else { if (number.indexOf(countryCallingCodePrefix) === 0) { return number.slice(countryCallingCodePrefix.length) } } } // If `country` doesn't fit the actual `number`. // Try all available country calling codes. for (const country_calling_code of Object.keys(metadata.country_calling_codes)) { if (number.indexOf(country_calling_code) === '+'.length) { return number.slice('+'.length + country_calling_code.length) } } return '' } /** * Parses a partially entered national phone number digits * (or a partially entered E.164 international phone number) * and returns the national significant number part. * National significant number returned doesn't come with a national prefix. * @param {string} number - National number digits. Or possibly incomplete E.164 phone number. * @param {string?} country * @param {object} metadata - `libphonenumber-js` metadata. * @return {string} [result] */ export function getNationalSignificantNumberDigits(number, country, metadata) { // Create "as you type" formatter. const formatter = new AsYouType(country, metadata) // Input partial national phone number. formatter.input(number) // Return the parsed partial national phone number. const phoneNumber = formatter.getNumber() return phoneNumber && phoneNumber.nationalNumber } /** * Checks if a partially entered E.164 phone number could belong to a country. * @param {string} number * @param {string} country * @return {boolean} */ export function couldNumberBelongToCountry(number, country, metadata) { const intlPhoneNumberPrefix = getInternationalPhoneNumberPrefix(country, metadata) let i = 0 while (i < number.length && i < intlPhoneNumberPrefix.length) { if (number[i] !== intlPhoneNumberPrefix[i]) { return false } i++ } return true } /** * Gets initial "phone digits" (including `+`, if using international format). * @return {string} [phoneDigits] Returns `undefined` if there should be no initial "phone digits". */ export function getInitialPhoneDigits({ value, phoneNumber, defaultCountry, international, useNationalFormat, metadata }) { // If the `value` (E.164 phone number) // belongs to the currently selected country // and `useNationalFormat` is `true` // then convert `value` (E.164 phone number) // to a local phone number digits. // E.g. '+78005553535' -> '88005553535'. if ((international === false || useNationalFormat) && phoneNumber && phoneNumber.country) { return generateNationalNumberDigits(phoneNumber) } // If `international` property is `true`, // meaning "enforce international phone number format", // then always show country calling code in the input field. if (!value && international && defaultCountry) { return getInternationalPhoneNumberPrefix(defaultCountry, metadata) } return value }