react-phone-hooks
Version:
React hooks and utility functions for parsing and validating phone numbers.
141 lines (140 loc) • 7.96 kB
JavaScript
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import * as phoneLocale from "./locale";
import countries from "./metadata/countries.json";
import timezones from "./metadata/timezones.json";
import validations from "./metadata/validations.json";
const slots = new Set(".");
export const getMetadata = (rawValue, countriesList = countries, country = null) => {
country = country == null && rawValue.startsWith("44") ? "gb" : country;
if (country != null)
countriesList = countriesList.filter((c) => c[0] === country);
return [...countriesList].sort((a, b) => b[2].length - a[2].length).find((c) => rawValue.startsWith(c[2]));
};
export const getCountry = (countryCode) => {
return countries.find(([iso]) => iso === countryCode);
};
export const getRawValue = (value) => {
if (typeof value === "string")
return value.replaceAll(/\D/g, "");
return [value === null || value === void 0 ? void 0 : value.countryCode, value === null || value === void 0 ? void 0 : value.areaCode, value === null || value === void 0 ? void 0 : value.phoneNumber].filter(Boolean).join("");
};
export const displayFormat = (value) => {
/** Returns the formatted value that can be displayed as an actual input value */
return value.replace(/[.\s\D]+$/, "").replace(/(\(\d+)$/, "$1)");
};
export const cleanInput = (input, pattern) => {
input = input.match(/\d/g) || [];
return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c);
};
export const getFormattedNumber = (rawValue, pattern) => {
var _a;
/** Returns the reformatted input value based on the given pattern */
pattern = pattern || ((_a = getMetadata(rawValue)) === null || _a === void 0 ? void 0 : _a[3]) || "";
return displayFormat(cleanInput(rawValue, pattern.replaceAll(/\d/g, ".")).join(""));
};
export const checkValidity = (metadata, strict = false) => {
/** Checks if both the area code and phone number match the validation pattern */
const pattern = validations[metadata.isoCode][Number(strict)];
return new RegExp(pattern).test([metadata.areaCode, metadata.phoneNumber].filter(Boolean).join(""));
};
export const getDefaultISO2Code = () => {
/** Returns the default ISO2 code, based on the user's timezone */
return (timezones[Intl.DateTimeFormat().resolvedOptions().timeZone] || "") || "us";
};
export const parsePhoneNumber = (formattedNumber, countriesList = countries, country = null) => {
var _a;
const value = getRawValue(formattedNumber);
const isoCode = ((_a = getMetadata(value, countriesList, country)) === null || _a === void 0 ? void 0 : _a[0]) || getDefaultISO2Code();
const countryCodePattern = /\+\d+/;
const areaCodePattern = /^\+\d+\s\(?(\d+)/;
/** Parses the matching partials of the phone number by predefined regex patterns */
const countryCodeMatch = formattedNumber ? (formattedNumber.match(countryCodePattern) || []) : [];
const areaCodeMatch = formattedNumber ? (formattedNumber.match(areaCodePattern) || []) : [];
/** Converts the parsed values of the country and area codes to integers if values present */
const countryCode = countryCodeMatch.length > 0 ? parseInt(countryCodeMatch[0]) : null;
const areaCode = areaCodeMatch.length > 1 ? areaCodeMatch[1] : null;
/** Parses the phone number by removing the country and area codes from the formatted value */
const phoneNumberPattern = new RegExp(`^${countryCode}${(areaCode || "")}(\\d+)`);
const phoneNumberMatch = value ? (value.match(phoneNumberPattern) || []) : [];
const phoneNumber = phoneNumberMatch.length > 1 ? phoneNumberMatch[1] : null;
return { countryCode, areaCode, phoneNumber, isoCode };
};
export const useMask = (pattern) => {
const backRef = useRef(false);
const clean = useCallback((input) => {
return cleanInput(input, pattern.replaceAll(/\d/g, "."));
}, [pattern]);
const first = useMemo(() => {
return [...pattern].findIndex(c => slots.has(c));
}, [pattern]);
const prev = useMemo((j = 0) => {
return Array.from(pattern.replaceAll(/\d/g, "."), (c, i) => {
return slots.has(c) ? j = i + 1 : j;
});
}, [pattern]);
const onKeyDown = useCallback((event) => {
backRef.current = event.key === "Backspace";
}, []);
const onInput = useCallback(({ target }) => {
const [i, j] = [target.selectionStart, target.selectionEnd].map((i) => {
i = clean(target.value.slice(0, i)).findIndex(c => slots.has(c));
return i < 0 ? prev[prev.length - 1] : backRef.current ? prev[i - 1] || first : i;
});
target.value = getFormattedNumber(target.value, pattern);
target.setSelectionRange(i, j);
backRef.current = false;
}, [clean, first, pattern, prev]);
return {
onInput,
onKeyDown,
};
};
export const usePhone = ({ query = "", locale = "", country = "", distinct = false, countryCode = "", initialValue = "", onlyCountries = [], excludeCountries = [], preferredCountries = [], disableParentheses = false, }) => {
var _a;
const defaultValue = getRawValue(initialValue);
const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country);
const defaultValueState = defaultValue || ((_a = countries.find(([iso]) => iso === (defaultMetadata === null || defaultMetadata === void 0 ? void 0 : defaultMetadata[0]))) === null || _a === void 0 ? void 0 : _a[2]);
const [value, setValue] = useState(defaultValueState);
const countriesOnly = useMemo(() => {
const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso);
return countries.filter(([iso, _1, dial]) => {
return (allowList.includes(iso) || allowList.includes(dial)) && !excludeCountries.includes(iso) && !excludeCountries.includes(dial);
});
}, [onlyCountries, excludeCountries]);
const countriesList = useMemo(() => {
const filteredCountries = countriesOnly.filter(([_1, name, dial, mask]) => {
var _a;
const q = query.toLowerCase();
const countries = locale && ((_a = (phoneLocale[locale])) === null || _a === void 0 ? void 0 : _a.countries);
const localized = countries && (countries[name] || "").toLowerCase();
return [localized, name.toLowerCase(), dial, mask].some(component => component.includes(q));
});
const seen = new Set();
const whitelistCountries = [
...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)),
...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)),
];
if (!distinct)
return whitelistCountries;
return whitelistCountries.filter(([iso]) => !seen.has(iso) && seen.add(iso));
}, [countriesOnly, preferredCountries, distinct, locale, query]);
const metadata = useMemo(() => {
const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode);
if (countriesList.find(([iso]) => iso === (calculatedMetadata === null || calculatedMetadata === void 0 ? void 0 : calculatedMetadata[0]) || iso === (defaultMetadata === null || defaultMetadata === void 0 ? void 0 : defaultMetadata[0]))) {
return calculatedMetadata || defaultMetadata;
}
return countriesList[0];
}, [countriesList, countryCode, defaultMetadata, value]);
const pattern = useMemo(() => {
const mask = (metadata === null || metadata === void 0 ? void 0 : metadata[3]) || (defaultMetadata === null || defaultMetadata === void 0 ? void 0 : defaultMetadata[3]) || "";
return disableParentheses ? mask.replace(/[()]/g, "") : mask;
}, [disableParentheses, defaultMetadata, metadata]);
return {
value,
pattern,
metadata,
setValue,
countriesList,
};
};