libphonenumber-js
Version:
A simpler (and smaller) rewrite of Google Android's libphonenumber library in javascript
461 lines (429 loc) • 15.7 kB
JavaScript
import Metadata from './metadata.js'
import PhoneNumber from './PhoneNumber.js'
import AsYouTypeState from './AsYouTypeState.js'
import AsYouTypeFormatter, { DIGIT_PLACEHOLDER } from './AsYouTypeFormatter.js'
import AsYouTypeParser, { extractFormattedDigitsAndPlus } from './AsYouTypeParser.js'
import getCountryByCallingCode from './helpers/getCountryByCallingCode.js'
import getCountryByNationalNumber from './helpers/getCountryByNationalNumber.js'
import isObject from './helpers/isObject.js'
const USE_NON_GEOGRAPHIC_COUNTRY_CODE = false
export default class AsYouType {
/**
* @param {(string|object)?} [optionsOrDefaultCountry] - The default country used for parsing non-international phone numbers. Can also be an `options` object.
* @param {Object} metadata
*/
constructor(optionsOrDefaultCountry, metadata) {
this.metadata = new Metadata(metadata)
const [defaultCountry, defaultCallingCode] = this.getCountryAndCallingCode(optionsOrDefaultCountry)
// `this.defaultCountry` and `this.defaultCallingCode` aren't required to be in sync.
// For example, `this.defaultCountry` could be `"AR"` and `this.defaultCallingCode` could be `undefined`.
// So `this.defaultCountry` and `this.defaultCallingCode` are totally independent.
this.defaultCountry = defaultCountry
this.defaultCallingCode = defaultCallingCode
this.reset()
}
getCountryAndCallingCode(optionsOrDefaultCountry) {
// Set `defaultCountry` and `defaultCallingCode` options.
let defaultCountry
let defaultCallingCode
// Turns out `null` also has type "object". Weird.
if (optionsOrDefaultCountry) {
if (isObject(optionsOrDefaultCountry)) {
defaultCountry = optionsOrDefaultCountry.defaultCountry
defaultCallingCode = optionsOrDefaultCountry.defaultCallingCode
} else {
defaultCountry = optionsOrDefaultCountry
}
}
if (defaultCountry && !this.metadata.hasCountry(defaultCountry)) {
defaultCountry = undefined
}
if (defaultCallingCode) {
/* istanbul ignore if */
if (USE_NON_GEOGRAPHIC_COUNTRY_CODE) {
if (this.metadata.isNonGeographicCallingCode(defaultCallingCode)) {
defaultCountry = '001'
}
}
}
return [defaultCountry, defaultCallingCode]
}
/**
* Inputs "next" phone number characters.
* @param {string} text
* @return {string} Formatted phone number characters that have been input so far.
*/
input(text) {
const {
digits,
justLeadingPlus
} = this.parser.input(text, this.state)
if (justLeadingPlus) {
this.formattedOutput = '+'
} else if (digits) {
this.determineTheCountryIfNeeded()
// Match the available formats by the currently available leading digits.
if (this.state.nationalSignificantNumber) {
this.formatter.narrowDownMatchingFormats(this.state)
}
let formattedNationalNumber
if (this.metadata.hasSelectedNumberingPlan()) {
formattedNationalNumber = this.formatter.format(digits, this.state)
}
if (formattedNationalNumber === undefined) {
// See if another national (significant) number could be re-extracted.
if (this.parser.reExtractNationalSignificantNumber(this.state)) {
this.determineTheCountryIfNeeded()
// If it could, then re-try formatting the new national (significant) number.
const nationalDigits = this.state.getNationalDigits()
if (nationalDigits) {
formattedNationalNumber = this.formatter.format(nationalDigits, this.state)
}
}
}
this.formattedOutput = formattedNationalNumber
? this.getFullNumber(formattedNationalNumber)
: this.getNonFormattedNumber()
}
return this.formattedOutput
}
reset() {
this.state = new AsYouTypeState({
onCountryChange: (country) => {
// Before version `1.6.0`, the official `AsYouType` formatter API
// included the `.country` property of an `AsYouType` instance.
// Since that property (along with the others) have been moved to
// `this.state`, `this.country` property is emulated for compatibility
// with the old versions.
this.country = country
},
onCallingCodeChange: (callingCode, country) => {
this.metadata.selectNumberingPlan(country, callingCode)
this.formatter.reset(this.metadata.numberingPlan, this.state)
this.parser.reset(this.metadata.numberingPlan)
}
})
this.formatter = new AsYouTypeFormatter({
state: this.state,
metadata: this.metadata
})
this.parser = new AsYouTypeParser({
defaultCountry: this.defaultCountry,
defaultCallingCode: this.defaultCallingCode,
metadata: this.metadata,
state: this.state,
onNationalSignificantNumberChange: () => {
this.determineTheCountryIfNeeded()
this.formatter.reset(this.metadata.numberingPlan, this.state)
}
})
this.state.reset({
country: this.defaultCountry,
callingCode: this.defaultCallingCode
})
this.formattedOutput = ''
return this
}
/**
* Returns `true` if the phone number is being input in international format.
* In other words, returns `true` if and only if the parsed phone number starts with a `"+"`.
* @return {boolean}
*/
isInternational() {
return this.state.international
}
/**
* Returns the "calling code" part of the phone number when it's being input
* in an international format.
* If no valid calling code has been entered so far, returns `undefined`.
* @return {string} [callingCode]
*/
getCallingCode() {
// If the number is being input in national format and some "default calling code"
// has been passed to `AsYouType` constructor, then `this.state.callingCode`
// is equal to that "default calling code".
//
// If the number is being input in national format and no "default calling code"
// has been passed to `AsYouType` constructor, then returns `undefined`,
// even if a "default country" has been passed to `AsYouType` constructor.
//
if (this.isInternational()) {
return this.state.callingCode
}
}
// A legacy alias.
getCountryCallingCode() {
return this.getCallingCode()
}
/**
* Returns a two-letter country code of the phone number.
* Returns `undefined` for "non-geographic" phone numbering plans.
* Returns `undefined` if no phone number has been input yet.
* @return {string} [country]
*/
getCountry() {
const { digits } = this.state
// Return `undefined` if no digits have been input yet.
if (digits) {
return this._getCountry()
}
}
/**
* Returns a two-letter country code of the phone number.
* Returns `undefined` for "non-geographic" phone numbering plans.
* @return {string} [country]
*/
_getCountry() {
const { country } = this.state
/* istanbul ignore if */
if (USE_NON_GEOGRAPHIC_COUNTRY_CODE) {
// `AsYouType.getCountry()` returns `undefined`
// for "non-geographic" phone numbering plans.
if (country === '001') {
return
}
}
return country
}
determineTheCountryIfNeeded() {
// Suppose a user enters a phone number in international format,
// and there're several countries corresponding to that country calling code,
// and a country has been derived from the number, and then
// a user enters one more digit and the number is no longer
// valid for the derived country, so the country should be re-derived
// on every new digit in those cases.
//
// If the phone number is being input in national format,
// then it could be a case when `defaultCountry` wasn't specified
// when creating `AsYouType` instance, and just `defaultCallingCode` was specified,
// and that "calling code" could correspond to a "non-geographic entity",
// or there could be several countries corresponding to that country calling code.
// In those cases, `this.country` is `undefined` and should be derived
// from the number. Again, if country calling code is ambiguous, then
// `this.country` should be re-derived with each new digit.
//
if (!this.state.country || this.isCountryCallingCodeAmbiguous()) {
this.determineTheCountry()
}
}
// Prepends `+CountryCode ` in case of an international phone number
getFullNumber(formattedNationalNumber) {
if (this.isInternational()) {
const prefix = (text) => this.formatter.getInternationalPrefixBeforeCountryCallingCode(this.state, {
spacing: text ? true : false
}) + text
const { callingCode } = this.state
if (!callingCode) {
return prefix(`${this.state.getDigitsWithoutInternationalPrefix()}`)
}
if (!formattedNationalNumber) {
return prefix(callingCode)
}
return prefix(`${callingCode} ${formattedNationalNumber}`)
}
return formattedNationalNumber
}
getNonFormattedNationalNumberWithPrefix() {
const {
nationalSignificantNumber,
complexPrefixBeforeNationalSignificantNumber,
nationalPrefix
} = this.state
let number = nationalSignificantNumber
const prefix = complexPrefixBeforeNationalSignificantNumber || nationalPrefix
if (prefix) {
number = prefix + number
}
return number
}
getNonFormattedNumber() {
const { nationalSignificantNumberMatchesInput } = this.state
return this.getFullNumber(
nationalSignificantNumberMatchesInput
? this.getNonFormattedNationalNumberWithPrefix()
: this.state.getNationalDigits()
)
}
getNonFormattedTemplate() {
const number = this.getNonFormattedNumber()
if (number) {
return number.replace(/[\+\d]/g, DIGIT_PLACEHOLDER)
}
}
isCountryCallingCodeAmbiguous() {
const { callingCode } = this.state
const countryCodes = this.metadata.getCountryCodesForCallingCode(callingCode)
return countryCodes && countryCodes.length > 1
}
// Determines the country of the phone number
// entered so far based on the country phone code
// and the national phone number.
determineTheCountry() {
this.state.setCountry(getCountryByCallingCode(
this.isInternational() ? this.state.callingCode : this.defaultCallingCode,
{
nationalNumber: this.state.nationalSignificantNumber,
defaultCountry: this.defaultCountry,
metadata: this.metadata
}
))
}
/**
* Returns a E.164 phone number value for the user's input.
*
* For example, for country `"US"` and input `"(222) 333-4444"`
* it will return `"+12223334444"`.
*
* For international phone number input, it will also auto-correct
* some minor errors such as using a national prefix when writing
* an international phone number. For example, if the user inputs
* `"+44 0 7400 000000"` then it will return an auto-corrected
* `"+447400000000"` phone number value.
*
* Will return `undefined` if no digits have been input,
* or when inputting a phone number in national format and no
* default country or default "country calling code" have been set.
*
* @return {string} [value]
*/
getNumberValue() {
const {
digits,
callingCode,
country,
nationalSignificantNumber
} = this.state
// Will return `undefined` if no digits have been input.
if (!digits) {
return
}
if (this.isInternational()) {
if (callingCode) {
return '+' + callingCode + nationalSignificantNumber
} else {
return '+' + digits
}
} else {
if (country || callingCode) {
const callingCode_ = country ? this.metadata.countryCallingCode() : callingCode
return '+' + callingCode_ + nationalSignificantNumber
}
}
}
/**
* Returns an instance of `PhoneNumber` class.
* Will return `undefined` if no national (significant) number
* digits have been entered so far, or if no `defaultCountry` has been
* set and the user enters a phone number not in international format.
*/
getNumber() {
const {
nationalSignificantNumber,
carrierCode,
callingCode
} = this.state
// `this._getCountry()` is basically same as `this.state.country`
// with the only change that it return `undefined` in case of a
// "non-geographic" numbering plan instead of `"001"` "internal use" value.
let country = this._getCountry()
if (!nationalSignificantNumber) {
return
}
// `state.country` and `state.callingCode` aren't required to be in sync.
// For example, `country` could be `"AR"` and `callingCode` could be `undefined`.
// So `country` and `callingCode` are totally independent.
if (!country && !callingCode) {
return
}
// By default, if `defaultCountry` parameter was passed when
// creating `AsYouType` instance, `state.country` is gonna be
// that `defaultCountry`, which doesn't entirely conform with
// `parsePhoneNumber()`'s behavior where it attempts to determine
// the country more precisely in cases when multiple countries
// could correspond to the same `countryCallingCode`.
// https://gitlab.com/catamphetamine/libphonenumber-js/-/issues/103#note_1417192969
//
// Because `AsYouType.getNumber()` method is supposed to be a 1:1
// equivalent for `parsePhoneNumber(AsYouType.getNumberValue())`,
// then it should also behave accordingly in cases of `country` ambiguity.
// That's how users of this library would expect it to behave anyway.
//
if (country) {
if (country === this.defaultCountry) {
// `state.country` and `state.callingCode` aren't required to be in sync.
// For example, `state.country` could be `"AR"` and `state.callingCode` could be `undefined`.
// So `state.country` and `state.callingCode` are totally independent.
const metadata = new Metadata(this.metadata.metadata)
metadata.selectNumberingPlan(country)
const callingCode = metadata.numberingPlan.callingCode()
const ambiguousCountries = this.metadata.getCountryCodesForCallingCode(callingCode)
if (ambiguousCountries.length > 1) {
const exactCountry = getCountryByNationalNumber(nationalSignificantNumber, {
countries: ambiguousCountries,
defaultCountry: this.defaultCountry,
metadata: this.metadata.metadata
})
if (exactCountry) {
country = exactCountry
}
}
}
}
const phoneNumber = new PhoneNumber(
country || callingCode,
nationalSignificantNumber,
this.metadata.metadata
)
if (carrierCode) {
phoneNumber.carrierCode = carrierCode
}
// Phone number extensions are not supported by "As You Type" formatter.
return phoneNumber
}
/**
* Returns `true` if the phone number is "possible".
* Is just a shortcut for `PhoneNumber.isPossible()`.
* @return {boolean}
*/
isPossible() {
const phoneNumber = this.getNumber()
if (!phoneNumber) {
return false
}
return phoneNumber.isPossible()
}
/**
* Returns `true` if the phone number is "valid".
* Is just a shortcut for `PhoneNumber.isValid()`.
* @return {boolean}
*/
isValid() {
const phoneNumber = this.getNumber()
if (!phoneNumber) {
return false
}
return phoneNumber.isValid()
}
/**
* @deprecated
* This method is used in `react-phone-number-input/source/input-control.js`
* in versions before `3.0.16`.
*/
getNationalNumber() {
return this.state.nationalSignificantNumber
}
/**
* Returns the phone number characters entered by the user.
* @return {string}
*/
getChars() {
return (this.state.international ? '+' : '') + this.state.digits
}
/**
* Returns the template for the formatted phone number.
* @return {string}
*/
getTemplate() {
return this.formatter.getTemplate(this.state) || this.getNonFormattedTemplate() || ''
}
}