diff --git a/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-2145678901-in-vivo-skin-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-2145678901-in-vivo-skin-1-snap.png new file mode 100644 index 000000000..af28fe5c4 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-2145678901-in-vivo-skin-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-34654834455-in-vivo-skin-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-34654834455-in-vivo-skin-1-snap.png new file mode 100644 index 000000000..d15bb0ab7 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-34654834455-in-vivo-skin-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-654834455-in-movistar-skin-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-654834455-in-movistar-skin-1-snap.png new file mode 100644 index 000000000..7fa9daf52 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/input-fields-screenshot-test-tsx-phone-number-field-lite-654834455-in-movistar-skin-1-snap.png differ diff --git a/src/__screenshot_tests__/input-fields-screenshot-test.tsx b/src/__screenshot_tests__/input-fields-screenshot-test.tsx index 051e86988..da4bcdcb6 100644 --- a/src/__screenshot_tests__/input-fields-screenshot-test.tsx +++ b/src/__screenshot_tests__/input-fields-screenshot-test.tsx @@ -521,6 +521,28 @@ test.each` expect(await fieldWrapper.screenshot()).toMatchImageSnapshot(); }); +test.each` + skin | number + ${'Vivo'} | ${'2145678901'} + ${'Vivo'} | ${'+34654834455'} + ${'Movistar'} | ${'654834455'} +`('PhoneNumberFieldLite - $number in $skin skin', async ({skin, number}) => { + await openStoryPage({ + id: 'components-input-fields-phonenumberfieldlite--uncontrolled', + device: 'MOBILE_IOS', + skin, + args: {defaultValue: number}, + }); + + const fieldWrapper = await screen.findByTestId('phone-number-field-lite'); + const field = await screen.findByLabelText('Label'); + + await field.click({clickCount: 3}); + await field.type(number); + + expect(await fieldWrapper.screenshot()).toMatchImageSnapshot(); +}); + test('CreditCardExpirationField', async () => { await openStoryPage({ id: 'components-input-fields-creditcardexpirationfield--uncontrolled', diff --git a/src/__stories__/phone-number-field-lite-story.tsx b/src/__stories__/phone-number-field-lite-story.tsx new file mode 100644 index 000000000..75717f828 --- /dev/null +++ b/src/__stories__/phone-number-field-lite-story.tsx @@ -0,0 +1,170 @@ +import * as React from 'react'; +import {Box, Text1, Stack, ResponsiveLayout, PhoneNumberFieldLite, Boxed} from '..'; +import {inspect} from 'util'; +import {phoneNumbersList} from './helpers'; + +export default { + title: 'Components/Input fields/PhoneNumberFieldLite', + parameters: {fullScreen: true}, +}; + +const getPhoneNumberSuggestions = (value: string) => + phoneNumbersList + .filter((s) => String(s).toLocaleLowerCase().startsWith(value.toLocaleLowerCase())) + .slice(0, 5); + +interface PhoneNumberFieldBaseArgs { + label: string; + placeholder: string; + prefix: string; + helperText: string; + error: boolean; + inverse: boolean; + optional: boolean; + disabled: boolean; + readOnly: boolean; + preventCopy: boolean; +} + +const defaultBaseArgs: PhoneNumberFieldBaseArgs = { + label: 'Label', + placeholder: '', + prefix: '', + helperText: '', + error: false, + inverse: false, + optional: false, + disabled: false, + readOnly: false, + preventCopy: false, +}; + +interface PhoneNumberFieldControlledArgs extends PhoneNumberFieldBaseArgs { + initialValue: string; + suggestions: boolean; +} + +const Description = () => { + return ( + + + +
+ This is a "light" version of the PhoneNumberField component. It does not use google's + libphonenumber library to reduce bundle size. +
+ +
- Only supported countries are formatted
+
- Not all phone number types are formatted
+
- Numbers in E164 are returned unformatted
+
+ - A custom formatter can be provided via props and the formatter used by this + component is exported as `formatPhoneLite` +
+
+
+
+
+ ); +}; + +export const Controlled: StoryComponent = ({ + inverse, + initialValue, + suggestions, + ...rest +}) => { + const [rawValue, setRawValue] = React.useState(initialValue); + const [value, setValue] = React.useState(undefined); + + return ( + + + + + { + setValue(value); + setRawValue(rawValue); + }} + name="phoneNumber" + autoComplete="off" + dataAttributes={{testid: 'phone-number-field-lite'}} + getSuggestions={suggestions ? getPhoneNumberSuggestions : undefined} + {...rest} + /> + + + value: {typeof value === 'undefined' ? '' : `(${typeof value}) ${inspect(value)}`} + + + rawValue:{' '} + {typeof rawValue === 'undefined' + ? '' + : `(${typeof rawValue}) ${inspect(rawValue)}`} + + + + + + ); +}; + +Controlled.storyName = 'controlled'; +Controlled.args = { + initialValue: '654834455', + ...defaultBaseArgs, + suggestions: false, +}; + +interface PhoneNumberFieldUncontrolledArgs extends PhoneNumberFieldBaseArgs { + defaultValue: string; +} + +export const Uncontrolled: StoryComponent = ({ + inverse, + defaultValue, + ...rest +}) => { + const [rawValue, setRawValue] = React.useState(undefined); + const [value, setValue] = React.useState(undefined); + + return ( + + + + + { + setValue(value); + setRawValue(rawValue); + }} + name="phoneNumber" + autoComplete="off" + dataAttributes={{testid: 'phone-number-field-lite'}} + {...rest} + /> + + + value: {typeof value === 'undefined' ? '' : `(${typeof value}) ${inspect(value)}`} + + + rawValue:{' '} + {typeof rawValue === 'undefined' + ? '' + : `(${typeof rawValue}) ${inspect(rawValue)}`} + + + + + + ); +}; + +Uncontrolled.storyName = 'uncontrolled'; +Uncontrolled.args = { + defaultValue: '654834455', + ...defaultBaseArgs, +}; diff --git a/src/__stories__/phone-number-field-story.tsx b/src/__stories__/phone-number-field-story.tsx index d58a6ef2e..01570e403 100644 --- a/src/__stories__/phone-number-field-story.tsx +++ b/src/__stories__/phone-number-field-story.tsx @@ -56,7 +56,7 @@ export const Controlled: StoryComponent = ({ const [value, setValue] = React.useState(undefined); return ( - + = ({ const [value, setValue] = React.useState(undefined); return ( - + { + const onChangeValue = jest.fn(); + const onChangeValueE164 = jest.fn(); + const onChangeValueUsingLibphonenumber = jest.fn(); + + render( + + + + + + ); + + const input = screen.getByLabelText('Phone'); + const inputE164 = screen.getByLabelText('Phone E164'); + const referenceInput = screen.getByLabelText('Reference'); + + await userEvent.type(input, number); + await userEvent.type(inputE164, number); + await userEvent.type(referenceInput, number); + + expect(onChangeValue).toHaveBeenLastCalledWith(expected, expectedRaw); + expect(onChangeValueE164).toHaveBeenLastCalledWith(expectedE164, expectedRaw); + + // We expect the same result as the libphonenumber version, except for the E164 format + if (!number.startsWith('+')) { + // This checks all the calls to onChangeValue (as you type) + expect(onChangeValue.mock.calls).toEqual(onChangeValueUsingLibphonenumber.mock.calls); + } + } +); + +test('PhoneNumberFieldLite custom formatter', async () => { + const onChangeValue = jest.fn(); + + render( + + { + return number.replace(/\D/g, '').split('').join('-'); + }} + /> + + ); + + const input = screen.getByLabelText('Phone'); + await userEvent.type(input, '654834455'); + + expect(onChangeValue).toHaveBeenLastCalledWith('654834455', '6-5-4-8-3-4-4-5-5'); +}); diff --git a/src/index.tsx b/src/index.tsx index 6cc2c9579..e8d1dc47e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -136,6 +136,7 @@ export {TextFieldBase} from './text-field-base'; export {default as SearchField} from './search-field'; export {default as EmailField} from './email-field'; export {default as PhoneNumberField} from './phone-number-field'; +export {default as PhoneNumberFieldLite, formatPhoneLite} from './phone-number-field-lite'; export {default as CreditCardNumberField} from './credit-card-number-field'; export {default as CreditCardExpirationField} from './credit-card-expiration-field'; export {default as CreditCardFields} from './credit-card-fields'; diff --git a/src/phone-number-field-lite.tsx b/src/phone-number-field-lite.tsx new file mode 100644 index 000000000..946954ada --- /dev/null +++ b/src/phone-number-field-lite.tsx @@ -0,0 +1,251 @@ +'use client'; +import * as React from 'react'; +import {useRifm} from 'rifm'; +import {useFieldProps} from './form-context'; +import {TextFieldBaseAutosuggest} from './text-field-base'; +import {useTheme} from './hooks'; +import {createChangeEvent} from './utils/dom'; +import {combineRefs} from './utils/common'; + +import type {CommonFormFieldProps} from './text-field-base'; +import type {RegionCode} from './utils/region-code'; + +const COUNTRY_CODE_TO_REGION_CODE: Record = { + '+34': 'ES', + '+55': 'BR', + '+49': 'DE', + '+44': 'GB', +}; + +const REGION_CODE_TO_COUNTRY_CODE: Record = Object.fromEntries( + Object.entries(COUNTRY_CODE_TO_REGION_CODE).map(([k, v]) => [v, k]) +); + +const clean = (number: string): string => { + return number.trim().replace(/[^\d\+]/g, ''); // keep digits and "+" +}; + +const asE164 = (number: string, regionCode: RegionCode): string => { + if (number.startsWith('+')) { + return number; + } + + switch (regionCode) { + case 'ES': + return `${REGION_CODE_TO_COUNTRY_CODE[regionCode]} ${number}`; + case 'BR': + return `${REGION_CODE_TO_COUNTRY_CODE[regionCode]} ${number.replace(/[\(\)]/g, '')}`; + case 'DE': + return `${REGION_CODE_TO_COUNTRY_CODE[regionCode]} ${number.replace(/^0/, '')}`; + case 'GB': + return `${REGION_CODE_TO_COUNTRY_CODE[regionCode]} ${number.replace(/^0/, '')}`; + default: + return number; + } +}; + +/** + * Simple phone formatter for a few countries and a subset of phone numbers + * + * Formatting conditions have been adapted to exactly match libphonenumber's as you type formatting + * Not all formatting rules are implemented, only the most common ones. For a more complete solution, use PhoneNumberField + */ +export const formatPhoneLite = (regionCode: RegionCode, number: string): string => { + const cleanNumber = clean(number); + const isE164 = cleanNumber.startsWith('+'); + let digits = cleanNumber.replace(/\D/g, ''); // keep digits only + let countryCode = ''; + let formattingRegionCode = regionCode; + + if (isE164) { + // check if the number matches a known country code + countryCode = + Object.keys(COUNTRY_CODE_TO_REGION_CODE).find((code) => cleanNumber.startsWith(code)) || ''; + + if (countryCode) { + digits = cleanNumber.slice(countryCode.length); // remove country code + formattingRegionCode = COUNTRY_CODE_TO_REGION_CODE[countryCode]; // override region code, the country code has precedence + } else { + // unknown E164 is returned without formatting + return '+' + digits; + } + } + + if (formattingRegionCode === 'ES') { + // https://en.wikipedia.org/wiki/Telephone_numbers_in_Spain + // Example mobile: 654 83 44 55 + // Example landline: 914 44 10 25 + if (digits.length <= 9) { + return `${countryCode} ${digits.slice(0, 3)} ${digits.slice(3, 5)} ${digits.slice(5, 7)} ${digits.slice(7)}`.trim(); + } + } else if (formattingRegionCode === 'BR') { + // https://en.wikipedia.org/wiki/Telephone_numbers_in_Brazil + // Example mobile: (xx) (6..9)xxxx-xxxx + // Example landline: (xx) xxxx-xxxx + let national: string | undefined; + if (digits.length === 11) { + national = `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`.replace(/\D+$/, ''); + } else if (digits.length > 2 && digits.length <= 11 && digits[2] <= '5') { + national = `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`.replace(/\D+$/, ''); + } + if (national) { + return isE164 ? asE164(national, formattingRegionCode) : national; + } + } else if (formattingRegionCode === 'DE') { + // https://en.wikipedia.org/wiki/Telephone_numbers_in_Germany + // Only formatting mobile numbers, landline numbers have a lot of variations: + // https://en.wikipedia.org/wiki/Telephone_numbers_in_Germany#/media/File:Karte_Telefonvorwahlen_Deutschland.png + // Example mobile: 0157 89012345 + // Example E164: +49 1578 9012345 + const zeroPadded = isE164 ? '0' + digits : digits; + if (zeroPadded.length >= 4 && zeroPadded.match(/^(015|016|017)/)) { + let national: string | undefined; + if (zeroPadded.length <= 12 && zeroPadded.startsWith('015')) { + national = `${zeroPadded.slice(0, 5)} ${zeroPadded.slice(5)}`.trim(); + } else { + national = `${countryCode} ${zeroPadded.slice(0, 4)} ${zeroPadded.slice(4)}`.trim(); + } + return isE164 ? asE164(national, formattingRegionCode) : national; + } + } else if (formattingRegionCode === 'GB') { + // https://en.wikipedia.org/wiki/Telephone_numbers_in_the_United_Kingdom#Mobile_telephones + // Like in DE, only mobile numbers are formatted + // Example mobile: 07xxx xxxxxx + const zeroPadded = isE164 ? '0' + digits : digits; + if (zeroPadded.length <= 11 && zeroPadded.startsWith('07')) { + const national = `${zeroPadded.slice(0, 5)} ${zeroPadded.slice(5)}`.trim(); + return isE164 ? asE164(national, formattingRegionCode) : national; + } + } + return isE164 ? `${countryCode} ${digits}` : digits; +}; + +type InputProps = Omit, 'value' | 'onInput'> & { + inputRef?: React.Ref; + value?: string; + defaultValue?: string; + onInput?: (event: React.FormEvent) => void; + prefix?: string; + format?: (number: string) => string; +}; + +const PhoneInput = ({ + inputRef, + value, + defaultValue, + onChange, + prefix, + format: formatFromProps, + ...other +}: InputProps) => { + const [selfValue, setSelfValue] = React.useState(defaultValue || ''); + const ref = React.useRef(null); + const { + i18n: {phoneNumberFormattingRegionCode: regionCode}, + } = useTheme(); + + const isControlledByParent = typeof value !== 'undefined'; + const controlledValue = (isControlledByParent ? value : selfValue) as string; + + const handleChangeValue = React.useCallback( + (newFormattedValue: string) => { + if (!isControlledByParent) { + setSelfValue(newFormattedValue); + } + if (ref.current) { + onChange?.(createChangeEvent(ref.current, newFormattedValue)); + } + }, + [isControlledByParent, onChange] + ); + + const format = React.useCallback( + (number: string): string => { + if (formatFromProps) { + return formatFromProps(number); + } + return formatPhoneLite(regionCode, number); + }, + [formatFromProps, regionCode] + ); + + const rifm = useRifm({ + format, + value: controlledValue, + accept: /[\d\+]+/g, + onChange: handleChangeValue, + }); + + return ( + + ); +}; + +export interface PhoneNumberFieldProps extends CommonFormFieldProps { + onChangeValue?: (value: string, rawValue: string) => void; + prefix?: string; + getSuggestions?: (value: string) => Array; + format?: (number: string) => string; + e164?: boolean; +} + +const PhoneNumberFieldLite = ({ + disabled, + error, + helperText, + name, + label, + optional, + validate, + onChange, + onChangeValue, + onBlur, + value, + defaultValue, + dataAttributes, + e164, + ...rest +}: PhoneNumberFieldProps): JSX.Element => { + const { + i18n: {phoneNumberFormattingRegionCode}, + } = useTheme(); + + const processValue = (value: string) => { + return e164 ? clean(asE164(value, phoneNumberFormattingRegionCode)) : clean(value); + }; + + const fieldProps = useFieldProps({ + name, + label, + value, + defaultValue, + processValue, + helperText, + optional, + error, + disabled, + onBlur, + validate, + onChange, + onChangeValue, + }); + + return ( + + ); +}; + +export default PhoneNumberFieldLite; diff --git a/src/phone-number-field.tsx b/src/phone-number-field.tsx index 032a5b965..4082c5821 100644 --- a/src/phone-number-field.tsx +++ b/src/phone-number-field.tsx @@ -20,12 +20,11 @@ type InputProps = Omit, 'value' | 'o defaultValue?: string; onInput?: (event: React.FormEvent) => void; prefix?: string; - e164?: boolean; }; const isValidPrefix = (prefix: string): boolean => !!prefix.match(/^\+\d+$/); -const PhoneInput = ({inputRef, value, defaultValue, onChange, prefix, e164, ...other}: InputProps) => { +const PhoneInput = ({inputRef, value, defaultValue, onChange, prefix, ...other}: InputProps) => { const [selfValue, setSelfValue] = React.useState(defaultValue ?? ''); const ref = React.useRef(null); const {i18n} = useTheme();