From ce629ce656818ee4f68b4d5bc42caf747e6cbdce Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Nov 2025 08:55:06 -0500 Subject: [PATCH 1/8] refactor(clerk-js): Social buttons mobile layout --- packages/clerk-js/src/ui/elements/SocialButtons.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index 854acf6814b..9b1e9ba12de 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -20,7 +20,7 @@ import { useAppearance, } from '../customizables'; import { useEnabledThirdPartyProviders } from '../hooks'; -import { mqu, type PropsOfComponent } from '../styledSystem'; +import { type PropsOfComponent } from '../styledSystem'; import { sleep } from '../utils/sleep'; import { LastAuthenticationStrategyBadge } from './Badge'; import { useCardState } from './contexts'; @@ -28,7 +28,7 @@ import { distributeStrategiesIntoRows } from './utils'; const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; const SOCIAL_BUTTON_PRE_TEXT_THRESHOLD = 1; -const MAX_STRATEGIES_PER_ROW = 6; +const MAX_STRATEGIES_PER_ROW = 5; export type SocialButtonsProps = React.PropsWithChildren<{ enableOAuthProviders: boolean; @@ -150,9 +150,6 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { gap={2} sx={t => ({ justifyContent: 'center', - [mqu.sm]: { - gridTemplateColumns: 'repeat(1, 1fr)', - }, gridTemplateColumns: strategies.length < 1 ? `repeat(1, 1fr)` From d5c20d43b36693f95bcf7bb674ec846a74a610e8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Nov 2025 10:41:12 -0500 Subject: [PATCH 2/8] add changeset --- .changeset/clean-taxes-check.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clean-taxes-check.md diff --git a/.changeset/clean-taxes-check.md b/.changeset/clean-taxes-check.md new file mode 100644 index 00000000000..9ede6c9589f --- /dev/null +++ b/.changeset/clean-taxes-check.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Update SocialButtons layout to be consistent across desktop and mobile. From 384723dbb7357b35116f412cef752a7240321d3c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 6 Nov 2025 12:17:56 -0500 Subject: [PATCH 3/8] fix missing text overflow --- packages/clerk-js/src/ui/elements/SocialButtons.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index 9b1e9ba12de..09812c6d651 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -152,13 +152,13 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { justifyContent: 'center', gridTemplateColumns: strategies.length < 1 - ? `repeat(1, 1fr)` + ? `repeat(1, minmax(0, 1fr))` : `repeat(${row.length}, ${ rowIndex === 0 - ? `1fr` + ? `minmax(0, 1fr)` : // Calculate the width of each button based on the width of the buttons within the first row. // t.sizes.$2 is used here to represent the gap defined on the Grid component. - `calc((100% - (${strategyRowOneLength} - 1) * ${t.sizes.$2}) / ${strategyRowOneLength})` + `minmax(0, calc((100% - (${strategyRowOneLength} - 1) * ${t.sizes.$2}) / ${strategyRowOneLength}))` })`, })} > From 34506c321d909da36958058561c4581c0538fb91 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 10 Nov 2025 17:31:13 -0500 Subject: [PATCH 4/8] stack 2 strategies on mobile --- packages/clerk-js/src/ui/elements/SocialButtons.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index 09812c6d651..ef6fe3077bb 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -20,7 +20,7 @@ import { useAppearance, } from '../customizables'; import { useEnabledThirdPartyProviders } from '../hooks'; -import { type PropsOfComponent } from '../styledSystem'; +import { mqu, type PropsOfComponent } from '../styledSystem'; import { sleep } from '../utils/sleep'; import { LastAuthenticationStrategyBadge } from './Badge'; import { useCardState } from './contexts'; @@ -150,6 +150,12 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { gap={2} sx={t => ({ justifyContent: 'center', + [mqu.sm]: { + // Force single-column on mobile when 2 strategies are present (without last auth) to prevent + // label overflow. When last auth is present, only 1 strategy remains here, so overflow isn't a concern. + gridTemplateColumns: + !lastAuthenticationStrategyPresent && strategies.length === 2 ? 'repeat(1, minmax(0, 1fr))' : undefined, + }, gridTemplateColumns: strategies.length < 1 ? `repeat(1, minmax(0, 1fr))` From 20412d52d881f82f57eb95463b24317c6f912e25 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 10 Nov 2025 18:11:06 -0500 Subject: [PATCH 5/8] update label rendering logic --- .changeset/clean-taxes-check.md | 4 +-- .../src/ui/elements/SocialButtons.tsx | 31 ++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.changeset/clean-taxes-check.md b/.changeset/clean-taxes-check.md index 9ede6c9589f..b740f9453c8 100644 --- a/.changeset/clean-taxes-check.md +++ b/.changeset/clean-taxes-check.md @@ -1,5 +1,5 @@ --- -'@clerk/clerk-js': patch +'@clerk/clerk-js': minor --- -Update SocialButtons layout to be consistent across desktop and mobile. +Update SocialButtons to show "Continue with" prefix for last auth strategy, and improve mobile layout consistency. \ No newline at end of file diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index ef6fe3077bb..d84290ffed7 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -100,7 +100,9 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { MAX_STRATEGIES_PER_ROW, lastAuthenticationStrategy, ); + const strategiesCount = lastAuthenticationStrategyPresent ? strategies.length - 1 : strategies.length; const strategyRowOneLength = strategyRows.at(lastAuthenticationStrategyPresent ? 1 : 0)?.length ?? 0; + const shouldForceSingleColumnOnMobile = !lastAuthenticationStrategyPresent && strategies.length === 2; const preferBlockButtons = socialButtonsVariant === 'blockButton' @@ -153,8 +155,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { [mqu.sm]: { // Force single-column on mobile when 2 strategies are present (without last auth) to prevent // label overflow. When last auth is present, only 1 strategy remains here, so overflow isn't a concern. - gridTemplateColumns: - !lastAuthenticationStrategyPresent && strategies.length === 2 ? 'repeat(1, minmax(0, 1fr))' : undefined, + gridTemplateColumns: shouldForceSingleColumnOnMobile ? 'repeat(1, minmax(0, 1fr))' : undefined, }, gridTemplateColumns: strategies.length < 1 @@ -169,19 +170,21 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { })} > {row.map(strategy => { - const label = - strategies.length === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD - ? `Continue with ${strategyToDisplayData[strategy].name}` - : strategyToDisplayData[strategy].name; + const shouldShowPreText = + strategiesCount === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD || + (strategy === lastAuthenticationStrategy && row.length === 1); - const localizedText = - strategies.length === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD - ? localizationKeys('socialButtonsBlockButton', { - provider: strategyToDisplayData[strategy].name, - }) - : localizationKeys('socialButtonsBlockButtonManyInView', { - provider: strategyToDisplayData[strategy].name, - }); + const label = shouldShowPreText + ? `Continue with ${strategyToDisplayData[strategy].name}` + : strategyToDisplayData[strategy].name; + + const localizedText = shouldShowPreText + ? localizationKeys('socialButtonsBlockButton', { + provider: strategyToDisplayData[strategy].name, + }) + : localizationKeys('socialButtonsBlockButtonManyInView', { + provider: strategyToDisplayData[strategy].name, + }); const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( Date: Mon, 10 Nov 2025 18:22:37 -0500 Subject: [PATCH 6/8] rename --- packages/clerk-js/src/ui/elements/SocialButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index d84290ffed7..b7df8170b54 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -100,7 +100,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { MAX_STRATEGIES_PER_ROW, lastAuthenticationStrategy, ); - const strategiesCount = lastAuthenticationStrategyPresent ? strategies.length - 1 : strategies.length; + const strategiesLength = lastAuthenticationStrategyPresent ? strategies.length - 1 : strategies.length; const strategyRowOneLength = strategyRows.at(lastAuthenticationStrategyPresent ? 1 : 0)?.length ?? 0; const shouldForceSingleColumnOnMobile = !lastAuthenticationStrategyPresent && strategies.length === 2; @@ -171,7 +171,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { > {row.map(strategy => { const shouldShowPreText = - strategiesCount === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD || + strategiesLength === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD || (strategy === lastAuthenticationStrategy && row.length === 1); const label = shouldShowPreText From 55ee6dcd8afbf6ebf337f06a604535ae291b43b2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 10 Nov 2025 18:49:01 -0500 Subject: [PATCH 7/8] rename --- packages/clerk-js/src/ui/elements/SocialButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index b7df8170b54..0ef1deb7023 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -100,8 +100,8 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { MAX_STRATEGIES_PER_ROW, lastAuthenticationStrategy, ); - const strategiesLength = lastAuthenticationStrategyPresent ? strategies.length - 1 : strategies.length; const strategyRowOneLength = strategyRows.at(lastAuthenticationStrategyPresent ? 1 : 0)?.length ?? 0; + const remainingStrategiesLength = lastAuthenticationStrategyPresent ? strategies.length - 1 : strategies.length; const shouldForceSingleColumnOnMobile = !lastAuthenticationStrategyPresent && strategies.length === 2; const preferBlockButtons = @@ -171,7 +171,7 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { > {row.map(strategy => { const shouldShowPreText = - strategiesLength === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD || + remainingStrategiesLength === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD || (strategy === lastAuthenticationStrategy && row.length === 1); const label = shouldShowPreText From ef90c7e93c89524d6293c9c6eb06fd144b50c3a3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 10 Nov 2025 19:01:43 -0500 Subject: [PATCH 8/8] add unit tests --- .../elements/__tests__/SocialButtons.test.tsx | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx diff --git a/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx b/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx new file mode 100644 index 00000000000..3976d0f3c88 --- /dev/null +++ b/packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx @@ -0,0 +1,239 @@ +// packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { SocialButtons } from '../SocialButtons'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('SocialButtons', () => { + const mockOAuthCallback = vi.fn(); + const mockWeb3Callback = vi.fn(); + const mockAlternativePhoneCodeCallback = vi.fn(); + + const defaultProps = { + oauthCallback: mockOAuthCallback, + web3Callback: mockWeb3Callback, + alternativePhoneCodeCallback: mockAlternativePhoneCodeCallback, + enableOAuthProviders: true, + enableWeb3Providers: true, + enableAlternativePhoneCodeProviders: true, + }; + + describe('Without last authentication strategy', () => { + it('should show "Continue with" prefix for single strategy', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = null; + + render( + + + , + { wrapper }, + ); + + const button = screen.getByRole('button', { name: /google/i }); + expect(button).toHaveTextContent('Continue with Google'); + }); + + it('should NOT show "Continue with" prefix for either button when two strategies exist', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withSocialProvider({ provider: 'github' }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = null; + + render( + + + , + { wrapper }, + ); + + const googleButton = screen.getByRole('button', { name: /google/i }); + const githubButton = screen.getByRole('button', { name: /github/i }); + + expect(googleButton).toHaveTextContent('Google'); + expect(googleButton).not.toHaveTextContent('Continue with Google'); + expect(githubButton).toHaveTextContent('GitHub'); + expect(githubButton).not.toHaveTextContent('Continue with GitHub'); + }); + + it('should NOT show "Continue with" prefix for any button when 3+ strategies exist', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withSocialProvider({ provider: 'github' }); + f.withSocialProvider({ provider: 'apple' }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = null; + + render( + + + , + { wrapper }, + ); + + const buttons = screen.getAllByRole('button'); + buttons.forEach(button => { + expect(button).not.toHaveTextContent(/Continue with/i); + }); + }); + + it('should return null when no strategies are enabled', async () => { + const { wrapper } = await createFixtures(); + + const { container } = render( + + + , + { wrapper }, + ); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe('With last authentication strategy', () => { + it('should show "Continue with" prefix for single strategy', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; + + render( + + + , + { wrapper }, + ); + + const button = screen.getByRole('button', { name: /google/i }); + expect(button).toHaveTextContent('Continue with Google'); + expect(button).toHaveTextContent('Last used'); + }); + + it('should show "Continue with" prefix for both buttons when two strategies exist', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withSocialProvider({ provider: 'github' }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; + + render( + + + , + { wrapper }, + ); + + const googleButton = screen.getByRole('button', { name: /google/i }); + const githubButton = screen.getByRole('button', { name: /github/i }); + + // Both should show "Continue with" when last auth is present + expect(googleButton).toHaveTextContent('Continue with Google'); + expect(githubButton).toHaveTextContent('Continue with GitHub'); + }); + + it('should show "Last used" badge on last auth strategy button', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withSocialProvider({ provider: 'github' }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; + + render( + + + , + { wrapper }, + ); + + const googleButton = screen.getByRole('button', { name: /google/i }); + expect(googleButton).toHaveTextContent('Last used'); + + const badge = screen.getByText('Last used'); + expect(badge).toBeInTheDocument(); + }); + + it('should show "Continue with" prefix when last auth strategy is alone in its row', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withSocialProvider({ provider: 'github' }); + f.withSocialProvider({ provider: 'apple' }); + }); + + fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google'; + + render( + + + , + { wrapper }, + ); + + // Google (last auth) should be in its own row and show "Continue with" + const googleButton = screen.getByRole('button', { name: /google/i }); + expect(googleButton).toHaveTextContent('Continue with Google'); + }); + + it('should handle SAML strategies converted to OAuth', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + }); + + // SAML strategy should be converted to OAuth + fixtures.clerk.client.lastAuthenticationStrategy = 'saml_google' as any; + + render( + + + , + { wrapper }, + ); + + const googleButton = screen.getByRole('button', { name: /google/i }); + expect(googleButton).toHaveTextContent('Continue with Google'); + }); + }); +});