@gravityforms/components
Version:
UI components for use in Gravity Forms development. Both React and vanilla js flavors.
581 lines (527 loc) • 15 kB
JavaScript
import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { IdProvider, useIdContext } from '@gravityforms/react-utils';
import { spacerClasses } from '@gravityforms/utils';
import exampleNumbers from 'libphonenumber-js/mobile/examples';
import { getExampleNumber } from 'libphonenumber-js';
import Dropdown from '../Dropdown';
import { getSelectedItemFromValue } from '../Dropdown/utils';
import HelpText from '../../elements/HelpText';
import Input from '../../elements/Input';
import { getId } from './utils';
import {
getFormattedInputText,
getCaretPosition,
setCaretPosition,
getTextAfterEraseSelection,
getSelection,
} from './input-format-utils';
const { useEffect, useRef, useState, forwardRef } = React;
const INPUT_SELECTOR = '.gform-input';
const DROPDOWN_TRIGGER_SELECTOR = '.gform-dropdown__trigger';
const BORDER_STYLE_DEFAULT = 'default';
const BORDER_STYLE_ERROR = 'error';
const BORDER_STYLE_CORRECT = 'correct';
/**
* @function getCountriesList
* @description Get the list of countries to display in the dropdown.
* If preferredCountries is empty, return the list as is.
* If preferredCountries is not empty, split the list into preferred and remaining countries.
*
* @since 5.5.0
*
* @param {Array} listItems List of countries.
* @param {Array} preferredCountries List of preferred countries.
* @param {object} i18n i18n object.
*
* @return {Array} List of countries.
*/
export const getCountriesList = ( listItems, preferredCountries, i18n ) => {
if ( preferredCountries.length === 0 ) {
return listItems;
}
const countryMap = new Map();
listItems.forEach( ( item ) => {
countryMap.set( item.value, item );
} );
// Process the preferred countries in their specified order.
const preferred = [];
const usedISOs = new Set();
preferredCountries.forEach( ( country ) => {
const iso = typeof country === 'string' ? country : country?.value || country?.iso;
if ( iso && countryMap.has( iso ) && ! usedISOs.has( iso ) ) {
preferred.push( countryMap.get( iso ) );
usedISOs.add( iso );
}
} );
// If no preferred countries were found, return the list as is.
if ( preferred.length === 0 ) {
return listItems;
}
const remaining = listItems.filter( ( item ) => ! usedISOs.has( item.value ) );
// If no remaining countries were found, return the preferred list.
if ( remaining.length === 0 ) {
return preferred;
}
return [
{
type: 'group',
items: preferred,
},
{
type: 'group',
label: {
type: 'groupLabel',
label: i18n.allCountries,
},
items: remaining,
},
];
};
const PhoneComponent = forwardRef( ( props, ref ) => {
const {
customAttributes,
customClasses,
disabled,
dropdownAttributes,
dropdownClasses,
helpTextAttributes,
helpTextClasses,
i18n,
inputAttributes,
inputClasses,
international,
label,
labelAttributes,
labelClasses,
onChange,
preferredCountries,
required,
requiredLabelAttributes,
requiredLabelClasses,
size,
spacing,
usePlaceholder,
useValidation,
width,
} = props;
// Refs
const internalRef = useRef( null );
const wrapperRef = ref || internalRef;
const dropdownRef = useRef( null );
const inputRef = useRef( null );
const phoneInputRef = useRef( null );
const id = useIdContext();
const [ country, setCountry ] = useState( () => {
if ( dropdownAttributes?.initialValue ) {
return getSelectedItemFromValue( dropdownAttributes?.initialValue, dropdownAttributes?.listItems || [], false );
}
if ( preferredCountries.length ) {
const preferred = getCountriesList( dropdownAttributes?.listItems, preferredCountries, i18n );
if ( preferred?.[ 0 ]?.type === 'group' ) {
return preferred?.[ 0 ]?.items?.[ 0 ] || {};
}
return dropdownAttributes?.listItems?.[ 0 ];
}
if ( dropdownAttributes?.listItems?.[ 0 ] ) {
return dropdownAttributes?.listItems?.[ 0 ];
}
return {};
} );
const [ phoneNumber, setPhoneNumber ] = useState( () => {
if ( inputAttributes?.value ) {
const { asYouType, formatted } = getFormattedInputText( inputAttributes?.value, 0, country?.value, international );
return {
asYouType,
chars: asYouType?.getChars() || '',
value: formatted.text,
numberValue: asYouType?.getNumberValue() || '',
};
}
return {
asYouType: null,
chars: '',
value: '',
numberValue: '',
};
} );
const [ borderStyle, setBorderStyle ] = useState( BORDER_STYLE_DEFAULT );
// Set phoneInputRef.
useEffect( () => {
if ( ! inputRef.current ) {
return;
}
if ( phoneInputRef.current === inputRef.current.querySelector( INPUT_SELECTOR ) ) {
return;
}
phoneInputRef.current = inputRef.current.querySelector( INPUT_SELECTOR );
}, [ inputRef, phoneInputRef ] );
/**
* @function getPlaceholder
* @description Get the placeholder text for the input.
*
* @since 5.5.0
*
* @return {string|undefined} Placeholder text.
*/
const getPlaceholder = () => {
if ( ! usePlaceholder ) {
return undefined;
}
const exampleNumber = getExampleNumber( country?.value, exampleNumbers );
if ( ! exampleNumber ) {
return undefined;
}
return international ? exampleNumber.formatInternational() : exampleNumber.formatNational();
};
/**
* @function validatePhoneNumber
* @description Validate the phone number.
*
* @since 5.5.0
*
*/
const validatePhoneNumber = () => {
// If validation is disabled, return early.
if ( ! useValidation ) {
if ( borderStyle !== BORDER_STYLE_DEFAULT ) {
setBorderStyle( BORDER_STYLE_DEFAULT );
}
return;
}
const { asYouType, numberValue } = phoneNumber;
const isPossible = asYouType?.isPossible();
if ( numberValue ) {
setBorderStyle( isPossible ? BORDER_STYLE_CORRECT : BORDER_STYLE_ERROR );
} else {
setBorderStyle( BORDER_STYLE_DEFAULT );
}
};
/**
* @function onCountryChange
* @description Handle country change.
*
* @since 5.5.0
*
* @param {Event} event The event object.
* @param {object} value The selected country object.
*/
const onCountryChange = ( event, value ) => {
setCountry( value );
const { asYouType, formatted } = getFormattedInputText( phoneNumber.chars, 0, value?.value, international );
const numberValue = asYouType?.getNumberValue() || '';
setPhoneNumber( {
asYouType,
chars: asYouType?.getChars() || '',
value: formatted.text,
numberValue,
} );
const changeValue = {
country: value?.value,
number: numberValue,
};
onChange( changeValue, event );
// Validate phone number after state updates.
requestAnimationFrame( validatePhoneNumber );
};
/**
* @function handleInputChange
* @description Input change handler.
*
* @since 5.5.0
*
* @param {string} value The input value.
* @param {string|undefined} operation The operation to perform.
* @param {Event} event The event object.
*/
const handleInputChange = ( value, operation, event ) => {
const phoneInput = phoneInputRef.current;
const caretPosition = getCaretPosition( phoneInput );
const { asYouType, formatted } = getFormattedInputText( value, caretPosition, country?.value, international, operation );
const numberValue = asYouType?.getNumberValue() || '';
setPhoneNumber( {
asYouType,
chars: asYouType?.getChars() || '',
value: formatted.text,
numberValue,
} );
const changeValue = {
country: country?.value,
number: numberValue,
};
// requestAnimationFrame to set caret after rerender.
requestAnimationFrame( () => {
setCaretPosition( phoneInput, formatted.caret );
} );
onChange( changeValue, event );
};
/**
* @function onInputChange
* @description Handle input change.
*
* @since 5.5.0
*
* @param {string} value The input value.
* @param {Event} event The event object.
*/
const onInputChange = ( value, event ) => {
handleInputChange( value, undefined, event );
};
/**
* @function onInputKeyDown
* @description Handle input key down.
*
* @since 5.5.0
*
* @param {Event} event The event object.
*/
const onInputKeyDown = ( event ) => {
if ( inputAttributes?.onKeyDown ) {
inputAttributes?.onKeyDown?.( event );
}
// If `onKeyDown()` handler above has called `event.preventDefault()` then ignore this `keydown` event.
if ( event.defaultPrevented ) {
return;
}
const phoneInput = phoneInputRef.current;
if ( phoneInput.hasAttribute( 'readonly' ) ) {
return;
}
const operation = event.key;
if ( ! [ 'Backspace', 'Delete' ].includes( operation ) ) {
return;
}
// Intercept this operation and perform it manually.
event.preventDefault();
const value = phoneInput.value;
const selection = getSelection( phoneInput );
// If a selection is made, get the text after erasing selection.
// Else, perform the (character erasing) operation manually.
const newValue = selection ? getTextAfterEraseSelection( value, selection ) : value;
const newOp = selection ? undefined : operation;
handleInputChange( newValue, newOp, event );
};
/**
* @function onInputBlur
* @description Handle input blur.
*
* @since 5.5.0
*
* @param {Event} event The event object.
*/
const onInputBlur = ( event ) => {
if ( inputAttributes?.onBlur ) {
inputAttributes?.onBlur?.( event );
}
// If `onBlur()` handler above has called `event.preventDefault()` then ignore this `blur` event.
if ( event.defaultPrevented ) {
return;
}
validatePhoneNumber();
};
/**
* @function onLabelClick
* @description Handle label click.
*
* @since 5.5.0
*
*/
const onLabelClick = () => {
const dropdownTrigger = dropdownRef?.current?.querySelector( DROPDOWN_TRIGGER_SELECTOR );
if ( dropdownTrigger ) {
dropdownTrigger.focus();
}
};
const helpTextId = getId( id, 'help-text' );
const labelId = getId( id, 'label' );
const componentProps = {
className: classnames( {
'gform-phone': true,
[ `gform-phone--size-${ size }` ]: true,
'gform-phone--disabled': disabled,
...spacerClasses( spacing ),
}, customClasses ),
id,
style: {
width: width ? `${ width }px` : undefined,
},
...customAttributes,
};
const labelProps = {
className: classnames( [
'gform-phone__label',
'gform-text',
'gform-text--color-port',
'gform-typography--size-text-sm',
'gform-typography--weight-medium',
], labelClasses ),
...labelAttributes,
id: labelId,
onClick: onLabelClick,
};
const requiredLabelProps = {
size: 'text-sm',
weight: 'medium',
...requiredLabelAttributes,
customClasses: classnames( [ 'gform-phone__required' ], requiredLabelClasses ),
};
const dropdownProps = {
hasSearch: true,
popoverMaxHeight: 300,
...dropdownAttributes,
customClasses: classnames( [
'gform-phone__dropdown',
], dropdownClasses ),
disabled,
id: getId( id, 'dropdown' ),
listItems: getCountriesList( dropdownAttributes?.listItems, preferredCountries, i18n ),
onChange: onCountryChange,
searchAttributes: {
wrapperClasses: [ 'gform-phone__dropdown-search-wrapper' ],
...( dropdownAttributes?.searchAttributes || {} ),
},
searchClasses: [ 'gform-phone__dropdown-search' ],
size,
triggerAttributes: {
'aria-labelledby': label ? labelId : undefined,
},
triggerClasses: [ 'gform-phone__dropdown-trigger' ],
};
const inputProps = {
...inputAttributes,
borderStyle,
customAttributes: {
'aria-describedby': helpTextAttributes.content && borderStyle === BORDER_STYLE_ERROR ? helpTextId : undefined,
'aria-labelledby': label ? labelId : undefined,
},
customClasses: classnames( [
'gform-phone__input',
], inputClasses ),
directControlled: true,
disabled,
id: getId( id, 'input' ),
onBlur: onInputBlur,
onChange: onInputChange,
onKeyDown: onInputKeyDown,
placeholder: getPlaceholder(),
required,
size: `size-${ size }`,
type: 'tel',
value: phoneNumber.value,
wrapperClasses: classnames( {
'gform-phone__input-wrapper': true,
}, inputAttributes?.wrapperClasses || [] ),
};
const helpTextProps = {
size: 'text-xs',
weight: 'regular',
...helpTextAttributes,
customClasses: classnames( [ 'gform-phone__help-text' ], helpTextClasses ),
id: helpTextId,
};
return (
<div { ...componentProps } ref={ wrapperRef }>
{ label && <div { ...labelProps }>{ label }{ required && <HelpText { ...requiredLabelProps } /> }</div> }
<div className="gform-phone__wrapper">
<Dropdown { ...dropdownProps } ref={ dropdownRef } />
<Input { ...inputProps } ref={ inputRef } />
</div>
{ borderStyle === BORDER_STYLE_ERROR && <HelpText { ...helpTextProps } /> }
</div>
);
} );
PhoneComponent.propTypes = {
customAttributes: PropTypes.object,
customClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
disabled: PropTypes.bool,
dropdownAttributes: PropTypes.object,
dropdownClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
helpTextAttributes: PropTypes.object,
helpTextClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
i18n: PropTypes.object,
id: PropTypes.string,
inputAttributes: PropTypes.object,
inputClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
international: PropTypes.bool,
label: PropTypes.string,
labelAttributes: PropTypes.object,
labelClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
onChange: PropTypes.func,
preferredCountries: PropTypes.array,
required: PropTypes.bool,
requiredLabelAttributes: PropTypes.object,
requiredLabelClasses: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.array,
PropTypes.object,
] ),
size: PropTypes.oneOf( [ 'r', 'l', 'xl' ] ),
spacing: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.number,
PropTypes.array,
PropTypes.object,
] ),
usePlaceholder: PropTypes.bool,
useValidation: PropTypes.bool,
width: PropTypes.number,
};
const PhoneComponentWithId = forwardRef( ( props, ref ) => {
const defaultProps = {
customAttributes: {},
customClasses: [],
disabled: false,
dropdownAttributes: {},
dropdownClasses: [],
helpTextAttributes: {},
helpTextClasses: [],
i18n: {
allCountries: 'All countries',
},
id: '',
inputAttributes: {},
inputClasses: [],
international: true,
label: '',
labelAttributes: {},
labelClasses: [],
onChange: () => {},
preferredCountries: [],
required: false,
requiredLabelAttributes: {},
requiredLabelClasses: [],
size: 'r',
spacing: '',
usePlaceholder: true,
useValidation: false,
width: 300,
};
const combinedProps = { ...defaultProps, ...props };
const { id: idProp } = combinedProps;
const idProviderProps = { id: idProp };
return (
<IdProvider { ...idProviderProps }>
<PhoneComponent { ...combinedProps } ref={ ref } />
</IdProvider>
);
} );
export default PhoneComponentWithId;