Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c601f20
feat(mosaic): add Field.Header for a label row with an action
austincalvelage Nov 12, 2026
aecc677
feat(mosaic): add phone, username and email-or-username sign-in ident…
austincalvelage Nov 12, 2026
8f6824a
feat(mosaic): add the sign-in passkey link to the identifier step
austincalvelage Nov 12, 2026
b905042
feat(mosaic): add the sign-in alternative phone channel screen
austincalvelage Nov 12, 2026
88edc36
feat(mosaic): mark the last used sign-in provider and identifier
austincalvelage Nov 12, 2026
e649fe0
test(mosaic): drop class checks from the Field.Header tests
austincalvelage Nov 12, 2026
64b8802
feat(mosaic): render the Captcha element on the sign-in identifier step
austincalvelage Nov 12, 2026
072f94c
feat(mosaic): offer joining the waitlist on the sign-in identifier step
austincalvelage Nov 12, 2026
ba9ec4b
docs(swingset): show the sign-in-only identifier stories that legacy …
austincalvelage Nov 12, 2026
076aa15
feat(mosaic): report when the Captcha challenge turns interactive
austincalvelage Nov 12, 2026
4a0da64
feat(mosaic): spotlight the Captcha challenge on the sign-in identifi…
austincalvelage Nov 12, 2026
eef8fcc
fix(mosaic): center the Captcha challenge on the sign-in identifier s…
austincalvelage Nov 12, 2026
8c34755
refactor(mosaic): move the sign-in start prompt and last used split i…
austincalvelage Nov 12, 2026
abf1b5a
refactor(mosaic): drop the channel icon from the sign-in alternative …
austincalvelage Nov 12, 2026
ebbf73b
fix(mosaic): keep separate phone and text values on the sign-in ident…
austincalvelage Nov 12, 2026
fabf1e9
fix(mosaic): focus the new input after switching the sign-in identifi…
austincalvelage Nov 12, 2026
af7f19c
fix(mosaic): give the sign-in start and alternative channel screens t…
austincalvelage Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-sign-in-identifier-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
16 changes: 14 additions & 2 deletions packages/mosaic/src/components/captcha/__tests__/captcha.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(<Captcha onInteractiveChange={onInteractiveChange} />);
const element = container.querySelector<HTMLElement>('#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);
});
});
23 changes: 22 additions & 1 deletion packages/mosaic/src/components/captcha/captcha.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -17,6 +18,8 @@ export interface CaptchaProps extends Omit<MosaicComponentProps<'div'>, '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;
}

/**
Expand All @@ -30,7 +33,7 @@ export interface CaptchaProps extends Omit<MosaicComponentProps<'div'>, 'id' | '
* <Captcha theme='dark' />
*/
export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>(function MosaicCaptcha(
{ render, xstyle, theme, size, language, ...rest },
{ render, xstyle, theme, size, language, onInteractiveChange, ...rest },
ref,
) {
const [element, setElement] = React.useState<HTMLDivElement | null>(null);
Expand All @@ -43,6 +46,24 @@ export const Captcha = React.forwardRef<HTMLDivElement, CaptchaProps>(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,
Expand Down
33 changes: 33 additions & 0 deletions packages/mosaic/src/components/field/__tests__/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,31 @@ 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(
<Field.Root>
<Field.Header>
<Field.Label>Password</Field.Label>
<button
type='button'
onClick={onAction}
>
Forgot password?
</button>
</Field.Header>
<Input />
</Field.Root>,
);

const input = screen.getByRole('textbox', { name: 'Password' });
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(
<Field.Root>
Expand Down Expand Up @@ -380,6 +405,7 @@ describe('Mosaic Field', () => {
const labelRef = React.createRef<HTMLLabelElement>();
const descriptionRef = React.createRef<HTMLParagraphElement>();
const contentRef = React.createRef<HTMLDivElement>();
const headerRef = React.createRef<HTMLDivElement>();
const errorRef = React.createRef<HTMLParagraphElement>();

render(
Expand All @@ -403,6 +429,10 @@ describe('Mosaic Field', () => {
ref={contentRef}
data-content='field'
/>
<Field.Header
ref={headerRef}
data-header='field'
/>
<Field.Error
ref={errorRef}
role='status'
Expand All @@ -416,6 +446,7 @@ describe('Mosaic 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');
});

Expand Down Expand Up @@ -471,13 +502,15 @@ describe('Mosaic Field', () => {
<Field.Label render={props => <label {...props} />}>Biography</Field.Label>
<Field.Description render={props => <div {...props} />}>Description</Field.Description>
<Field.Content render={props => <section {...props} />}>Content</Field.Content>
<Field.Header render={props => <header {...props} />}>Header</Field.Header>
<Field.Error render={props => <div {...props} />}>Error</Field.Error>
</Field.Root>,
);

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');
});

Expand Down
7 changes: 7 additions & 0 deletions packages/mosaic/src/components/field/field.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
16 changes: 16 additions & 0 deletions packages/mosaic/src/components/field/field.tsx

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed to support inline actions in header like "use phone"

CleanShot 2026-11-12 at 15 00 04@2x

Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@ const Label = React.forwardRef<HTMLElement, FieldLabelProps>(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<HTMLDivElement, FieldHeaderProps>(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'>;

Expand Down Expand Up @@ -326,6 +341,7 @@ const Feedback = React.forwardRef<HTMLDivElement, FieldFeedbackProps>(function M
/** Styled parts for composing an automatically associated single-control field. */
export const Field = {
Root,
Header,
Label,
Content,
Description,
Expand Down
1 change: 1 addition & 0 deletions packages/mosaic/src/components/field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type {
FieldDescriptionProps,
FieldErrorProps,
FieldFeedbackProps,
FieldHeaderProps,
FieldHintProps,
FieldLabelProps,
FieldMessageProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,30 @@ 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,
splitLastUsedProvider,
startPrompt,
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: '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'],
Expand Down Expand Up @@ -104,3 +122,71 @@ 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',
});
});
});

describe('startPrompt', () => {
it('offers the waitlist over sign-up, and nothing without either', () => {
const onSignUp = () => undefined;
const onJoinWaitlist = () => undefined;
expect(startPrompt(signInMessages, { onSignUp })).toEqual({
text: 'Don’t have an account?',
link: 'Sign up',
onClick: onSignUp,
});
expect(startPrompt(signInMessages, { onSignUp, onJoinWaitlist })).toEqual({
text: 'Want early access?',
link: 'Join waitlist',
onClick: onJoinWaitlist,
});
expect(startPrompt(signInMessages, {})).toBeUndefined();
});
});

describe('splitLastUsedProvider', () => {
const google = { id: 'oauth_google', name: 'Google', iconUrl: 'google.svg' };
const github = { id: 'oauth_github', name: 'GitHub', iconUrl: 'github.svg' };
const x = { id: 'oauth_x', name: 'X', iconUrl: 'x.svg' };

it('takes the last used provider out of the others, keeping their order', () => {
expect(splitLastUsedProvider([google, github, x], 'oauth_github')).toEqual({
lastUsed: github,
others: [google, x],
});
});

it('leaves every provider in the others when none was used last or it is not offered', () => {
expect(splitLastUsedProvider([google, github], undefined)).toEqual({
lastUsed: undefined,
others: [google, github],
});
expect(splitLastUsedProvider([google, github], 'oauth_apple')).toEqual({
lastUsed: undefined,
others: [google, github],
});
});
});
1 change: 1 addition & 0 deletions packages/mosaic/src/features/sign-in/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { SignInView } from './sign-in.view';
export type {
SignInActionBlockedDetails,
SignInFactor,
SignInIdentifierField,
SignInProvider,
SignInStep,
SignInSubmitEvent,
Expand Down
22 changes: 22 additions & 0 deletions packages/mosaic/src/features/sign-in/sign-in.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,32 @@ 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',
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',
usePasskey: 'Use passkey instead',
signUpPrompt: 'Don’t have an account?',
signUpLink: 'Sign up',
waitlistPrompt: 'Want early access?',
waitlistLink: 'Join waitlist',
},
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',
Expand Down
14 changes: 11 additions & 3 deletions packages/mosaic/src/features/sign-in/sign-in.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,17 @@ export const styles = stylex.create({
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
},
forgotPassword: {
alignSelf: 'start',
justifySelf: 'start',
captcha: {
justifySelf: 'center',
},
spotlightGroup: {
display: 'contents',
},
spotlightHidden: {
display: 'none',
},
passkeyAction: {
alignSelf: 'center',
},
footerText: {
textAlign: 'center',
Expand Down
Loading
Loading