|
| 1 | +import {ChangeEvent, useCallback, useMemo, useRef, useState} from "react"; |
| 2 | + |
| 3 | +import {PhoneNumber, usePhoneOptions} from "./types"; |
| 4 | + |
| 5 | +import countries from "./metadata/countries.json"; |
| 6 | +import timezones from "./metadata/timezones.json"; |
| 7 | +import validations from "./metadata/validations.json"; |
| 8 | + |
| 9 | +const slots = new Set("."); |
| 10 | + |
| 11 | +export const getMetadata = (rawValue: string, countriesList: typeof countries = countries, country: any = null) => { |
| 12 | + country = country == null && rawValue.startsWith("44") ? "gb" : country; |
| 13 | + if (country != null) { |
| 14 | + countriesList = countriesList.filter((c) => c[0] === country); |
| 15 | + countriesList = countriesList.sort((a, b) => b[2].length - a[2].length); |
| 16 | + } |
| 17 | + return countriesList.find((c) => rawValue.startsWith(c[2])); |
| 18 | +} |
| 19 | + |
| 20 | +export const getCountry = (countryCode: keyof typeof countries) => { |
| 21 | + return countries.find(([iso]) => iso === countryCode); |
| 22 | +} |
| 23 | + |
| 24 | +export const getRawValue = (value: PhoneNumber | string) => { |
| 25 | + if (typeof value === "string") return value.replaceAll(/\D/g, ""); |
| 26 | + return [value?.countryCode, value?.areaCode, value?.phoneNumber].filter(Boolean).join(""); |
| 27 | +} |
| 28 | + |
| 29 | +export const displayFormat = (value: string) => { |
| 30 | + return value.replace(/[.\s\D]+$/, "").replace(/(\(\d+)$/, "$1)"); |
| 31 | +} |
| 32 | + |
| 33 | +export const cleanInput = (input: any, pattern: string) => { |
| 34 | + input = input.match(/\d/g) || []; |
| 35 | + return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c); |
| 36 | +} |
| 37 | + |
| 38 | +export const checkValidity = (metadata: PhoneNumber, strict: boolean = false) => { |
| 39 | + /** Checks if both the area code and phone number match the validation pattern */ |
| 40 | + const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][Number(strict)]; |
| 41 | + return new RegExp(pattern).test([metadata.areaCode, metadata.phoneNumber].filter(Boolean).join("")); |
| 42 | +} |
| 43 | + |
| 44 | +export const getDefaultISO2Code = () => { |
| 45 | + /** Returns the default ISO2 code, based on the user's timezone */ |
| 46 | + return (timezones[Intl.DateTimeFormat().resolvedOptions().timeZone as keyof typeof timezones] || "") || "us"; |
| 47 | +} |
| 48 | + |
| 49 | +export const parsePhoneNumber = (formattedNumber: string, countriesList: typeof countries = countries, country: any = null): PhoneNumber => { |
| 50 | + const value = getRawValue(formattedNumber); |
| 51 | + const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code(); |
| 52 | + const countryCodePattern = /\+\d+/; |
| 53 | + const areaCodePattern = /\((\d+)\)/; |
| 54 | + |
| 55 | + /** Parses the matching partials of the phone number by predefined regex patterns */ |
| 56 | + const countryCodeMatch = formattedNumber ? (formattedNumber.match(countryCodePattern) || []) : []; |
| 57 | + const areaCodeMatch = formattedNumber ? (formattedNumber.match(areaCodePattern) || []) : []; |
| 58 | + |
| 59 | + /** Converts the parsed values of the country and area codes to integers if values present */ |
| 60 | + const countryCode = countryCodeMatch.length > 0 ? parseInt(countryCodeMatch[0]) : null; |
| 61 | + const areaCode = areaCodeMatch.length > 1 ? areaCodeMatch[1] : null; |
| 62 | + |
| 63 | + /** Parses the phone number by removing the country and area codes from the formatted value */ |
| 64 | + const phoneNumberPattern = new RegExp(`^${countryCode}${(areaCode || "")}(\\d+)`); |
| 65 | + const phoneNumberMatch = value ? (value.match(phoneNumberPattern) || []) : []; |
| 66 | + const phoneNumber = phoneNumberMatch.length > 1 ? phoneNumberMatch[1] : null; |
| 67 | + |
| 68 | + return {countryCode, areaCode, phoneNumber, isoCode}; |
| 69 | +} |
| 70 | + |
| 71 | +export const usePhone = ({ |
| 72 | + query = "", |
| 73 | + country = "", |
| 74 | + countryCode = "", |
| 75 | + initialValue = "", |
| 76 | + onlyCountries = [], |
| 77 | + excludeCountries = [], |
| 78 | + preferredCountries = [], |
| 79 | + }: usePhoneOptions) => { |
| 80 | + const defaultValue = getRawValue(initialValue); |
| 81 | + const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country); |
| 82 | + const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string; |
| 83 | + |
| 84 | + const backRef = useRef<boolean>(false); |
| 85 | + const [value, setValue] = useState<string>(defaultValueState); |
| 86 | + |
| 87 | + const countriesOnly = useMemo(() => { |
| 88 | + const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso); |
| 89 | + return countries.map(([iso]) => iso).filter((iso) => { |
| 90 | + return allowList.includes(iso) && !excludeCountries.includes(iso); |
| 91 | + }); |
| 92 | + }, [onlyCountries, excludeCountries]) |
| 93 | + |
| 94 | + const countriesList = useMemo(() => { |
| 95 | + const filteredCountries = countries.filter(([iso, name, _1, dial]) => { |
| 96 | + return countriesOnly.includes(iso) && ( |
| 97 | + name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query) |
| 98 | + ); |
| 99 | + }); |
| 100 | + return [ |
| 101 | + ...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)), |
| 102 | + ...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)), |
| 103 | + ]; |
| 104 | + }, [countriesOnly, preferredCountries, query]) |
| 105 | + |
| 106 | + const metadata = useMemo(() => { |
| 107 | + const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode); |
| 108 | + if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) { |
| 109 | + return calculatedMetadata || defaultMetadata; |
| 110 | + } |
| 111 | + return countriesList[0]; |
| 112 | + }, [countriesList, countryCode, defaultMetadata, value]) |
| 113 | + |
| 114 | + const pattern = useMemo(() => { |
| 115 | + return metadata?.[3] || defaultMetadata?.[3] || ""; |
| 116 | + }, [defaultMetadata, metadata]) |
| 117 | + |
| 118 | + const clean = useCallback((input: any) => { |
| 119 | + return cleanInput(input, pattern.replaceAll(/\d/g, ".")); |
| 120 | + }, [pattern]) |
| 121 | + |
| 122 | + const first = useMemo(() => { |
| 123 | + return [...pattern].findIndex(c => slots.has(c)); |
| 124 | + }, [pattern]) |
| 125 | + |
| 126 | + const prev = useMemo((j = 0) => { |
| 127 | + return Array.from(pattern.replaceAll(/\d/g, "."), (c, i) => { |
| 128 | + return slots.has(c) ? j = i + 1 : j; |
| 129 | + }); |
| 130 | + }, [pattern]) |
| 131 | + |
| 132 | + const format = useCallback(({target}: ChangeEvent<HTMLInputElement>) => { |
| 133 | + const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => { |
| 134 | + i = clean(target.value.slice(0, i)).findIndex(c => slots.has(c)); |
| 135 | + return i < 0 ? prev[prev.length - 1] : backRef.current ? prev[i - 1] || first : i; |
| 136 | + }); |
| 137 | + target.value = displayFormat(clean(target.value).join("")); |
| 138 | + target.setSelectionRange(i, j); |
| 139 | + backRef.current = false; |
| 140 | + setValue(target.value); |
| 141 | + }, [clean, first, prev]) |
| 142 | + |
| 143 | + return { |
| 144 | + clean, |
| 145 | + value, |
| 146 | + format, |
| 147 | + metadata, |
| 148 | + setValue, |
| 149 | + countriesList, |
| 150 | + } |
| 151 | +} |
0 commit comments