Skip to content

Commit 495ce18

Browse files
committed
feat(clerk-js,shared,ui): Add phoneNumberCountryCode and preferredSignInIdentifier
Adds two new features to the SignIn component: - `phoneNumberCountryCode` (in `initialValues`): Preselects the country in the phone dropdown using an ISO 3166 alpha-2 code, without pre-filling the phone number. Precedence: parsed phone > defaultCountryIso > geo-IP > 'us'. - `preferredSignInIdentifier` (in `appearance.options`): Selects which identifier type (email, phone, username) shows first without pre-filling a value. Falls back to default ordering when the identifier is not enabled.
1 parent d976a82 commit 495ce18

14 files changed

Lines changed: 228 additions & 24 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/shared': minor
3+
'@clerk/ui': minor
4+
---
5+
6+
Add `phoneNumberCountryCode` to `SignIn` component's `initialValues` prop for preselecting the phone dropdown country via ISO 3166 alpha-2 code. Add `preferredSignInIdentifier` to `appearance.options` for selecting which identifier type (email, phone, username) shows first without pre-filling a value.

packages/shared/src/internal/clerk-js/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const ERROR_CODES = {
5050
USER_DEACTIVATED: 'user_deactivated',
5151
} as const;
5252

53-
export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username'];
53+
export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'phone_number_country_code'];
5454
export const SIGN_UP_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'first_name', 'last_name'];
5555

5656
export const DEBOUNCE_MS = 350;

packages/shared/src/types/clerk.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,7 @@ export type SignInInitialValues = {
13661366
emailAddress?: string;
13671367
phoneNumber?: string;
13681368
username?: string;
1369+
phoneNumberCountryCode?: string;
13691370
};
13701371

13711372
export type SignUpInitialValues = {

packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PhoneCodeChannelData } from '@clerk/shared/types';
2+
import type { CountryIso } from '@/ui/elements/PhoneInput/countryCodeData';
23

34
import { Card } from '@/ui/elements/Card';
45
import { useCardState } from '@/ui/elements/contexts';
@@ -16,10 +17,11 @@ type SignUpAlternativePhoneCodePhoneNumberCardProps = {
1617
phoneNumberFormState: FormControlState<any>;
1718
onUseAnotherMethod: () => void;
1819
phoneCodeProvider: PhoneCodeChannelData;
20+
defaultCountryIso?: CountryIso;
1921
};
2022

2123
export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternativePhoneCodePhoneNumberCardProps) => {
22-
const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider } = props;
24+
const { handleSubmit, phoneNumberFormState, onUseAnotherMethod, phoneCodeProvider, defaultCountryIso } = props;
2325
const { providerToDisplayData, strategyToDisplayData } = useEnabledThirdPartyProviders();
2426
const provider = phoneCodeProvider.name;
2527
const channel = phoneCodeProvider.channel;
@@ -72,6 +74,7 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati
7274
<Form.ControlRow elementId='phoneNumber'>
7375
<Form.PhoneInput
7476
{...phoneNumberFormState.props}
77+
defaultCountryIso={defaultCountryIso}
7578
label={localizationKeys('signIn.start.alternativePhoneCodeProvider.label', { provider })}
7679
isRequired
7780
isOptional={false}

packages/ui/src/components/SignIn/SignInStart.tsx

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
SignInCreateParams,
1111
SignInResource,
1212
} from '@clerk/shared/types';
13+
import type { CountryIso } from '@/ui/elements/PhoneInput/countryCodeData';
1314
import { isWebAuthnAutofillSupported, isWebAuthnSupported } from '@clerk/shared/webauthn';
1415
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1516

@@ -33,7 +34,7 @@ import {
3334
withRedirectToSignInTask,
3435
} from '../../common';
3536
import { useCoreSignIn, useEnvironment, useSignInContext } from '../../contexts';
36-
import { Col, descriptors, Flow, localizationKeys } from '../../customizables';
37+
import { Col, descriptors, Flow, localizationKeys, useAppearance } from '../../customizables';
3738
import { CaptchaElement } from '../../elements/CaptchaElement';
3839
import { useLoadingStatus } from '../../hooks';
3940
import { useSupportEmail } from '../../hooks/useSupportEmail';
@@ -88,6 +89,7 @@ function SignInStartInternal(): JSX.Element {
8889
const ctx = useSignInContext();
8990
const { afterSignInUrl, signUpUrl, waitlistUrl, isCombinedFlow, navigateOnSetActive } = ctx;
9091
const supportEmail = useSupportEmail();
92+
const { parsedOptions } = useAppearance();
9193
const totalEnabledAuthMethods = useTotalEnabledAuthMethods();
9294
const identifierAttributes = useMemo<SignInStartIdentifier[]>(
9395
() => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers),
@@ -103,13 +105,46 @@ function SignInStartInternal(): JSX.Element {
103105
const authenticateWithPasskey = useHandleAuthenticateWithPasskey(onSecondFactor);
104106
const isWebSupported = isWebAuthnSupported();
105107

106-
const onlyPhoneNumberInitialValueExists =
107-
!!ctx.initialValues?.phoneNumber && !(ctx.initialValues.emailAddress || ctx.initialValues.username);
108-
const shouldStartWithPhoneNumberIdentifier =
109-
onlyPhoneNumberInitialValueExists && identifierAttributes.includes('phone_number');
110-
const [identifierAttribute, setIdentifierAttribute] = useState<SignInStartIdentifier>(
111-
shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '',
112-
);
108+
const resolveInitialIdentifier = (): SignInStartIdentifier => {
109+
const iv = ctx.initialValues;
110+
111+
const mapToIdentifierAttribute = (key: string): SignInStartIdentifier | undefined => {
112+
if (key === 'phoneNumber') {
113+
return identifierAttributes.includes('phone_number') ? 'phone_number' : undefined;
114+
}
115+
if (key === 'emailAddress') {
116+
if (identifierAttributes.includes('email_address')) return 'email_address';
117+
if (identifierAttributes.includes('email_address_username')) return 'email_address_username';
118+
return undefined;
119+
}
120+
if (key === 'username') {
121+
if (identifierAttributes.includes('email_address_username')) return 'email_address_username';
122+
if (identifierAttributes.includes('username')) return 'username';
123+
return undefined;
124+
}
125+
return undefined;
126+
};
127+
128+
const filledValues = [
129+
iv?.emailAddress && 'emailAddress',
130+
iv?.phoneNumber && 'phoneNumber',
131+
iv?.username && 'username',
132+
].filter(Boolean) as string[];
133+
134+
if (filledValues.length === 1) {
135+
const mapped = mapToIdentifierAttribute(filledValues[0]);
136+
if (mapped) return mapped;
137+
}
138+
139+
if (parsedOptions.preferredSignInIdentifier) {
140+
const mapped = mapToIdentifierAttribute(parsedOptions.preferredSignInIdentifier);
141+
if (mapped) return mapped;
142+
}
143+
144+
return identifierAttributes[0] || '';
145+
};
146+
147+
const [identifierAttribute, setIdentifierAttribute] = useState<SignInStartIdentifier>(resolveInitialIdentifier);
113148
const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false);
114149

115150
const organizationTicket = getClerkQueryParam('__clerk_ticket') || '';
@@ -596,6 +631,9 @@ function SignInStartInternal(): JSX.Element {
596631
actionLabel={nextIdentifier?.action}
597632
onActionClicked={switchToNextIdentifier}
598633
{...identifierFieldProps}
634+
defaultCountryIso={
635+
ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined
636+
}
599637
autoFocus={shouldAutofocus}
600638
autoComplete={isWebAuthnAutofillSupported ? 'webauthn' : undefined}
601639
isLastAuthenticationStrategy={isIdentifierLastAuthenticationStrategy}
@@ -650,6 +688,7 @@ function SignInStartInternal(): JSX.Element {
650688
phoneNumberFormState={phoneIdentifierField}
651689
onUseAnotherMethod={onAlternativePhoneCodeUseAnotherMethod}
652690
phoneCodeProvider={alternativePhoneCodeProvider}
691+
defaultCountryIso={ctx.initialValues?.phoneNumberCountryCode?.toLowerCase() as CountryIso | undefined}
653692
/>
654693
)}
655694
</Flow.Part>

packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,65 @@ describe('SignInStart', () => {
479479
});
480480
});
481481

482+
describe('preferredSignInIdentifier (appearance option)', () => {
483+
it('selects phone_number tab when set to phoneNumber', async () => {
484+
const { wrapper, props } = await createFixtures(f => {
485+
f.withEmailAddress();
486+
f.withPhoneNumber();
487+
});
488+
props.setProps({ appearance: { options: { preferredSignInIdentifier: 'phoneNumber' } } });
489+
490+
render(<SignInStart />, { wrapper });
491+
screen.getByText(/phone number/i);
492+
});
493+
494+
it('selects email_address tab when set to emailAddress', async () => {
495+
const { wrapper, props } = await createFixtures(f => {
496+
f.withEmailAddress();
497+
f.withPhoneNumber();
498+
});
499+
props.setProps({ appearance: { options: { preferredSignInIdentifier: 'emailAddress' } } });
500+
501+
render(<SignInStart />, { wrapper });
502+
screen.getByText(/email address/i);
503+
});
504+
505+
it('is ignored when identifier is not enabled', async () => {
506+
const { wrapper, props } = await createFixtures(f => {
507+
f.withEmailAddress();
508+
});
509+
props.setProps({ appearance: { options: { preferredSignInIdentifier: 'phoneNumber' } } });
510+
511+
render(<SignInStart />, { wrapper });
512+
screen.getByText(/email address/i);
513+
});
514+
515+
it('single filled initialValues value takes precedence', async () => {
516+
const { wrapper, props } = await createFixtures(f => {
517+
f.withEmailAddress();
518+
f.withPhoneNumber();
519+
});
520+
props.setProps({
521+
initialValues: { phoneNumber: '+306911111111' },
522+
appearance: { options: { preferredSignInIdentifier: 'emailAddress' } },
523+
});
524+
525+
render(<SignInStart />, { wrapper });
526+
screen.getByDisplayValue(/691 1111111/i);
527+
});
528+
529+
it('username maps to email_address_username when both enabled', async () => {
530+
const { wrapper, props } = await createFixtures(f => {
531+
f.withEmailAddress();
532+
f.withUsername();
533+
});
534+
props.setProps({ appearance: { options: { preferredSignInIdentifier: 'username' } } });
535+
536+
render(<SignInStart />, { wrapper });
537+
screen.getByText(/email address or username/i);
538+
});
539+
});
540+
482541
describe('Submitting form via instant password autofill', () => {
483542
const ERROR_CODES = ['strategy_for_user_invalid', 'form_password_incorrect', 'form_password_pwned'];
484543
ERROR_CODES.forEach(code => {

packages/ui/src/customizables/parseAppearance.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717

1818
export type ParsedElements = Elements[];
1919
export type ParsedInternalTheme = InternalTheme;
20-
export type ParsedOptions = Required<Options>;
20+
export type ParsedOptions = Required<Omit<Options, 'preferredSignInIdentifier'>> &
21+
Pick<Options, 'preferredSignInIdentifier'>;
2122
export type ParsedCaptcha = Required<CaptchaAppearanceOptions>;
2223

2324
type PublicAppearanceTopLevelKey = Exclude<
@@ -51,6 +52,7 @@ const defaultOptions: ParsedOptions = {
5152
shimmer: true,
5253
animations: true,
5354
unsafe_disableDevelopmentModeWarnings: false,
55+
preferredSignInIdentifier: undefined,
5456
};
5557

5658
const defaultCaptchaOptions: ParsedCaptcha = {

packages/ui/src/elements/FieldControl.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type { FormFeedbackProps } from './FormControl';
2525
import { FormFeedback } from './FormControl';
2626
import { InputGroup } from './InputGroup';
2727
import { PasswordInput } from './PasswordInput';
28+
import type { CountryIso } from './PhoneInput/countryCodeData';
2829
import { PhoneInput } from './PhoneInput';
2930
import { RadioItem, RadioLabel } from './RadioGroup';
3031

@@ -168,7 +169,7 @@ const FieldFeedback = (props: Pick<FormFeedbackProps, 'elementDescriptors' | 'ce
168169
);
169170
};
170171

171-
const PhoneInputElement = forwardRef<HTMLInputElement>((_, ref) => {
172+
const PhoneInputElement = forwardRef<HTMLInputElement, { defaultCountryIso?: CountryIso }>((props, ref) => {
172173
const { t } = useLocalizations();
173174
const formField = useFormField();
174175
const { placeholder, ...inputProps } = sanitizeInputProps(formField);
@@ -179,6 +180,7 @@ const PhoneInputElement = forwardRef<HTMLInputElement>((_, ref) => {
179180
elementDescriptor={descriptors.formFieldInput}
180181
elementId={descriptors.formFieldInput.setId(formField.fieldId)}
181182
{...inputProps}
183+
defaultCountryIso={props.defaultCountryIso}
182184
feedbackType={formField.feedbackType}
183185
placeholder={t(placeholder)}
184186
/>

packages/ui/src/elements/Form.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createContextAndHook } from '@clerk/shared/react';
22
import type { FieldId } from '@clerk/shared/types';
3+
import type { CountryIso } from './PhoneInput/countryCodeData';
34
import type { PropsWithChildren } from 'react';
45
import React, { forwardRef, useState } from 'react';
56

@@ -196,10 +197,11 @@ const PasswordInput = forwardRef<HTMLInputElement, CommonInputProps>((props, ref
196197
);
197198
});
198199

199-
const PhoneInput = (props: CommonInputProps) => {
200+
const PhoneInput = (props: CommonInputProps & { defaultCountryIso?: CountryIso }) => {
201+
const { defaultCountryIso, ...rest } = props;
200202
return (
201-
<CommonInputWrapper {...props}>
202-
<Field.PhoneInput />
203+
<CommonInputWrapper {...rest}>
204+
<Field.PhoneInput defaultCountryIso={defaultCountryIso} />
203205
</CommonInputWrapper>
204206
);
205207
};

packages/ui/src/elements/PhoneInput/__tests__/useFormattedPhoneNumber.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,62 @@ describe('useFormattedPhoneNumber', () => {
118118

119119
unmount();
120120
});
121+
122+
it('defaultCountryIso is used when no phone number and no locationBasedCountryIso', () => {
123+
const { result } = renderHook(() =>
124+
useFormattedPhoneNumber({
125+
initPhoneWithCode: '',
126+
defaultCountryIso: 'gr',
127+
locationBasedCountryIso: undefined,
128+
}),
129+
);
130+
131+
expect(result.current.iso).toBe('gr');
132+
});
133+
134+
it('defaultCountryIso takes precedence over locationBasedCountryIso', () => {
135+
const { result } = renderHook(() =>
136+
useFormattedPhoneNumber({
137+
initPhoneWithCode: '',
138+
defaultCountryIso: 'de',
139+
locationBasedCountryIso: 'fr',
140+
}),
141+
);
142+
143+
expect(result.current.iso).toBe('de');
144+
});
145+
146+
it('parsed phone number takes precedence over defaultCountryIso', () => {
147+
const { result } = renderHook(() =>
148+
useFormattedPhoneNumber({
149+
initPhoneWithCode: '+71111111111',
150+
defaultCountryIso: 'gr',
151+
}),
152+
);
153+
154+
expect(result.current.iso).toBe('ru');
155+
});
156+
157+
it('invalid defaultCountryIso falls back to locationBasedCountryIso', () => {
158+
const { result } = renderHook(() =>
159+
useFormattedPhoneNumber({
160+
initPhoneWithCode: '',
161+
defaultCountryIso: 'xx' as any,
162+
locationBasedCountryIso: 'fr',
163+
}),
164+
);
165+
166+
expect(result.current.iso).toBe('fr');
167+
});
168+
169+
it('invalid defaultCountryIso with no locationBasedCountryIso falls back to us', () => {
170+
const { result } = renderHook(() =>
171+
useFormattedPhoneNumber({
172+
initPhoneWithCode: '',
173+
defaultCountryIso: 'zz' as any,
174+
}),
175+
);
176+
177+
expect(result.current.iso).toBe('us');
178+
});
121179
});

0 commit comments

Comments
 (0)