Skip to content

Commit eecf4cb

Browse files
committed
GH-7: Implement useMask hook for handling mask
1 parent 205e72e commit eecf4cb

File tree

1 file changed

+125
-115
lines changed

1 file changed

+125
-115
lines changed

development/src/phone-hooks/index.ts

Lines changed: 125 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {ChangeEvent, useCallback, useMemo, useRef, useState} from "react";
1+
import {ChangeEvent, KeyboardEvent, useCallback, useMemo, useRef, useState} from "react";
22

33
import {PhoneNumber, usePhoneOptions} from "./types";
44

@@ -9,149 +9,159 @@ import validations from "./metadata/validations.json";
99
const slots = new Set(".");
1010

1111
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]));
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]));
1818
}
1919

2020
export const getCountry = (countryCode: keyof typeof countries) => {
21-
return countries.find(([iso]) => iso === countryCode);
21+
return countries.find(([iso]) => iso === countryCode);
2222
}
2323

2424
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("");
25+
if (typeof value === "string") return value.replaceAll(/\D/g, "");
26+
return [value?.countryCode, value?.areaCode, value?.phoneNumber].filter(Boolean).join("");
2727
}
2828

2929
export const displayFormat = (value: string) => {
30-
/** Returns the formatted value that can be displayed as an actual input value */
31-
return value.replace(/[.\s\D]+$/, "").replace(/(\(\d+)$/, "$1)");
30+
/** Returns the formatted value that can be displayed as an actual input value */
31+
return value.replace(/[.\s\D]+$/, "").replace(/(\(\d+)$/, "$1)");
3232
}
3333

3434
export const cleanInput = (input: any, pattern: string) => {
35-
input = input.match(/\d/g) || [];
36-
return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c);
35+
input = input.match(/\d/g) || [];
36+
return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c);
3737
}
3838

3939
export const getFormattedNumber = (rawValue: any, pattern: string) => {
40-
/** Returns the reformatted input value based on the given pattern */
41-
return displayFormat(cleanInput(rawValue, pattern.replaceAll(/\d/g, ".")).join(""));
40+
/** Returns the reformatted input value based on the given pattern */
41+
return displayFormat(cleanInput(rawValue, pattern.replaceAll(/\d/g, ".")).join(""));
4242
}
4343

4444
export const checkValidity = (metadata: PhoneNumber, strict: boolean = false) => {
45-
/** Checks if both the area code and phone number match the validation pattern */
46-
const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][Number(strict)];
47-
return new RegExp(pattern).test([metadata.areaCode, metadata.phoneNumber].filter(Boolean).join(""));
45+
/** Checks if both the area code and phone number match the validation pattern */
46+
const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][Number(strict)];
47+
return new RegExp(pattern).test([metadata.areaCode, metadata.phoneNumber].filter(Boolean).join(""));
4848
}
4949

5050
export const getDefaultISO2Code = () => {
51-
/** Returns the default ISO2 code, based on the user's timezone */
52-
return (timezones[Intl.DateTimeFormat().resolvedOptions().timeZone as keyof typeof timezones] || "") || "us";
51+
/** Returns the default ISO2 code, based on the user's timezone */
52+
return (timezones[Intl.DateTimeFormat().resolvedOptions().timeZone as keyof typeof timezones] || "") || "us";
5353
}
5454

5555
export const parsePhoneNumber = (formattedNumber: string, countriesList: typeof countries = countries, country: any = null): PhoneNumber => {
56-
const value = getRawValue(formattedNumber);
57-
const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code();
58-
const countryCodePattern = /\+\d+/;
59-
const areaCodePattern = /\((\d+)\)/;
56+
const value = getRawValue(formattedNumber);
57+
const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code();
58+
const countryCodePattern = /\+\d+/;
59+
const areaCodePattern = /\((\d+)\)/;
6060

61-
/** Parses the matching partials of the phone number by predefined regex patterns */
62-
const countryCodeMatch = formattedNumber ? (formattedNumber.match(countryCodePattern) || []) : [];
63-
const areaCodeMatch = formattedNumber ? (formattedNumber.match(areaCodePattern) || []) : [];
61+
/** Parses the matching partials of the phone number by predefined regex patterns */
62+
const countryCodeMatch = formattedNumber ? (formattedNumber.match(countryCodePattern) || []) : [];
63+
const areaCodeMatch = formattedNumber ? (formattedNumber.match(areaCodePattern) || []) : [];
6464

65-
/** Converts the parsed values of the country and area codes to integers if values present */
66-
const countryCode = countryCodeMatch.length > 0 ? parseInt(countryCodeMatch[0]) : null;
67-
const areaCode = areaCodeMatch.length > 1 ? areaCodeMatch[1] : null;
65+
/** Converts the parsed values of the country and area codes to integers if values present */
66+
const countryCode = countryCodeMatch.length > 0 ? parseInt(countryCodeMatch[0]) : null;
67+
const areaCode = areaCodeMatch.length > 1 ? areaCodeMatch[1] : null;
6868

69-
/** Parses the phone number by removing the country and area codes from the formatted value */
70-
const phoneNumberPattern = new RegExp(`^${countryCode}${(areaCode || "")}(\\d+)`);
71-
const phoneNumberMatch = value ? (value.match(phoneNumberPattern) || []) : [];
72-
const phoneNumber = phoneNumberMatch.length > 1 ? phoneNumberMatch[1] : null;
69+
/** Parses the phone number by removing the country and area codes from the formatted value */
70+
const phoneNumberPattern = new RegExp(`^${countryCode}${(areaCode || "")}(\\d+)`);
71+
const phoneNumberMatch = value ? (value.match(phoneNumberPattern) || []) : [];
72+
const phoneNumber = phoneNumberMatch.length > 1 ? phoneNumberMatch[1] : null;
7373

74-
return {countryCode, areaCode, phoneNumber, isoCode};
74+
return {countryCode, areaCode, phoneNumber, isoCode};
75+
}
76+
77+
export const useMask = (pattern: string) => {
78+
const backRef = useRef<boolean>(false);
79+
80+
const clean = useCallback((input: any) => {
81+
return cleanInput(input, pattern.replaceAll(/\d/g, "."));
82+
}, [pattern])
83+
84+
const first = useMemo(() => {
85+
return [...pattern].findIndex(c => slots.has(c));
86+
}, [pattern])
87+
88+
const prev = useMemo((j = 0) => {
89+
return Array.from(pattern.replaceAll(/\d/g, "."), (c, i) => {
90+
return slots.has(c) ? j = i + 1 : j;
91+
});
92+
}, [pattern])
93+
94+
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
95+
backRef.current = event.key === "Backspace";
96+
}, [])
97+
98+
const onInput = useCallback(({target}: ChangeEvent<HTMLInputElement>) => {
99+
const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => {
100+
i = clean(target.value.slice(0, i)).findIndex(c => slots.has(c));
101+
return i < 0 ? prev[prev.length - 1] : backRef.current ? prev[i - 1] || first : i;
102+
});
103+
target.value = getFormattedNumber(target.value, pattern);
104+
target.setSelectionRange(i, j);
105+
backRef.current = false;
106+
}, [clean, first, pattern, prev])
107+
108+
return {
109+
onInput,
110+
onKeyDown,
111+
}
75112
}
76113

77114
export const usePhone = ({
78-
query = "",
79-
country = "",
80-
countryCode = "",
81-
initialValue = "",
82-
onlyCountries = [],
83-
excludeCountries = [],
84-
preferredCountries = [],
85-
}: usePhoneOptions) => {
86-
const defaultValue = getRawValue(initialValue);
87-
const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country);
88-
const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string;
89-
90-
const backRef = useRef<boolean>(false);
91-
const [value, setValue] = useState<string>(defaultValueState);
92-
93-
const countriesOnly = useMemo(() => {
94-
const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso);
95-
return countries.map(([iso]) => iso).filter((iso) => {
96-
return allowList.includes(iso) && !excludeCountries.includes(iso);
97-
});
98-
}, [onlyCountries, excludeCountries])
99-
100-
const countriesList = useMemo(() => {
101-
const filteredCountries = countries.filter(([iso, name, _1, dial]) => {
102-
return countriesOnly.includes(iso) && (
103-
name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query)
104-
);
105-
});
106-
return [
107-
...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)),
108-
...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)),
109-
];
110-
}, [countriesOnly, preferredCountries, query])
111-
112-
const metadata = useMemo(() => {
113-
const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode);
114-
if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) {
115-
return calculatedMetadata || defaultMetadata;
116-
}
117-
return countriesList[0];
118-
}, [countriesList, countryCode, defaultMetadata, value])
119-
120-
const pattern = useMemo(() => {
121-
return metadata?.[3] || defaultMetadata?.[3] || "";
122-
}, [defaultMetadata, metadata])
123-
124-
const clean = useCallback((input: any) => {
125-
return cleanInput(input, pattern.replaceAll(/\d/g, "."));
126-
}, [pattern])
127-
128-
const first = useMemo(() => {
129-
return [...pattern].findIndex(c => slots.has(c));
130-
}, [pattern])
131-
132-
const prev = useMemo((j = 0) => {
133-
return Array.from(pattern.replaceAll(/\d/g, "."), (c, i) => {
134-
return slots.has(c) ? j = i + 1 : j;
135-
});
136-
}, [pattern])
137-
138-
const format = useCallback(({target}: ChangeEvent<HTMLInputElement>) => {
139-
const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => {
140-
i = clean(target.value.slice(0, i)).findIndex(c => slots.has(c));
141-
return i < 0 ? prev[prev.length - 1] : backRef.current ? prev[i - 1] || first : i;
142-
});
143-
target.value = getFormattedNumber(target.value, pattern);
144-
target.setSelectionRange(i, j);
145-
backRef.current = false;
146-
setValue(target.value);
147-
}, [clean, first, pattern, prev])
148-
149-
return {
150-
clean,
151-
value,
152-
format,
153-
metadata,
154-
setValue,
155-
countriesList,
156-
}
115+
query = "",
116+
country = "",
117+
countryCode = "",
118+
initialValue = "",
119+
onlyCountries = [],
120+
excludeCountries = [],
121+
preferredCountries = [],
122+
}: usePhoneOptions) => {
123+
const defaultValue = getRawValue(initialValue);
124+
const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country);
125+
const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string;
126+
127+
const [value, setValue] = useState<string>(defaultValueState);
128+
129+
const countriesOnly = useMemo(() => {
130+
const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso);
131+
return countries.map(([iso]) => iso).filter((iso) => {
132+
return allowList.includes(iso) && !excludeCountries.includes(iso);
133+
});
134+
}, [onlyCountries, excludeCountries])
135+
136+
const countriesList = useMemo(() => {
137+
const filteredCountries = countries.filter(([iso, name, _1, dial]) => {
138+
return countriesOnly.includes(iso) && (
139+
name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query)
140+
);
141+
});
142+
return [
143+
...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)),
144+
...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)),
145+
];
146+
}, [countriesOnly, preferredCountries, query])
147+
148+
const metadata = useMemo(() => {
149+
const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode);
150+
if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) {
151+
return calculatedMetadata || defaultMetadata;
152+
}
153+
return countriesList[0];
154+
}, [countriesList, countryCode, defaultMetadata, value])
155+
156+
const pattern = useMemo(() => {
157+
return metadata?.[3] || defaultMetadata?.[3] || "";
158+
}, [defaultMetadata, metadata])
159+
160+
return {
161+
value,
162+
pattern,
163+
metadata,
164+
setValue,
165+
countriesList,
166+
}
157167
}

0 commit comments

Comments
 (0)