import { useState, useEffect, useMemo, useRef } from 'react'; import { useIntl } from 'react-intl'; import { Size, SizeLarge, SizeMedium, SizeSmall } from '../common'; import { useFieldLabelRef, useInputAttributes } from '../inputs/contexts'; import { useInputPaddings } from '../inputs/InputGroup'; import { SelectInput, SelectInputOptionContent, SelectInputProps } from '../inputs/SelectInput'; import messages from './PhoneNumberInput.messages'; import countries from './data/countries'; import { explodeNumberModel, isValidPhoneNumber, cleanNumber, setDefaultPrefix, sortArrayByProperty, groupCountriesByPrefix, excludeCountries, findCountryByPrefix, } from './utils'; import { PhoneNumber } from './utils/explodeNumberModel'; import { Input } from '../inputs/Input'; const ALLOWED_PHONE_CHARS = /^$|^[\d-\s]+$/; export interface PhoneNumberInputProps { id?: string; 'aria-labelledby'?: string; required?: boolean; disabled?: boolean; initialValue?: string; onChange: (value: string | null, prefix: string) => void; onFocus?: React.FocusEventHandler; onBlur?: () => void; countryCode?: string; /** @default 'Prefix' */ searchPlaceholder?: string; /** @default 'md' */ size?: SizeSmall | SizeMedium | SizeLarge; placeholder?: string; /** @default {} */ selectProps?: Partial>; /** * List of iso3 codes of countries to remove from the list * @default [] */ disabledCountries?: readonly string[]; } const defaultSelectProps = {} satisfies PhoneNumberInputProps['selectProps']; const defaultDisabledCountries = [] satisfies PhoneNumberInputProps['disabledCountries']; const PhoneNumberInput = ({ id, 'aria-labelledby': ariaLabelledByProp, required, disabled, initialValue, onChange, onFocus, onBlur, countryCode, searchPlaceholder = 'Prefix', size = Size.MEDIUM, placeholder, selectProps = defaultSelectProps, disabledCountries = defaultDisabledCountries, }: PhoneNumberInputProps) => { const countryCodeSelectRef = useRef(null); const phoneNumberInputRef = useRef(null); const inputAttributes = useInputAttributes({ nonLabelable: true }); const { paddingInlineStart } = useInputPaddings(); const hasInputGroupAddonStart = paddingInlineStart != null; const fieldLabelRef = useFieldLabelRef(); const ariaLabelledBy = ariaLabelledByProp ?? inputAttributes['aria-labelledby']; const { locale, formatMessage } = useIntl(); const createId = (customID: string | undefined, backup: string): string => { if (customID) { return customID + (backup ? `-${backup}` : ''); } const random = Math.random().toString(36).slice(2, 8); return `${backup}-${random}`; }; // Link the first non-disabled input to the the Field label, if present const ids = { countryCode: { label: createId(id, 'country-code-label'), select: createId(id, 'country-code-select'), }, phoneNumber: { label: createId(id, 'phone-number-label'), input: createId(id, id ? '' : 'phone-number-input'), }, }; const [internalValue, setInternalValue] = useState(() => { const cleanValue = initialValue ? cleanNumber(initialValue) : null; if (!cleanValue || !isValidPhoneNumber(cleanValue, 1)) { return { prefix: setDefaultPrefix(locale, countryCode), suffix: '', }; } return explodeNumberModel(cleanValue); }); const [broadcastedValue, setBroadcastedValue] = useState(null); const [suffixDirty, setSuffixDirty] = useState(false); useEffect(() => { if (internalValue.suffix) { setSuffixDirty(true); } }, [internalValue.suffix]); const countriesByPrefix = useMemo( () => groupCountriesByPrefix( sortArrayByProperty(excludeCountries(countries, disabledCountries), 'iso3'), ), [disabledCountries], ); const onSuffixChange: React.ChangeEventHandler = (event) => { const suffix = event.target.value; if (ALLOWED_PHONE_CHARS.test(suffix)) { setInternalValue((prev) => ({ ...prev, suffix })); } }; const onPaste: React.ClipboardEventHandler = (event) => { if (!event.nativeEvent.clipboardData) { return; } const pastedValue = (event.nativeEvent.clipboardData.getData('text/plain') || '').replace( /(\s|-)+/g, '', ); const pastedNumber = explodeNumberModel(pastedValue); if ( pastedNumber.prefix != null && countriesByPrefix.has(pastedNumber.prefix) && ALLOWED_PHONE_CHARS.test(pastedNumber.suffix) ) { setInternalValue(pastedNumber); } }; useEffect(() => { if (broadcastedValue === null) { setBroadcastedValue(internalValue); return; } const internalPhoneNumber = `${internalValue.prefix ?? ''}${internalValue.suffix}`; const broadcastedPhoneNumber = `${broadcastedValue.prefix ?? ''}${broadcastedValue.suffix}`; if (internalPhoneNumber === broadcastedPhoneNumber) { return; } const newValue = isValidPhoneNumber(internalPhoneNumber) ? cleanNumber(internalPhoneNumber) : null; onChange( newValue, internalValue.prefix ?? '', // TODO: Allow `null` in public API ); setBroadcastedValue(internalValue); }, [onChange, broadcastedValue, internalValue]); useEffect(() => { const labelRef = fieldLabelRef?.current; if (labelRef) { const handleLabelClick = () => { if (!selectProps.disabled) { countryCodeSelectRef.current?.click(); } else { phoneNumberInputRef.current?.focus(); } }; labelRef.addEventListener('click', handleLabelClick); return () => { labelRef?.removeEventListener('click', handleLabelClick); }; } }, [fieldLabelRef, selectProps.disabled]); return (
({ type: 'option', value: prefix, filterMatchers: [ prefix, ...countries.map((country) => country.name), ...countries.map((country) => country.iso3), ], }))} value={internalValue.prefix} renderValue={(prefix, withinTrigger) => ( country.iso3) .join(', ') } /> )} filterable filterPlaceholder={searchPlaceholder} disabled={disabled} size={size} id={ids.countryCode.select} UNSAFE_triggerButtonProps={{ id: ids.countryCode.select, 'aria-labelledby': ids.countryCode.label, 'aria-describedby': undefined, 'aria-invalid': undefined, }} onChange={(prefix) => { const country = prefix != null ? findCountryByPrefix(prefix) : null; setInternalValue((prev) => ({ ...prev, prefix, format: country?.phoneFormat })); }} onClose={() => { if (suffixDirty) { onBlur?.(); } }} {...selectProps} />
onBlur?.()} />
); }; export default PhoneNumberInput;