'use client'; import type { CountryEntry, CountryIso } from '@clerk/shared/phone'; import { extractDigits, getFlagEmojiFromCountryIso, IsoToCountryMap, parsePhoneString } from '@clerk/shared/phone'; import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { ChevronDown } from '../../icons/glyphs/chevron-down'; import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass'; import { fill } from '../../localization/messages'; import { Button } from '../../primitives/button'; import { Combobox as HeadlessCombobox } from '../../primitives/combobox'; import { useFormattedInput } from '../../primitives/hooks'; import type { MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../styles/focus-outline.styles'; import { reset } from '../../styles/reset.styles'; import { styles as buttonStyles } from '../button/button.styles'; import { Combobox } from '../combobox'; import { Field } from '../field'; import { useOptionalFieldContext } from '../field/field.context'; import { Icon } from '../icon'; import { sizes as inputSizes, styles as inputStyles } from '../input/input.styles'; import { InputGroup } from '../input-group'; import { Popover } from '../popover'; import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import { formatNationalNumber, getNationalNumber, toE164 } from './phone-input.format'; import { styles } from './phone-input.styles'; const countryOptions = [...IsoToCountryMap.values()]; function getCountry(iso: CountryIso | undefined): CountryEntry { const country = iso ? IsoToCountryMap.get(iso) : undefined; const fallback = IsoToCountryMap.get('us') ?? countryOptions[0]; if (!fallback) { throw new Error('PhoneInput requires at least one country'); } return country ?? fallback; } export function toCountryIso(value: string | null | undefined): CountryIso | undefined { const iso = value?.toLowerCase(); return iso ? countryOptions.find(country => country.iso === iso)?.iso : undefined; } function getInitialCountry(value: string | undefined, defaultCountry: CountryIso | undefined): CountryIso { return value ? parsePhoneString(value).iso : getCountry(defaultCountry).iso; } export interface PhoneInputProps extends Omit< MosaicElementProps<'input'>, 'type' | 'size' | 'value' | 'defaultValue' | 'onChange' > { /** The normalized E.164 value. */ value?: string; /** The initial normalized E.164 value for an uncontrolled input. */ defaultValue?: string; /** Called with the normalized E.164 value whenever the number or country changes. */ onValueChange?: (value: string) => void; /** Controls the selected country independently when calling codes are ambiguous. */ country?: CountryIso; /** Initial country when neither `country` nor a phone number selects one. @default 'us' */ defaultCountry?: CountryIso; onCountryChange?: (country: CountryIso) => void; size?: 'sm' | 'md' | 'lg'; /** The country trigger's accessible name, with `{country}` filled in. */ countryTriggerLabel: string; countryPopupLabel: string; countrySearchLabel: string; countrySearchPlaceholder?: string; noResultsMessage?: string; } export const PhoneInput = React.forwardRef(function MosaicPhoneInput( { value: valueProp, defaultValue = '', onValueChange, country: countryProp, defaultCountry, onCountryChange, size = 'md', countryTriggerLabel, countryPopupLabel, countrySearchLabel, countrySearchPlaceholder = 'Search', noResultsMessage = 'No countries found', disabled: disabledProp, required: requiredProp, id, name, form, autoComplete = 'tel-national', inputMode = 'tel', maxLength = 25, spellCheck = false, xstyle, 'aria-invalid': ariaInvalidProp, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...inputProps }, forwardedRef, ) { const field = useOptionalFieldContext(); const disabled = disabledProp ?? field?.disabled ?? false; const ariaInvalid = ariaInvalidProp ?? (field?.invalid ? true : undefined); const invalid = ariaInvalid === true || ariaInvalid === 'true'; const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue); const value = valueProp ?? uncontrolledValue; const [uncontrolledCountry, setUncontrolledCountry] = React.useState(() => getInitialCountry(valueProp ?? defaultValue, defaultCountry), ); const emittedValueRef = React.useRef(null); const country = getCountry(countryProp ?? uncontrolledCountry); const nationalNumber = getNationalNumber(value, country); const formattedNumber = formatNationalNumber(nationalNumber, country.pattern); const [open, setOpen] = React.useState(false); const [anchor, setAnchor] = React.useState(null); const [query, setQuery] = React.useState(''); const inputRef = React.useRef(null); const mergedInputRef = useMergeRefs([inputRef, forwardedRef]); React.useEffect(() => { const emittedValue = emittedValueRef.current; emittedValueRef.current = null; if (valueProp === undefined || valueProp === emittedValue) { return; } if (countryProp === undefined && valueProp) { // eslint-disable-next-line react/set-state-in-effect -- Match the country to a phone number supplied by the parent. setUncontrolledCountry(parsePhoneString(valueProp).iso); } }, [countryProp, valueProp]); const filteredCountries = React.useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { return countryOptions; } return countryOptions.filter(option => `${option.name} ${option.iso} +${option.code}`.toLowerCase().includes(normalizedQuery), ); }, [query]); const setValue = React.useCallback( (nextValue: string) => { emittedValueRef.current = nextValue; if (valueProp === undefined) { setUncontrolledValue(nextValue); } onValueChange?.(nextValue); }, [onValueChange, valueProp], ); // eslint-disable-next-line react/preserve-manual-memoization -- Keep the existing country selection callback. const setCountry = React.useCallback( (nextCountry: CountryEntry) => { if (countryProp === undefined) { setUncontrolledCountry(nextCountry.iso); } onCountryChange?.(nextCountry.iso); setValue(toE164(nextCountry, nationalNumber)); setOpen(false); setQuery(''); inputRef.current?.focus(); }, // eslint-disable-next-line react/preserve-manual-memoization -- The value callback records its last emission in a ref. [countryProp, nationalNumber, onCountryChange, setValue], ); const numberInput = useFormattedInput({ value: formattedNumber, accept: /[\d+]/, format: nextValue => nextValue.includes('+') ? nextValue : formatNationalNumber(extractDigits(nextValue), country.pattern), onChange: nextValue => { if (nextValue.includes('+')) { const parsed = parsePhoneString(nextValue); const parsedCountry = getCountry(parsed.iso); if (countryProp === undefined) { setUncontrolledCountry(parsedCountry.iso); } onCountryChange?.(parsedCountry.iso); setValue(toE164(parsedCountry, parsed.number)); return; } setValue(toE164(country, nextValue)); }, }); return ( <> { setOpen(nextOpen); if (!nextOpen) { setQuery(''); } }} initialFocus='first' placement='bottom-start' sideOffset={8} > } type='button' disabled={disabled} aria-label={fill(countryTriggerLabel, { country: country.name })} > { const nextCountry = countryOptions.find(option => option.iso === iso); if (nextCountry) { setCountry(nextCountry); } }} > {filteredCountries.length > 0 ? ( filteredCountries.map(option => ( {option.name} +{option.code} )) ) : (

{noResultsMessage}

)}
{name ? ( ) : null} ); });