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:
parent
7f25060044
commit
8f533167f7
|
@ -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 );
|
||||
} )();
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
An international phone number input with country selection, and mobile phone numbers validation.
|
|
@ -104,6 +104,7 @@ export {
|
|||
SelectTreeMenuSlot as __experimentalSelectTreeMenuSlot,
|
||||
} from './experimental-select-tree-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.
|
||||
export {
|
||||
|
|
|
@ -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
|
@ -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 } />
|
||||
);
|
|
@ -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;
|
|
@ -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 }`
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
`;
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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(
|
||||
'ãçéí'
|
||||
);
|
||||
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' ],
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,10 @@
|
|||
export type DataType = Record<
|
||||
string,
|
||||
{
|
||||
alpha2: string;
|
||||
code: string;
|
||||
priority: number;
|
||||
start?: string[];
|
||||
lengths?: number[];
|
||||
}
|
||||
>;
|
|
@ -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 ];
|
|
@ -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;
|
||||
};
|
|
@ -60,3 +60,4 @@
|
|||
@import 'product-section-layout/style.scss';
|
||||
@import 'tree-select-control/index.scss';
|
||||
@import 'progress-bar/style.scss';
|
||||
@import 'phone-number-input/style.scss';
|
||||
|
|
|
@ -2,6 +2,7 @@ declare global {
|
|||
interface Window {
|
||||
wcSettings: {
|
||||
variationTitleAttributesSeparator?: string;
|
||||
countries: Record< string, string >;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue