Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(PhoneNumberFieldLite): Phone number field with simple formatting to reduce bundle size #1276

Merged
merged 7 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/__screenshot_tests__/input-fields-screenshot-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ test.each`
expect(await fieldWrapper.screenshot()).toMatchImageSnapshot();
});

test.only.each`
test.each`
skin | number
${'Vivo'} | ${'2145678901'}
${'Vivo'} | ${'+34654834455'}
Expand Down
104 changes: 61 additions & 43 deletions src/__tests__/phone-number-field-lite-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,55 +7,73 @@ import * as React from 'react';
import PhoneNumberField from '../phone-number-field';

test.each`
regionCode | number | expected | expectedRaw | description
${'ZZ'} | ${'123456789012345'} | ${'123456789012345'} | ${'123456789012345'} | ${'Unknown region'}
${'ES'} | ${'654834455'} | ${'654834455'} | ${'654 83 44 55'} | ${'ES mobile'}
${'ES'} | ${'914-44/10 25'} | ${'914441025'} | ${'914 44 10 25'} | ${'ES landline'}
${'ES'} | ${'6548344556'} | ${'6548344556'} | ${'6548344556'} | ${'ES mobile too long'}
${'ES'} | ${'914-44/10 256'} | ${'9144410256'} | ${'9144410256'} | ${'ES landline too long'}
${'ES'} | ${'+34 654 834 455'} | ${'+34654834455'} | ${'+34654834455'} | ${'E164 is not formatted'}
${'BR'} | ${'21987654321'} | ${'21987654321'} | ${'(21) 98765-4321'} | ${'BR mobile'}
${'BR'} | ${'219876543210'} | ${'219876543210'} | ${'219876543210'} | ${'BR mobile too long'}
${'BR'} | ${'2123456789'} | ${'2123456789'} | ${'(21) 2345-6789'} | ${'BR landline'}
${'BR'} | ${'21234567890'} | ${'21234567890'} | ${'(21) 23456-7890'} | ${'BR landline long'}
${'BR'} | ${'212345678901'} | ${'212345678901'} | ${'212345678901'} | ${'BR landline too long'}
${'DE'} | ${'015789012345'} | ${'015789012345'} | ${'01578 9012345'} | ${'DE mobile 15'}
${'DE'} | ${'015789012345'} | ${'015789012345'} | ${'01578 9012345'} | ${'DE mobile 15 too long'}
${'DE'} | ${'01601234567'} | ${'01601234567'} | ${'0160 1234567'} | ${'DE mobile 16'}
${'DE'} | ${'01701234567'} | ${'01701234567'} | ${'0170 1234567'} | ${'DE mobile 17'}
${'DE'} | ${'12345678901'} | ${'12345678901'} | ${'12345678901'} | ${'DE unknown'}
${'GB'} | ${'07123456789'} | ${'07123456789'} | ${'07123 456789'} | ${'GB mobile'}
${'GB'} | ${'071234567890'} | ${'071234567890'} | ${'071234567890'} | ${'GB mobile too long'}
`('PhoneNumberFieldLite - $description - $number', async ({regionCode, number, expected, expectedRaw}) => {
const onChangeValue = jest.fn();
const onChangeValueUsingLibphonenumber = jest.fn();
regionCode | number | expected | expectedRaw | expectedE164 | description
${'ZZ'} | ${'123456789012345'} | ${'123456789012345'} | ${'123456789012345'} | ${'123456789012345'} | ${'Unknown region'}
${'ES'} | ${'654834455'} | ${'654834455'} | ${'654 83 44 55'} | ${'+34654834455'} | ${'ES mobile'}
${'ES'} | ${'914-44/10 25'} | ${'914441025'} | ${'914 44 10 25'} | ${'+34914441025'} | ${'ES landline'}
${'ES'} | ${'6548344556'} | ${'6548344556'} | ${'6548344556'} | ${'+346548344556'} | ${'ES mobile too long'}
${'ES'} | ${'914-44/10 256'} | ${'9144410256'} | ${'9144410256'} | ${'+349144410256'} | ${'ES landline too long'}
${'ES'} | ${'+34 654 834 455'} | ${'+34654834455'} | ${'+34 654 83 44 55'} | ${'+34654834455'} | ${'ES E164 mobile'}
${'ES'} | ${'+34 914-44/10 25'} | ${'+34914441025'} | ${'+34 914 44 10 25'} | ${'+34914441025'} | ${'ES E164 landline'}
${'BR'} | ${'21987654321'} | ${'21987654321'} | ${'(21) 98765-4321'} | ${'+5521987654321'} | ${'BR mobile'}
${'BR'} | ${'219876543210'} | ${'219876543210'} | ${'219876543210'} | ${'+55219876543210'} | ${'BR mobile too long'}
${'BR'} | ${'2123456789'} | ${'2123456789'} | ${'(21) 2345-6789'} | ${'+552123456789'} | ${'BR landline'}
${'BR'} | ${'21234567890'} | ${'21234567890'} | ${'(21) 23456-7890'} | ${'+5521234567890'} | ${'BR landline long'}
${'BR'} | ${'212345678901'} | ${'212345678901'} | ${'212345678901'} | ${'+55212345678901'} | ${'BR landline too long'}
${'BR'} | ${'+5521987654321'} | ${'+5521987654321'} | ${'+55 21 98765-4321'} | ${'+5521987654321'} | ${'BR E164 mobile'}
${'BR'} | ${'+34654834455'} | ${'+34654834455'} | ${'+34 654 83 44 55'} | ${'+34654834455'} | ${'BR with ES E164'}
${'DE'} | ${'015789012345'} | ${'015789012345'} | ${'01578 9012345'} | ${'+4915789012345'} | ${'DE mobile 15'}
${'DE'} | ${'01601234567'} | ${'01601234567'} | ${'0160 1234567'} | ${'+491601234567'} | ${'DE mobile 16'}
${'DE'} | ${'01701234567'} | ${'01701234567'} | ${'0170 1234567'} | ${'+491701234567'} | ${'DE mobile 17'}
${'DE'} | ${'12345678901'} | ${'12345678901'} | ${'12345678901'} | ${'+4912345678901'} | ${'DE unknown'}
${'DE'} | ${'+4915789012345'} | ${'+4915789012345'} | ${'+49 1578 9012345'} | ${'+4915789012345'} | ${'DE E164 mobile'}
${'DE'} | ${'+49015789012345'} | ${'+49015789012345'} | ${'+49 015789012345'} | ${'+49015789012345'} | ${'DE E164 mobile wrong zero'}
${'GB'} | ${'07123456789'} | ${'07123456789'} | ${'07123 456789'} | ${'+447123456789'} | ${'GB mobile'}
${'GB'} | ${'071234567890'} | ${'071234567890'} | ${'071234567890'} | ${'+4471234567890'} | ${'GB mobile too long'}
${'GB'} | ${'+447123456789'} | ${'+447123456789'} | ${'+44 7123 456789'} | ${'+447123456789'} | ${'GB E164 mobile'}
${'GB'} | ${'+4407123456789'} | ${'+4407123456789'} | ${'+44 07123456789'} | ${'+4407123456789'} | ${'GB E164 mobile wrong zero'}
`(
'PhoneNumberFieldLite - $description - $number',
async ({regionCode, number, expected, expectedRaw, expectedE164}) => {
const onChangeValue = jest.fn();
const onChangeValueE164 = jest.fn();
const onChangeValueUsingLibphonenumber = jest.fn();

render(
<ThemeContextProvider
theme={{
skin: getMovistarSkin(),
i18n: {locale: 'es-ES', phoneNumberFormattingRegionCode: regionCode},
}}
>
<PhoneNumberFieldLite name="a" label="Phone" onChangeValue={onChangeValue} />
<PhoneNumberField name="b" label="Reference" onChangeValue={onChangeValueUsingLibphonenumber} />
</ThemeContextProvider>
);
render(
<ThemeContextProvider
theme={{
skin: getMovistarSkin(),
i18n: {locale: 'es-ES', phoneNumberFormattingRegionCode: regionCode},
}}
>
<PhoneNumberFieldLite name="phone" label="Phone" onChangeValue={onChangeValue} />
<PhoneNumberFieldLite name="e164" label="Phone E164" onChangeValue={onChangeValueE164} e164 />
<PhoneNumberField
name="ref"
label="Reference"
onChangeValue={onChangeValueUsingLibphonenumber}
/>
</ThemeContextProvider>
);

const input = screen.getByLabelText('Phone');
await userEvent.type(input, number);
const input = screen.getByLabelText('Phone');
const inputE164 = screen.getByLabelText('Phone E164');
const referenceInput = screen.getByLabelText('Reference');

const referenceInput = screen.getByLabelText('Reference');
await userEvent.type(referenceInput, number);
await userEvent.type(input, number);
await userEvent.type(inputE164, number);
await userEvent.type(referenceInput, number);

expect(onChangeValue).toHaveBeenLastCalledWith(expected, expectedRaw);
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);
// 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();
Expand Down
111 changes: 88 additions & 23 deletions src/phone-number-field-lite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,114 @@ 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<string, RegionCode> = {
'+34': 'ES',
'+55': 'BR',
'+49': 'DE',
'+44': 'GB',
};

const REGION_CODE_TO_COUNTRY_CODE: Record<string, string> = 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 digits = number.replace(/\D/g, ''); // strip non-digits
if (number.startsWith('+')) {
// E164 returned without formatting
return '+' + digits;
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 (regionCode === 'ES') {

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 `${digits.slice(0, 3)} ${digits.slice(3, 5)} ${digits.slice(5, 7)} ${digits.slice(7)}`.trim();
return `${countryCode} ${digits.slice(0, 3)} ${digits.slice(3, 5)} ${digits.slice(5, 7)} ${digits.slice(7)}`.trim();
}
return digits;
} else if (regionCode === 'BR') {
} 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) {
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7)}`.replace(/\D+$/, '');
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') {
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`.replace(/\D+$/, '');
national = `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6)}`.replace(/\D+$/, '');
}
} else if (regionCode === 'DE') {
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
if (digits.length >= 4 && digits.match(/^(015|016|017)/)) {
if (digits.length <= 12 && digits.startsWith('015')) {
return `${digits.slice(0, 5)} ${digits.slice(5)}`.trim();
// 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 {
return `${digits.slice(0, 4)} ${digits.slice(4)}`.trim();
national = `${countryCode} ${zeroPadded.slice(0, 4)} ${zeroPadded.slice(4)}`.trim();
}
return isE164 ? asE164(national, formattingRegionCode) : national;
}
} else if (regionCode === 'GB') {
} 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
if (digits.length <= 11 && digits.startsWith('07')) {
return `${digits.slice(0, 5)} ${digits.slice(5)}`.trim();
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 digits;
return isE164 ? `${countryCode} ${digits}` : digits;
};

type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onInput'> & {
Expand All @@ -81,9 +140,10 @@ const PhoneInput = ({
}: InputProps) => {
const [selfValue, setSelfValue] = React.useState(defaultValue || '');
const ref = React.useRef<HTMLInputElement | null>(null);
const {i18n} = useTheme();
const {
i18n: {phoneNumberFormattingRegionCode: regionCode},
} = useTheme();

const regionCode = i18n.phoneNumberFormattingRegionCode;
const isControlledByParent = typeof value !== 'undefined';
const controlledValue = (isControlledByParent ? value : selfValue) as string;

Expand Down Expand Up @@ -132,6 +192,7 @@ export interface PhoneNumberFieldProps extends CommonFormFieldProps {
prefix?: string;
getSuggestions?: (value: string) => Array<string>;
format?: (number: string) => string;
e164?: boolean;
}

const PhoneNumberFieldLite = ({
Expand All @@ -148,11 +209,15 @@ const PhoneNumberFieldLite = ({
value,
defaultValue,
dataAttributes,
e164,
...rest
}: PhoneNumberFieldProps): JSX.Element => {
const {
i18n: {phoneNumberFormattingRegionCode},
} = useTheme();

const processValue = (value: string) => {
// keep only digits
return value.startsWith('+') ? value : value.replace(/\D/g, '');
return e164 ? clean(asE164(value, phoneNumberFormattingRegionCode)) : clean(value);
};

const fieldProps = useFieldProps({
Expand Down
3 changes: 1 addition & 2 deletions src/phone-number-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,11 @@ type InputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'o
defaultValue?: string;
onInput?: (event: React.FormEvent<HTMLInputElement>) => 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<HTMLInputElement | null>(null);
const {i18n} = useTheme();
Expand Down
Loading