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,
|
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 {
|
||||||
|
|
|
@ -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 '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';
|
||||||
|
|
|
@ -2,6 +2,7 @@ declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
wcSettings: {
|
wcSettings: {
|
||||||
variationTitleAttributesSeparator?: string;
|
variationTitleAttributesSeparator?: string;
|
||||||
|
countries: Record< string, string >;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue