Skip to content

Commit 5965e8f

Browse files
committed
Setup TS config and the minimal set of dependencies
1 parent 63bd7f3 commit 5965e8f

File tree

9 files changed

+5844
-2
lines changed

9 files changed

+5844
-2
lines changed

package.json

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,74 @@
11
{
2-
"version": "0.0.0-reserve",
2+
"version": "0.1.0",
33
"name": "react-phone-hooks",
4-
"description": "The package is under maintenance and will appear soon..."
4+
"description": "React hooks and utility functions for parsing and validating phone numbers.",
5+
"keywords": [
6+
"react",
7+
"phone",
8+
"input",
9+
"hooks",
10+
"number",
11+
"parsing",
12+
"utilities",
13+
"validation"
14+
],
15+
"homepage": "https://github.com/typesnippet/react-phone-hooks",
16+
"bugs": {
17+
"url": "https://github.com/typesnippet/react-phone-hooks/issues"
18+
},
19+
"repository": {
20+
"type": "git",
21+
"url": "https://github.com/typesnippet/react-phone-hooks"
22+
},
23+
"exports": {
24+
".": {
25+
"import": "./index.js",
26+
"require": "./index.cjs.js",
27+
"types": {
28+
"default": "./index.d.ts"
29+
}
30+
},
31+
"./types": {
32+
"import": "./types.js",
33+
"require": "./types.cjs.js",
34+
"types": {
35+
"default": "./types.d.ts"
36+
}
37+
},
38+
"./styles": {
39+
"import": "./styles.js",
40+
"require": "./styles.cjs.js",
41+
"types": {
42+
"default": "./styles.d.ts"
43+
}
44+
},
45+
"./stylesheet.json": "./stylesheet.json",
46+
"./package.json": "./package.json"
47+
},
48+
"files": [
49+
"index*",
50+
"types*",
51+
"styles*",
52+
"LICENSE",
53+
"metadata",
54+
"README.md",
55+
"package.json",
56+
"stylesheet.json"
57+
],
58+
"scripts": {
59+
"rename": "bash -c 'for file in *.js; do mv $file \"${file%.js}.$0.js\"; done'",
60+
"build": "tsc --module commonjs && npm run rename -- cjs && tsc --declaration",
61+
"prebuild": "rm -r metadata stylesheet.json index* types* styles* || true",
62+
"postbuild": "cp resources/stylesheet.json stylesheet.json"
63+
},
64+
"license": "MIT",
65+
"peerDependencies": {
66+
"react": ">=16"
67+
},
68+
"devDependencies": {
69+
"@types/react": "^18.2.34",
70+
"tslib": "^2.6.2",
71+
"tsx": "^3.12.10",
72+
"typescript": "^5.2.2"
73+
}
574
}

resources/stylesheet.json

Lines changed: 707 additions & 0 deletions
Large diffs are not rendered by default.

src/index.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)