From c601f202ff24350b5346b06da780fb80e21ded41 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 12:14:24 -0700 Subject: [PATCH 01/17] feat(mosaic): add Field.Header for a label row with an action --- .../components/field/__tests__/field.test.tsx | 40 +++++++++++++++++++ .../src/components/field/field.styles.ts | 7 ++++ .../mosaic/src/components/field/field.tsx | 16 ++++++++ packages/mosaic/src/components/field/index.ts | 1 + .../swingset/src/stories/field.component.mdx | 10 +++++ .../src/stories/field.component.stories.tsx | 22 ++++++++++ 6 files changed, 96 insertions(+) diff --git a/packages/mosaic/src/components/field/__tests__/field.test.tsx b/packages/mosaic/src/components/field/__tests__/field.test.tsx index 23660d012c5..6baf732fa04 100644 --- a/packages/mosaic/src/components/field/__tests__/field.test.tsx +++ b/packages/mosaic/src/components/field/__tests__/field.test.tsx @@ -18,6 +18,7 @@ const overrides = stylex.create({ label: { fontWeight: 700 }, description: { opacity: 0.8 }, content: { gap: '4px' }, + header: { columnGap: '4px' }, error: { fontWeight: 600 }, }); @@ -239,6 +240,32 @@ describe('Mosaic Field', () => { expect(checkbox).toBeChecked(); }); + it('keeps the label associated when it shares a header row with an action', async () => { + const onAction = vi.fn(); + render( + + + Password + + + + , + ); + + const input = screen.getByRole('textbox', { name: 'Password' }); + expect(screen.getByTestId('header')).toHaveClass('cl-field-header'); + await userEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + expect(onAction).toHaveBeenCalledOnce(); + expect(input).not.toHaveFocus(); + await userEvent.click(screen.getByText('Password')); + expect(input).toHaveFocus(); + }); + it('toggles a checkbox wrapped in its label', async () => { render( @@ -380,6 +407,7 @@ describe('Mosaic Field', () => { const labelRef = React.createRef(); const descriptionRef = React.createRef(); const contentRef = React.createRef(); + const headerRef = React.createRef(); const errorRef = React.createRef(); render( @@ -403,6 +431,10 @@ describe('Mosaic Field', () => { ref={contentRef} data-content='field' /> + { expect(labelRef.current).toHaveAttribute('for', 'name'); expect(descriptionRef.current).toHaveAttribute('title', 'Help'); expect(contentRef.current).toHaveAttribute('data-content', 'field'); + expect(headerRef.current).toHaveAttribute('data-header', 'field'); expect(errorRef.current).toHaveAttribute('role', 'status'); }); @@ -431,12 +464,17 @@ describe('Mosaic Field', () => { xstyle={overrides.content} data-testid='content' /> + Error , ); expect(screen.getByTestId('root')).toHaveClass('cl-field-root', ...atoms(overrides.root)); expect(screen.getByTestId('content')).toHaveClass('cl-field-content', ...atoms(overrides.content)); + expect(screen.getByTestId('header')).toHaveClass('cl-field-header', ...atoms(overrides.header)); expect(screen.getByText('Email')).toHaveClass('cl-field-label', ...atoms(overrides.label)); expect(screen.getByText('Description')).toHaveClass('cl-field-description', ...atoms(overrides.description)); expect(screen.getByText('Error').closest('p')).toHaveClass('cl-field-error', ...atoms(overrides.error)); @@ -471,6 +509,7 @@ describe('Mosaic Field', () => {
}>Description
}>Content +
}>Header
}>Error , ); @@ -478,6 +517,7 @@ describe('Mosaic Field', () => { expect(screen.getByText('Biography').closest('section')).not.toBeNull(); expect(screen.getByText('Description').tagName).toBe('DIV'); expect(screen.getByText('Content').tagName).toBe('SECTION'); + expect(screen.getByText('Header').tagName).toBe('HEADER'); expect(screen.getByText('Error').closest('div')).toHaveClass('cl-field-error'); }); diff --git a/packages/mosaic/src/components/field/field.styles.ts b/packages/mosaic/src/components/field/field.styles.ts index 088766a8c43..b897a8c651d 100644 --- a/packages/mosaic/src/components/field/field.styles.ts +++ b/packages/mosaic/src/components/field/field.styles.ts @@ -13,6 +13,13 @@ export const styles = stylex.create({ alignItems: 'flex-start', flexDirection: 'row', }, + header: { + alignItems: 'baseline', + columnGap: space['2'], + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, content: { '--_cl-feedback-gap': space['1'], gap: space['1'], diff --git a/packages/mosaic/src/components/field/field.tsx b/packages/mosaic/src/components/field/field.tsx index 0703224c391..5b8a301591f 100644 --- a/packages/mosaic/src/components/field/field.tsx +++ b/packages/mosaic/src/components/field/field.tsx @@ -142,6 +142,21 @@ const Label = React.forwardRef(function MosaicFiel }); }); +/** Props for the row that puts a field's label at the start and an action, such as a link, at the end. */ +export type FieldHeaderProps = MosaicComponentProps<'div'>; + +const Header = React.forwardRef(function MosaicFieldHeader( + { render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: mergeStyleProps(themeProps('field-header'), stylex.props(reset.base, styles.header, xstyle), rest), + }); +}); + /** Props for the container that stacks a horizontal field's label and supporting text. */ export type FieldContentProps = MosaicComponentProps<'div'>; @@ -326,6 +341,7 @@ const Feedback = React.forwardRef(function M /** Styled parts for composing an automatically associated single-control field. */ export const Field = { Root, + Header, Label, Content, Description, diff --git a/packages/mosaic/src/components/field/index.ts b/packages/mosaic/src/components/field/index.ts index 9daf83fd93f..4fa95d14164 100644 --- a/packages/mosaic/src/components/field/index.ts +++ b/packages/mosaic/src/components/field/index.ts @@ -3,6 +3,7 @@ export type { FieldDescriptionProps, FieldErrorProps, FieldFeedbackProps, + FieldHeaderProps, FieldHintProps, FieldLabelProps, FieldMessageProps, diff --git a/packages/swingset/src/stories/field.component.mdx b/packages/swingset/src/stories/field.component.mdx index 0c6db42e283..46819c06398 100644 --- a/packages/swingset/src/stories/field.component.mdx +++ b/packages/swingset/src/stories/field.component.mdx @@ -65,6 +65,15 @@ Wrap `Field.Error` in `Field.Message` and pass the message as its children. `Fie `Field.Success` is the positive counterpart. Render both inside one `Field.Message` and pass each its own message; when one replaces the other, the outgoing message fades out over the incoming one while the container height moves once from the old height to the new. +### Header action + + + +Wrap `Field.Label` in `Field.Header` to put an action, such as a link-style `Button`, at the end of the label's row. The action sits outside the `label`, so pressing it doesn't focus the control. + ### Controls without a native label + + Password + + + + + ); +} + export function VisuallyHiddenLabel() { return ( From aecc6775b2547e4625c16fc38ee463fd8b7b6815 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 12:14:25 -0700 Subject: [PATCH 02/17] feat(mosaic): add phone, username and email-or-username sign-in identifier fields --- .../mosaic-sign-in-identifier-fields.md | 2 + .../sign-in/__tests__/sign-in.utils.test.ts | 38 ++++++++++++- packages/mosaic/src/features/sign-in/index.ts | 1 + .../src/features/sign-in/sign-in.messages.ts | 13 +++++ .../src/features/sign-in/sign-in.styles.ts | 4 -- .../src/features/sign-in/sign-in.types.ts | 5 +- .../src/features/sign-in/sign-in.utils.ts | 29 +++++++++- .../src/features/sign-in/sign-in.view.tsx | 44 ++++++++------ .../sign-in/steps/identifier-step.tsx | 57 +++++++++++++++---- .../sign-in/steps/sign-in-password.tsx | 28 ++++----- packages/swingset/src/stories/sign-in.mdx | 6 +- .../swingset/src/stories/sign-in.stories.tsx | 47 +++++++++++---- 12 files changed, 213 insertions(+), 61 deletions(-) create mode 100644 .changeset/mosaic-sign-in-identifier-fields.md diff --git a/.changeset/mosaic-sign-in-identifier-fields.md b/.changeset/mosaic-sign-in-identifier-fields.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-sign-in-identifier-fields.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts index d9cacdc2b38..a28027df1fd 100644 --- a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts +++ b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts @@ -2,12 +2,20 @@ import { describe, expect, it } from 'vitest'; import { signInMessages } from '../sign-in.messages'; import type { SignInStep } from '../sign-in.types'; -import { codeCopy, codeIdentity, codeNotice, codeSecondaryAction, httpsUrl, stepKey } from '../sign-in.utils'; +import { + codeCopy, + codeIdentity, + codeNotice, + codeSecondaryAction, + httpsUrl, + identifierFieldCopy, + stepKey, +} from '../sign-in.utils'; describe('stepKey', () => { it('keys each step by its kind and the variant that changes its copy', () => { const steps: [SignInStep, string][] = [ - [{ kind: 'identifier' }, 'identifier'], + [{ kind: 'identifier', field: 'email' }, 'identifier'], [{ kind: 'credential', credential: 'password', identifier: 'jane@acme.com' }, 'credential:password'], [{ kind: 'credential', credential: 'backup-code', identifier: 'jane@acme.com' }, 'credential:backup-code'], [{ kind: 'credential', credential: 'passkey', identifier: 'jane@acme.com' }, 'credential:passkey'], @@ -104,3 +112,29 @@ describe('code step copy', () => { }); }); }); + +describe('identifierFieldCopy', () => { + it('labels each identifier field and the link that switches to it', () => { + const m = signInMessages; + expect(identifierFieldCopy('email', m)).toEqual({ + label: 'Email address', + placeholder: 'Enter your email address', + switchLabel: 'Use email', + }); + expect(identifierFieldCopy('email-or-username', m)).toEqual({ + label: 'Email address or username', + placeholder: 'Enter email or username', + switchLabel: 'Use email or username', + }); + expect(identifierFieldCopy('username', m)).toEqual({ + label: 'Username', + placeholder: 'Enter your username', + switchLabel: 'Use username', + }); + expect(identifierFieldCopy('phone', m)).toEqual({ + label: 'Phone number', + placeholder: 'Enter your phone number', + switchLabel: 'Use phone', + }); + }); +}); diff --git a/packages/mosaic/src/features/sign-in/index.ts b/packages/mosaic/src/features/sign-in/index.ts index 12162ad0bad..a28cda2df2d 100644 --- a/packages/mosaic/src/features/sign-in/index.ts +++ b/packages/mosaic/src/features/sign-in/index.ts @@ -2,6 +2,7 @@ export { SignInView } from './sign-in.view'; export type { SignInActionBlockedDetails, SignInFactor, + SignInIdentifierField, SignInProvider, SignInStep, SignInSubmitEvent, diff --git a/packages/mosaic/src/features/sign-in/sign-in.messages.ts b/packages/mosaic/src/features/sign-in/sign-in.messages.ts index e591e0edd64..d31c6e335a0 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -8,6 +8,19 @@ export const signInMessages = { divider: 'or', emailLabel: 'Email address', emailPlaceholder: 'Enter your email address', + phoneLabel: 'Phone number', + phonePlaceholder: 'Enter your phone number', + usernameLabel: 'Username', + usernamePlaceholder: 'Enter your username', + emailOrUsernameLabel: 'Email address or username', + emailOrUsernamePlaceholder: 'Enter email or username', + useEmail: 'Use email', + usePhone: 'Use phone', + useUsername: 'Use username', + useEmailOrUsername: 'Use email or username', + selectedCountry: 'Country, {country}', + chooseCountry: 'Choose a country', + searchCountries: 'Search countries', signUpPrompt: 'Don’t have an account?', signUpLink: 'Sign up', }, diff --git a/packages/mosaic/src/features/sign-in/sign-in.styles.ts b/packages/mosaic/src/features/sign-in/sign-in.styles.ts index f1c6baf3267..bde54c86ed9 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.styles.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -44,10 +44,6 @@ export const styles = stylex.create({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', }, - forgotPassword: { - alignSelf: 'start', - justifySelf: 'start', - }, footerText: { textAlign: 'center', }, diff --git a/packages/mosaic/src/features/sign-in/sign-in.types.ts b/packages/mosaic/src/features/sign-in/sign-in.types.ts index a1635b51a87..a33e477b012 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.types.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -16,8 +16,10 @@ export type SignInFactor = type SignInCodeChannel = 'email' | 'phone' | 'totp'; +export type SignInIdentifierField = 'email' | 'email-or-username' | 'username' | 'phone'; + export type SignInStep = - | { kind: 'identifier' } + | { kind: 'identifier'; field: SignInIdentifierField; nextField?: SignInIdentifierField } | { kind: 'credential'; credential: 'password' | 'backup-code' | 'passkey'; identifier: string } | { kind: 'code'; @@ -91,6 +93,7 @@ export interface SignInViewProps { globalErrorMessage?: string; newPasswordFeedback?: FieldFeedback; onEditIdentifier: () => void; + onSwitchField?: () => void; onSignUp?: () => void; onForgotPassword?: () => void; onCancel?: () => void; diff --git a/packages/mosaic/src/features/sign-in/sign-in.utils.ts b/packages/mosaic/src/features/sign-in/sign-in.utils.ts index 546122b086c..dfbc66adc1e 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.utils.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.utils.ts @@ -1,6 +1,6 @@ import type { MosaicMessages } from '../../localization'; import { fill } from '../../localization'; -import type { SignInStep } from './sign-in.types'; +import type { SignInIdentifierField, SignInStep } from './sign-in.types'; export function stepKey(step: SignInStep): string { switch (step.kind) { @@ -151,3 +151,30 @@ export function codeSecondaryAction( } return { label: m.useAnotherMethod, onClick: onCancel }; } + +interface IdentifierFieldCopy { + label: string; + placeholder: string; + switchLabel: string; +} + +export function identifierFieldCopy(field: SignInIdentifierField, m: Messages): IdentifierFieldCopy { + switch (field) { + case 'email': + return { label: m.start.emailLabel, placeholder: m.start.emailPlaceholder, switchLabel: m.start.useEmail }; + case 'email-or-username': + return { + label: m.start.emailOrUsernameLabel, + placeholder: m.start.emailOrUsernamePlaceholder, + switchLabel: m.start.useEmailOrUsername, + }; + case 'username': + return { + label: m.start.usernameLabel, + placeholder: m.start.usernamePlaceholder, + switchLabel: m.start.useUsername, + }; + case 'phone': + return { label: m.start.phoneLabel, placeholder: m.start.phonePlaceholder, switchLabel: m.start.usePhone }; + } +} diff --git a/packages/mosaic/src/features/sign-in/sign-in.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx index b98989a3f64..39df14835b3 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -68,6 +68,7 @@ export function SignInView({ globalErrorMessage, newPasswordFeedback, onEditIdentifier, + onSwitchField, onSignUp, onForgotPassword, onCancel, @@ -106,6 +107,8 @@ export function SignInView({ onClick: onResend, }; + const logo = applicationLogoUrl ? { src: applicationLogoUrl, alt: applicationName } : undefined; + return ( - + {current.kind === 'identifier' ? ( + + ) : null} @@ -213,7 +221,7 @@ export function SignInView({ onSelectFactor={onSelectFactor} onShowHelp={onShowHelp} onBack={onBack} - logo={applicationLogoUrl ? { src: applicationLogoUrl, alt: applicationName } : undefined} + logo={logo} globalErrorMessage={globalErrorMessage} pendingProviderId={pendingProviderId} onSelectProvider={onSelectProvider} @@ -238,7 +246,7 @@ export function SignInView({ focusRef={focusRef} step={current} applicationName={applicationName} - logo={applicationLogoUrl ? { src: applicationLogoUrl, alt: applicationName } : undefined} + logo={logo} globalErrorMessage={globalErrorMessage} isPending={isPending} resend={current.purpose === 'email-link' ? resendFor(m.emailLink) : undefined} diff --git a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx index 7397d687529..4390d17e9da 100644 --- a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx @@ -8,14 +8,19 @@ import { Field } from '../../../components/field'; import { useFlowAutoFocus } from '../../../components/flow'; import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; +import { PhoneInput } from '../../../components/phone-input'; import { ProviderButton, ProviderButtonGroup } from '../../../components/provider-button'; import { Text } from '../../../components/text'; import { fill, useMessages } from '../../../localization'; import { Form } from '../../../primitives/form'; import { styles } from '../sign-in.styles'; -import type { SignInProvider, SignInSubmitEvent, SignInValues } from '../sign-in.types'; +import type { SignInIdentifierField, SignInProvider, SignInSubmitEvent, SignInValues } from '../sign-in.types'; +import { identifierFieldCopy } from '../sign-in.utils'; interface IdentifierStepProps { + field: SignInIdentifierField; + nextField?: SignInIdentifierField; + onSwitchField?: () => void; applicationName: string; combined: boolean; logo?: { src: string; alt: string }; @@ -33,6 +38,9 @@ interface IdentifierStepProps { } export function IdentifierStep({ + field, + nextField, + onSwitchField, applicationName, combined, logo, @@ -50,7 +58,11 @@ export function IdentifierStep({ }: IdentifierStepProps) { const m = useMessages('signIn'); const { ref: identifierFieldRef, ...identifier } = form.register('identifier'); - const identifierRef = useMergeRefs([identifierFieldRef, focusRef, useFlowAutoFocus()]); + const { ref: phoneFieldRef, ...phone } = form.registerValue('identifier'); + const autoFocusRef = useFlowAutoFocus(); + const identifierRef = useMergeRefs([identifierFieldRef, focusRef, autoFocusRef]); + const phoneRef = useMergeRefs([phoneFieldRef, focusRef, autoFocusRef]); + const copy = identifierFieldCopy(field, m); const feedback: FieldFeedback | undefined = errorMessage ? { type: 'error', message: errorMessage } : undefined; return ( @@ -121,14 +133,39 @@ export function IdentifierStep({ disabled={isDisabled} focusableWhenDisabled > - {m.start.emailLabel} - + + {copy.label} + {nextField && onSwitchField ? ( + + ) : null} + + {field === 'phone' ? ( + + ) : ( + + )} diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx index e06f67caa85..994ac301d22 100644 --- a/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-password.tsx @@ -9,7 +9,6 @@ import type { FieldFeedback, UseFormResult } from '../../../components/form'; import { PasswordInput } from '../../../components/password-input'; import { useMessages } from '../../../localization'; import { Form } from '../../../primitives/form'; -import { styles } from '../sign-in.styles'; import type { SignInSubmitEvent, SignInValues } from '../sign-in.types'; import { SignInIdentityPreview } from './sign-in-identity-preview'; @@ -72,7 +71,20 @@ export function SignInPassword({ disabled={isPending} focusableWhenDisabled > - {m.password.label} + + {m.password.label} + {onForgotPassword ? ( + + ) : null} + - {onForgotPassword ? ( - - ) : null} diff --git a/packages/swingset/src/stories/sign-in.mdx b/packages/swingset/src/stories/sign-in.mdx index e7607ce2914..267b20675b6 100644 --- a/packages/swingset/src/stories/sign-in.mdx +++ b/packages/swingset/src/stories/sign-in.mdx @@ -50,7 +50,7 @@ Each section is one component in `features/sign-in/steps/`, and each example und ### Identifier step -`identifier-step.tsx` draws `{ kind: 'identifier' }`: social providers, the email field and, outside the combined flow, the sign-up link. +`identifier-step.tsx` draws `{ kind: 'identifier' }`: social providers, the identifier field and, outside the combined flow, the sign-up link. `field` picks the email, username, email-or-username or phone input, and `nextField` adds the link that switches to it through `onSwitchField`. #### Default @@ -88,6 +88,10 @@ Each section is one component in `features/sign-in/steps/`, and each example und +#### Email or username + + + #### Passkey diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index b88b20bacf7..a9c5761d5d8 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -3,6 +3,7 @@ import type { FieldsConfig } from '@clerk/mosaic/components/form'; import { useForm } from '@clerk/mosaic/components/form'; import type { SignInFactor, + SignInIdentifierField, SignInProvider, SignInStep, SignInSubmitEvent, @@ -109,7 +110,7 @@ function offersResend(step: SignInStep): boolean { type Outcome = { kind: 'signed-in' } | { kind: 'signed-up' } | { kind: 'redirect'; provider: string }; function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void }) { - const [step, setStep] = useState({ kind: 'identifier' }); + const [step, setStep] = useState({ kind: 'identifier', field: 'email' }); const [direction, setDirection] = useState<-1 | 1>(1); const [errorMessage, setErrorMessage] = useState(); const [globalErrorMessage, setGlobalErrorMessage] = useState(); @@ -185,7 +186,7 @@ function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void return; } if (values.password.toLowerCase() === 'locked') { - navigate({ kind: 'identifier' }, -1); + navigate({ kind: 'identifier', field: 'email' }, -1); setGlobalErrorMessage('Your account is locked. You will be able to try again in 30 minutes.'); return; } @@ -395,7 +396,7 @@ function WorkingExample({ onComplete }: { onComplete: (outcome: Outcome) => void } onEditIdentifier={() => { setStage('first'); - navigate({ kind: 'identifier' }, -1); + navigate({ kind: 'identifier', field: 'email' }, -1); }} onForgotPassword={() => navigate({ kind: 'notice', purpose: 'forgot-password', identifier }, 1)} onCancel={cancelFor(step)} @@ -462,6 +463,7 @@ function StepPanel({ providers = socialProviders, identifier, otherMethods = true, + onSwitchField, }: { step: SignInStep; combined?: boolean; @@ -472,6 +474,7 @@ function StepPanel({ providers?: SignInProvider[]; identifier?: string; otherMethods?: boolean; + onSwitchField?: () => void; }) { const attempted = errorMessage && step.kind !== 'identifier' ? 'incorrect' : ''; const form = useForm({ @@ -494,6 +497,7 @@ function StepPanel({ socialProviders={providers} pendingProviderId={pendingProviderId} onSelectProvider={() => undefined} + onSwitchField={onSwitchField} form={form} onSubmit={event => event?.preventDefault()} onCodeComplete={code => form.setValue('code', code)} @@ -520,13 +524,13 @@ function StepPanel({ const passwordStep: SignInStep = { kind: 'credential', credential: 'password', identifier: IDENTIFIER }; export function IdentifierDefault() { - return ; + return ; } export function IdentifierSignInOnly() { return ( ); @@ -535,7 +539,7 @@ export function IdentifierSignInOnly() { export function IdentifierManyProviders() { return ( ); @@ -557,7 +561,7 @@ export function IdentifierProviderPending() { export function IdentifierError() { return ( ); @@ -566,7 +570,7 @@ export function IdentifierError() { export function IdentifierLocked() { return ( 1 ? fields[(index + 1) % fields.length] : undefined; + return ( + setIndex(current => (current + 1) % fields.length)} + /> + ); +} + +export function IdentifierPhone() { + return ; +} + +export function IdentifierUsername() { + return ; +} + +export function IdentifierEmailOrUsername() { + return ; +} + export function IdentifierBannerError() { return ( ); From 8f6824a89863bd16abb2017749e604faaaa121be Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 12:50:13 -0700 Subject: [PATCH 03/17] feat(mosaic): add the sign-in passkey link to the identifier step --- .../src/features/sign-in/sign-in.messages.ts | 1 + .../mosaic/src/features/sign-in/sign-in.styles.ts | 3 +++ .../mosaic/src/features/sign-in/sign-in.types.ts | 1 + .../mosaic/src/features/sign-in/sign-in.view.tsx | 2 ++ .../src/features/sign-in/steps/identifier-step.tsx | 14 ++++++++++++++ packages/swingset/src/stories/sign-in.stories.tsx | 12 ++++++++++++ 6 files changed, 33 insertions(+) diff --git a/packages/mosaic/src/features/sign-in/sign-in.messages.ts b/packages/mosaic/src/features/sign-in/sign-in.messages.ts index d31c6e335a0..2723c2fe640 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -21,6 +21,7 @@ export const signInMessages = { selectedCountry: 'Country, {country}', chooseCountry: 'Choose a country', searchCountries: 'Search countries', + usePasskey: 'Use passkey instead', signUpPrompt: 'Don’t have an account?', signUpLink: 'Sign up', }, diff --git a/packages/mosaic/src/features/sign-in/sign-in.styles.ts b/packages/mosaic/src/features/sign-in/sign-in.styles.ts index bde54c86ed9..be224ae5826 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.styles.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -44,6 +44,9 @@ export const styles = stylex.create({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', }, + passkeyAction: { + alignSelf: 'center', + }, footerText: { textAlign: 'center', }, diff --git a/packages/mosaic/src/features/sign-in/sign-in.types.ts b/packages/mosaic/src/features/sign-in/sign-in.types.ts index a33e477b012..6050ff6f60a 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.types.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -84,6 +84,7 @@ export interface SignInViewProps { socialProviders: readonly SignInProvider[]; pendingProviderId?: string; onSelectProvider: (id: string) => void; + onPasskey?: () => void; form: UseFormResult; onSubmit: (event?: SignInSubmitEvent) => void; onCodeComplete: (code: string) => void; diff --git a/packages/mosaic/src/features/sign-in/sign-in.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx index 39df14835b3..243cec9d698 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -59,6 +59,7 @@ export function SignInView({ socialProviders, pendingProviderId, onSelectProvider, + onPasskey, form, onSubmit, onCodeComplete, @@ -133,6 +134,7 @@ export function SignInView({ socialProviders={socialProviders} pendingProviderId={pendingProviderId} onSelectProvider={onSelectProvider} + onPasskey={onPasskey} form={form} errorMessage={errorMessage} globalErrorMessage={globalErrorMessage} diff --git a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx index 4390d17e9da..ca8d28dfa60 100644 --- a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx @@ -27,6 +27,7 @@ interface IdentifierStepProps { socialProviders: readonly SignInProvider[]; pendingProviderId?: string; onSelectProvider: (id: string) => void; + onPasskey?: () => void; form: UseFormResult; errorMessage?: string; globalErrorMessage?: string; @@ -47,6 +48,7 @@ export function IdentifierStep({ socialProviders, pendingProviderId, onSelectProvider, + onPasskey, form, errorMessage, globalErrorMessage, @@ -169,6 +171,18 @@ export function IdentifierStep({ + {onPasskey ? ( + + ) : null} {onSignUp ? ( void; + onPasskey?: () => void; }) { const attempted = errorMessage && step.kind !== 'identifier' ? 'incorrect' : ''; const form = useForm({ @@ -498,6 +500,7 @@ function StepPanel({ pendingProviderId={pendingProviderId} onSelectProvider={() => undefined} onSwitchField={onSwitchField} + onPasskey={onPasskey} form={form} onSubmit={event => event?.preventDefault()} onCodeComplete={code => form.setValue('code', code)} @@ -597,6 +600,15 @@ export function IdentifierUsername() { return ; } +export function IdentifierPasskey() { + return ( + undefined} + /> + ); +} + export function IdentifierEmailOrUsername() { return ; } From b905042bd0345b20d1f4ee86965329b0734128e4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 13:01:57 -0700 Subject: [PATCH 04/17] feat(mosaic): add the sign-in alternative phone channel screen --- .../sign-in/__tests__/sign-in.utils.test.ts | 8 ++ .../src/features/sign-in/sign-in.messages.ts | 5 + .../src/features/sign-in/sign-in.styles.ts | 4 + .../src/features/sign-in/sign-in.types.ts | 1 + .../src/features/sign-in/sign-in.utils.ts | 1 + .../src/features/sign-in/sign-in.view.tsx | 6 +- .../sign-in/steps/identifier-step.tsx | 49 ++++++- .../steps/sign-in-alternative-channel.tsx | 126 ++++++++++++++++++ .../swingset/src/stories/sign-in.stories.tsx | 31 ++++- 9 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 packages/mosaic/src/features/sign-in/steps/sign-in-alternative-channel.tsx diff --git a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts index a28027df1fd..69d9d88620b 100644 --- a/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts +++ b/packages/mosaic/src/features/sign-in/__tests__/sign-in.utils.test.ts @@ -16,6 +16,14 @@ describe('stepKey', () => { it('keys each step by its kind and the variant that changes its copy', () => { const steps: [SignInStep, string][] = [ [{ kind: 'identifier', field: 'email' }, 'identifier'], + [ + { + kind: 'identifier', + field: 'phone', + channel: { id: 'whatsapp', name: 'WhatsApp', iconUrl: 'https://img.clerk.com/static/whatsapp.svg' }, + }, + 'identifier:channel', + ], [{ kind: 'credential', credential: 'password', identifier: 'jane@acme.com' }, 'credential:password'], [{ kind: 'credential', credential: 'backup-code', identifier: 'jane@acme.com' }, 'credential:backup-code'], [{ kind: 'credential', credential: 'passkey', identifier: 'jane@acme.com' }, 'credential:passkey'], diff --git a/packages/mosaic/src/features/sign-in/sign-in.messages.ts b/packages/mosaic/src/features/sign-in/sign-in.messages.ts index 2723c2fe640..50fe5493807 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -25,6 +25,11 @@ export const signInMessages = { signUpPrompt: 'Don’t have an account?', signUpLink: 'Sign up', }, + alternativeChannel: { + title: 'Sign in to {applicationName} with {provider}', + description: 'Enter your phone number to get a verification code on {provider}.', + label: '{provider} phone number', + }, password: { title: 'Enter your password', description: 'Enter the password associated with your account', diff --git a/packages/mosaic/src/features/sign-in/sign-in.styles.ts b/packages/mosaic/src/features/sign-in/sign-in.styles.ts index be224ae5826..08179e01fc9 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.styles.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -15,6 +15,10 @@ export const styles = stylex.create({ height: space['4'], width: space['4'], }, + channelIcon: { + height: space['8'], + width: space['8'], + }, divider: { gap: space['4'], alignItems: 'center', diff --git a/packages/mosaic/src/features/sign-in/sign-in.types.ts b/packages/mosaic/src/features/sign-in/sign-in.types.ts index 6050ff6f60a..e4a7e7bdff2 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.types.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -20,6 +20,7 @@ export type SignInIdentifierField = 'email' | 'email-or-username' | 'username' | export type SignInStep = | { kind: 'identifier'; field: SignInIdentifierField; nextField?: SignInIdentifierField } + | { kind: 'identifier'; field: 'phone'; channel: SignInProvider } | { kind: 'credential'; credential: 'password' | 'backup-code' | 'passkey'; identifier: string } | { kind: 'code'; diff --git a/packages/mosaic/src/features/sign-in/sign-in.utils.ts b/packages/mosaic/src/features/sign-in/sign-in.utils.ts index dfbc66adc1e..50353329140 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.utils.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.utils.ts @@ -5,6 +5,7 @@ import type { SignInIdentifierField, SignInStep } from './sign-in.types'; export function stepKey(step: SignInStep): string { switch (step.kind) { case 'identifier': + return 'channel' in step ? 'identifier:channel' : 'identifier'; case 'sign-up': return step.kind; case 'credential': diff --git a/packages/mosaic/src/features/sign-in/sign-in.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx index 243cec9d698..f5c226c0387 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -121,11 +121,11 @@ export function SignInView({ const secondary = current.kind === 'code' ? codeSecondaryAction(current, m, { onCancel, onBack }) : undefined; return ( <> - + {current.kind === 'identifier' ? ( ; + +interface IdentifierStepProps extends Omit { + step: IdentifierStepData; + onCancel?: () => void; +} + +export function IdentifierStep({ step, onCancel, ...props }: IdentifierStepProps) { + if ('channel' in step) { + return ( + + ); + } + return ( + + ); +} + +interface IdentifierStartProps { field: SignInIdentifierField; nextField?: SignInIdentifierField; onSwitchField?: () => void; @@ -38,7 +79,7 @@ interface IdentifierStepProps { focusRef?: Ref; } -export function IdentifierStep({ +function IdentifierStart({ field, nextField, onSwitchField, @@ -57,7 +98,7 @@ export function IdentifierStep({ onSubmit, onSignUp, focusRef, -}: IdentifierStepProps) { +}: IdentifierStartProps) { const m = useMessages('signIn'); const { ref: identifierFieldRef, ...identifier } = form.register('identifier'); const { ref: phoneFieldRef, ...phone } = form.registerValue('identifier'); diff --git a/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-channel.tsx b/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-channel.tsx new file mode 100644 index 00000000000..09780339695 --- /dev/null +++ b/packages/mosaic/src/features/sign-in/steps/sign-in-alternative-channel.tsx @@ -0,0 +1,126 @@ +import { useMergeRefs } from '@floating-ui/react'; +import * as stylex from '@stylexjs/stylex'; +import type { Ref } from 'react'; + +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Field } from '../../../components/field'; +import { useFlowAutoFocus } from '../../../components/flow'; +import type { FieldFeedback, UseFormResult } from '../../../components/form'; +import { PhoneInput } from '../../../components/phone-input'; +import { fill, useMessages } from '../../../localization'; +import { Form } from '../../../primitives/form'; +import { styles } from '../sign-in.styles'; +import type { SignInProvider, SignInSubmitEvent, SignInValues } from '../sign-in.types'; + +interface SignInAlternativeChannelProps { + channel: SignInProvider; + applicationName: string; + logo?: { src: string; alt: string }; + form: UseFormResult; + errorMessage?: string; + globalErrorMessage?: string; + isPending: boolean; + isDisabled: boolean; + onSubmit: (event: SignInSubmitEvent) => void; + onCancel?: () => void; + focusRef?: Ref; +} + +export function SignInAlternativeChannel({ + channel, + applicationName, + logo, + form, + errorMessage, + globalErrorMessage, + isPending, + isDisabled, + onSubmit, + onCancel, + focusRef, +}: SignInAlternativeChannelProps) { + const m = useMessages('signIn'); + const { ref: phoneFieldRef, ...phone } = form.registerValue('identifier'); + const phoneRef = useMergeRefs([phoneFieldRef, focusRef, useFlowAutoFocus()]); + const feedback: FieldFeedback | undefined = errorMessage ? { type: 'error', message: errorMessage } : undefined; + const provider = channel.name; + + return ( + <> + + {logo ? ( + + {logo.alt} + + ) : null} + + + + {fill(m.alternativeChannel.title, { applicationName, provider })} + {fill(m.alternativeChannel.description, { provider })} + + + {globalErrorMessage} + + +
+ + {fill(m.alternativeChannel.label, { provider })} + + + +
+
+ + {onCancel ? ( + + ) : null} + + {m.continue} + + + + ); +} diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index cdc2b1ccf10..ba8ae4d2da5 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -465,6 +465,8 @@ function StepPanel({ otherMethods = true, onSwitchField, onPasskey, + onSelectProvider, + onCancel, }: { step: SignInStep; combined?: boolean; @@ -477,6 +479,8 @@ function StepPanel({ otherMethods?: boolean; onSwitchField?: () => void; onPasskey?: () => void; + onSelectProvider?: (id: string) => void; + onCancel?: () => void; }) { const attempted = errorMessage && step.kind !== 'identifier' ? 'incorrect' : ''; const form = useForm({ @@ -498,7 +502,7 @@ function StepPanel({ combined={combined} socialProviders={providers} pendingProviderId={pendingProviderId} - onSelectProvider={() => undefined} + onSelectProvider={onSelectProvider ?? (() => undefined)} onSwitchField={onSwitchField} onPasskey={onPasskey} form={form} @@ -512,7 +516,8 @@ function StepPanel({ onSignUp={combined ? undefined : () => undefined} onForgotPassword={() => undefined} onCancel={ - !otherMethods || (step.kind === 'code' && step.purpose === 'verify-email') ? undefined : () => undefined + onCancel ?? + (!otherMethods || (step.kind === 'code' && step.purpose === 'verify-email') ? undefined : () => undefined) } onShowHelp={() => undefined} onBack={() => undefined} @@ -609,6 +614,28 @@ export function IdentifierPasskey() { ); } +const whatsApp: SignInProvider = { + id: 'whatsapp', + name: 'WhatsApp', + iconUrl: 'https://img.clerk.com/static/whatsapp.svg', +}; + +export function IdentifierAlternativePhoneChannel() { + const [step, setStep] = useState({ kind: 'identifier', field: 'phone', channel: whatsApp }); + return ( + { + if (id === whatsApp.id) { + setStep({ kind: 'identifier', field: 'phone', channel: whatsApp }); + } + }} + onCancel={() => setStep({ kind: 'identifier', field: 'email' })} + /> + ); +} + export function IdentifierEmailOrUsername() { return ; } From 88edc36e196bc7f9e41e7d199ca742898ac78ecb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 13:07:24 -0700 Subject: [PATCH 05/17] feat(mosaic): mark the last used sign-in provider and identifier --- .../src/features/sign-in/sign-in.messages.ts | 1 + .../src/features/sign-in/sign-in.types.ts | 3 +- .../src/features/sign-in/sign-in.view.tsx | 2 + .../sign-in/steps/identifier-step.tsx | 118 ++++++++++++------ packages/swingset/src/stories/sign-in.mdx | 4 + .../swingset/src/stories/sign-in.stories.tsx | 17 +++ 6 files changed, 107 insertions(+), 38 deletions(-) diff --git a/packages/mosaic/src/features/sign-in/sign-in.messages.ts b/packages/mosaic/src/features/sign-in/sign-in.messages.ts index 50fe5493807..c8c563fd896 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.messages.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.messages.ts @@ -6,6 +6,7 @@ export const signInMessages = { descriptionCombined: 'Welcome! Sign in or create an account to continue', socialButton: 'Continue with {provider}', divider: 'or', + lastUsed: 'Last used', emailLabel: 'Email address', emailPlaceholder: 'Enter your email address', phoneLabel: 'Phone number', diff --git a/packages/mosaic/src/features/sign-in/sign-in.types.ts b/packages/mosaic/src/features/sign-in/sign-in.types.ts index e4a7e7bdff2..4e3f7bee9dd 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.types.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.types.ts @@ -19,7 +19,7 @@ type SignInCodeChannel = 'email' | 'phone' | 'totp'; export type SignInIdentifierField = 'email' | 'email-or-username' | 'username' | 'phone'; export type SignInStep = - | { kind: 'identifier'; field: SignInIdentifierField; nextField?: SignInIdentifierField } + | { kind: 'identifier'; field: SignInIdentifierField; nextField?: SignInIdentifierField; lastUsed?: boolean } | { kind: 'identifier'; field: 'phone'; channel: SignInProvider } | { kind: 'credential'; credential: 'password' | 'backup-code' | 'passkey'; identifier: string } | { @@ -84,6 +84,7 @@ export interface SignInViewProps { combined: boolean; socialProviders: readonly SignInProvider[]; pendingProviderId?: string; + lastUsedProviderId?: string; onSelectProvider: (id: string) => void; onPasskey?: () => void; form: UseFormResult; diff --git a/packages/mosaic/src/features/sign-in/sign-in.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx index f5c226c0387..364fcc52f89 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -58,6 +58,7 @@ export function SignInView({ combined, socialProviders, pendingProviderId, + lastUsedProviderId, onSelectProvider, onPasskey, form, @@ -133,6 +134,7 @@ export function SignInView({ combined={combined} socialProviders={socialProviders} pendingProviderId={pendingProviderId} + lastUsedProviderId={lastUsedProviderId} onSelectProvider={onSelectProvider} onPasskey={onPasskey} form={form} diff --git a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx index f91b4539ecf..171cc4e86e1 100644 --- a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx @@ -2,6 +2,7 @@ import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { Ref } from 'react'; +import { Badge } from '../../../components/badge'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import { Field } from '../../../components/field'; @@ -26,7 +27,7 @@ import { SignInAlternativeChannel } from './sign-in-alternative-channel'; type IdentifierStepData = Extract; -interface IdentifierStepProps extends Omit { +interface IdentifierStepProps extends Omit { step: IdentifierStepData; onCancel?: () => void; } @@ -53,6 +54,7 @@ export function IdentifierStep({ step, onCancel, ...props }: IdentifierStepProps ); @@ -61,6 +63,8 @@ export function IdentifierStep({ step, onCancel, ...props }: IdentifierStepProps interface IdentifierStartProps { field: SignInIdentifierField; nextField?: SignInIdentifierField; + lastUsed?: boolean; + lastUsedProviderId?: string; onSwitchField?: () => void; applicationName: string; combined: boolean; @@ -82,6 +86,8 @@ interface IdentifierStartProps { function IdentifierStart({ field, nextField, + lastUsed = false, + lastUsedProviderId, onSwitchField, applicationName, combined, @@ -130,42 +136,13 @@ function IdentifierStart({ {globalErrorMessage} - {socialProviders.length > 0 ? ( - <> - - {socialProviders.map(provider => ( - - } - longLabel={fill(m.start.socialButton, { provider: provider.name })} - shortLabel={provider.name} - isPending={pendingProviderId === provider.id} - pendingLabel={m.verifying} - onClick={() => onSelectProvider(provider.id)} - /> - ))} - -
- - - {m.start.divider} - - -
- - ) : null} +
{identifierFieldCopy(nextField, m).switchLabel} + ) : lastUsed ? ( + {m.start.lastUsed} ) : null} {field === 'phone' ? ( @@ -257,3 +236,68 @@ function IdentifierStart({ ); } + +interface SignInProvidersProps { + providers: readonly SignInProvider[]; + pendingProviderId?: string; + lastUsedProviderId?: string; + isDisabled: boolean; + onSelectProvider: (id: string) => void; +} + +function SignInProviders({ + providers, + pendingProviderId, + lastUsedProviderId, + isDisabled, + onSelectProvider, +}: SignInProvidersProps) { + const m = useMessages('signIn'); + if (providers.length === 0) { + return null; + } + const lastUsedProvider = providers.find(provider => provider.id === lastUsedProviderId); + const otherProviders = providers.filter(provider => provider !== lastUsedProvider); + const providerButton = (provider: SignInProvider, badge?: string) => ( + + } + longLabel={fill(m.start.socialButton, { provider: provider.name })} + shortLabel={provider.name} + badge={badge} + isPending={pendingProviderId === provider.id} + pendingLabel={m.verifying} + onClick={() => onSelectProvider(provider.id)} + /> + ); + + return ( + <> + + {otherProviders.map(provider => providerButton(provider))} + +
+ + + {m.start.divider} + + +
+ + ); +} diff --git a/packages/swingset/src/stories/sign-in.mdx b/packages/swingset/src/stories/sign-in.mdx index 267b20675b6..106f4e5fe52 100644 --- a/packages/swingset/src/stories/sign-in.mdx +++ b/packages/swingset/src/stories/sign-in.mdx @@ -108,6 +108,10 @@ Each section is one component in `features/sign-in/steps/`, and each example und +#### Last used identifier + + + #### Captcha diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index ba8ae4d2da5..d222ea6a1c7 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -467,6 +467,7 @@ function StepPanel({ onPasskey, onSelectProvider, onCancel, + lastUsedProviderId, }: { step: SignInStep; combined?: boolean; @@ -481,6 +482,7 @@ function StepPanel({ onPasskey?: () => void; onSelectProvider?: (id: string) => void; onCancel?: () => void; + lastUsedProviderId?: string; }) { const attempted = errorMessage && step.kind !== 'identifier' ? 'incorrect' : ''; const form = useForm({ @@ -502,6 +504,7 @@ function StepPanel({ combined={combined} socialProviders={providers} pendingProviderId={pendingProviderId} + lastUsedProviderId={lastUsedProviderId} onSelectProvider={onSelectProvider ?? (() => undefined)} onSwitchField={onSwitchField} onPasskey={onPasskey} @@ -636,6 +639,20 @@ export function IdentifierAlternativePhoneChannel() { ); } +export function IdentifierLastUsed() { + return ( + + ); +} + +export function IdentifierLastUsedField() { + return ; +} + export function IdentifierEmailOrUsername() { return ; } From e649fe084ebd40c931737d0007b07084b6e78c7f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 13:42:58 -0700 Subject: [PATCH 06/17] test(mosaic): drop class checks from the Field.Header tests --- .../mosaic/src/components/field/__tests__/field.test.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/mosaic/src/components/field/__tests__/field.test.tsx b/packages/mosaic/src/components/field/__tests__/field.test.tsx index 6baf732fa04..26d35fa6887 100644 --- a/packages/mosaic/src/components/field/__tests__/field.test.tsx +++ b/packages/mosaic/src/components/field/__tests__/field.test.tsx @@ -18,7 +18,6 @@ const overrides = stylex.create({ label: { fontWeight: 700 }, description: { opacity: 0.8 }, content: { gap: '4px' }, - header: { columnGap: '4px' }, error: { fontWeight: 600 }, }); @@ -244,7 +243,7 @@ describe('Mosaic Field', () => { const onAction = vi.fn(); render( - + Password ) : null} - {onSignUp ? ( - - {m.start.signUpPrompt}{' '} - - - ) : null} +
); } + +interface StartPromptProps { + onSignUp?: () => void; + onJoinWaitlist?: () => void; + isDisabled: boolean; +} + +function StartPrompt({ onSignUp, onJoinWaitlist, isDisabled }: StartPromptProps) { + const m = useMessages('signIn'); + const prompt = onJoinWaitlist + ? { text: m.start.waitlistPrompt, link: m.start.waitlistLink, onClick: onJoinWaitlist } + : onSignUp + ? { text: m.start.signUpPrompt, link: m.start.signUpLink, onClick: onSignUp } + : undefined; + if (!prompt) { + return null; + } + + return ( + + {prompt.text}{' '} + + + ); +} diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index 51419f0dae4..e4449cf094f 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -469,6 +469,7 @@ function StepPanel({ onSelectProvider, onCancel, lastUsedProviderId, + onJoinWaitlist, }: { step: SignInStep; combined?: boolean; @@ -484,6 +485,7 @@ function StepPanel({ onSelectProvider?: (id: string) => void; onCancel?: () => void; lastUsedProviderId?: string; + onJoinWaitlist?: () => void; }) { const attempted = errorMessage && step.kind !== 'identifier' ? 'incorrect' : ''; const form = useForm({ @@ -518,6 +520,7 @@ function StepPanel({ globalErrorMessage={globalErrorMessage} onEditIdentifier={() => undefined} onSignUp={combined ? undefined : () => undefined} + onJoinWaitlist={onJoinWaitlist} onForgotPassword={() => undefined} onCancel={ onCancel ?? @@ -664,6 +667,16 @@ export function IdentifierCaptcha() { ); } +export function IdentifierWaitlist() { + return ( + undefined} + /> + ); +} + export function IdentifierEmailOrUsername() { return ; } From ba9ec4b12a0fcefa67aa361c9e76e88d14381ff9 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 14:23:55 -0700 Subject: [PATCH 09/17] docs(swingset): show the sign-in-only identifier stories that legacy is compared with --- .../swingset/src/stories/sign-in.stories.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/sign-in.stories.tsx b/packages/swingset/src/stories/sign-in.stories.tsx index e4449cf094f..9151f2181ca 100644 --- a/packages/swingset/src/stories/sign-in.stories.tsx +++ b/packages/swingset/src/stories/sign-in.stories.tsx @@ -598,6 +598,7 @@ function SwitchingIdentifier({ fields }: { fields: readonly SignInIdentifierFiel const next = fields.length > 1 ? fields[(index + 1) % fields.length] : undefined; return ( setIndex(current => (current + 1) % fields.length)} /> @@ -615,6 +616,7 @@ export function IdentifierUsername() { export function IdentifierPasskey() { return ( undefined} /> @@ -631,6 +633,7 @@ export function IdentifierAlternativePhoneChannel() { const [step, setStep] = useState({ kind: 'identifier', field: 'phone', channel: whatsApp }); return ( { @@ -646,6 +649,7 @@ export function IdentifierAlternativePhoneChannel() { export function IdentifierLastUsed() { return ( ; + return ( + + ); } export function IdentifierCaptcha() { @@ -662,7 +671,10 @@ export function IdentifierCaptcha() { useTestTurnstile(() => ref.current?.querySelector('.cl-captcha')); return (
- +
); } From 076aa152197ec8e75c0ba63f57ae1bb6dd110af8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 14:47:27 -0700 Subject: [PATCH 10/17] feat(mosaic): report when the Captcha challenge turns interactive --- .../captcha/__tests__/captcha.test.tsx | 16 +++++++++++-- .../mosaic/src/components/captcha/captcha.tsx | 23 ++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx index 9e298b89703..52ef0ae0038 100644 --- a/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx +++ b/packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx @@ -1,6 +1,6 @@ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Captcha } from '../captcha'; @@ -31,4 +31,16 @@ describe('Captcha', () => { expect(element).toHaveAttribute('data-cl-interactive', 'true'); expect(element).toHaveAttribute('data-cl-theme', 'dark'); }); + + it('reports when clerk-js starts and stops showing an interactive challenge', async () => { + const onInteractiveChange = vi.fn(); + const { container } = render(); + const element = container.querySelector('#clerk-captcha'); + + element?.setAttribute('data-cl-interactive', 'true'); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(true)); + element?.removeAttribute('data-cl-interactive'); + await waitFor(() => expect(onInteractiveChange).toHaveBeenLastCalledWith(false)); + expect(onInteractiveChange).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/mosaic/src/components/captcha/captcha.tsx b/packages/mosaic/src/components/captcha/captcha.tsx index 36c389fcd44..2d3cf668798 100644 --- a/packages/mosaic/src/components/captcha/captcha.tsx +++ b/packages/mosaic/src/components/captcha/captcha.tsx @@ -1,5 +1,6 @@ import { CAPTCHA_ELEMENT_ID } from '@clerk/shared/internal/clerk-js/constants'; import { useSafeLayoutEffect } from '@clerk/shared/react'; +import { useLatestRef } from '@floating-ui/react/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -17,6 +18,8 @@ export interface CaptchaProps extends Omit, 'id' | ' size?: 'normal' | 'flexible' | 'compact'; /** The Turnstile widget's language, an ISO 639-1 code such as `en` or `en-us`. Turnstile follows the visitor's language when it is unset. */ language?: string; + /** Called when Turnstile starts or stops asking the visitor to solve an interactive challenge, so the caller can make room for it. */ + onInteractiveChange?: (interactive: boolean) => void; } /** @@ -30,7 +33,7 @@ export interface CaptchaProps extends Omit, 'id' | ' * */ export const Captcha = React.forwardRef(function MosaicCaptcha( - { render, xstyle, theme, size, language, ...rest }, + { render, xstyle, theme, size, language, onInteractiveChange, ...rest }, ref, ) { const [element, setElement] = React.useState(null); @@ -43,6 +46,24 @@ export const Captcha = React.forwardRef(function M setSchemeTheme(themeFromColorScheme(getComputedStyle(element).colorScheme)); }); + const onInteractiveChangeRef = useLatestRef(onInteractiveChange); + + React.useEffect(() => { + if (!element) { + return undefined; + } + let interactive = element.hasAttribute('data-cl-interactive'); + const observer = new MutationObserver(() => { + const next = element.hasAttribute('data-cl-interactive'); + if (next !== interactive) { + interactive = next; + onInteractiveChangeRef.current?.(next); + } + }); + observer.observe(element, { attributes: true, attributeFilter: ['data-cl-interactive'] }); + return () => observer.disconnect(); + }, [element, onInteractiveChangeRef]); + return useRender({ defaultTagName: 'div', render, From 4a0da64a7ac9c42865b3be8cd150a052a24ab830 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 14:51:04 -0700 Subject: [PATCH 11/17] feat(mosaic): spotlight the Captcha challenge on the sign-in identifier step --- .../src/features/sign-in/sign-in.styles.ts | 6 + .../sign-in/steps/identifier-step.tsx | 122 ++++++++++-------- 2 files changed, 72 insertions(+), 56 deletions(-) diff --git a/packages/mosaic/src/features/sign-in/sign-in.styles.ts b/packages/mosaic/src/features/sign-in/sign-in.styles.ts index 08179e01fc9..2d40a8d0c04 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.styles.ts +++ b/packages/mosaic/src/features/sign-in/sign-in.styles.ts @@ -48,6 +48,12 @@ export const styles = stylex.create({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', }, + spotlightGroup: { + display: 'contents', + }, + spotlightHidden: { + display: 'none', + }, passkeyAction: { alignSelf: 'center', }, diff --git a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx index 1ad583d4192..e51bdb999fb 100644 --- a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx @@ -1,6 +1,7 @@ +import { inertProps } from '@clerk/shared/inert'; import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; -import type { Ref } from 'react'; +import React, { type Ref } from 'react'; import { Badge } from '../../../components/badge'; import { Button, SubmitButton } from '../../../components/button'; @@ -115,6 +116,7 @@ function IdentifierStart({ const identifierRef = useMergeRefs([identifierFieldRef, focusRef, autoFocusRef]); const phoneRef = useMergeRefs([phoneFieldRef, focusRef, autoFocusRef]); const copy = identifierFieldCopy(field, m); + const [isChallengeShown, setIsChallengeShown] = React.useState(false); const feedback: FieldFeedback | undefined = errorMessage ? { type: 'error', message: errorMessage } : undefined; return ( @@ -139,62 +141,67 @@ function IdentifierStart({ {globalErrorMessage} - -
- + - - {copy.label} - {nextField && onSwitchField ? ( - - ) : lastUsed ? ( - {m.start.lastUsed} - ) : null} - - {field === 'phone' ? ( - - ) : ( - - )} - - -
- + + + {copy.label} + {nextField && onSwitchField ? ( + + ) : lastUsed ? ( + {m.start.lastUsed} + ) : null} + + {field === 'phone' ? ( + + ) : ( + + )} + + + +
+ {onPasskey ? (
- + {onPasskey ? ( From af7f19c22ca394c9803a9aba8228ea371f0b1139 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 12 Nov 2026 16:24:30 -0700 Subject: [PATCH 17/17] fix(mosaic): give the sign-in start and alternative channel screens their own Flow steps --- .../src/features/sign-in/sign-in.view.tsx | 54 ++++++++++--------- .../sign-in/steps/identifier-step.tsx | 7 +-- .../steps/sign-in-alternative-channel.tsx | 4 +- .../sign-in/steps/sign-in-captcha.tsx | 20 +++++++ 4 files changed, 53 insertions(+), 32 deletions(-) create mode 100644 packages/mosaic/src/features/sign-in/steps/sign-in-captcha.tsx diff --git a/packages/mosaic/src/features/sign-in/sign-in.view.tsx b/packages/mosaic/src/features/sign-in/sign-in.view.tsx index ce72b7bed90..1b25f76cbd9 100644 --- a/packages/mosaic/src/features/sign-in/sign-in.view.tsx +++ b/packages/mosaic/src/features/sign-in/sign-in.view.tsx @@ -121,33 +121,37 @@ export function SignInView({ > {current => { const secondary = current.kind === 'code' ? codeSecondaryAction(current, m, { onCancel, onBack }) : undefined; + const identifierStep = + current.kind === 'identifier' ? ( + + ) : null; return ( <> - - {current.kind === 'identifier' ? ( - - ) : null} + {stepKey(current) === 'identifier' ? identifierStep : null} + + + {stepKey(current) === 'identifier:channel' ? identifierStep : null} diff --git a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx index a59a99fda3c..2b117cfc6d6 100644 --- a/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx +++ b/packages/mosaic/src/features/sign-in/steps/identifier-step.tsx @@ -5,7 +5,6 @@ import React, { type Ref } from 'react'; import { Badge } from '../../../components/badge'; import { Button, SubmitButton } from '../../../components/button'; -import { Captcha } from '../../../components/captcha'; import { Card } from '../../../components/card'; import { Field } from '../../../components/field'; import { useFlowAutoFocus } from '../../../components/flow'; @@ -26,6 +25,7 @@ import type { } from '../sign-in.types'; import { identifierFieldCopy, splitLastUsedProvider, startPrompt } from '../sign-in.utils'; import { SignInAlternativeChannel } from './sign-in-alternative-channel'; +import { SignInCaptcha } from './sign-in-captcha'; type IdentifierStepData = Extract; @@ -212,10 +212,7 @@ function IdentifierStart({ - + {onPasskey ? (