Add PhoneNumberInput component – cooldown (#40335)

* Move component from WooPayments

* Expose component on the package

And include its style sheet.

* Add missing global typing

* Fallback to alpha2 codes

when wcSettings.countries is not available

* Add build data script and its output

* Move defaults to their own file

* Add readme and JSDocs

* Add storyboard examples

* Add JS unit and snapshot tests

* Move `DataType` to a `types.ts` file

To get rid of the type definition in the build data script

* Fix markdown issues

* Add changefile(s) from automation for the following project(s): @woocommerce/components

* Minor markdown update

* Fix input sanitization

* Add component output to storybook examples

* Remove consecutive spaces or hyphens from input sanitization

* Improve consecutive character sanitization

Move it to the component keyDown event to avoid the caret to be displaced

* Ensure imports from @wordpress/element

* Refactor to avoid using lodash

* Add changefile(s) from automation for the following project(s): @woocommerce/components

* Use $gray-5 for highlighted items for better contrast

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Ismael Martín Alabarce 2023-10-16 10:19:36 +02:00 committed by GitHub
parent 7f25060044
commit 8f533167f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1091 additions and 1 deletions

View File

@ -0,0 +1,131 @@
#!/usr/bin/env node
const https = require( 'https' );
const vm = require( 'vm' );
const fs = require( 'fs' );
const path = require( 'path' );
const intlUrl =
'https://raw.githubusercontent.com/jackocnr/intl-tel-input/master/src/js/data.js';
const phoneUrl =
'https://raw.githubusercontent.com/AfterShip/phone/master/src/data/country_phone_data.ts';
const fetch = ( url ) =>
new Promise( ( resolve, reject ) => {
https
.get( url, ( res ) => {
let body = '';
res.on( 'data', ( chunk ) => {
body += chunk;
} );
res.on( 'end', () => {
resolve( body );
} );
} )
.on( 'error', reject );
} );
const numberOrString = ( str ) =>
Number( str ).toString().length !== str.length ? str : Number( str );
const evaluate = ( code ) => {
const script = new vm.Script( code );
const context = vm.createContext();
script.runInContext( context );
return context;
};
const parse = ( data /*: any[]*/ ) /*: DataType*/ =>
data.reduce(
( acc, item ) => ( {
...acc,
[ item[ 0 ] ]: {
alpha2: item[ 0 ],
code: item[ 1 ].toString(),
priority: item[ 2 ] || 0,
start: item[ 3 ]?.map( String ),
lengths: item[ 4 ],
},
} ),
{}
);
const saveToFile = ( data ) => {
const dataString = JSON.stringify( data ).replace( /null/g, '' );
const parseString = parse.toString().replace( / \/\*(.+?)\*\//g, '$1' );
const code = [
'// Do not edit this file directly.',
'// Generated by /bin/packages/js/components/phone-number-input/build-data.js',
'',
'/* eslint-disable */',
'',
'import type { DataType } from "./types";',
'',
`const parse = ${ parseString }`,
'',
`const data = ${ dataString }`,
'',
'export default parse(data);',
].join( '\n' );
const filePath = path.resolve(
'packages/js/components/src/phone-number-input/data.ts'
);
fs.writeFileSync( filePath, code );
};
( async () => {
const intlData = await fetch( intlUrl ).then( evaluate );
const phoneData = await fetch( phoneUrl )
.then( ( data ) => 'var data = ' + data.substring( 15 ) )
.then( evaluate );
// Convert phoneData array to object
const phoneCountries = phoneData.data.reduce(
( acc, item ) => ( {
...acc,
[ item.alpha2.toLowerCase() ]: item,
} ),
{}
);
// Traverse intlData to create a new array with required fields
const countries = intlData.allCountries.map( ( item ) => {
const phoneCountry = phoneCountries[ item.iso2 ];
const result = [
item.iso2.toUpperCase(), // alpha2
Number( item.dialCode ), // code
/* [2] priority */
/* [3] start */
/* [4] lengths */
,
,
,
];
if ( item.priority ) {
result[ 2 ] = item.priority;
}
const areaCodes = item.areaCodes || [];
const beginWith = phoneCountry?.mobile_begin_with || [];
if ( areaCodes.length || beginWith.length ) {
result[ 3 ] = [ ...new Set( [ ...areaCodes, ...beginWith ] ) ].map(
numberOrString
);
}
if ( phoneCountry?.phone_number_lengths ) {
result[ 4 ] = phoneCountry.phone_number_lengths;
}
return result;
} );
saveToFile( countries );
} )();

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
An international phone number input with country selection, and mobile phone numbers validation.

View File

@ -104,6 +104,7 @@ export {
SelectTreeMenuSlot as __experimentalSelectTreeMenuSlot, SelectTreeMenuSlot as __experimentalSelectTreeMenuSlot,
} from './experimental-select-tree-control'; } from './experimental-select-tree-control';
export { default as TreeSelectControl } from './tree-select-control'; export { default as TreeSelectControl } from './tree-select-control';
export { default as PhoneNumberInput } from './phone-number-input';
// Exports below can be removed once the @woocommerce/product-editor package is released. // Exports below can be removed once the @woocommerce/product-editor package is released.
export { export {

View File

@ -0,0 +1,32 @@
# PhoneNumberInput
An international phone number input with a country code select and a phone textfield which supports numbers, spaces and hyphens. And returns the full number as it is, in E.164 format, and the selected country alpha2.
Includes mobile phone numbers validation.
## Usage
```jsx
<PhoneNumberInput
value={ phoneNumber }
onChange={ ( value, e164, country ) => setState( value ) }
/>
```
### Props
| Name | Type | Default | Description |
| ---------------- | -------- | ----------------------- | ------------------------------------------------------------------------------------------------------- |
| `value` | String | `undefined` | (Required) Phone number with spaces and hyphens |
| `onChange` | Function | `undefined` | (Required) Callback function when the value changes |
| `id` | String | `undefined` | ID for the input element, to bind a `<label>` |
| `className` | String | `undefined` | Additional class name applied to parent `<div>` |
| `selectedRender` | Function | `defaultSelectedRender` | Render function for the selected country, displays the country flag and code by default. |
| `itemRender` | Function | `itemRender` | Render function for each country in the dropdown, displays the country flag, name, and code by default. |
| `arrowRender` | Function | `defaultArrowRender` | Render function for the dropdown arrow, displays a chevron down icon by default. |
### `onChange` params
- `value`: Phone number with spaces and hyphens. e.g. `+1 234-567-8901`
- `e164`: Phone number in E.164 format. e.g. `+12345678901`
- `country`: Country alpha2 code. e.g. `US`

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import React from 'react';
import { createElement, Fragment } from '@wordpress/element';
import { Icon, chevronDown } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { Country } from './utils';
const Flag: React.FC< { alpha2: string; src: string } > = ( {
alpha2,
src,
} ) => (
<img
alt={ `${ alpha2 } flag` }
src={ src }
className="wcpay-component-phone-number-input__flag"
/>
);
export const defaultSelectedRender = ( { alpha2, code, flag }: Country ) => (
<>
<Flag alpha2={ alpha2 } src={ flag } />
{ ` +${ code }` }
</>
);
export const defaultItemRender = ( { alpha2, name, code, flag }: Country ) => (
<>
<Flag alpha2={ alpha2 } src={ flag } />
{ `${ name } +${ code }` }
</>
);
export const defaultArrowRender = () => (
<Icon icon={ chevronDown } size={ 18 } />
);

View File

@ -0,0 +1,223 @@
/**
* External dependencies
*/
import {
createElement,
useState,
useRef,
useLayoutEffect,
} from '@wordpress/element';
import { useSelect } from 'downshift';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import data from './data';
import {
parseData,
Country,
sanitizeInput,
guessCountryKey,
numberToE164,
} from './utils';
import {
defaultSelectedRender,
defaultItemRender,
defaultArrowRender,
} from './defaults';
interface Props {
/**
* Phone number with spaces and hyphens.
*/
value: string;
/**
* Callback function when the value changes.
*
* @param value Phone number with spaces and hyphens. e.g. `+1 234-567-8901`
* @param e164 Phone number in E.164 format. e.g. `+12345678901`
* @param country Country alpha2 code. e.g. `US`
*/
onChange: ( value: string, e164: string, country: string ) => void;
/**
* ID for the input element, to bind a `<label>`.
*
* @default undefined
*/
id?: string;
/**
* Additional class name applied to parent `<div>`.
*
* @default undefined
*/
className?: string;
/**
* Render function for the selected country.
* Displays the country flag and code by default.
*
* @default defaultSelectedRender
*/
selectedRender?: ( country: Country ) => React.ReactNode;
/**
* Render function for each country in the dropdown.
* Displays the country flag, name, and code by default.
*
* @default defaultItemRender
*/
itemRender?: ( country: Country ) => React.ReactNode;
/**
* Render function for the dropdown arrow.
* Displays a chevron down icon by default.
*
* @default defaultArrowRender
*/
arrowRender?: () => React.ReactNode;
}
const { countries, countryCodes } = parseData( data );
/**
* An international phone number input with a country code select and a phone textfield which supports numbers, spaces and hyphens. And returns the full number as it is, in E.164 format, and the selected country alpha2.
*/
const PhoneNumberInput: React.FC< Props > = ( {
value,
onChange,
id,
className,
selectedRender = defaultSelectedRender,
itemRender = defaultItemRender,
arrowRender = defaultArrowRender,
} ) => {
const menuRef = useRef< HTMLButtonElement >( null );
const inputRef = useRef< HTMLInputElement >( null );
const [ menuWidth, setMenuWidth ] = useState( 0 );
const [ countryKey, setCountryKey ] = useState(
guessCountryKey( value, countryCodes )
);
useLayoutEffect( () => {
if ( menuRef.current ) {
setMenuWidth( menuRef.current.offsetWidth );
}
}, [ menuRef, countryKey ] );
const phoneNumber = sanitizeInput( value )
.replace( countries[ countryKey ].code, '' )
.trimStart();
const handleChange = ( code: string, number: string ) => {
// Return value, phone number in E.164 format, and country alpha2 code.
number = `+${ countries[ code ].code } ${ number }`;
onChange( number, numberToE164( number ), code );
};
const handleSelect = ( code: string ) => {
setCountryKey( code );
handleChange( code, phoneNumber );
};
const handleInput = ( event: React.ChangeEvent< HTMLInputElement > ) => {
handleChange( countryKey, sanitizeInput( event.target.value ) );
};
const handleKeyDown = (
event: React.KeyboardEvent< HTMLInputElement >
) => {
const pos = inputRef.current?.selectionStart || 0;
const newValue =
phoneNumber.slice( 0, pos ) + event.key + phoneNumber.slice( pos );
if ( /[- ]{2,}/.test( newValue ) ) {
event.preventDefault();
}
};
const {
isOpen,
getToggleButtonProps,
getMenuProps,
highlightedIndex,
getItemProps,
} = useSelect( {
id,
items: Object.keys( countries ),
initialSelectedItem: countryKey,
itemToString: ( item ) => countries[ item || '' ].name,
onSelectedItemChange: ( { selectedItem } ) => {
if ( selectedItem ) handleSelect( selectedItem );
},
stateReducer: ( state, { changes } ) => {
if ( state.isOpen === true && changes.isOpen === false ) {
inputRef.current?.focus();
}
return changes;
},
} );
return (
<div
className={ classNames(
className,
'wcpay-component-phone-number-input'
) }
>
<button
{ ...getToggleButtonProps( {
ref: menuRef,
type: 'button',
className: classNames(
'wcpay-component-phone-number-input__button'
),
} ) }
>
{ selectedRender( countries[ countryKey ] ) }
<span
className={ classNames(
'wcpay-component-phone-number-input__button-arrow',
{ invert: isOpen }
) }
>
{ arrowRender() }
</span>
</button>
<input
id={ id }
ref={ inputRef }
type="text"
value={ phoneNumber }
onKeyDown={ handleKeyDown }
onChange={ handleInput }
className="wcpay-component-phone-number-input__input"
style={ { paddingLeft: `${ menuWidth }px` } }
/>
<ul
{ ...getMenuProps( {
'aria-hidden': ! isOpen,
className: 'wcpay-component-phone-number-input__menu',
} ) }
>
{ isOpen &&
Object.keys( countries ).map( ( key, index ) => (
// eslint-disable-next-line react/jsx-key
<li
{ ...getItemProps( {
key,
index,
item: key,
className: classNames(
'wcpay-component-phone-number-input__menu-item',
{ highlighted: highlightedIndex === index }
),
} ) }
>
{ itemRender( countries[ key ] ) }
</li>
) ) }
</ul>
</div>
);
};
export default PhoneNumberInput;

View File

@ -0,0 +1,72 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import React, { useState } from 'react';
/**
* Internal dependencies
*/
import PhoneNumberInput from '../';
import { validatePhoneNumber } from '../validation';
export default {
title: 'WooCommerce Admin/components/PhoneNumberInput',
component: PhoneNumberInput,
};
const PNI: React.FC<
Partial< React.ComponentPropsWithoutRef< typeof PhoneNumberInput > >
> = ( { children, onChange, ...rest } ) => {
const [ phone, setPhone ] = useState( '' );
const [ output, setOutput ] = useState( '' );
const handleChange = ( value, i164, country ) => {
setPhone( value );
setOutput( JSON.stringify( { value, i164, country }, null, 2 ) );
onChange?.( value, i164, country );
};
return (
<>
<PhoneNumberInput
{ ...rest }
value={ phone }
onChange={ handleChange }
/>
{ children }
<pre>{ output }</pre>
</>
);
};
export const Examples = () => {
const [ valid, setValid ] = useState( false );
const handleValidation = ( _, i164, country ) => {
setValid( validatePhoneNumber( i164, country ) );
};
return (
<>
<h2>Basic</h2>
<PNI />
<h2>Labeled</h2>
<label htmlFor="pniID">Phone number</label>
<br />
<PNI id="pniID" />
<h2>Validation</h2>
<PNI onChange={ handleValidation }>
<pre>valid: { valid.toString() }</pre>
</PNI>
<h2>Custom renders</h2>
<PNI
arrowRender={ () => '🔻' }
itemRender={ ( { name, code } ) => `+${ code }:${ name }` }
selectedRender={ ( { alpha2, code } ) =>
`+${ code }:${ alpha2 }`
}
/>
</>
);
};

View File

@ -0,0 +1,61 @@
.wcpay-component-phone-number-input {
display: inline-block;
position: relative;
&__button {
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
padding-left: $gap-smaller;
padding-right: $gap-smallest;
background: none;
border: none;
&-arrow {
margin-top: 2px;
&.invert {
margin-top: 0;
transform: rotate(180deg);
}
}
}
&__input {
font-size: inherit;
}
&__menu {
position: absolute;
max-height: 200px;
min-width: 100%;
background-color: #fff;
border-radius: 2px;
border: 1px solid $gray-700;
margin: 1px 0;
z-index: 10000;
overflow-y: auto;
&[aria-hidden="true"] {
display: none;
}
&-item {
padding: $gap-smallest $gap-smaller;
margin: 0;
display: flex;
align-items: center;
&.highlighted {
background: #dcdcde; // $gray-5
}
}
}
&__flag {
width: 18px;
margin-right: $gap-smallest;
}
}

View File

@ -0,0 +1,93 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PhoneNumberInput should match snapshot 1`] = `
<div>
<div
class="wcpay-component-phone-number-input"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="downshift-0-label downshift-0-toggle-button"
class="wcpay-component-phone-number-input__button"
id="downshift-0-toggle-button"
type="button"
>
<img
alt="US flag"
class="wcpay-component-phone-number-input__flag"
src="https://s.w.org/images/core/emoji/14.0.0/72x72/1f1fa-1f1f8.png"
/>
+1
<span
class="wcpay-component-phone-number-input__button-arrow"
>
<svg
aria-hidden="true"
focusable="false"
height="18"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.5 11.6L12 16l-5.5-4.4.9-1.2L12 14l4.5-3.6 1 1.2z"
/>
</svg>
</span>
</button>
<input
class="wcpay-component-phone-number-input__input"
style="padding-left: 0px;"
type="text"
value=""
/>
<ul
aria-hidden="true"
aria-labelledby="downshift-0-label"
class="wcpay-component-phone-number-input__menu"
id="downshift-0-menu"
role="listbox"
tabindex="-1"
/>
</div>
</div>
`;
exports[`PhoneNumberInput should match snapshot with custom renders 1`] = `
<div>
<div
class="wcpay-component-phone-number-input"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="downshift-1-label downshift-1-toggle-button"
class="wcpay-component-phone-number-input__button"
id="downshift-1-toggle-button"
type="button"
>
US
<span
class="wcpay-component-phone-number-input__button-arrow"
>
⬇️
</span>
</button>
<input
class="wcpay-component-phone-number-input__input"
style="padding-left: 0px;"
type="text"
value=""
/>
<ul
aria-hidden="true"
aria-labelledby="downshift-1-label"
class="wcpay-component-phone-number-input__menu"
id="downshift-1-menu"
role="listbox"
tabindex="-1"
/>
</div>
</div>
`;

View File

@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { noop } from 'lodash';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import PhoneNumberInput from '..';
describe( 'PhoneNumberInput', () => {
it( 'should match snapshot', () => {
const { container } = render(
<PhoneNumberInput value="" onChange={ noop } />
);
expect( container ).toMatchSnapshot();
} );
it( 'should match snapshot with custom renders', () => {
const { container } = render(
<PhoneNumberInput
value=""
onChange={ noop }
selectedRender={ ( { name } ) => name }
itemRender={ ( { code } ) => code }
arrowRender={ () => '⬇️' }
/>
);
expect( container ).toMatchSnapshot();
} );
it( 'should render with provided `id`', () => {
render( <PhoneNumberInput id="test-id" value="" onChange={ noop } /> );
expect( screen.getByRole( 'textbox' ) ).toHaveAttribute(
'id',
'test-id'
);
} );
it( 'calls onChange callback on number input', () => {
const onChange = jest.fn();
render( <PhoneNumberInput value="" onChange={ onChange } /> );
const input = screen.getByRole( 'textbox' );
userEvent.type( input, '1' );
expect( onChange ).toHaveBeenCalledWith( '+1 1', '+11', 'US' );
} );
it( 'calls onChange callback when a country is selected', () => {
const onChange = jest.fn();
render( <PhoneNumberInput value="0 0" onChange={ onChange } /> );
const select = screen.getByRole( 'button' );
userEvent.click( select );
const option = screen.getByRole( 'option', { name: /es/i } );
userEvent.click( option );
expect( onChange ).toHaveBeenCalledWith( '+34 0 0', '+3400', 'ES' );
} );
it( 'prevents consecutive spaces and hyphens', () => {
const onChange = jest.fn();
render( <PhoneNumberInput value="0-" onChange={ onChange } /> );
const input = screen.getByRole( 'textbox' );
userEvent.type( input, '-' );
expect( onChange ).not.toHaveBeenCalled();
} );
} );

View File

@ -0,0 +1,105 @@
/**
* Internal dependencies
*/
import {
sanitizeNumber,
sanitizeInput,
numberToE164,
guessCountryKey,
decodeHtmlEntities,
countryToFlag,
parseData,
} from '../utils';
describe( 'PhoneNumberInput Utils', () => {
describe( 'sanitizeNumber', () => {
it( 'removes non-digit characters', () => {
const result = sanitizeNumber( '+1 23-45 67' );
expect( result ).toBe( '1234567' );
} );
} );
describe( 'sanitizeInput', () => {
it( 'removes non-digit characters except space and hyphen', () => {
const result = sanitizeInput( '+1 23--45 67 abc' );
expect( result ).toBe( '1 23--45 67 ' );
} );
} );
describe( 'numberToE164', () => {
it( 'converts a valid phone number to E.164 format', () => {
const result = numberToE164( '+1 23-45 67' );
expect( result ).toBe( '+1234567' );
} );
} );
describe( 'guessCountryKey', () => {
it( 'guesses the country code from a phone number', () => {
const countryCodes = {
'1': [ 'US' ],
'34': [ 'ES' ],
};
const result = guessCountryKey( '34666777888', countryCodes );
expect( result ).toBe( 'ES' );
} );
it( 'falls back to US if no match is found', () => {
const countryCodes = {
'34': [ 'ES' ],
};
const result = guessCountryKey( '1234567890', countryCodes );
expect( result ).toBe( 'US' );
} );
} );
describe( 'decodeHtmlEntities', () => {
it( 'replaces HTML entities from a predefined table', () => {
const result = decodeHtmlEntities(
'&atilde;&ccedil;&eacute;&iacute;'
);
expect( result ).toBe( 'ãçéí' );
} );
} );
describe( 'countryToFlag', () => {
it( 'converts a country code to a flag twemoji URL', () => {
const result = countryToFlag( 'US' );
expect( result ).toBe(
'https://s.w.org/images/core/emoji/14.0.0/72x72/1f1fa-1f1f8.png'
);
} );
} );
describe( 'parseData', () => {
it( 'parses the data into a more usable format', () => {
const data = {
AF: {
alpha2: 'AF',
code: '93',
priority: 0,
start: [ '7' ],
lengths: [ 9 ],
},
};
const { countries, countryCodes } = parseData( data );
expect( countries ).toEqual( {
AF: {
alpha2: 'AF',
code: '93',
flag: 'https://s.w.org/images/core/emoji/14.0.0/72x72/1f1e6-1f1eb.png',
lengths: [ 9 ],
name: 'AF',
priority: 0,
start: [ '7' ],
},
} );
expect( countryCodes ).toEqual( {
'93': [ 'AF' ],
'937': [ 'AF' ],
} );
} );
} );
} );

View File

@ -0,0 +1,30 @@
/**
* Internal dependencies
*/
import { validatePhoneNumber } from '../validation';
describe( 'PhoneNumberInput Validation', () => {
it( 'should return true for a valid US phone number', () => {
expect( validatePhoneNumber( '+12345678901', 'US' ) ).toBe( true );
} );
it( 'should return true for a valid phone number with country guessed from the number', () => {
expect( validatePhoneNumber( '+447123456789' ) ).toBe( true );
} );
it( 'should return false for a phone number with invalid format', () => {
expect( validatePhoneNumber( '1234567890', 'US' ) ).toBe( false );
} );
it( 'should return false for a phone number with incorrect country', () => {
expect( validatePhoneNumber( '+12345678901', 'GB' ) ).toBe( false );
} );
it( 'should return false for a phone number with incorrect length', () => {
expect( validatePhoneNumber( '+123456', 'US' ) ).toBe( false );
} );
it( 'should return false for a phone number with incorrect start', () => {
expect( validatePhoneNumber( '+11234567890', 'US' ) ).toBe( false );
} );
} );

View File

@ -0,0 +1,10 @@
export type DataType = Record<
string,
{
alpha2: string;
code: string;
priority: number;
start?: string[];
lengths?: number[];
}
>;

View File

@ -0,0 +1,133 @@
/**
* Internal dependencies
*/
import type { DataType } from './types';
const mapValues = < T, U >(
object: Record< string, T >,
iteratee: ( value: T ) => U
): Record< string, U > => {
const result: Record< string, U > = {};
for ( const key in object ) {
result[ key ] = iteratee( object[ key ] );
}
return result;
};
/**
* Removes any non-digit character.
*/
export const sanitizeNumber = ( number: string ): string =>
number.replace( /\D/g, '' );
/**
* Removes any non-digit character, except space and hyphen.
*/
export const sanitizeInput = ( number: string ): string =>
number.replace( /[^\d -]/g, '' );
/**
* Converts a valid phone number to E.164 format.
*/
export const numberToE164 = ( number: string ): string =>
`+${ sanitizeNumber( number ) }`;
/**
* Guesses the country code from a phone number.
* If no match is found, it will fallback to US.
*
* @param number Phone number including country code.
* @param countryCodes List of country codes.
* @return Country code in ISO 3166-1 alpha-2 format. e.g. US
*/
export const guessCountryKey = (
number: string,
countryCodes: Record< string, string[] >
): string => {
number = sanitizeNumber( number );
// Match each digit against countryCodes until a match is found
for ( let i = number.length; i > 0; i-- ) {
const match = countryCodes[ number.substring( 0, i ) ];
if ( match ) return match[ 0 ];
}
return 'US';
};
const entityTable: Record< string, string > = {
atilde: 'ã',
ccedil: 'ç',
eacute: 'é',
iacute: 'í',
};
/**
* Replaces HTML entities from a predefined table.
*/
export const decodeHtmlEntities = ( str: string ): string =>
str.replace( /&(\S+?);/g, ( match, p1 ) => entityTable[ p1 ] || match );
const countryNames: Record< string, string > = mapValues(
{
AC: 'Ascension Island',
XK: 'Kosovo',
...( window.wcSettings?.countries || [] ),
},
( name ) => decodeHtmlEntities( name )
);
/**
* Converts a country code to a flag twemoji URL from `s.w.org`.
*
* @param alpha2 Country code in ISO 3166-1 alpha-2 format. e.g. US
* @return Country flag emoji URL.
*/
export const countryToFlag = ( alpha2: string ): string => {
const name = alpha2
.split( '' )
.map( ( char ) =>
( 0x1f1e5 + ( char.charCodeAt( 0 ) % 32 ) ).toString( 16 )
)
.join( '-' );
return `https://s.w.org/images/core/emoji/14.0.0/72x72/${ name }.png`;
};
const pushOrAdd = (
acc: Record< string, string[] >,
key: string,
value: string
) => {
if ( acc[ key ] ) {
if ( ! acc[ key ].includes( value ) ) acc[ key ].push( value );
} else {
acc[ key ] = [ value ];
}
};
/**
* Parses the data from `data.ts` into a more usable format.
*/
export const parseData = ( data: DataType ) => ( {
countries: mapValues( data, ( country ) => ( {
...country,
name: countryNames[ country.alpha2 ] ?? country.alpha2,
flag: countryToFlag( country.alpha2 ),
} ) ),
countryCodes: Object.values( data )
.sort( ( a, b ) => ( a.priority > b.priority ? 1 : -1 ) )
.reduce( ( acc, { code, alpha2, start } ) => {
pushOrAdd( acc, code, alpha2 );
if ( start ) {
for ( const str of start ) {
for ( let i = 1; i <= str.length; i++ ) {
pushOrAdd( acc, code + str.substring( 0, i ), alpha2 );
}
}
}
return acc;
}, {} as Record< string, string[] > ),
} );
export type Country = ReturnType< typeof parseData >[ 'countries' ][ 0 ];

View File

@ -0,0 +1,53 @@
/**
* Internal dependencies
*/
import data from './data';
import { guessCountryKey, parseData } from './utils';
const { countries, countryCodes } = parseData( data );
/**
* Mobile phone number validation based on `data.ts` rules.
* If no country is provided, it will try to guess it from the number or fallback to US.
*
* @param number Phone number to validate in E.164 format. e.g. +12345678901
* @param countryAlpha2 Country code in ISO 3166-1 alpha-2 format. e.g. US
* @return boolean
*/
export const validatePhoneNumber = (
number: string,
countryAlpha2?: string
): boolean => {
// Sanitize number.
number = '+' + number.replace( /\D/g, '' );
// Return early If format is not E.164.
if ( ! /^\+[1-9]\d{1,14}$/.test( number ) ) {
return false;
}
// If country is not provided, try to guess it from the number or fallback to US.
if ( ! countryAlpha2 ) {
countryAlpha2 = guessCountryKey( number, countryCodes );
}
const country = countries[ countryAlpha2 ];
// Remove `+` and country code.
number = number.slice( country.code.length + 1 );
// If country as `lengths` defined check if number matches.
if ( country.lengths && ! country.lengths.includes( number.length ) ) {
return false;
}
// If country has `start` defined check if number starts with one of them.
if (
country.start &&
! country.start.some( ( prefix ) => number.startsWith( prefix ) )
) {
return false;
}
return true;
};

View File

@ -60,3 +60,4 @@
@import 'product-section-layout/style.scss'; @import 'product-section-layout/style.scss';
@import 'tree-select-control/index.scss'; @import 'tree-select-control/index.scss';
@import 'progress-bar/style.scss'; @import 'progress-bar/style.scss';
@import 'phone-number-input/style.scss';

View File

@ -2,6 +2,7 @@ declare global {
interface Window { interface Window {
wcSettings: { wcSettings: {
variationTitleAttributesSeparator?: string; variationTitleAttributesSeparator?: string;
countries: Record< string, string >;
}; };
} }
} }