Skip to content

Commit b39215e

Browse files
Beta release: hooks and utilities (GH-4)
2 parents ed89437 + d95acba commit b39215e

33 files changed

+2190
-1277
lines changed

.github/workflows/publish.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Publish to NPM
2+
3+
on:
4+
release:
5+
types: [ published ]
6+
7+
jobs:
8+
deploy:
9+
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v3
15+
16+
- name: Set up Node
17+
uses: actions/setup-node@v3
18+
with:
19+
node-version: 16.x
20+
registry-url: https://registry.npmjs.org/
21+
22+
- name: Install dependencies
23+
run: yarn && yarn install
24+
25+
- name: Build and publish
26+
env:
27+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
28+
run: yarn build && yarn publish

.github/workflows/update.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: Update Validation Patterns
2+
3+
on:
4+
schedule:
5+
- cron: "0 0 * * 0"
6+
7+
jobs:
8+
update:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- name: Checkout code
13+
uses: actions/checkout@v2
14+
15+
- name: Setup Python
16+
uses: actions/setup-python@v3
17+
18+
- name: Fetch third-party metadata
19+
run: |
20+
curl -o resources/metadata.xml https://raw.githubusercontent.com/google/libphonenumber/master/resources/PhoneNumberMetadata.xml
21+
22+
- name: Update metadata
23+
run: |
24+
python scripts/prepare-metadata
25+
26+
- name: Compare metadata
27+
id: compare
28+
run: |
29+
git diff --exit-code resources/metadata.xml || echo "::set-output name=differs::true"
30+
31+
- name: Create Pull Request
32+
if: steps.compare.outputs.differs == 'true'
33+
uses: peter-evans/create-pull-request@v5
34+
with:
35+
token: ${{ secrets.GH_TOKEN }}
36+
branch-suffix: short-commit-hash
37+
branch: update-validation-patterns
38+
title: Update the validation patterns
39+
commit-message: Update the validation patterns
40+
body: This PR updates the validation patterns i.e. metadata.xml to keep the repository up-to-date with the upstream changes.

.npmignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
development
2+
resources
3+
scripts
4+
tests

README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
# react-phone-hooks
1+
# react-phone-hooks <img src="https://github.com/typesnippet.png" align="right" height="64" />
22

3-
React hooks and utility functions for parsing and validation phone numbers.
3+
[![npm](https://img.shields.io/npm/v/react-phone-hooks)](https://www.npmjs.com/package/react-phone-hooks)
4+
[![React](https://img.shields.io/badge/react-%E2%89%A516-blue)](https://www.npmjs.com/package/react-phone-hooks)
5+
[![types](https://img.shields.io/npm/types/react-phone-hooks)](https://www.npmjs.com/package/react-phone-hooks)
6+
[![License](https://img.shields.io/npm/l/react-phone-hooks)](https://github.com/typesnippet/react-phone-hooks/blob/master/LICENSE)
7+
[![Tests](https://github.com/pysnippet/react-phone-hooks/actions/workflows/tests.yml/badge.svg)](https://github.com/pysnippet/react-phone-hooks/actions/workflows/tests.yml)
48

5-
## TODO
9+
This comprehensive toolkit features custom hooks and utility functions tailored for phone number formatting, parsing,
10+
and validation. It supports international standards, making it suitable for phone number processing applications across
11+
different countries and regions.
612

7-
- [ ] Create an example app: half screen antd-phone-input, half screen mui-phone-input
8-
- [ ] Define the set of hooks and utility functions and implement them
9-
- [ ] Create unit tests for the hooks and utility functions
10-
- [ ] Safely transfer the antd-phone-input to github.com/typesnippet
11-
- [ ] Bring the mui-phone-input to a state to be published (alpha)
12-
- [ ] Create two concurrent PRs for hooks and utility functions
13-
- [ ] Publish the hooks and utility functions
13+
## Contribute
14+
15+
Any contribution is welcome. Don't hesitate to open an issue or discussion if you have questions about your project's
16+
usage and integration. For ideas or suggestions, please open a pull request. Your name will shine on our contributors'
17+
list. Be proud of what you build!
18+
19+
## License
20+
21+
Copyright (C) 2023 Artyom Vancyan. [MIT](https://github.com/pysnippet/react-phone-hooks/blob/master/LICENSE)

development/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
"@types/react": "^18.2.0",
1010
"@types/react-dom": "^18.2.0",
1111
"antd": "^5.8.3",
12-
"antd-phone-input": "^0.3.0",
1312
"react": "^18.2.0",
1413
"react-dom": "^18.2.0",
14+
"react-phone-hooks": "file:../react-phone-hooks-0.1.0.tgz",
1515
"react-scripts": "^5.0.1",
1616
"typescript": "^4.9.5"
1717
},

development/src/AntDemo.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {useState} from "react";
22
import Form from "antd/es/form";
33
import theme from "antd/es/theme";
4+
import Input from "antd/es/input";
45
import Button from "antd/es/button";
56
import Card from "antd/es/card/Card";
67
import FormItem from "antd/es/form/FormItem";
@@ -47,6 +48,9 @@ const AntDemo = () => {
4748
<FormItem name="phone" rules={[{validator}]}>
4849
<PhoneInput enableSearch onChange={(e) => setValue(e as any)}/>
4950
</FormItem>
51+
<FormItem name="test">
52+
<Input/>
53+
</FormItem>
5054
<div style={{display: "flex", gap: 24}}>
5155
<Button htmlType="reset">Reset Value</Button>
5256
<Button onClick={changeTheme}>Change Theme</Button>

development/src/MuiDemo.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {useCallback, useMemo, useState} from "react";
22
import {createTheme, ThemeProvider} from "@mui/material/styles";
3-
import {Button, Container, CssBaseline} from "@mui/material";
3+
import {Button, Container, CssBaseline, TextField} from "@mui/material";
44

5-
import CustomInput from "./mui-phone";
5+
import PhoneInput from "./mui-phone";
66

77
const Demo = () => {
88
const [value, setValue] = useState({});
@@ -29,13 +29,14 @@ const Demo = () => {
2929
</pre>
3030
)}
3131
<form noValidate autoComplete="off" onSubmit={e => e.preventDefault()}>
32-
<CustomInput
32+
<PhoneInput
3333
enableSearch
3434
error={error}
3535
variant="filled"
3636
searchVariant="standard"
3737
onChange={(e) => setValue(e as any)}
3838
/>
39+
<TextField variant="filled" style={{marginTop: "1.5rem"}}/>
3940
<div style={{display: "flex", gap: 24, marginTop: "1rem"}}>
4041
<Button type="reset">Reset Value</Button>
4142
<Button onClick={handleThemeChange}>Change Theme</Button>

development/src/ant-phone/index.tsx

Lines changed: 37 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -4,72 +4,22 @@ import {FormContext} from "antd/es/form/context";
44
import Select from "antd/es/select";
55
import Input from "antd/es/input";
66

7+
import {
8+
checkValidity,
9+
cleanInput,
10+
displayFormat,
11+
getCountry,
12+
getDefaultISO2Code,
13+
getMetadata,
14+
getRawValue,
15+
parsePhoneNumber,
16+
usePhone,
17+
} from "react-phone-hooks";
18+
19+
import {injectMergedStyles} from "./styles";
720
import {PhoneInputProps, PhoneNumber} from "./types";
821

9-
import styleInject from "./styles";
10-
import timezones from "./metadata/timezones.json";
11-
import countries from "./metadata/countries.json";
12-
import validations from "./metadata/validations.json";
13-
14-
styleInject("styles.css");
15-
16-
const slots = new Set(".");
17-
18-
const getMetadata = (rawValue: string, countriesList: typeof countries = countries, country: any = null) => {
19-
country = country == null && rawValue.startsWith("44") ? "gb" : country;
20-
if (country != null) {
21-
countriesList = countriesList.filter((c) => c[0] === country);
22-
countriesList = countriesList.sort((a, b) => b[2].length - a[2].length);
23-
}
24-
return countriesList.find((c) => rawValue.startsWith(c[2]));
25-
}
26-
27-
const getRawValue = (value: PhoneNumber | string) => {
28-
if (typeof value === "string") return value.replaceAll(/\D/g, "");
29-
return [value?.countryCode, value?.areaCode, value?.phoneNumber].filter(Boolean).join("");
30-
}
31-
32-
const displayFormat = (value: string) => {
33-
return value.replace(/[.\s\D]+$/, "").replace(/(\(\d+)$/, "$1)");
34-
}
35-
36-
const cleanInput = (input: any, pattern: string) => {
37-
input = input.match(/\d/g) || [];
38-
return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c);
39-
}
40-
41-
const checkValidity = (metadata: PhoneNumber, strict: boolean = false) => {
42-
/** Checks if both the area code and phone number match the validation pattern */
43-
const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][Number(strict)];
44-
return new RegExp(pattern).test([metadata.areaCode, metadata.phoneNumber].filter(Boolean).join(""));
45-
}
46-
47-
const getDefaultISO2Code = () => {
48-
/** Returns the default ISO2 code, based on the user's timezone */
49-
return (timezones[Intl.DateTimeFormat().resolvedOptions().timeZone as keyof typeof timezones] || "") || "us";
50-
}
51-
52-
const parsePhoneNumber = (formattedNumber: string, countriesList: typeof countries = countries, country: any = null): PhoneNumber => {
53-
const value = getRawValue(formattedNumber);
54-
const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code();
55-
const countryCodePattern = /\+\d+/;
56-
const areaCodePattern = /\((\d+)\)/;
57-
58-
/** Parses the matching partials of the phone number by predefined regex patterns */
59-
const countryCodeMatch = formattedNumber ? (formattedNumber.match(countryCodePattern) || []) : [];
60-
const areaCodeMatch = formattedNumber ? (formattedNumber.match(areaCodePattern) || []) : [];
61-
62-
/** Converts the parsed values of the country and area codes to integers if values present */
63-
const countryCode = countryCodeMatch.length > 0 ? parseInt(countryCodeMatch[0]) : null;
64-
const areaCode = areaCodeMatch.length > 1 ? areaCodeMatch[1] : null;
65-
66-
/** Parses the phone number by removing the country and area codes from the formatted value */
67-
const phoneNumberPattern = new RegExp(`^${countryCode}${(areaCode || "")}(\\d+)`);
68-
const phoneNumberMatch = value ? (value.match(phoneNumberPattern) || []) : [];
69-
const phoneNumber = phoneNumberMatch.length > 1 ? phoneNumberMatch[1] : null;
70-
71-
return {countryCode, areaCode, phoneNumber, isoCode};
72-
}
22+
injectMergedStyles();
7323

7424
const PhoneInput = ({
7525
value: initialValue = "",
@@ -87,67 +37,34 @@ const PhoneInput = ({
8737
onKeyDown: handleKeyDown = () => null,
8838
...antInputProps
8939
}: PhoneInputProps) => {
90-
const defaultValue = getRawValue(initialValue);
91-
const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country);
92-
const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string;
93-
9440
const formInstance = useFormInstance();
9541
const formContext = useContext(FormContext);
9642
const backRef = useRef<boolean>(false);
9743
const initiatedRef = useRef<boolean>(false);
9844
const [query, setQuery] = useState<string>("");
99-
const [value, setValue] = useState<string>(defaultValueState);
10045
const [minWidth, setMinWidth] = useState<number>(0);
10146
const [countryCode, setCountryCode] = useState<string>(country);
10247

103-
const countriesOnly = useMemo(() => {
104-
const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso);
105-
return countries.map(([iso]) => iso).filter((iso) => {
106-
return allowList.includes(iso) && !excludeCountries.includes(iso);
107-
});
108-
}, [onlyCountries, excludeCountries])
109-
110-
const countriesList = useMemo(() => {
111-
const filteredCountries = countries.filter(([iso, name, _1, dial]) => {
112-
return countriesOnly.includes(iso) && (
113-
name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query)
114-
);
115-
});
116-
return [
117-
...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)),
118-
...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)),
119-
];
120-
}, [countriesOnly, preferredCountries, query])
121-
122-
const metadata = useMemo(() => {
123-
const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode);
124-
if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) {
125-
return calculatedMetadata || defaultMetadata;
126-
}
127-
return countriesList[0];
128-
}, [countriesList, countryCode, defaultMetadata, value])
129-
130-
const pattern = useMemo(() => {
131-
return metadata?.[3] || defaultMetadata?.[3] || "";
132-
}, [defaultMetadata, metadata])
133-
134-
const clean = useCallback((input: any) => {
135-
return cleanInput(input, pattern.replaceAll(/\d/g, "."));
136-
}, [pattern])
137-
138-
const first = useMemo(() => {
139-
return [...pattern].findIndex(c => slots.has(c));
140-
}, [pattern])
141-
142-
const prev = useMemo((j = 0) => {
143-
return Array.from(pattern.replaceAll(/\d/g, "."), (c, i) => {
144-
return slots.has(c) ? j = i + 1 : j;
145-
});
146-
}, [pattern])
48+
const {
49+
clean,
50+
value,
51+
format,
52+
metadata,
53+
setValue,
54+
countriesList,
55+
} = usePhone({
56+
query,
57+
country,
58+
countryCode,
59+
initialValue,
60+
onlyCountries,
61+
excludeCountries,
62+
preferredCountries,
63+
});
14764

14865
const selectValue = useMemo(() => {
14966
let metadata = getMetadata(getRawValue(value), countriesList);
150-
metadata = metadata || countries.find(([iso]) => iso === countryCode);
67+
metadata = metadata || getCountry(countryCode as any);
15168
return ({...metadata})?.[0] + ({...metadata})?.[2];
15269
}, [countriesList, countryCode, value])
15370

@@ -164,17 +81,6 @@ const PhoneInput = ({
16481
}
16582
}, [antInputProps, formContext, formInstance])
16683

167-
const format = useCallback(({target}: ChangeEvent<HTMLInputElement>) => {
168-
const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => {
169-
i = clean(target.value.slice(0, i)).findIndex(c => slots.has(c));
170-
return i < 0 ? prev[prev.length - 1] : backRef.current ? prev[i - 1] || first : i;
171-
});
172-
target.value = displayFormat(clean(target.value).join(""));
173-
target.setSelectionRange(i, j);
174-
backRef.current = false;
175-
setValue(target.value);
176-
}, [clean, first, prev])
177-
17884
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
17985
backRef.current = event.key === "Backspace";
18086
handleKeyDown(event);
@@ -206,16 +112,17 @@ const PhoneInput = ({
206112
const formattedNumber = displayFormat(clean(initialValue).join(""));
207113
const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList);
208114
onMount({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)});
209-
setCountryCode(phoneMetadata.isoCode as keyof typeof validations);
115+
setCountryCode(phoneMetadata.isoCode as any);
210116
setValue(formattedNumber);
211-
}, [clean, countriesList, metadata, onMount, value])
117+
}, [clean, countriesList, metadata, onMount, setValue, value])
212118

213119
const countriesSelect = useMemo(() => (
214120
<Select
215121
suffixIcon={null}
216122
value={selectValue}
217123
open={disableDropdown ? false : undefined}
218-
onSelect={(selectedOption, {key: mask}) => {
124+
onSelect={(selectedOption, {key}) => {
125+
const [_, mask] = key.split("_");
219126
if (selectValue === selectedOption) return;
220127
const selectedCountryCode = selectedOption.slice(0, 2);
221128
const formattedNumber = displayFormat(cleanInput(mask, mask).join(""));
@@ -241,8 +148,8 @@ const PhoneInput = ({
241148
>
242149
{countriesList.map(([iso, name, dial, mask]) => (
243150
<Select.Option
244-
key={iso + mask}
245151
value={iso + dial}
152+
key={`${iso}_${mask}`}
246153
label={<div className={`flag ${iso}`}/>}
247154
children={<div className="ant-phone-input-select-item">
248155
<div className={`flag ${iso}`}/>
@@ -251,7 +158,7 @@ const PhoneInput = ({
251158
/>
252159
))}
253160
</Select>
254-
), [selectValue, disableDropdown, minWidth, searchNotFound, countriesList, setFieldValue, enableSearch, searchPlaceholder])
161+
), [selectValue, disableDropdown, minWidth, searchNotFound, countriesList, setFieldValue, setValue, enableSearch, searchPlaceholder])
255162

256163
return (
257164
<div className="ant-phone-input-wrapper"

0 commit comments

Comments
 (0)