From 8f6ea123d1ca8ceec67d4030e169d607fd2b3f33 Mon Sep 17 00:00:00 2001 From: Thinh Trinh Date: Tue, 5 Mar 2024 10:48:51 +0700 Subject: [PATCH] fix: usePhoneInput result not in national format (#84) * fix: usePhoneInput result not in national format * feat: updated use phone input hook * chore: pumped version --- apps/docs/package.json | 2 +- .../docs/phone-input/use-phone-input.mdx | 173 +++++++++++++++++- packages/components/CHANGELOG.md | 7 + packages/components/package.json | 4 +- packages/phone-input/CHANGELOG.md | 6 + packages/phone-input/package.json | 2 +- .../formatWithFixedCountry.spec.ts | 11 ++ .../formatWithFixedCountry.ts | 25 +++ packages/phone-input/src/helpers/index.ts | 1 + .../src/hooks/usePhoneInput.spec.tsx | 4 +- .../phone-input/src/hooks/usePhoneInput.tsx | 111 +++++++++-- 11 files changed, 317 insertions(+), 29 deletions(-) create mode 100644 packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.spec.ts create mode 100644 packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.ts diff --git a/apps/docs/package.json b/apps/docs/package.json index 4134c6c..b00dbb7 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,7 +10,7 @@ "clean": "rimraf .turbo && rimraf node_modules && rimraf .next" }, "dependencies": { - "@react-awesome/components": "1.0.14", + "@react-awesome/components": "1.0.15", "classnames": "^2.5.1", "lodash": "^4.17.21", "lucide-react": "^0.315.0", diff --git a/apps/docs/src/pages/docs/phone-input/use-phone-input.mdx b/apps/docs/src/pages/docs/phone-input/use-phone-input.mdx index 6908d8d..f388e47 100644 --- a/apps/docs/src/pages/docs/phone-input/use-phone-input.mdx +++ b/apps/docs/src/pages/docs/phone-input/use-phone-input.mdx @@ -20,6 +20,7 @@ npm i @react-awesome/phone-input import { useState } from 'react' import { Container } from '../../../components/Container' import { usePhoneInput } from '@react-awesome/components' +import { Callout } from 'nextra/components' export const Example = () => { const [value, setValue] = useState({ @@ -83,13 +84,19 @@ const Example = () => { } ``` -## Local Phone Input +## Phone Input With National Format By default **usePhoneInput** has `mode` is set to `international`. When `mode` is `international` the value will be formatted as `e164`. -When `mode` is `national` the country code and the `+` sign will be ignored. Value is formatted as national format of the current selected country which provided via `country` property. +When `mode` is `national` the country code and the `+` sign will be ignored. Value is formatted as national format of the current selected country. + + + Even though the phone number is formatted follow the current selected country + but phone input also automatically change the country when user paste a value + include a different country code. + export const LocalExample = () => { const [value, setValue] = useState({ @@ -101,21 +108,23 @@ export const LocalExample = () => { formattedValue: '', isSupported: true, }); - const { register } = usePhoneInput({ + const { register, phoneCode } = usePhoneInput({ onChange: (_, m) => { setValue(m); }, - mode: 'local', - country: value.country + mode: 'national', + defaultCountry: 'VN' }); return (
-
+84
+
+ +{phoneCode} +
@@ -149,7 +158,6 @@ import { usePhoneInput } from '@react-awesome/phone-input' const Example = () => { const { register } = usePhoneInput({ mode: 'national', - country: 'VN', }) return ( @@ -161,6 +169,155 @@ const Example = () => { } ``` +## Phone Input With Fixed Country + +**usePhoneInput** also accepts `country` prop. + +When `country` is provided then the enterred value is formatted based on the provided country code and the country detection behaviour will be disabled. + +export const FixedCountryExample = () => { + const [value, setValue] = useState({ + isPossible: false, + isValid: false, + e164Value: '', + country: 'VN', + phoneCode: '84', + formattedValue: '', + isSupported: true, + }); + const { register, phoneCode } = usePhoneInput({ + onChange: (_, m) => { + setValue(m); + }, + country: 'VN', + defaultCountry: 'VN' + }); + +return ( + +
+
+ +
+

+ onChange event +

+
    + {Object.keys(value).map((key) => { + const v = value[key] + return ( +
  • + 👉 {key} + + {v.toString()} + +
  • + ) + })} +
+
+); }; + + + + + +```jsx +import { usePhoneInput } from '@react-awesome/phone-input' + +const Example = () => { + const { register } = usePhoneInput({ + country: 'VN', + }) + + return ( + + ) +} +``` + +`country` can also work with national format. + +export const FixedCountryWithNationalExample = () => { + const [value, setValue] = useState({ + isPossible: false, + isValid: false, + e164Value: '', + country: 'VN', + phoneCode: '84', + formattedValue: '', + isSupported: true, + }); + const { register, phoneCode } = usePhoneInput({ + onChange: (_, m) => { + setValue(m); + }, + country: 'VN', + defaultCountry: 'VN', + mode: 'national' + }); + +return ( + +
+
+
+ +{phoneCode} +
+ +
+

+ onChange event +

+
    + {Object.keys(value).map((key) => { + const v = value[key] + return ( +
  • + 👉 {key} + + {v.toString()} + +
  • + ) + })} +
+
+); }; + + + + + +```jsx +import { usePhoneInput } from '@react-awesome/phone-input' + +const Example = () => { + const { register } = usePhoneInput({ + country: 'VN', + mode: 'national', + }) + + return ( + + ) +} +``` + ## Parameters The `usePhoneInput` takes the following parameters: diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index ba715ef..373014b 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,5 +1,12 @@ # @react-awesome/components +## 1.0.15 + +### Patch Changes + +- Updated dependencies + - @react-awesome/phone-input@1.1.3 + ## 1.0.14 ### Patch Changes diff --git a/packages/components/package.json b/packages/components/package.json index 7bd0fad..1ee3e9e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@react-awesome/components", - "version": "1.0.14", + "version": "1.0.15", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@react-awesome/phone-input": "1.1.2", + "@react-awesome/phone-input": "1.1.3", "@react-awesome/use-click-outside": "0.0.3", "@react-awesome/use-preserve-input-caret-position": "0.0.3", "@react-awesome/use-selection-range": "0.0.3", diff --git a/packages/phone-input/CHANGELOG.md b/packages/phone-input/CHANGELOG.md index dacee6e..9034ff5 100644 --- a/packages/phone-input/CHANGELOG.md +++ b/packages/phone-input/CHANGELOG.md @@ -1,5 +1,11 @@ # @react-awesome/phone-input +## 1.1.3 + +### Patch Changes + +- Fix phone number is not in national format + ## 1.1.2 ### Patch Changes diff --git a/packages/phone-input/package.json b/packages/phone-input/package.json index b9afc2f..d2b6423 100644 --- a/packages/phone-input/package.json +++ b/packages/phone-input/package.json @@ -1,6 +1,6 @@ { "name": "@react-awesome/phone-input", - "version": "1.1.2", + "version": "1.1.3", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.spec.ts b/packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.spec.ts new file mode 100644 index 0000000..9295867 --- /dev/null +++ b/packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.spec.ts @@ -0,0 +1,11 @@ +import { formatWithFixedCountry } from './formatWithFixedCountry' + +describe('formatWithFixedCountry', () => { + it('Should leave as-is when the value is already has valid country code.', () => { + expect(formatWithFixedCountry('+123456', 'US')).toBe('+123456') + }) + + it('Should format to the correct country code.', () => { + expect(formatWithFixedCountry('+123456', 'VN')).toBe('+84123456') + }) +}) diff --git a/packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.ts b/packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.ts new file mode 100644 index 0000000..a865d03 --- /dev/null +++ b/packages/phone-input/src/helpers/formatWithFixedCountry/formatWithFixedCountry.ts @@ -0,0 +1,25 @@ +import { + CountryCode, + getCountryCallingCode, + parseIncompletePhoneNumber, +} from 'libphonenumber-js' + +export const formatWithFixedCountry = ( + phoneValue: string, + country: CountryCode, +) => { + if (!phoneValue) return '' + + const prefix = `+${getCountryCallingCode(country)}` + + if (phoneValue.startsWith(prefix)) + return parseIncompletePhoneNumber(phoneValue) + + if (phoneValue.startsWith('+')) + return `${prefix}${parseIncompletePhoneNumber(phoneValue.replace(/\+/g, ''))}` + + if (phoneValue.startsWith('0')) + return `${prefix}${parseIncompletePhoneNumber(phoneValue.slice(1))}` + + return `${prefix}${parseIncompletePhoneNumber(phoneValue)}` +} diff --git a/packages/phone-input/src/helpers/index.ts b/packages/phone-input/src/helpers/index.ts index 3f73dfc..5e00a76 100644 --- a/packages/phone-input/src/helpers/index.ts +++ b/packages/phone-input/src/helpers/index.ts @@ -3,3 +3,4 @@ export * from './formatInternational/formatInternational' export * from './getPossibleCountriesByCallingCode/getPossibleCountriesByCallingCode' export * from './checkCountryValidity/checkCountryValidity' export * from './formatNational/formatNational' +export * from './formatWithFixedCountry/formatWithFixedCountry' diff --git a/packages/phone-input/src/hooks/usePhoneInput.spec.tsx b/packages/phone-input/src/hooks/usePhoneInput.spec.tsx index c4aa63a..7624674 100644 --- a/packages/phone-input/src/hooks/usePhoneInput.spec.tsx +++ b/packages/phone-input/src/hooks/usePhoneInput.spec.tsx @@ -265,7 +265,9 @@ describe('usePhoneInput', () => { it('Should only trigger change event when value is actually changed', async () => { const onChange = vitest.fn() - const { container } = render() + const { container } = render( + , + ) const input = container.querySelector('input') if (!input) { diff --git a/packages/phone-input/src/hooks/usePhoneInput.tsx b/packages/phone-input/src/hooks/usePhoneInput.tsx index 6a3bb08..c8f0548 100644 --- a/packages/phone-input/src/hooks/usePhoneInput.tsx +++ b/packages/phone-input/src/hooks/usePhoneInput.tsx @@ -6,12 +6,14 @@ import { getCountryCallingCode, formatIncompletePhoneNumber, AsYouType, + parsePhoneNumber, } from 'libphonenumber-js' import { usePreserveInputCaretPosition } from '@react-awesome/use-preserve-input-caret-position' import { guessCountryByIncompleteNumber, formatInternational, formatNational, + formatWithFixedCountry, checkCountryValidity, } from '../helpers' @@ -88,6 +90,7 @@ export const usePhoneInput = ({ /** * States */ + const [isPasted, setPasted] = React.useState(false) const [inputRef, setInputRef] = React.useState(null) const [innerValue, setInnerValue] = React.useState< NonNullable<{ phone: string; country: CountryCode }> @@ -141,13 +144,43 @@ export const usePhoneInput = ({ /** * Helpers */ + const normalizeValue = React.useCallback( + (phone: string) => { + if (country && mode === 'national') { + return formatWithFixedCountry(phone, country).replace( + '+' + getCountryCallingCode(country), + '', + ) + } + + if (country) return formatWithFixedCountry(phone, country) + + switch (mode) { + case 'international': + return formatInternational(phone) + case 'national': + return formatNational(phone) + default: + return phone + } + }, + [country, mode], + ) const guessCountry = React.useCallback( (value: string) => { + /** + * When country is passed, the guessCountry is disabled. + */ if (country) return country + /** + * When mode is `national`, country should be parsed based on the current selected country + */ + if (mode === 'national' && innerValue.country) return innerValue.country + return guessCountryByIncompleteNumber(value) }, - [country], + [country, innerValue.country, mode], ) const openCountrySelect = React.useCallback(() => setSelectOpen(true), []) const closeCountrySelect = React.useCallback(() => setSelectOpen(false), []) @@ -157,23 +190,34 @@ export const usePhoneInput = ({ ) const generateMetadata = React.useCallback( (value: string, currentCountry: CountryCode): PhoneInputChangeMetadata => { - const _value = formatInternational(value) - const guessedCountry = guessCountry(_value) || currentCountry + const guessedCountry = guessCountry(value) || currentCountry + const isSupported = checkCountryValidity( guessedCountry, supportedCountries, ) + // If country is not supported country then return the defaultCountry or the first country in the option list. const country = isSupported ? guessedCountry : defaultCountry || options[0].iso2 + /** + * Reset asYouType to the latest country and parse from it. + */ + asYouType.current = new AsYouType(country) + asYouType.current.reset() + asYouType.current.input(value) + const formattedValue = formatIncompletePhoneNumber(value, country) return { isPossible: asYouType.current.isPossible(), isValid: asYouType.current.isValid(), - e164Value: asYouType.current.getNumber()?.format('E.164') || '', + e164Value: + asYouType.current.getNumber()?.format('E.164', { + fromCountry: country, + }) || '', country, phoneCode: asYouType.current.getCallingCode() || getCountryCallingCode(country), @@ -196,6 +240,33 @@ export const usePhoneInput = ({ }, [closeCountrySelect, generateMetadata, onPhoneChange], ) + const handlePastedValue = React.useCallback( + (value: string) => { + if (isPasted && mode === 'national') { + const asYouPaste = new AsYouType() + asYouPaste.input(value) + + if (value.startsWith('+')) { + const pastedCountry = + country || + asYouPaste.getCountry() || + guessCountryByIncompleteNumber(value) + if (pastedCountry) { + asYouType.current = new AsYouType(pastedCountry) + innerValue.country = pastedCountry + return value.replace(`+${getCountryCallingCode(pastedCountry)}`, '') + } + } else if (value.startsWith('0')) { + return value.slice(0) + } + + setPasted(false) + } + + return value + }, + [country, innerValue, isPasted, mode], + ) /** * Event Handlers @@ -204,18 +275,13 @@ export const usePhoneInput = ({ (e: React.ChangeEvent) => { const allowFormat = mode === 'international' ? INTERNATIONAL_FORMAT : LOCAL_FORMAT - const formatFn = - mode === 'international' ? formatInternational : formatNational // format raw value and assign back to the event target - e.target.value = formatFn(e.target.value) + e.target.value = normalizeValue(handlePastedValue(e.target.value)) - if (e.target.value === formatFn(innerValue.phone)) return + if (e.target.value === normalizeValue(innerValue.phone)) return if (allowFormat.test(e.target.value) || e.target.value === '') { - asYouType.current.reset() - asYouType.current.input(e.target.value) - const metadata = generateMetadata(e.target.value, innerValue.country) e.target.value = metadata.formattedValue @@ -231,13 +297,19 @@ export const usePhoneInput = ({ }, [ generateMetadata, + handlePastedValue, innerValue.country, innerValue.phone, mode, + normalizeValue, onPhoneChange, ], ) + const onPaste = React.useCallback(() => { + setPasted(true) + }, []) + const register = React.useCallback( ( name?: string, @@ -246,18 +318,25 @@ export const usePhoneInput = ({ React.InputHTMLAttributes, HTMLInputElement >, - 'ref' | 'name' | 'type' | 'autoComplete' | 'value' | 'onChange' + | 'ref' + | 'name' + | 'type' + | 'autoComplete' + | 'value' + | 'onChange' + | 'onPaste' > => { return { ref: setInputRef, name, value: innerValue.phone, onChange, + onPaste, type: 'tel', autoComplete: 'tel', } }, - [innerValue.phone, onChange], + [innerValue.phone, onChange, onPaste], ) /** @@ -272,9 +351,9 @@ export const usePhoneInput = ({ } }, [country]) - React.useEffect(() => { - asYouType.current = new AsYouType(innerValue.country) - }, [innerValue.country]) + // React.useEffect(() => { + // asYouType.current = new AsYouType(innerValue.country) + // }, [innerValue.country]) React.useEffect(() => { if (!value) return