diff --git a/.changeset/phone-country-preferred-identifier.md b/.changeset/phone-country-preferred-identifier.md new file mode 100644 index 00000000000..3dac0eb5935 --- /dev/null +++ b/.changeset/phone-country-preferred-identifier.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +'@clerk/ui': minor +--- + +Add `phoneNumberCountryCode` to `SignIn` component's `initialValues` prop for preselecting the phone dropdown country via ISO 3166 alpha-2 code. Add `preferredIdentifier` to `appearance.options` for selecting which identifier type (email, phone, username) shows first without pre-filling a value. diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index f81693798e1..6c503f12400 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -50,7 +50,7 @@ export const ERROR_CODES = { USER_DEACTIVATED: 'user_deactivated', } as const; -export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username']; +export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'phone_number_country_code']; export const SIGN_UP_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'first_name', 'last_name']; export const DEBOUNCE_MS = 350; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 670a1a21ba0..9bb4452ec7b 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1366,6 +1366,7 @@ export type SignInInitialValues = { emailAddress?: string; phoneNumber?: string; username?: string; + phoneNumberCountryCode?: string; }; export type SignUpInitialValues = { diff --git a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx index 5c784ba612b..09e43d42fb8 100644 --- a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx +++ b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx @@ -1,4 +1,5 @@ import type { PhoneCodeChannelData } from '@clerk/shared/types'; +import type { CountryIso } from '@/ui/elements/PhoneInput/countryCodeData'; import { Card } from '@/ui/elements/Card'; import { useCardState } from '@/ui/elements/contexts'; @@ -16,10 +17,11 @@ type SignUpAlternativePhoneCodePhoneNumberCardProps = { phoneNumberFormState: FormControlState; onUseAnotherMethod: () => void; phoneCodeProvider: PhoneCodeChannelData; + defaultCountryIso?: CountryIso; }; export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternativePhoneCodePhoneNumberCardProps) => { - const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider } = props; + const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider, defaultCountryIso } = props; const { providerToDisplayData, strategyToDisplayData } = useEnabledThirdPartyProviders(); const provider = phoneCodeProvider.name; const channel = phoneCodeProvider.channel; @@ -72,6 +74,7 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati ( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), @@ -103,13 +105,46 @@ function SignInStartInternal(): JSX.Element { const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor); const isWebSupported = isWebAuthnSupported(); - const onlyPhoneNumberInitialValueExists = - !!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username); - const shouldStartWithPhoneNumberIdentifier = - onlyPhoneNumberInitialValueExists && identifierAttributes.includes('phone_number'); - const [identifierAttribute, setIdentifierAttribute] = useState( - shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '', - ); + const resolveInitialIdentifier = (): SignInStartIdentifier => { + const iv = ctx.initialValues; + + const mapToIdentifierAttribute = (key: string): SignInStartIdentifier | undefined => { + if (key === 'phoneNumber') { + return identifierAttributes.includes('phone_number') ? 'phone_number' : undefined; + } + if (key === 'emailAddress') { + if (identifierAttributes.includes('email_address')) return 'email_address'; + if (identifierAttributes.includes('email_address_username')) return 'email_address_username'; + return undefined; + } + if (key === 'username') { + if (identifierAttributes.includes('email_address_username')) return 'email_address_username'; + if (identifierAttributes.includes('username')) return 'username'; + return undefined; + } + return undefined; + }; + + const filledValues = [ + iv?.emailAddress && 'emailAddress', + iv?.phoneNumber && 'phoneNumber', + iv?.username && 'username', + ].filter(Boolean) as string[]; + + if (filledValues.length === 1) { + const mapped = mapToIdentifierAttribute(filledValues[0]); + if (mapped) return mapped; + } + + if (parsedOptions.preferredIdentifier) { + const mapped = mapToIdentifierAttribute(parsedOptions.preferredIdentifier); + if (mapped) return mapped; + } + + return identifierAttributes[0] || ''; + }; + + const [identifierAttribute, setIdentifierAttribute] = useState(resolveInitialIdentifier); const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false); const organizationTicket = getClerkQueryParam('__clerk_ticket') || ''; @@ -596,6 +631,9 @@ function SignInStartInternal(): JSX.Element { actionLabel={nextIdentifier?.action} onActionClicked={switchToNextIdentifier} {...identifierFieldProps} + defaultCountryIso={ + ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined + } autoFocus={shouldAutofocus} autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined} isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy} @@ -650,6 +688,7 @@ function SignInStartInternal(): JSX.Element { phoneNumberFormState={phoneIdentifierField} onUseAnotherMethod={onAlternativePhoneCodeUseAnotherMethod} phoneCodeProvider={alternativePhoneCodeProvider} + defaultCountryIso={ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined} /> )} diff --git a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx index 7b14917cbe5..bc3ea7dc4c2 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx @@ -479,6 +479,65 @@ describe('SignInStart', () => { }); }); + describe('preferredIdentifier (appearance option)', () => { + it('selects phone_number tab when set to phoneNumber', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + }); + props.setProps({ appearance: { options: { preferredIdentifier: 'phoneNumber' } } }); + + render(, { wrapper }); + screen.getByText(/phone number/i); + }); + + it('selects email_address tab when set to emailAddress', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + }); + props.setProps({ appearance: { options: { preferredIdentifier: 'emailAddress' } } }); + + render(, { wrapper }); + screen.getByText(/email address/i); + }); + + it('is ignored when identifier is not enabled', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + }); + props.setProps({ appearance: { options: { preferredIdentifier: 'phoneNumber' } } }); + + render(, { wrapper }); + screen.getByText(/email address/i); + }); + + it('single filled initialValues value takes precedence', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + }); + props.setProps({ + initialValues: { phoneNumber: '+306911111111' }, + appearance: { options: { preferredIdentifier: 'emailAddress' } }, + }); + + render(, { wrapper }); + screen.getByDisplayValue(/691 1111111/i); + }); + + it('username maps to email_address_username when both enabled', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + f.withUsername(); + }); + props.setProps({ appearance: { options: { preferredIdentifier: 'username' } } }); + + render(, { wrapper }); + screen.getByText(/email address or username/i); + }); + }); + describe('Submitting form via instant password autofill', () => { const ERROR_CODES = ['strategy_for_user_invalid', 'form_password_incorrect', 'form_password_pwned']; ERROR_CODES.forEach(code => { diff --git a/packages/ui/src/components/SignUp/SignUpContinue.tsx b/packages/ui/src/components/SignUp/SignUpContinue.tsx index 29ae18c8e31..365af7e1b08 100644 --- a/packages/ui/src/components/SignUp/SignUpContinue.tsx +++ b/packages/ui/src/components/SignUp/SignUpContinue.tsx @@ -12,7 +12,7 @@ import { buildRequest, useFormControl } from '@/ui/utils/useFormControl'; import { createUsernameError } from '@/ui/utils/usernameUtils'; import { SignInContext, useCoreSignUp, useEnvironment, useSignUpContext } from '../../contexts'; -import { descriptors, Flex, Flow, localizationKeys, useLocalizations } from '../../customizables'; +import { descriptors, Flex, Flow, localizationKeys, useAppearance, useLocalizations } from '../../customizables'; import { useRouter } from '../../router'; import { SignUpForm } from './SignUpForm'; import type { ActiveIdentifier } from './signUpFormHelpers'; @@ -44,8 +44,9 @@ function SignUpContinueInternal() { const isWithinSignInContext = !!React.useContext(SignInContext); const isCombinedFlow = !!(_isCombinedFlow && !!isWithinSignInContext); const isProgressiveSignUp = userSettings.signUp.progressive; + const { preferredIdentifier } = useAppearance().parsedOptions; const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( - getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), + getInitialActiveIdentifier(attributes, userSettings.signUp.progressive, undefined, preferredIdentifier), ); const ctx = useSignUpContext(); diff --git a/packages/ui/src/components/SignUp/SignUpStart.tsx b/packages/ui/src/components/SignUp/SignUpStart.tsx index 90059b9a2f5..7cdb7e669b8 100644 --- a/packages/ui/src/components/SignUp/SignUpStart.tsx +++ b/packages/ui/src/components/SignUp/SignUpStart.tsx @@ -37,7 +37,7 @@ function SignUpStartInternal(): JSX.Element { const clerk = useClerk(); const status = useLoadingStatus(); const signUp = useCoreSignUp(); - const { showOptionalFields } = useAppearance().parsedOptions; + const { showOptionalFields, preferredIdentifier } = useAppearance().parsedOptions; const { userSettings, authConfig } = useEnvironment(); const { navigate } = useRouter(); const { attributes } = userSettings; @@ -47,16 +47,21 @@ function SignUpStartInternal(): JSX.Element { const { afterSignUpUrl, signInUrl, unsafeMetadata, navigateOnSetActive } = ctx; const isCombinedFlow = !!(ctx.isCombinedFlow && !!isWithinSignInContext); const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState(() => - getInitialActiveIdentifier(attributes, userSettings.signUp.progressive, { - phoneNumber: ctx.initialValues?.phoneNumber === null ? undefined : ctx.initialValues?.phoneNumber, - emailAddress: ctx.initialValues?.emailAddress === null ? undefined : ctx.initialValues?.emailAddress, - ...(isCombinedFlow - ? { - emailAddress: signUp.emailAddress, - phoneNumber: signUp.phoneNumber, - } - : {}), - }), + getInitialActiveIdentifier( + attributes, + userSettings.signUp.progressive, + { + phoneNumber: ctx.initialValues?.phoneNumber === null ? undefined : ctx.initialValues?.phoneNumber, + emailAddress: ctx.initialValues?.emailAddress === null ? undefined : ctx.initialValues?.emailAddress, + ...(isCombinedFlow + ? { + emailAddress: signUp.emailAddress, + phoneNumber: signUp.phoneNumber, + } + : {}), + }, + preferredIdentifier, + ), ); const { t, locale } = useLocalizations(); const initialValues = ctx.initialValues || {}; diff --git a/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts b/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts index 5472b9b3fd9..36deba01eda 100644 --- a/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts +++ b/packages/ui/src/components/SignUp/__tests__/signUpFormHelpers.test.ts @@ -1047,4 +1047,71 @@ describe('getInitialActiveIdentifier()', () => { expect(getInitialActiveIdentifier(attributes, false)).toBe(null); }); }); + + describe('respects preferredIdentifier on ties', () => { + it('returns phoneNumber in email-or-phone when preferredIdentifier is phoneNumber', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'phoneNumber')).toBe('phoneNumber'); + }); + + it('returns emailAddress in email-or-phone when preferredIdentifier is emailAddress', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'emailAddress')).toBe('emailAddress'); + }); + + it('returns phoneNumber when both are required and preferredIdentifier is phoneNumber', () => { + const attributes = { + email_address: createAttributeData('email_address', true, true, true), + phone_number: createAttributeData('phone_number', true, true, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'phoneNumber')).toBe('phoneNumber'); + }); + + it('defaults to emailAddress in email-or-phone when no preferredIdentifier', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false)).toBe('emailAddress'); + }); + + it('ignores preferredIdentifier username since SignUp only has email/phone', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, undefined, 'username')).toBe('emailAddress'); + }); + + it('initialValues take precedence over preferredIdentifier', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, false, { emailAddress: 'test@example.com' }, 'phoneNumber')).toBe( + 'emailAddress', + ); + }); + + it('returns phoneNumber in progressive signup email-or-phone with preferredIdentifier', () => { + const attributes = { + email_address: createAttributeData('email_address', true, false, true), + phone_number: createAttributeData('phone_number', true, false, true), + }; + + expect(getInitialActiveIdentifier(attributes, true, undefined, 'phoneNumber')).toBe('phoneNumber'); + }); + }); }); diff --git a/packages/ui/src/components/SignUp/signUpFormHelpers.ts b/packages/ui/src/components/SignUp/signUpFormHelpers.ts index c2fc0c9ce3f..d6cf131592d 100644 --- a/packages/ui/src/components/SignUp/signUpFormHelpers.ts +++ b/packages/ui/src/components/SignUp/signUpFormHelpers.ts @@ -107,6 +107,7 @@ export const getInitialActiveIdentifier = ( attributes: Partial, isProgressiveSignUp: boolean, initialValues?: { phoneNumber?: string | null; emailAddress?: string | null }, + preferredIdentifier?: 'emailAddress' | 'phoneNumber' | 'username', ): ActiveIdentifier => { if (initialValues?.emailAddress) { return 'emailAddress'; @@ -115,18 +116,29 @@ export const getInitialActiveIdentifier = ( return 'phoneNumber'; } + const preferred = + preferredIdentifier === 'emailAddress' || preferredIdentifier === 'phoneNumber' ? preferredIdentifier : undefined; + if (emailOrPhone(attributes, isProgressiveSignUp)) { - // If we are in the case of Email OR Phone, email takes priority - return 'emailAddress'; + return preferred ?? 'emailAddress'; } const { email_address, phone_number } = attributes; - if (email_address?.enabled && isProgressiveSignUp ? email_address.required : email_address?.used_for_first_factor) { + const emailMatches = + email_address?.enabled && (isProgressiveSignUp ? email_address.required : email_address?.used_for_first_factor); + const phoneMatches = + phone_number?.enabled && (isProgressiveSignUp ? phone_number.required : phone_number?.used_for_first_factor); + + if (emailMatches && phoneMatches) { + return preferred ?? 'emailAddress'; + } + + if (emailMatches) { return 'emailAddress'; } - if (phone_number?.enabled && isProgressiveSignUp ? phone_number.required : phone_number?.used_for_first_factor) { + if (phoneMatches) { return 'phoneNumber'; } diff --git a/packages/ui/src/customizables/parseAppearance.ts b/packages/ui/src/customizables/parseAppearance.ts index a61b883191f..cc194fc574b 100644 --- a/packages/ui/src/customizables/parseAppearance.ts +++ b/packages/ui/src/customizables/parseAppearance.ts @@ -17,7 +17,7 @@ import { export type ParsedElements = Elements[]; export type ParsedInternalTheme = InternalTheme; -export type ParsedOptions = Required; +export type ParsedOptions = Required> & Pick; export type ParsedCaptcha = Required; type PublicAppearanceTopLevelKey = Exclude< @@ -51,6 +51,7 @@ const defaultOptions: ParsedOptions = { shimmer: true, animations: true, unsafe_disableDevelopmentModeWarnings: false, + preferredIdentifier: undefined, }; const defaultCaptchaOptions: ParsedCaptcha = { diff --git a/packages/ui/src/elements/FieldControl.tsx b/packages/ui/src/elements/FieldControl.tsx index 94995209229..2d21bc90010 100644 --- a/packages/ui/src/elements/FieldControl.tsx +++ b/packages/ui/src/elements/FieldControl.tsx @@ -25,6 +25,7 @@ import type { FormFeedbackProps } from './FormControl'; import { FormFeedback } from './FormControl'; import { InputGroup } from './InputGroup'; import { PasswordInput } from './PasswordInput'; +import type { CountryIso } from './PhoneInput/countryCodeData'; import { PhoneInput } from './PhoneInput'; import { RadioItem, RadioLabel } from './RadioGroup'; @@ -168,7 +169,7 @@ const FieldFeedback = (props: Pick((_, ref) => { +const PhoneInputElement = forwardRef((props, ref) => { const { t } = useLocalizations(); const formField = useFormField(); const { placeholder, ...inputProps } = sanitizeInputProps(formField); @@ -179,6 +180,7 @@ const PhoneInputElement = forwardRef((_, ref) => { elementDescriptor={descriptors.formFieldInput} elementId={descriptors.formFieldInput.setId(formField.fieldId)} {...inputProps} + defaultCountryIso={props.defaultCountryIso} feedbackType={formField.feedbackType} placeholder={t(placeholder)} /> diff --git a/packages/ui/src/elements/Form.tsx b/packages/ui/src/elements/Form.tsx index 933b09ff461..13ee06a2748 100644 --- a/packages/ui/src/elements/Form.tsx +++ b/packages/ui/src/elements/Form.tsx @@ -1,5 +1,6 @@ import { createContextAndHook } from '@clerk/shared/react'; import type { FieldId } from '@clerk/shared/types'; +import type { CountryIso } from './PhoneInput/countryCodeData'; import type { PropsWithChildren } from 'react'; import React, { forwardRef, useState } from 'react'; @@ -196,10 +197,11 @@ const PasswordInput = forwardRef((props, ref ); }); -const PhoneInput = (props: CommonInputProps) => { +const PhoneInput = (props: CommonInputProps & { defaultCountryIso?: CountryIso }) => { + const { defaultCountryIso, ...rest } = props; return ( - - + + ); }; diff --git a/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts b/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts index 9801aec8695..e95fb7f12a1 100644 --- a/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts +++ b/packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts @@ -118,4 +118,62 @@ describe('useFormattedPhoneNumber', () => { unmount(); }); + + it('defaultCountryIso is used when no phone number and no locationBasedCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'gr', + locationBasedCountryIso: undefined, + }), + ); + + expect(result.current.iso).toBe('gr'); + }); + + it('defaultCountryIso takes precedence over locationBasedCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'de', + locationBasedCountryIso: 'fr', + }), + ); + + expect(result.current.iso).toBe('de'); + }); + + it('parsed phone number takes precedence over defaultCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '+71111111111', + defaultCountryIso: 'gr', + }), + ); + + expect(result.current.iso).toBe('ru'); + }); + + it('invalid defaultCountryIso falls back to locationBasedCountryIso', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'xx' as any, + locationBasedCountryIso: 'fr', + }), + ); + + expect(result.current.iso).toBe('fr'); + }); + + it('invalid defaultCountryIso with no locationBasedCountryIso falls back to us', () => { + const { result } = renderHook(() => + useFormattedPhoneNumber({ + initPhoneWithCode: '', + defaultCountryIso: 'zz' as any, + }), + ); + + expect(result.current.iso).toBe('us'); + }); }); diff --git a/packages/ui/src/elements/PhoneInput/index.tsx b/packages/ui/src/elements/PhoneInput/index.tsx index 7882e0623be..5c8f8412be0 100644 --- a/packages/ui/src/elements/PhoneInput/index.tsx +++ b/packages/ui/src/elements/PhoneInput/index.tsx @@ -23,15 +23,27 @@ const createSelectOption = (country: CountryEntry) => { const countryOptions = [...IsoToCountryMap.values()].map(createSelectOption); -type PhoneInputProps = PropsOfComponent & { locationBasedCountryIso?: CountryIso }; +type PhoneInputProps = PropsOfComponent & { + locationBasedCountryIso?: CountryIso; + defaultCountryIso?: CountryIso; +}; const PhoneInputBase = forwardRef((props, ref) => { - const { onChange: onChangeProp, value, locationBasedCountryIso, feedbackType, sx, ...rest } = props; + const { + onChange: onChangeProp, + value, + locationBasedCountryIso, + defaultCountryIso, + feedbackType, + sx, + ...rest + } = props; const phoneInputRef = useRef(null); const phoneInputBox = useRef(null); const { setNumber, setIso, setNumberAndIso, numberWithCode, iso, formattedNumber } = useFormattedPhoneNumber({ initPhoneWithCode: value as string, locationBasedCountryIso, + defaultCountryIso, }); const callOnChangeProp = () => { diff --git a/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts b/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts index 8fec8c52271..92f5bfb84b7 100644 --- a/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts +++ b/packages/ui/src/elements/PhoneInput/useFormattedPhoneNumber.ts @@ -5,7 +5,11 @@ import { extractDigits, formatPhoneNumber, parsePhoneString } from '@/ui/utils/p import type { CountryIso } from './countryCodeData'; import { IsoToCountryMap } from './countryCodeData'; -type UseFormattedPhoneNumberProps = { initPhoneWithCode: string; locationBasedCountryIso?: CountryIso }; +type UseFormattedPhoneNumberProps = { + initPhoneWithCode: string; + locationBasedCountryIso?: CountryIso; + defaultCountryIso?: CountryIso; +}; const format = (str: string, iso: CountryIso) => { if (!str) { @@ -21,11 +25,16 @@ export const useFormattedPhoneNumber = (props: UseFormattedPhoneNumberProps) => return number; }); - const [iso, setIso] = React.useState( - parsePhoneString(props.initPhoneWithCode || '').number - ? parsePhoneString(props.initPhoneWithCode || '').iso - : props.locationBasedCountryIso || 'us', - ); + const [iso, setIso] = React.useState(() => { + const parsed = parsePhoneString(props.initPhoneWithCode || ''); + if (parsed.number) { + return parsed.iso; + } + if (props.defaultCountryIso && IsoToCountryMap.has(props.defaultCountryIso)) { + return props.defaultCountryIso; + } + return props.locationBasedCountryIso || 'us'; + }); React.useEffect(() => { setNumber(extractDigits(number)); diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index cf4b8ac09d6..e9ca1a6d81d 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -963,6 +963,14 @@ export type Options = { * @default false */ unsafe_disableDevelopmentModeWarnings?: boolean; + + /** + * Controls which identifier type is shown first on the SignIn and SignUp components + * when multiple identifiers are enabled. Does not prefill any value. + * + * @default undefined + */ + preferredIdentifier?: 'emailAddress' | 'phoneNumber' | 'username'; }; export type CaptchaAppearanceOptions = { diff --git a/packages/ui/src/test/create-fixtures.tsx b/packages/ui/src/test/create-fixtures.tsx index 6824c98ec14..2e16fe5edc6 100644 --- a/packages/ui/src/test/create-fixtures.tsx +++ b/packages/ui/src/test/create-fixtures.tsx @@ -125,7 +125,10 @@ const unboundCreateFixtures = ( - + {contextWrappedChildren}