diff --git a/.changeset/clean-taxes-check.md b/.changeset/clean-taxes-check.md new file mode 100644 index 00000000000..b740f9453c8 --- /dev/null +++ b/.changeset/clean-taxes-check.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +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 854acf6814b..0ef1deb7023 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -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; @@ -101,6 +101,8 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { lastAuthenticationStrategy, ); 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 = socialButtonsVariant === 'blockButton' @@ -151,34 +153,38 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { sx={t => ({ justifyContent: 'center', [mqu.sm]: { - gridTemplateColumns: 'repeat(1, 1fr)', + // 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: shouldForceSingleColumnOnMobile ? 'repeat(1, minmax(0, 1fr))' : undefined, }, 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}))` })`, })} > {row.map(strategy => { - const label = - strategies.length === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD - ? `Continue with ${strategyToDisplayData[strategy].name}` - : strategyToDisplayData[strategy].name; + const shouldShowPreText = + remainingStrategiesLength === 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 ? ( { + 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'); + }); + }); +});