diff --git a/development/public/index.html b/development/public/index.html index 800ce6d..b7d836e 100644 --- a/development/public/index.html +++ b/development/public/index.html @@ -1,9 +1,9 @@ - - - Development + + + Development
diff --git a/development/src/AntDemo.tsx b/development/src/AntDemo.tsx index 078119c..95a51f8 100644 --- a/development/src/AntDemo.tsx +++ b/development/src/AntDemo.tsx @@ -12,54 +12,54 @@ import PhoneInput from "./ant-phone"; import "antd/dist/reset.css"; const AntDemo = () => { - const [value, setValue] = useState({}); - const [algorithm, setAlgorithm] = useState("defaultAlgorithm"); + const [value, setValue] = useState({}); + const [algorithm, setAlgorithm] = useState("defaultAlgorithm"); - const validator = (_: any, {valid}: any) => { - if (valid()) { - return Promise.resolve(); - } - return Promise.reject("Invalid phone number"); - } + const validator = (_: any, {valid}: any) => { + if (valid()) { + return Promise.resolve(); + } + return Promise.reject("Invalid phone number"); + } - const changeTheme = () => { - if (algorithm === "defaultAlgorithm") { - setAlgorithm("darkAlgorithm"); - } else { - setAlgorithm("defaultAlgorithm"); - } - } + const changeTheme = () => { + if (algorithm === "defaultAlgorithm") { + setAlgorithm("darkAlgorithm"); + } else { + setAlgorithm("defaultAlgorithm"); + } + } - return ( - - -
- {value && ( -
+    return (
+        
+            
+                
+ {value && ( +
                             {JSON.stringify(value, null, 2)}
                         
- )} -
- - setValue(e as any)}/> - - - - -
- - -
-
-
-
-
- ) + )} +
+ + setValue(e as any)}/> + + + + +
+ + +
+
+
+
+
+ ) } export default AntDemo; diff --git a/development/src/Demo.tsx b/development/src/Demo.tsx index a62cc95..30be583 100644 --- a/development/src/Demo.tsx +++ b/development/src/Demo.tsx @@ -2,16 +2,16 @@ import AntDemo from "./AntDemo"; import MuiDemo from "./MuiDemo"; const Demo = () => { - return ( -
-
- -
-
- -
-
- ) + return ( +
+
+ +
+
+ +
+
+ ) } export default Demo; diff --git a/development/src/MuiDemo.tsx b/development/src/MuiDemo.tsx index b2f66ef..3a69ae3 100644 --- a/development/src/MuiDemo.tsx +++ b/development/src/MuiDemo.tsx @@ -5,47 +5,47 @@ import {Button, Container, CssBaseline, TextField} from "@mui/material"; import PhoneInput from "./mui-phone"; const Demo = () => { - const [value, setValue] = useState({}); - const [mode, setMode] = useState("dark"); + const [value, setValue] = useState({}); + const [mode, setMode] = useState("dark"); - const error = useMemo(() => (value as any).valid && !(value as any).valid(), [value]); + const error = useMemo(() => (value as any).valid && !(value as any).valid(), [value]); - const theme = useMemo(() => createTheme({palette: {mode: mode as any}}), [mode]); + const theme = useMemo(() => createTheme({palette: {mode: mode as any}}), [mode]); - const handleThemeChange = useCallback(() => setMode(mode === "dark" ? "light" : "dark"), [mode]); + const handleThemeChange = useCallback(() => setMode(mode === "dark" ? "light" : "dark"), [mode]); - return ( - - - -
- {value && ( -
+    return (
+        
+            
+            
+                
+ {value && ( +
                             {JSON.stringify(value, null, 2)}
                         
- )} -
e.preventDefault()}> - setValue(e as any)} - /> - -
- - -
- -
-
-
- ); + )} +
e.preventDefault()}> + setValue(e as any)} + /> + +
+ + +
+ +
+
+
+ ); } export default Demo; diff --git a/development/src/ant-phone/index.tsx b/development/src/ant-phone/index.tsx index f1cbce1..c26b2d4 100644 --- a/development/src/ant-phone/index.tsx +++ b/development/src/ant-phone/index.tsx @@ -1,179 +1,197 @@ -import {ChangeEvent, KeyboardEvent, useCallback, useContext, useEffect, useMemo, useRef, useState} from "react"; +import { + ChangeEvent, + forwardRef, + KeyboardEvent, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState +} from "react"; import useFormInstance from "antd/es/form/hooks/useFormInstance"; import {FormContext} from "antd/es/form/context"; import Select from "antd/es/select"; import Input from "antd/es/input"; import { - checkValidity, - cleanInput, - displayFormat, - getCountry, - getDefaultISO2Code, - getMetadata, - getRawValue, - parsePhoneNumber, - usePhone, -} from "react-phone-hooks"; + checkValidity, + cleanInput, + displayFormat, + getCountry, + getDefaultISO2Code, + getFormattedNumber, + getMetadata, + getRawValue, + parsePhoneNumber, + useMask, + usePhone, +} from "../phone-hooks"; import {injectMergedStyles} from "./styles"; import {PhoneInputProps, PhoneNumber} from "./types"; injectMergedStyles(); -const PhoneInput = ({ - value: initialValue = "", - country = getDefaultISO2Code(), - enableSearch = false, - disableDropdown = false, - onlyCountries = [], - excludeCountries = [], - preferredCountries = [], - searchNotFound = "No country found", - searchPlaceholder = "Search country", - onMount: handleMount = () => null, - onInput: handleInput = () => null, - onChange: handleChange = () => null, - onKeyDown: handleKeyDown = () => null, - ...antInputProps - }: PhoneInputProps) => { - const formInstance = useFormInstance(); - const formContext = useContext(FormContext); - const backRef = useRef(false); - const initiatedRef = useRef(false); - const [query, setQuery] = useState(""); - const [minWidth, setMinWidth] = useState(0); - const [countryCode, setCountryCode] = useState(country); - - const { - clean, - value, - format, - metadata, - setValue, - countriesList, - } = usePhone({ - query, - country, - countryCode, - initialValue, - onlyCountries, - excludeCountries, - preferredCountries, - }); - - const selectValue = useMemo(() => { - let metadata = getMetadata(getRawValue(value), countriesList); - metadata = metadata || getCountry(countryCode as any); - return ({...metadata})?.[0] + ({...metadata})?.[2]; - }, [countriesList, countryCode, value]) - - const setFieldValue = useCallback((value: PhoneNumber) => { - if (formInstance) { - let namePath = []; - let formName = (formContext as any)?.name || ""; - let fieldName = (antInputProps as any)?.id || ""; - if (formName) { - namePath.push(formName); - fieldName = fieldName.slice(formName.length + 1); - } - formInstance.setFieldValue(namePath.concat(fieldName.split("_")), value); - } - }, [antInputProps, formContext, formInstance]) - - const onKeyDown = useCallback((event: KeyboardEvent) => { - backRef.current = event.key === "Backspace"; - handleKeyDown(event); - }, [handleKeyDown]) - - const onChange = useCallback((event: ChangeEvent) => { - const formattedNumber = displayFormat(clean(event.target.value).join("")); - const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); - handleChange({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}, event); - }, [clean, countriesList, handleChange]) - - const onInput = useCallback((event: ChangeEvent) => { - handleInput(event); - format(event); - }, [format, handleInput]) - - const onMount = useCallback((value: PhoneNumber) => { - setFieldValue(value); - handleMount(value); - }, [handleMount, setFieldValue]) - - useEffect(() => { - if (initiatedRef.current) return; - initiatedRef.current = true; - let initialValue = getRawValue(value); - if (!initialValue.startsWith(metadata?.[2] as string)) { - initialValue = metadata?.[2] as string; - } - const formattedNumber = displayFormat(clean(initialValue).join("")); - const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); - onMount({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}); - setCountryCode(phoneMetadata.isoCode as any); - setValue(formattedNumber); - }, [clean, countriesList, metadata, onMount, setValue, value]) - - const countriesSelect = useMemo(() => ( - setQuery(target.value)} - /> - )} - {menu} - - )} - > - {countriesList.map(([iso, name, dial, mask]) => ( - } - children={
-
- {name} {displayFormat(mask)} -
} - /> - ))} - - ), [selectValue, disableDropdown, minWidth, searchNotFound, countriesList, setFieldValue, setValue, enableSearch, searchPlaceholder]) - - return ( -
setMinWidth(node?.offsetWidth || 0)}> - -
- ) -} +const PhoneInput = forwardRef(({ + value: initialValue = "", + country = getDefaultISO2Code(), + enableSearch = false, + disableDropdown = false, + onlyCountries = [], + excludeCountries = [], + preferredCountries = [], + searchNotFound = "No country found", + searchPlaceholder = "Search country", + onMount: handleMount = () => null, + onInput: handleInput = () => null, + onChange: handleChange = () => null, + onKeyDown: handleKeyDown = () => null, + ...antInputProps + }: PhoneInputProps, ref: any) => { + const formInstance = useFormInstance(); + const formContext = useContext(FormContext); + const initiatedRef = useRef(false); + const [query, setQuery] = useState(""); + const [minWidth, setMinWidth] = useState(0); + const [countryCode, setCountryCode] = useState(country); + + const { + value, + pattern, + metadata, + setValue, + countriesList, + } = usePhone({ + query, + country, + countryCode, + initialValue, + onlyCountries, + excludeCountries, + preferredCountries, + }); + + const { + onInput: onInputMaskHandler, + onKeyDown: onKeyDownMaskHandler, + } = useMask(pattern); + + const selectValue = useMemo(() => { + let metadata = getMetadata(getRawValue(value), countriesList); + metadata = metadata || getCountry(countryCode as any); + return ({...metadata})?.[0] + ({...metadata})?.[2]; + }, [countriesList, countryCode, value]) + + const setFieldValue = useCallback((value: PhoneNumber) => { + if (formInstance) { + let namePath = []; + let formName = (formContext as any)?.name || ""; + let fieldName = (antInputProps as any)?.id || ""; + if (formName) { + namePath.push(formName); + fieldName = fieldName.slice(formName.length + 1); + } + formInstance.setFieldValue(namePath.concat(fieldName.split("_")), value); + } + }, [antInputProps, formContext, formInstance]) + + const onKeyDown = useCallback((event: KeyboardEvent) => { + onKeyDownMaskHandler(event); + handleKeyDown(event); + }, [handleKeyDown, onKeyDownMaskHandler]) + + const onChange = useCallback((event: ChangeEvent) => { + const formattedNumber = getFormattedNumber(event.target.value, pattern); + const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); + setCountryCode(phoneMetadata.isoCode as any); + setValue(formattedNumber); + handleChange({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}, event); + }, [countriesList, handleChange, pattern, setValue]) + + const onInput = useCallback((event: ChangeEvent) => { + onInputMaskHandler(event); + handleInput(event); + }, [onInputMaskHandler, handleInput]) + + const onMount = useCallback((value: PhoneNumber) => { + setFieldValue(value); + handleMount(value); + }, [handleMount, setFieldValue]) + + useEffect(() => { + if (initiatedRef.current) return; + initiatedRef.current = true; + let initialValue = getRawValue(value); + if (!initialValue.startsWith(metadata?.[2] as string)) { + initialValue = metadata?.[2] as string; + } + const formattedNumber = getFormattedNumber(initialValue, pattern); + const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); + onMount({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}); + setCountryCode(phoneMetadata.isoCode as any); + setValue(formattedNumber); + }, [countriesList, metadata, onMount, pattern, setValue, value]) + + const countriesSelect = useMemo(() => ( + setQuery(target.value)} + /> + )} + {menu} +
+ )} + > + {countriesList.map(([iso, name, dial, mask]) => ( + } + children={
+
+ {name} {displayFormat(mask)} +
} + /> + ))} + + ), [selectValue, disableDropdown, minWidth, searchNotFound, countriesList, setFieldValue, setValue, enableSearch, searchPlaceholder]) + + return ( +
setMinWidth(node?.offsetWidth || 0)}> + +
+ ) +}) export default PhoneInput; diff --git a/development/src/ant-phone/types.ts b/development/src/ant-phone/types.ts index cada42c..eb0807b 100644 --- a/development/src/ant-phone/types.ts +++ b/development/src/ant-phone/types.ts @@ -5,30 +5,30 @@ import {InputProps} from "antd/es/input"; export type PhoneNumber = types.PhoneNumber; export interface PhoneInputProps extends Omit { - value?: PhoneNumber | string; + value?: PhoneNumber | string; - country?: string; + country?: string; - enableSearch?: boolean; + enableSearch?: boolean; - searchNotFound?: string; + searchNotFound?: string; - searchPlaceholder?: string; + searchPlaceholder?: string; - disableDropdown?: boolean; + disableDropdown?: boolean; - onlyCountries?: string[]; + onlyCountries?: string[]; - excludeCountries?: string[]; + excludeCountries?: string[]; - preferredCountries?: string[]; + preferredCountries?: string[]; - onMount?(value: PhoneNumber): void; + onMount?(value: PhoneNumber): void; - onInput?(event: ChangeEvent): void; + onInput?(event: ChangeEvent): void; - onKeyDown?(event: KeyboardEvent): void; + onKeyDown?(event: KeyboardEvent): void; - /** NOTE: This differs from the antd Input onChange interface */ - onChange?(value: PhoneNumber, event: ChangeEvent): void; + /** NOTE: This differs from the antd Input onChange interface */ + onChange?(value: PhoneNumber, event: ChangeEvent): void; } diff --git a/development/src/mui-phone/index.tsx b/development/src/mui-phone/index.tsx index 0b7bed5..5f647f5 100644 --- a/development/src/mui-phone/index.tsx +++ b/development/src/mui-phone/index.tsx @@ -1,175 +1,183 @@ -import {ChangeEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from "react"; +import {ChangeEvent, forwardRef, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState} from "react"; import {InputAdornment, MenuItem, Select, TextField} from "@mui/material"; import { - checkValidity, - cleanInput, - displayFormat, - getCountry, - getDefaultISO2Code, - getMetadata, - getRawValue, - parsePhoneNumber, - usePhone, -} from "react-phone-hooks"; + checkValidity, + cleanInput, + displayFormat, + getCountry, + getDefaultISO2Code, + getFormattedNumber, + getMetadata, + getRawValue, + parsePhoneNumber, + useMask, + usePhone, +} from "../phone-hooks"; import {injectMergedStyles} from "./styles"; import {PhoneInputProps, PhoneNumber} from "./types"; injectMergedStyles(); -const PhoneInput = ({ - value: initialValue = "", - variant = undefined, - searchVariant = undefined, - country = getDefaultISO2Code(), - enableSearch = false, - disableDropdown = false, - onlyCountries = [], - excludeCountries = [], - preferredCountries = [], - searchNotFound = "No country found", - searchPlaceholder = "Search country", - onMount: handleMount = () => null, - onInput: handleInput = () => null, - onChange: handleChange = () => null, - onKeyDown: handleKeyDown = () => null, - ...muiInputProps - }: PhoneInputProps) => { - searchVariant = searchVariant || variant; - const backRef = useRef(false); - const searchRef = useRef(false); - const initiatedRef = useRef(false); - const [query, setQuery] = useState(""); - const [open, setOpen] = useState(false); - const [maxWidth, setMaxWidth] = useState(0); - const [countryCode, setCountryCode] = useState(country); +const PhoneInput = forwardRef(({ + value: initialValue = "", + variant = undefined, + searchVariant = undefined, + country = getDefaultISO2Code(), + enableSearch = false, + disableDropdown = false, + onlyCountries = [], + excludeCountries = [], + preferredCountries = [], + searchNotFound = "No country found", + searchPlaceholder = "Search country", + onMount: handleMount = () => null, + onInput: handleInput = () => null, + onChange: handleChange = () => null, + onKeyDown: handleKeyDown = () => null, + ...muiInputProps + }: PhoneInputProps, ref: any) => { + searchVariant = searchVariant || variant; + const searchRef = useRef(false); + const initiatedRef = useRef(false); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [maxWidth, setMaxWidth] = useState(0); + const [countryCode, setCountryCode] = useState(country); - const { - clean, - value, - format, - metadata, - setValue, - countriesList, - } = usePhone({ - query, - country, - countryCode, - initialValue, - onlyCountries, - excludeCountries, - preferredCountries, - }); + const { + value, + pattern, + metadata, + setValue, + countriesList, + } = usePhone({ + query, + country, + countryCode, + initialValue, + onlyCountries, + excludeCountries, + preferredCountries, + }); - const selectValue = useMemo(() => { - let metadata = getMetadata(getRawValue(value), countriesList); - metadata = metadata || getCountry(countryCode as any); - return ({...metadata})?.[0] + ({...metadata})?.[2]; - }, [countriesList, countryCode, value]) + const { + onInput: onInputMaskHandler, + onKeyDown: onKeyDownMaskHandler, + } = useMask(pattern); - const onKeyDown = useCallback((event: KeyboardEvent) => { - backRef.current = event.key === "Backspace"; - handleKeyDown(event); - }, [handleKeyDown]) + const selectValue = useMemo(() => { + let metadata = getMetadata(getRawValue(value), countriesList); + metadata = metadata || getCountry(countryCode as any); + return ({...metadata})?.[0] + ({...metadata})?.[2]; + }, [countriesList, countryCode, value]) - const onChange = useCallback((event: ChangeEvent) => { - const formattedNumber = displayFormat(clean(event.target.value).join("")); - const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); - setValue(formattedNumber); - handleChange({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}, event); - }, [clean, countriesList, handleChange, setValue]) + const onKeyDown = useCallback((event: KeyboardEvent) => { + onKeyDownMaskHandler(event); + handleKeyDown(event); + }, [handleKeyDown, onKeyDownMaskHandler]) - const onInput = useCallback((event: ChangeEvent) => { - handleInput(event); - format(event); - }, [format, handleInput]) + const onChange = useCallback((event: ChangeEvent) => { + const formattedNumber = getFormattedNumber(event.target.value, pattern); + const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); + setCountryCode(phoneMetadata.isoCode as any); + setValue(formattedNumber); + handleChange({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}, event); + }, [countriesList, handleChange, pattern, setValue]) - const onMount = useCallback((value: PhoneNumber) => { - handleMount(value); - }, [handleMount]) + const onInput = useCallback((event: ChangeEvent) => { + onInputMaskHandler(event); + handleInput(event); + }, [handleInput, onInputMaskHandler]) - useEffect(() => { - if (initiatedRef.current) return; - initiatedRef.current = true; - let initialValue = getRawValue(value); - if (!initialValue.startsWith(metadata?.[2] as string)) { - initialValue = metadata?.[2] as string; - } - const formattedNumber = displayFormat(clean(initialValue).join("")); - const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); - onMount({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}); - setCountryCode(phoneMetadata.isoCode as any); - setValue(formattedNumber); - }, [clean, countriesList, metadata, onMount, setValue, value]) + const onMount = useCallback((value: PhoneNumber) => { + handleMount(value); + }, [handleMount]) - return ( -
setMaxWidth(node?.offsetWidth || 0)}> - - + useEffect(() => { + if (initiatedRef.current) return; + initiatedRef.current = true; + let initialValue = getRawValue(value); + if (!initialValue.startsWith(metadata?.[2] as string)) { + initialValue = metadata?.[2] as string; + } + const formattedNumber = getFormattedNumber(initialValue, pattern); + const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); + onMount({...phoneMetadata, valid: (strict: boolean) => checkValidity(phoneMetadata, strict)}); + setCountryCode(phoneMetadata.isoCode as any); + setValue(formattedNumber); + }, [countriesList, metadata, onMount, pattern, setValue, value]) + + return ( +
setMaxWidth(node?.offsetWidth || 0)}> + + setOpen(!open)} - > + style={{cursor: "pointer"}} + onClick={() => setOpen(!open)} + >
- - ) - }} - {...muiInputProps} - /> -
- ) -} + + ) + }} + {...muiInputProps} + /> +
+ ) +}) export default PhoneInput; diff --git a/development/src/mui-phone/types.ts b/development/src/mui-phone/types.ts index 4788e23..beb0436 100644 --- a/development/src/mui-phone/types.ts +++ b/development/src/mui-phone/types.ts @@ -5,34 +5,33 @@ import {TextFieldProps} from "@mui/material/TextField"; export type PhoneNumber = types.PhoneNumber; export interface PhoneInputProps extends Omit { - value?: PhoneNumber | string; + value?: PhoneNumber | string; - variant?: "outlined" | "filled" | "standard"; + variant?: "outlined" | "filled" | "standard"; - searchVariant?: "outlined" | "filled" | "standard"; + searchVariant?: "outlined" | "filled" | "standard"; - country?: string; + country?: string; - enableSearch?: boolean; + enableSearch?: boolean; - searchNotFound?: string; + searchNotFound?: string; - searchPlaceholder?: string; + searchPlaceholder?: string; - disableDropdown?: boolean; + disableDropdown?: boolean; - onlyCountries?: string[]; + onlyCountries?: string[]; - excludeCountries?: string[]; + excludeCountries?: string[]; - preferredCountries?: string[]; + preferredCountries?: string[]; - onMount?(value: PhoneNumber): void; + onMount?(value: PhoneNumber): void; - onInput?(event: ChangeEvent): void; + onInput?(event: ChangeEvent): void; - onKeyDown?(event: KeyboardEvent): void; + onKeyDown?(event: KeyboardEvent): void; - /** NOTE: This differs from the antd Input onChange interface */ - onChange?(value: PhoneNumber, event: ChangeEvent): void; + onChange?(value: PhoneNumber, event: ChangeEvent): void; } diff --git a/development/src/phone-hooks/index.ts b/development/src/phone-hooks/index.ts index e4fb779..eb68e77 100644 --- a/development/src/phone-hooks/index.ts +++ b/development/src/phone-hooks/index.ts @@ -1,4 +1,4 @@ -import {ChangeEvent, useCallback, useMemo, useRef, useState} from "react"; +import {ChangeEvent, KeyboardEvent, useCallback, useMemo, useRef, useState} from "react"; import {PhoneNumber, usePhoneOptions} from "./types"; @@ -9,149 +9,159 @@ import validations from "./metadata/validations.json"; const slots = new Set("."); export const getMetadata = (rawValue: string, countriesList: typeof countries = countries, country: any = null) => { - country = country == null && rawValue.startsWith("44") ? "gb" : country; - if (country != null) { - countriesList = countriesList.filter((c) => c[0] === country); - countriesList = countriesList.sort((a, b) => b[2].length - a[2].length); - } - return countriesList.find((c) => rawValue.startsWith(c[2])); + country = country == null && rawValue.startsWith("44") ? "gb" : country; + if (country != null) { + countriesList = countriesList.filter((c) => c[0] === country); + countriesList = countriesList.sort((a, b) => b[2].length - a[2].length); + } + return countriesList.find((c) => rawValue.startsWith(c[2])); } export const getCountry = (countryCode: keyof typeof countries) => { - return countries.find(([iso]) => iso === countryCode); + return countries.find(([iso]) => iso === countryCode); } export const getRawValue = (value: PhoneNumber | string) => { - if (typeof value === "string") return value.replaceAll(/\D/g, ""); - return [value?.countryCode, value?.areaCode, value?.phoneNumber].filter(Boolean).join(""); + if (typeof value === "string") return value.replaceAll(/\D/g, ""); + return [value?.countryCode, value?.areaCode, value?.phoneNumber].filter(Boolean).join(""); } export const displayFormat = (value: string) => { - /** Returns the formatted value that can be displayed as an actual input value */ - return value.replace(/[.\s\D]+$/, "").replace(/(\(\d+)$/, "$1)"); + /** 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: any, pattern: string) => { - input = input.match(/\d/g) || []; - return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c); + input = input.match(/\d/g) || []; + return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c); } export const getFormattedNumber = (rawValue: any, pattern: string) => { - /** Returns the reformatted input value based on the given pattern */ - return displayFormat(cleanInput(rawValue, pattern.replaceAll(/\d/g, ".")).join("")); + /** Returns the reformatted input value based on the given pattern */ + return displayFormat(cleanInput(rawValue, pattern.replaceAll(/\d/g, ".")).join("")); } export const checkValidity = (metadata: PhoneNumber, strict: boolean = false) => { - /** Checks if both the area code and phone number match the validation pattern */ - const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][Number(strict)]; - return new RegExp(pattern).test([metadata.areaCode, metadata.phoneNumber].filter(Boolean).join("")); + /** Checks if both the area code and phone number match the validation pattern */ + const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][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 as keyof typeof timezones] || "") || "us"; + /** Returns the default ISO2 code, based on the user's timezone */ + return (timezones[Intl.DateTimeFormat().resolvedOptions().timeZone as keyof typeof timezones] || "") || "us"; } export const parsePhoneNumber = (formattedNumber: string, countriesList: typeof countries = countries, country: any = null): PhoneNumber => { - const value = getRawValue(formattedNumber); - const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code(); - const countryCodePattern = /\+\d+/; - const areaCodePattern = /\((\d+)\)/; + const value = getRawValue(formattedNumber); + const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code(); + const countryCodePattern = /\+\d+/; + const areaCodePattern = /\((\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) || []) : []; + /** 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; + /** 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; + /** 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}; + return {countryCode, areaCode, phoneNumber, isoCode}; +} + +export const useMask = (pattern: string) => { + const backRef = useRef(false); + + const clean = useCallback((input: any) => { + 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: KeyboardEvent) => { + backRef.current = event.key === "Backspace"; + }, []) + + const onInput = useCallback(({target}: ChangeEvent) => { + const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => { + 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 = "", - country = "", - countryCode = "", - initialValue = "", - onlyCountries = [], - excludeCountries = [], - preferredCountries = [], - }: usePhoneOptions) => { - const defaultValue = getRawValue(initialValue); - const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country); - const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string; - - const backRef = useRef(false); - const [value, setValue] = useState(defaultValueState); - - const countriesOnly = useMemo(() => { - const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso); - return countries.map(([iso]) => iso).filter((iso) => { - return allowList.includes(iso) && !excludeCountries.includes(iso); - }); - }, [onlyCountries, excludeCountries]) - - const countriesList = useMemo(() => { - const filteredCountries = countries.filter(([iso, name, _1, dial]) => { - return countriesOnly.includes(iso) && ( - name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query) - ); - }); - return [ - ...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)), - ...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)), - ]; - }, [countriesOnly, preferredCountries, query]) - - const metadata = useMemo(() => { - const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode); - if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) { - return calculatedMetadata || defaultMetadata; - } - return countriesList[0]; - }, [countriesList, countryCode, defaultMetadata, value]) - - const pattern = useMemo(() => { - return metadata?.[3] || defaultMetadata?.[3] || ""; - }, [defaultMetadata, metadata]) - - const clean = useCallback((input: any) => { - 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 format = useCallback(({target}: ChangeEvent) => { - const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => { - 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; - setValue(target.value); - }, [clean, first, pattern, prev]) - - return { - clean, - value, - format, - metadata, - setValue, - countriesList, - } + query = "", + country = "", + countryCode = "", + initialValue = "", + onlyCountries = [], + excludeCountries = [], + preferredCountries = [], + }: usePhoneOptions) => { + const defaultValue = getRawValue(initialValue); + const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country); + const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string; + + const [value, setValue] = useState(defaultValueState); + + const countriesOnly = useMemo(() => { + const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso); + return countries.map(([iso]) => iso).filter((iso) => { + return allowList.includes(iso) && !excludeCountries.includes(iso); + }); + }, [onlyCountries, excludeCountries]) + + const countriesList = useMemo(() => { + const filteredCountries = countries.filter(([iso, name, _1, dial]) => { + return countriesOnly.includes(iso) && ( + name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query) + ); + }); + return [ + ...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)), + ...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)), + ]; + }, [countriesOnly, preferredCountries, query]) + + const metadata = useMemo(() => { + const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode); + if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) { + return calculatedMetadata || defaultMetadata; + } + return countriesList[0]; + }, [countriesList, countryCode, defaultMetadata, value]) + + const pattern = useMemo(() => { + return metadata?.[3] || defaultMetadata?.[3] || ""; + }, [defaultMetadata, metadata]) + + return { + value, + pattern, + metadata, + setValue, + countriesList, + } } diff --git a/development/src/phone-hooks/styles.ts b/development/src/phone-hooks/styles.ts index ff070db..5671720 100644 --- a/development/src/phone-hooks/styles.ts +++ b/development/src/phone-hooks/styles.ts @@ -1,22 +1,22 @@ export const jsonToCss = (stylesheet: object) => { - /** Convert the given `stylesheet` object to raw CSS */ - return Object.entries(stylesheet).map(([selector, rules]) => { - return `${selector} {` + Object.entries(rules).map(([key, value]) => { - return `${key}: ${value}; `; - }).join("") + "} "; - }).join(""); + /** Convert the given `stylesheet` object to raw CSS */ + return Object.entries(stylesheet).map(([selector, rules]) => { + return `${selector} {` + Object.entries(rules).map(([key, value]) => { + return `${key}: ${value}; `; + }).join("") + "} "; + }).join(""); } export const injectStyles = (cssText: string) => { - /** Inject the given `cssText` in the document head */ - const style = document.createElement("style"); - style.setAttribute("type", "text/css"); + /** Inject the given `cssText` in the document head */ + const style = document.createElement("style"); + style.setAttribute("type", "text/css"); - if ((style as any).styleSheet) { - (style as any).styleSheet.cssText = cssText; - } else { - style.appendChild(document.createTextNode(cssText)); - } + if ((style as any).styleSheet) { + (style as any).styleSheet.cssText = cssText; + } else { + style.appendChild(document.createTextNode(cssText)); + } - document.head.appendChild(style); + document.head.appendChild(style); } diff --git a/development/src/phone-hooks/types.ts b/development/src/phone-hooks/types.ts index 3eef142..147461f 100644 --- a/development/src/phone-hooks/types.ts +++ b/development/src/phone-hooks/types.ts @@ -1,18 +1,18 @@ export interface PhoneNumber { - countryCode?: number | null; - areaCode?: string | null; - phoneNumber?: string | null; - isoCode?: string; + countryCode?: number | null; + areaCode?: string | null; + phoneNumber?: string | null; + isoCode?: string; - valid?(strict?: boolean): boolean; + valid?(strict?: boolean): boolean; } export interface usePhoneOptions { - query?: string; - country?: string; - countryCode?: string; - onlyCountries?: string[]; - excludeCountries?: string[]; - preferredCountries?: string[]; - initialValue?: PhoneNumber | string; + query?: string; + country?: string; + countryCode?: string; + onlyCountries?: string[]; + excludeCountries?: string[]; + preferredCountries?: string[]; + initialValue?: PhoneNumber | string; } diff --git a/package.json b/package.json index 0636e36..92b392d 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ }, "devDependencies": { "@testing-library/react": "^14.1.2", + "@testing-library/user-event": "^14.5.1", "@types/jest": "^29.5.7", "@types/react": "^18.2.34", "jest": "^29.7.0", diff --git a/src/index.ts b/src/index.ts index e4fb779..eb68e77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import {ChangeEvent, useCallback, useMemo, useRef, useState} from "react"; +import {ChangeEvent, KeyboardEvent, useCallback, useMemo, useRef, useState} from "react"; import {PhoneNumber, usePhoneOptions} from "./types"; @@ -9,149 +9,159 @@ import validations from "./metadata/validations.json"; const slots = new Set("."); export const getMetadata = (rawValue: string, countriesList: typeof countries = countries, country: any = null) => { - country = country == null && rawValue.startsWith("44") ? "gb" : country; - if (country != null) { - countriesList = countriesList.filter((c) => c[0] === country); - countriesList = countriesList.sort((a, b) => b[2].length - a[2].length); - } - return countriesList.find((c) => rawValue.startsWith(c[2])); + country = country == null && rawValue.startsWith("44") ? "gb" : country; + if (country != null) { + countriesList = countriesList.filter((c) => c[0] === country); + countriesList = countriesList.sort((a, b) => b[2].length - a[2].length); + } + return countriesList.find((c) => rawValue.startsWith(c[2])); } export const getCountry = (countryCode: keyof typeof countries) => { - return countries.find(([iso]) => iso === countryCode); + return countries.find(([iso]) => iso === countryCode); } export const getRawValue = (value: PhoneNumber | string) => { - if (typeof value === "string") return value.replaceAll(/\D/g, ""); - return [value?.countryCode, value?.areaCode, value?.phoneNumber].filter(Boolean).join(""); + if (typeof value === "string") return value.replaceAll(/\D/g, ""); + return [value?.countryCode, value?.areaCode, value?.phoneNumber].filter(Boolean).join(""); } export const displayFormat = (value: string) => { - /** Returns the formatted value that can be displayed as an actual input value */ - return value.replace(/[.\s\D]+$/, "").replace(/(\(\d+)$/, "$1)"); + /** 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: any, pattern: string) => { - input = input.match(/\d/g) || []; - return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c); + input = input.match(/\d/g) || []; + return Array.from(pattern, c => input[0] === c || slots.has(c) ? input.shift() || c : c); } export const getFormattedNumber = (rawValue: any, pattern: string) => { - /** Returns the reformatted input value based on the given pattern */ - return displayFormat(cleanInput(rawValue, pattern.replaceAll(/\d/g, ".")).join("")); + /** Returns the reformatted input value based on the given pattern */ + return displayFormat(cleanInput(rawValue, pattern.replaceAll(/\d/g, ".")).join("")); } export const checkValidity = (metadata: PhoneNumber, strict: boolean = false) => { - /** Checks if both the area code and phone number match the validation pattern */ - const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][Number(strict)]; - return new RegExp(pattern).test([metadata.areaCode, metadata.phoneNumber].filter(Boolean).join("")); + /** Checks if both the area code and phone number match the validation pattern */ + const pattern = (validations as any)[metadata.isoCode as keyof typeof validations][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 as keyof typeof timezones] || "") || "us"; + /** Returns the default ISO2 code, based on the user's timezone */ + return (timezones[Intl.DateTimeFormat().resolvedOptions().timeZone as keyof typeof timezones] || "") || "us"; } export const parsePhoneNumber = (formattedNumber: string, countriesList: typeof countries = countries, country: any = null): PhoneNumber => { - const value = getRawValue(formattedNumber); - const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code(); - const countryCodePattern = /\+\d+/; - const areaCodePattern = /\((\d+)\)/; + const value = getRawValue(formattedNumber); + const isoCode = getMetadata(value, countriesList, country)?.[0] || getDefaultISO2Code(); + const countryCodePattern = /\+\d+/; + const areaCodePattern = /\((\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) || []) : []; + /** 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; + /** 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; + /** 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}; + return {countryCode, areaCode, phoneNumber, isoCode}; +} + +export const useMask = (pattern: string) => { + const backRef = useRef(false); + + const clean = useCallback((input: any) => { + 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: KeyboardEvent) => { + backRef.current = event.key === "Backspace"; + }, []) + + const onInput = useCallback(({target}: ChangeEvent) => { + const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => { + 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 = "", - country = "", - countryCode = "", - initialValue = "", - onlyCountries = [], - excludeCountries = [], - preferredCountries = [], - }: usePhoneOptions) => { - const defaultValue = getRawValue(initialValue); - const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country); - const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string; - - const backRef = useRef(false); - const [value, setValue] = useState(defaultValueState); - - const countriesOnly = useMemo(() => { - const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso); - return countries.map(([iso]) => iso).filter((iso) => { - return allowList.includes(iso) && !excludeCountries.includes(iso); - }); - }, [onlyCountries, excludeCountries]) - - const countriesList = useMemo(() => { - const filteredCountries = countries.filter(([iso, name, _1, dial]) => { - return countriesOnly.includes(iso) && ( - name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query) - ); - }); - return [ - ...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)), - ...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)), - ]; - }, [countriesOnly, preferredCountries, query]) - - const metadata = useMemo(() => { - const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode); - if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) { - return calculatedMetadata || defaultMetadata; - } - return countriesList[0]; - }, [countriesList, countryCode, defaultMetadata, value]) - - const pattern = useMemo(() => { - return metadata?.[3] || defaultMetadata?.[3] || ""; - }, [defaultMetadata, metadata]) - - const clean = useCallback((input: any) => { - 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 format = useCallback(({target}: ChangeEvent) => { - const [i, j] = [target.selectionStart, target.selectionEnd].map((i: any) => { - 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; - setValue(target.value); - }, [clean, first, pattern, prev]) - - return { - clean, - value, - format, - metadata, - setValue, - countriesList, - } + query = "", + country = "", + countryCode = "", + initialValue = "", + onlyCountries = [], + excludeCountries = [], + preferredCountries = [], + }: usePhoneOptions) => { + const defaultValue = getRawValue(initialValue); + const defaultMetadata = getMetadata(defaultValue) || countries.find(([iso]) => iso === country); + const defaultValueState = defaultValue || countries.find(([iso]) => iso === defaultMetadata?.[0])?.[2] as string; + + const [value, setValue] = useState(defaultValueState); + + const countriesOnly = useMemo(() => { + const allowList = onlyCountries.length > 0 ? onlyCountries : countries.map(([iso]) => iso); + return countries.map(([iso]) => iso).filter((iso) => { + return allowList.includes(iso) && !excludeCountries.includes(iso); + }); + }, [onlyCountries, excludeCountries]) + + const countriesList = useMemo(() => { + const filteredCountries = countries.filter(([iso, name, _1, dial]) => { + return countriesOnly.includes(iso) && ( + name.toLowerCase().startsWith(query.toLowerCase()) || dial.includes(query) + ); + }); + return [ + ...filteredCountries.filter(([iso]) => preferredCountries.includes(iso)), + ...filteredCountries.filter(([iso]) => !preferredCountries.includes(iso)), + ]; + }, [countriesOnly, preferredCountries, query]) + + const metadata = useMemo(() => { + const calculatedMetadata = getMetadata(getRawValue(value), countriesList, countryCode); + if (countriesList.find(([iso]) => iso === calculatedMetadata?.[0] || iso === defaultMetadata?.[0])) { + return calculatedMetadata || defaultMetadata; + } + return countriesList[0]; + }, [countriesList, countryCode, defaultMetadata, value]) + + const pattern = useMemo(() => { + return metadata?.[3] || defaultMetadata?.[3] || ""; + }, [defaultMetadata, metadata]) + + return { + value, + pattern, + metadata, + setValue, + countriesList, + } } diff --git a/src/styles.ts b/src/styles.ts index ff070db..5671720 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,22 +1,22 @@ export const jsonToCss = (stylesheet: object) => { - /** Convert the given `stylesheet` object to raw CSS */ - return Object.entries(stylesheet).map(([selector, rules]) => { - return `${selector} {` + Object.entries(rules).map(([key, value]) => { - return `${key}: ${value}; `; - }).join("") + "} "; - }).join(""); + /** Convert the given `stylesheet` object to raw CSS */ + return Object.entries(stylesheet).map(([selector, rules]) => { + return `${selector} {` + Object.entries(rules).map(([key, value]) => { + return `${key}: ${value}; `; + }).join("") + "} "; + }).join(""); } export const injectStyles = (cssText: string) => { - /** Inject the given `cssText` in the document head */ - const style = document.createElement("style"); - style.setAttribute("type", "text/css"); + /** Inject the given `cssText` in the document head */ + const style = document.createElement("style"); + style.setAttribute("type", "text/css"); - if ((style as any).styleSheet) { - (style as any).styleSheet.cssText = cssText; - } else { - style.appendChild(document.createTextNode(cssText)); - } + if ((style as any).styleSheet) { + (style as any).styleSheet.cssText = cssText; + } else { + style.appendChild(document.createTextNode(cssText)); + } - document.head.appendChild(style); + document.head.appendChild(style); } diff --git a/src/types.ts b/src/types.ts index 3eef142..147461f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,18 @@ export interface PhoneNumber { - countryCode?: number | null; - areaCode?: string | null; - phoneNumber?: string | null; - isoCode?: string; + countryCode?: number | null; + areaCode?: string | null; + phoneNumber?: string | null; + isoCode?: string; - valid?(strict?: boolean): boolean; + valid?(strict?: boolean): boolean; } export interface usePhoneOptions { - query?: string; - country?: string; - countryCode?: string; - onlyCountries?: string[]; - excludeCountries?: string[]; - preferredCountries?: string[]; - initialValue?: PhoneNumber | string; + query?: string; + country?: string; + countryCode?: string; + onlyCountries?: string[]; + excludeCountries?: string[]; + preferredCountries?: string[]; + initialValue?: PhoneNumber | string; } diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 896cacd..deb74c9 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,135 +1,153 @@ +import assert from "assert"; + import {useCallback, useEffect, useRef, useState} from "react"; -import {act, renderHook} from "@testing-library/react"; +import {act, render, renderHook, screen} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; -import {cleanInput, displayFormat, getRawValue, parsePhoneNumber, usePhone} from "../src"; +import {cleanInput, displayFormat, getFormattedNumber, getRawValue, parsePhoneNumber, useMask, usePhone} from "../src"; const usePhoneTester = ({ - country = "us", - initialValue = "", - onlyCountries = [], - excludeCountries = [], - preferredCountries = [], - }) => { - const initiatedRef = useRef(false); - const [query, setQuery] = useState(""); - const [countryCode, setCountryCode] = useState(country); - - const { - clean, - value, - metadata, - setValue, - countriesList, - } = usePhone({ - query, - country, - countryCode, - initialValue, - onlyCountries, - excludeCountries, - preferredCountries, - }); - - const update = useCallback((value: string) => { - const formattedNumber = displayFormat(clean(value).join("")); - const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); - setCountryCode(phoneMetadata.isoCode as any); - setValue(formattedNumber); - }, [clean, countriesList, setValue]); - - const backspace = useCallback(() => { - const rawValue = getRawValue(value); - const formattedNumber = displayFormat(rawValue.slice(0, -1)); - const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); - setCountryCode(phoneMetadata.isoCode as any); - setValue(formattedNumber); - }, [value, countriesList, setValue]); - - const search = useCallback(setQuery, []); - - const select = useCallback((isoCode: string) => { - const mask = (countriesList.find(([iso]) => iso === isoCode) as any)[3]; - setValue(displayFormat(cleanInput(mask, mask).join(""))); - setCountryCode(isoCode); - }, [setValue, countriesList]); - - useEffect(() => { - if (initiatedRef.current) return; - initiatedRef.current = true; - let initialValue = getRawValue(value); - if (!initialValue.startsWith(metadata?.[2] as string)) { - initialValue = metadata?.[2] as string; - } - const formattedNumber = displayFormat(clean(initialValue).join("")); - const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); - setCountryCode(phoneMetadata.isoCode as any); - setValue(formattedNumber); - }, [clean, countriesList, metadata, setValue, value]) - - return {update, search, select, value, metadata, backspace, countriesList}; + country = "us", + initialValue = "", + onlyCountries = [], + excludeCountries = [], + preferredCountries = [], + }) => { + const initiatedRef = useRef(false); + const [query, setQuery] = useState(""); + const [countryCode, setCountryCode] = useState(country); + + const { + value, + pattern, + metadata, + setValue, + countriesList, + } = usePhone({ + query, + country, + countryCode, + initialValue, + onlyCountries, + excludeCountries, + preferredCountries, + }); + + const update = useCallback((value: string) => { + const formattedNumber = getFormattedNumber(value, pattern); + const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); + setCountryCode(phoneMetadata.isoCode as any); + setValue(formattedNumber); + }, [countriesList, pattern, setValue]); + + const backspace = useCallback(() => { + const formattedNumber = displayFormat(getRawValue(value).slice(0, -1)); + const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); + setCountryCode(phoneMetadata.isoCode as any); + setValue(formattedNumber); + }, [value, countriesList, setValue]); + + const search = useCallback(setQuery, []); + + const select = useCallback((isoCode: string) => { + const mask = (countriesList.find(([iso]) => iso === isoCode) as any)[3]; + setValue(displayFormat(cleanInput(mask, mask).join(""))); + setCountryCode(isoCode); + }, [setValue, countriesList]); + + useEffect(() => { + if (initiatedRef.current) return; + initiatedRef.current = true; + let initialValue = getRawValue(value); + if (!initialValue.startsWith(metadata?.[2] as string)) { + initialValue = metadata?.[2] as string; + } + const formattedNumber = getFormattedNumber(initialValue, pattern); + const phoneMetadata = parsePhoneNumber(formattedNumber, countriesList); + setCountryCode(phoneMetadata.isoCode as any); + setValue(formattedNumber); + }, [countriesList, pattern, metadata, setValue, value]) + + return {update, search, select, value, metadata, backspace, countriesList}; +} + +const UseMaskTester = ({pattern = "", ...props}: any) => { + return ; } describe("Verifying the functionality of hooks", () => { - it("Check the usePhone hook initiation and updates", () => { - const {result} = renderHook(usePhoneTester, { - initialProps: { - initialValue: "37411111111", - } - }); - expect(result.current.value).toBe("+374 (11) 111 111"); - expect((result.current.metadata as any)[0]).toBe("am"); + it("Check the usePhone hook initiation and updates", () => { + const {result} = renderHook(usePhoneTester, { + initialProps: { + initialValue: "37411111111", + } + }); + expect(result.current.value).toBe("+374 (11) 111 111"); + expect((result.current.metadata as any)[0]).toBe("am"); + + act(() => result.current.update("1")); + act(() => result.current.update("1111")); + + expect(result.current.value).toBe("+1 (111)"); + expect((result.current.metadata as any)[0]).toBe("us"); + }) - act(() => result.current.update("1")); - act(() => result.current.update("1111")); + it("Check usePhone for country code update", () => { + const {result} = renderHook(usePhoneTester, { + initialProps: { + initialValue: "17021234567", + } + }); + expect(result.current.value).toBe("+1 (702) 123 4567"); + expect((result.current.metadata as any)[0]).toBe("us"); - expect(result.current.value).toBe("+1 (111)"); - expect((result.current.metadata as any)[0]).toBe("us"); - }) + act(() => result.current.select("ua")); - it("Check usePhone for country code update", () => { - const {result} = renderHook(usePhoneTester, { - initialProps: { - initialValue: "17021234567", - } - }); - expect(result.current.value).toBe("+1 (702) 123 4567"); - expect((result.current.metadata as any)[0]).toBe("us"); + expect(result.current.value).toBe("+380"); + expect((result.current.metadata as any)[0]).toBe("ua"); + }) - act(() => result.current.select("ua")); + it("Check usePhone for searching a country", () => { + const {result} = renderHook(usePhoneTester, { + initialProps: {} + }); - expect(result.current.value).toBe("+380"); - expect((result.current.metadata as any)[0]).toBe("ua"); - }) + act(() => result.current.search("Armenia")); - it("Check usePhone for searching a country", () => { - const {result} = renderHook(usePhoneTester, { - initialProps: {} - }); + expect(result.current.countriesList).toHaveLength(1); - act(() => result.current.search("Armenia")); + act(() => result.current.select(result.current.countriesList[0][0])); - expect(result.current.countriesList).toHaveLength(1); + expect((result.current.metadata as any)[0]).toBe("am"); + }) - act(() => result.current.select(result.current.countriesList[0][0])); + it("Check usePhone for country detection", () => { + const {result} = renderHook(usePhoneTester, { + initialProps: {} + }); - expect((result.current.metadata as any)[0]).toBe("am"); - }) + act(() => result.current.update("1")); - it("Check usePhone for country detection", () => { - const {result} = renderHook(usePhoneTester, { - initialProps: {} - }); + expect((result.current.metadata as any)[0]).toBe("us"); - act(() => result.current.update("1")); + act(() => result.current.update("1204")); - expect((result.current.metadata as any)[0]).toBe("us"); + expect((result.current.metadata as any)[0]).toBe("ca"); - act(() => result.current.update("1204")); + act(() => result.current.backspace()); - expect((result.current.metadata as any)[0]).toBe("ca"); + expect((result.current.metadata as any)[0]).toBe("us"); + }) - act(() => result.current.backspace()); + it("Check useMask for basic use case", async () => { + render( { + const isValid = "+380 (11) 222 34567".startsWith(e.target.value); + assert(isValid || "+380 (1)" === e.target.value); + }} + />); - expect((result.current.metadata as any)[0]).toBe("us"); - }) + await userEvent.type(screen.getByTestId("input"), "3801122234567"); + }) }) diff --git a/tests/utils.test.tsx b/tests/utils.test.tsx index 87e22e2..07b8ecf 100644 --- a/tests/utils.test.tsx +++ b/tests/utils.test.tsx @@ -3,42 +3,42 @@ import assert from "assert"; import {checkValidity, getFormattedNumber, getMetadata, getRawValue, parsePhoneNumber} from "../src"; describe("Verifying the basic functionality", () => { - it("Check the basic back-forward utilities", () => { - const rawValue = "17021234567"; - const metadata = getMetadata(rawValue); - - const formattedNumber = getFormattedNumber(rawValue, (metadata as any)[3]); - const parsedPhoneNumber = parsePhoneNumber(formattedNumber); - const rawPhoneNumber = getRawValue(formattedNumber); - - assert(formattedNumber !== null && formattedNumber === "+1 (702) 123 4567"); - assert(parsedPhoneNumber !== null && parsedPhoneNumber.countryCode === 1); - assert(parsedPhoneNumber.areaCode === "702" && parsedPhoneNumber.phoneNumber === "1234567"); - assert(rawPhoneNumber === rawValue); - }) - - it("Check the phone number validity", () => { - assert(checkValidity(parsePhoneNumber("+1 (702) 123 4567")) === true); - assert(checkValidity(parsePhoneNumber("+1 (702) 123 456")) === false); - - assert(checkValidity(parsePhoneNumber("+1 (702) 123 4567"), true) === true); - assert(checkValidity(parsePhoneNumber("+1 (100) 123 4567"), true) === false); - }) - - it("Check the order accuracy of getMetadata result", () => { - const bqMetadata = getMetadata("5990651111"); - const cwMetadata = getMetadata("5997171111", undefined, "cw"); - assert(bqMetadata !== null && (bqMetadata as any)[0] === "bq"); - assert(cwMetadata !== null && (cwMetadata as any)[0] === "cw"); - - const gbMetadata = getMetadata("440201111111"); - const jeMetadata = getMetadata("447797111111", undefined, "je"); - assert(gbMetadata !== null && (gbMetadata as any)[0] === "gb"); - assert(jeMetadata !== null && (jeMetadata as any)[0] === "je"); - - const itMetadata = getMetadata("39310111111111"); - const vaMetadata = getMetadata("39066981111111", undefined, "va"); - assert(itMetadata !== null && (itMetadata as any)[0] === "it"); - assert(vaMetadata !== null && (vaMetadata as any)[0] === "va"); - }) + it("Check the basic back-forward utilities", () => { + const rawValue = "17021234567"; + const metadata = getMetadata(rawValue); + + const formattedNumber = getFormattedNumber(rawValue, (metadata as any)[3]); + const parsedPhoneNumber = parsePhoneNumber(formattedNumber); + const rawPhoneNumber = getRawValue(formattedNumber); + + assert(formattedNumber !== null && formattedNumber === "+1 (702) 123 4567"); + assert(parsedPhoneNumber !== null && parsedPhoneNumber.countryCode === 1); + assert(parsedPhoneNumber.areaCode === "702" && parsedPhoneNumber.phoneNumber === "1234567"); + assert(rawPhoneNumber === rawValue); + }) + + it("Check the phone number validity", () => { + assert(checkValidity(parsePhoneNumber("+1 (702) 123 4567")) === true); + assert(checkValidity(parsePhoneNumber("+1 (702) 123 456")) === false); + + assert(checkValidity(parsePhoneNumber("+1 (702) 123 4567"), true) === true); + assert(checkValidity(parsePhoneNumber("+1 (100) 123 4567"), true) === false); + }) + + it("Check the order accuracy of getMetadata result", () => { + const bqMetadata = getMetadata("5990651111"); + const cwMetadata = getMetadata("5997171111", undefined, "cw"); + assert(bqMetadata !== null && (bqMetadata as any)[0] === "bq"); + assert(cwMetadata !== null && (cwMetadata as any)[0] === "cw"); + + const gbMetadata = getMetadata("440201111111"); + const jeMetadata = getMetadata("447797111111", undefined, "je"); + assert(gbMetadata !== null && (gbMetadata as any)[0] === "gb"); + assert(jeMetadata !== null && (jeMetadata as any)[0] === "je"); + + const itMetadata = getMetadata("39310111111111"); + const vaMetadata = getMetadata("39066981111111", undefined, "va"); + assert(itMetadata !== null && (itMetadata as any)[0] === "it"); + assert(vaMetadata !== null && (vaMetadata as any)[0] === "va"); + }) })