UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

581 lines (527 loc) 15 kB
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;