expo-international-phone-number
Version:
International mobile phone input component with mask for React Native
535 lines (494 loc) • 16.1 kB
JavaScript
import React, { useEffect, useState, useRef, forwardRef } from "react";
import { View, Text, TouchableOpacity, TextInput } from "react-native";
import { CountryPicker, CountryButton } from "expo-country-codes-picker";
import getInputMask from "./utils/getInputMask";
import getAllCountries from "./utils/getAllCountries";
import getCountriesByName from "./utils/getCountriesByName";
import getCountriesByCallingCode from "./utils/getCountriesByCallingCode";
import getCountryByCca2 from "./utils/getCountryByCca2";
import getCountryByPhoneNumber from "./utils/getCountryByPhoneNumber";
import isValidPhoneNumber from "./utils/isValidPhoneNumber";
import {
getCountryNotFoundMessage,
getPhoneNumberInputPlaceholder,
getPopularCountriesSectionTitle,
getRestOfCountriesSectionTitle,
getSearchInputPlaceholder,
} from "./utils/getPlaceholders";
import {
getCaretStyle,
getContainerStyle,
getCountryPickerStyle,
getDividerStyle,
getFlagContainerStyle,
getFlagStyle,
getFlagTextStyle,
getInputStyle,
getModalSectionTitleStyle,
} from "./utils/getStyles";
const PhoneInput = forwardRef(
(
{
theme,
language,
placeholder,
placeholderTextColor,
selectionColor,
phoneInputStyles,
modalStyles,
disabled,
modalDisabled,
modalHeight,
defaultCountry,
defaultValue,
onChangePhoneNumber,
selectedCountry,
onChangeSelectedCountry,
customMask,
showOnly,
excludedCountries,
popularCountries,
popularCountriesSectionTitle,
restOfCountriesSectionTitle,
modalSectionTitleDisabled,
modalSearchInputPlaceholder,
modalSearchInputPlaceholderTextColor,
modalSearchInputSelectionColor,
modalNotFoundCountryMessage,
customCaret,
rtl,
allowZeroAfterCallingCode,
testID,
accessibilityRoleFlagContainerButton,
accessibilityLabelFlagContainerButton,
accessibilityHintFlagContainerButton,
accessibilityRolePhoneInput,
accessibilityLabelPhoneInput,
accessibilityHintPhoneInput,
...rest
},
ref
) => {
const [show, setShow] = useState(false);
const [defaultCca2, setDefaultCca2] = useState("");
const [inputValue, setInputValue] = useState(null);
const [countryValue, setCountryValue] = useState(null);
const textInputRef = useRef(null);
const refBase = {
...textInputRef.current,
onFocus: () => textInputRef.current?.focus(),
focus: () => textInputRef.current?.focus(),
blur: () => textInputRef.current?.blur(),
clear: () => textInputRef.current?.clear(),
getValue: () => inputValue,
value: inputValue,
getFullPhoneNumber: () => `${countryValue?.callingCode} ${inputValue}`,
fullPhoneNumber: `${countryValue?.callingCode} ${inputValue}`,
getSelectedCountry: () => countryValue,
selectedCountry: countryValue,
isValid: isValidPhoneNumber(inputValue, selectedCountry),
props: {
theme,
language,
placeholder,
placeholderTextColor,
selectionColor,
phoneInputStyles,
modalStyles,
disabled,
modalDisabled,
modalHeight,
defaultCountry,
defaultValue,
onChangePhoneNumber,
selectedCountry,
onChangeSelectedCountry,
customMask,
showOnly,
excludedCountries,
popularCountries,
popularCountriesSectionTitle,
restOfCountriesSectionTitle,
modalSectionTitleDisabled,
modalSearchInputPlaceholder,
modalSearchInputPlaceholderTextColor,
modalSearchInputSelectionColor,
modalNotFoundCountryMessage,
customCaret,
rtl,
allowZeroAfterCallingCode,
testID,
accessibilityRoleFlagContainerButton,
accessibilityLabelFlagContainerButton,
accessibilityHintFlagContainerButton,
accessibilityRolePhoneInput,
accessibilityLabelPhoneInput,
accessibilityHintPhoneInput,
...rest,
},
};
function updateRef(phoneNumber, country) {
if (ref) {
ref.current = {
...refBase,
getValue: () => phoneNumber,
value: phoneNumber,
getFullPhoneNumber: () => `${country?.callingCode} ${phoneNumber}`,
fullPhoneNumber: `${country?.callingCode} ${phoneNumber}`,
getSelectedCountry: () => country,
selectedCountry: country,
isValid: isValidPhoneNumber(phoneNumber, country),
props: {
...refBase.props,
value: phoneNumber,
selectedCountry: country,
},
};
}
}
function onSelect(country) {
setShow(false);
if (ref) {
setInputValue("");
} else {
onChangePhoneNumber("");
}
if (onChangeSelectedCountry || ref) {
const newValue = {
name: country.name,
cca2: country.code,
flag: country.flag,
callingCode: country.dial_code,
};
if (ref) {
setCountryValue(newValue);
updateRef("", newValue);
} else {
onChangeSelectedCountry(newValue);
}
}
}
function onChangeText(phoneNumber, callingCode) {
if (phoneNumber.includes("+")) {
const matchingCountry = getCountryByPhoneNumber(phoneNumber);
if (matchingCountry) {
setDefaultCca2(matchingCountry.cca2);
if (ref) {
setCountryValue(matchingCountry);
updateRef("", matchingCountry);
} else {
onChangeSelectedCountry(matchingCountry);
}
onChangeText(
phoneNumber.replace(matchingCountry.callingCode, ""),
null
);
}
return;
}
const res = getInputMask(
phoneNumber,
callingCode ? callingCode : countryValue?.callingCode,
countryValue?.cca2,
customMask ? customMask : null,
allowZeroAfterCallingCode === false ? false : true
);
if (ref) {
setInputValue(res);
updateRef(res, countryValue);
} else {
onChangePhoneNumber(res);
}
}
useEffect(() => {
if (!countryValue && !defaultCountry) {
const defaultCountry = getCountryByCca2("BR");
if (ref) {
setCountryValue(defaultCountry);
updateRef("", defaultCountry);
} else {
onChangeSelectedCountry(defaultCountry);
}
} else {
if (ref) {
updateRef("", countryValue);
}
}
}, []);
useEffect(() => {
if (defaultCountry) {
if (ref) {
setCountryValue(getCountryByCca2(defaultCountry));
updateRef("", getCountryByCca2(defaultCountry));
} else {
onChangeSelectedCountry(getCountryByCca2(defaultCountry));
}
}
}, [defaultCountry]);
useEffect(() => {
if (ref) {
setInputValue("");
} else {
onChangePhoneNumber("");
}
if (defaultValue) {
const matchingCountry = getCountryByPhoneNumber(defaultValue);
if (matchingCountry) {
setDefaultCca2(matchingCountry.cca2);
if (ref) {
setCountryValue(matchingCountry);
updateRef("", matchingCountry);
} else {
onChangeSelectedCountry(matchingCountry);
}
} else {
setDefaultCca2(null);
if (ref) {
setCountryValue(null);
updateRef("", null);
} else {
onChangeSelectedCountry(null);
}
onChangeText("", null);
console.warn(
"The default number provided (defaultValue) don't match with anyone country. Please, correct it to be shown in the input. For more information: https://github.com/AstrOOnauta/react-native-international-phone-number#intermediate-usage---typescript--default-phone-number-value"
);
}
}
}, [defaultValue]);
useEffect(() => {
if (
defaultValue &&
countryValue &&
countryValue.cca2 === defaultCca2 &&
!inputValue
) {
const callingCode = countryValue.callingCode;
let phoneNumber = defaultValue;
if (
callingCode === "+1" &&
countryValue.cca2 !== "CA" &&
countryValue.cca2 !== "US"
) {
phoneNumber = defaultValue
.replace(/\s/g, "")
.substring(
callingCode.length + 3,
defaultValue.replace(/\D/g, "").length + callingCode.length
);
} else {
phoneNumber = defaultValue
.replace(/\s/g, "")
.substring(
callingCode.length,
defaultValue.replace(/\D/g, "").length + callingCode.length
);
}
onChangeText(phoneNumber, callingCode);
}
}, [countryValue]);
useEffect(() => {
if (!ref) {
setInputValue(rest.value);
setCountryValue(selectedCountry);
}
}, [selectedCountry]);
if (
ref &&
(rest.value ||
onChangePhoneNumber ||
selectedCountry ||
onChangeSelectedCountry)
) {
throw new Error(
"Error: Don't use the useRef hook combined with the useState hook to manage the phoneNumber and selectedCountry values. Instead, choose to use just one of them (useRef or useState)."
);
} else {
// Create a separate constant for each part of the component
const touchableStart = (
<>
<Text style={getFlagStyle(phoneInputStyles?.flag)}>
{countryValue?.flag}
</Text>
{customCaret || (
<View style={phoneInputStyles?.caret}>
<View
style={{
flexDirection: "row",
justifyContent: "center",
paddingTop: 4,
}}
>
<View style={getCaretStyle(theme, phoneInputStyles?.caret)} />
</View>
</View>
)}
</>
);
const touchableMiddle = (
<View style={getDividerStyle(theme, phoneInputStyles?.divider)} />
);
const touchableEnd = (
<Text style={getFlagTextStyle(theme, phoneInputStyles?.callingCode)}>
{countryValue?.callingCode}
</Text>
);
const touchablePart = (
<TouchableOpacity
testID={
testID
? `${testID}FlagContainerButton`
: `countryPickerFlagContainerButton`
}
accessibillityRole={accessibilityRoleFlagContainerButton || "button"}
accessibilityLabel={
accessibilityLabelFlagContainerButton || "Countries button"
}
accessibilityHint={
accessibilityHintFlagContainerButton ||
"Click to open the countries modal"
}
activeOpacity={disabled || modalDisabled ? 1 : 0.6}
onPress={() => (disabled || modalDisabled ? null : setShow(true))}
style={getFlagContainerStyle(theme, phoneInputStyles?.flagContainer)}
>
{/* LTR Display */}
{!rtl && touchableStart}
{!rtl && touchableMiddle}
{!rtl && touchableEnd}
{/* RTL Display */}
{rtl && touchableEnd}
{rtl && touchableMiddle}
{rtl && touchableStart}
</TouchableOpacity>
);
const inputPart = (
<TextInput
style={getInputStyle(theme, phoneInputStyles?.input)}
placeholder={
placeholder === "" || placeholder
? placeholder
: getPhoneNumberInputPlaceholder(language || "en")
}
placeholderTextColor={
placeholderTextColor || (theme === "dark" ? "#CCCCCC" : "#AAAAAA")
}
selectionColor={
selectionColor ||
(theme === "dark" ? "rgba(255,255,255, .4)" : "rgba(0 ,0 ,0 , .4)")
}
editable={!disabled}
value={inputValue}
onChangeText={onChangeText}
keyboardType="number-pad"
ref={textInputRef}
testID={testID ? `${testID}PhoneInput` : `countryPickerPhoneInput`}
accessibillityRole={accessibilityRolePhoneInput || "input"}
accessibilityLabel={
accessibilityLabelPhoneInput || "Phone Number input"
}
accessibilityHint={
accessibilityHintPhoneInput || "Write the phone number"
}
{...rest}
/>
);
return (
<>
<View
style={getContainerStyle(
theme,
phoneInputStyles?.container,
disabled
)}
>
{/* LTR Display */}
{!rtl && touchablePart}
{!rtl && inputPart}
{/* RTL Display */}
{rtl && inputPart}
{rtl && touchablePart}
</View>
{!disabled && !modalDisabled && show ? (
<CountryPicker
show={show}
lang={language}
inputPlaceholder={
modalSearchInputPlaceholder ||
getSearchInputPlaceholder(language || "en")
}
inputPlaceholderTextColor={
modalSearchInputPlaceholderTextColor ||
(theme === "dark" ? "#CCCCCC" : "#AAAAAA")
}
selectionColor={
modalSearchInputSelectionColor ||
(theme === "dark"
? "rgba(255,255,255, .4)"
: "rgba(0 ,0 ,0 , .4)")
}
searchMessage={
modalNotFoundCountryMessage ||
getCountryNotFoundMessage(language || "en")
}
enableModalAvoiding
style={getCountryPickerStyle(theme, modalHeight, modalStyles)}
pickerButtonOnPress={onSelect}
onBackdropPress={() => setShow(false)}
onRequestClose={() => setShow(false)}
showOnly={showOnly}
excludedCountries={excludedCountries}
popularCountries={popularCountries}
ListHeaderComponent={({ countries, lang, onPress }) => {
return (
<View>
{!modalSectionTitleDisabled && (
<Text
style={getModalSectionTitleStyle(theme, modalStyles)}
>
{popularCountriesSectionTitle ||
getPopularCountriesSectionTitle(language || "en")}
</Text>
)}
{countries.map((country, index) => {
return (
<CountryButton
key={index}
item={country}
name={country?.name?.[lang || "en"]}
onPress={() => onPress(country)}
style={getCountryPickerStyle(
theme,
modalHeight,
modalStyles
)}
/>
);
})}
{!modalSectionTitleDisabled && (
<Text
style={getModalSectionTitleStyle(theme, modalStyles)}
>
{restOfCountriesSectionTitle ||
getRestOfCountriesSectionTitle(language || "en")}
</Text>
)}
</View>
);
}}
/>
) : null}
</>
);
}
}
);
export default PhoneInput;
export {
getAllCountries,
getCountryByPhoneNumber,
getCountryByCca2,
getCountriesByCallingCode,
getCountriesByName,
isValidPhoneNumber,
};