Skip to content

Commit d08263f

Browse files
refactor(clerk-js): Social buttons mobile layout (#7169)
1 parent b944ff3 commit d08263f

File tree

3 files changed

+267
-17
lines changed

3 files changed

+267
-17
lines changed

.changeset/clean-taxes-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
Update SocialButtons to show "Continue with" prefix for last auth strategy, and improve mobile layout consistency.

packages/clerk-js/src/ui/elements/SocialButtons.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { distributeStrategiesIntoRows } from './utils';
2828

2929
const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2;
3030
const SOCIAL_BUTTON_PRE_TEXT_THRESHOLD = 1;
31-
const MAX_STRATEGIES_PER_ROW = 6;
31+
const MAX_STRATEGIES_PER_ROW = 5;
3232

3333
export type SocialButtonsProps = React.PropsWithChildren<{
3434
enableOAuthProviders: boolean;
@@ -101,6 +101,8 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => {
101101
lastAuthenticationStrategy,
102102
);
103103
const strategyRowOneLength = strategyRows.at(lastAuthenticationStrategyPresent ? 1 : 0)?.length ?? 0;
104+
const remainingStrategiesLength = lastAuthenticationStrategyPresent ? strategies.length - 1 : strategies.length;
105+
const shouldForceSingleColumnOnMobile = !lastAuthenticationStrategyPresent && strategies.length === 2;
104106

105107
const preferBlockButtons =
106108
socialButtonsVariant === 'blockButton'
@@ -151,34 +153,38 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => {
151153
sx={t => ({
152154
justifyContent: 'center',
153155
[mqu.sm]: {
154-
gridTemplateColumns: 'repeat(1, 1fr)',
156+
// Force single-column on mobile when 2 strategies are present (without last auth) to prevent
157+
// label overflow. When last auth is present, only 1 strategy remains here, so overflow isn't a concern.
158+
gridTemplateColumns: shouldForceSingleColumnOnMobile ? 'repeat(1, minmax(0, 1fr))' : undefined,
155159
},
156160
gridTemplateColumns:
157161
strategies.length < 1
158-
? `repeat(1, 1fr)`
162+
? `repeat(1, minmax(0, 1fr))`
159163
: `repeat(${row.length}, ${
160164
rowIndex === 0
161-
? `1fr`
165+
? `minmax(0, 1fr)`
162166
: // Calculate the width of each button based on the width of the buttons within the first row.
163167
// t.sizes.$2 is used here to represent the gap defined on the Grid component.
164-
`calc((100% - (${strategyRowOneLength} - 1) * ${t.sizes.$2}) / ${strategyRowOneLength})`
168+
`minmax(0, calc((100% - (${strategyRowOneLength} - 1) * ${t.sizes.$2}) / ${strategyRowOneLength}))`
165169
})`,
166170
})}
167171
>
168172
{row.map(strategy => {
169-
const label =
170-
strategies.length === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD
171-
? `Continue with ${strategyToDisplayData[strategy].name}`
172-
: strategyToDisplayData[strategy].name;
173+
const shouldShowPreText =
174+
remainingStrategiesLength === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD ||
175+
(strategy === lastAuthenticationStrategy && row.length === 1);
173176

174-
const localizedText =
175-
strategies.length === SOCIAL_BUTTON_PRE_TEXT_THRESHOLD
176-
? localizationKeys('socialButtonsBlockButton', {
177-
provider: strategyToDisplayData[strategy].name,
178-
})
179-
: localizationKeys('socialButtonsBlockButtonManyInView', {
180-
provider: strategyToDisplayData[strategy].name,
181-
});
177+
const label = shouldShowPreText
178+
? `Continue with ${strategyToDisplayData[strategy].name}`
179+
: strategyToDisplayData[strategy].name;
180+
181+
const localizedText = shouldShowPreText
182+
? localizationKeys('socialButtonsBlockButton', {
183+
provider: strategyToDisplayData[strategy].name,
184+
})
185+
: localizationKeys('socialButtonsBlockButtonManyInView', {
186+
provider: strategyToDisplayData[strategy].name,
187+
});
182188

183189
const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? (
184190
<Image
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// packages/clerk-js/src/ui/elements/__tests__/SocialButtons.test.tsx
2+
import { render, screen } from '@testing-library/react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
import { bindCreateFixtures } from '@/test/utils';
6+
import { CardStateProvider } from '@/ui/elements/contexts';
7+
8+
import { SocialButtons } from '../SocialButtons';
9+
10+
const { createFixtures } = bindCreateFixtures('SignIn');
11+
12+
describe('SocialButtons', () => {
13+
const mockOAuthCallback = vi.fn();
14+
const mockWeb3Callback = vi.fn();
15+
const mockAlternativePhoneCodeCallback = vi.fn();
16+
17+
const defaultProps = {
18+
oauthCallback: mockOAuthCallback,
19+
web3Callback: mockWeb3Callback,
20+
alternativePhoneCodeCallback: mockAlternativePhoneCodeCallback,
21+
enableOAuthProviders: true,
22+
enableWeb3Providers: true,
23+
enableAlternativePhoneCodeProviders: true,
24+
};
25+
26+
describe('Without last authentication strategy', () => {
27+
it('should show "Continue with" prefix for single strategy', async () => {
28+
const { wrapper, fixtures } = await createFixtures(f => {
29+
f.withSocialProvider({ provider: 'google' });
30+
});
31+
32+
fixtures.clerk.client.lastAuthenticationStrategy = null;
33+
34+
render(
35+
<CardStateProvider>
36+
<SocialButtons
37+
{...defaultProps}
38+
showLastAuthenticationStrategy={false}
39+
/>
40+
</CardStateProvider>,
41+
{ wrapper },
42+
);
43+
44+
const button = screen.getByRole('button', { name: /google/i });
45+
expect(button).toHaveTextContent('Continue with Google');
46+
});
47+
48+
it('should NOT show "Continue with" prefix for either button when two strategies exist', async () => {
49+
const { wrapper, fixtures } = await createFixtures(f => {
50+
f.withSocialProvider({ provider: 'google' });
51+
f.withSocialProvider({ provider: 'github' });
52+
});
53+
54+
fixtures.clerk.client.lastAuthenticationStrategy = null;
55+
56+
render(
57+
<CardStateProvider>
58+
<SocialButtons
59+
{...defaultProps}
60+
showLastAuthenticationStrategy={false}
61+
/>
62+
</CardStateProvider>,
63+
{ wrapper },
64+
);
65+
66+
const googleButton = screen.getByRole('button', { name: /google/i });
67+
const githubButton = screen.getByRole('button', { name: /github/i });
68+
69+
expect(googleButton).toHaveTextContent('Google');
70+
expect(googleButton).not.toHaveTextContent('Continue with Google');
71+
expect(githubButton).toHaveTextContent('GitHub');
72+
expect(githubButton).not.toHaveTextContent('Continue with GitHub');
73+
});
74+
75+
it('should NOT show "Continue with" prefix for any button when 3+ strategies exist', async () => {
76+
const { wrapper, fixtures } = await createFixtures(f => {
77+
f.withSocialProvider({ provider: 'google' });
78+
f.withSocialProvider({ provider: 'github' });
79+
f.withSocialProvider({ provider: 'apple' });
80+
});
81+
82+
fixtures.clerk.client.lastAuthenticationStrategy = null;
83+
84+
render(
85+
<CardStateProvider>
86+
<SocialButtons
87+
{...defaultProps}
88+
showLastAuthenticationStrategy={false}
89+
/>
90+
</CardStateProvider>,
91+
{ wrapper },
92+
);
93+
94+
const buttons = screen.getAllByRole('button');
95+
buttons.forEach(button => {
96+
expect(button).not.toHaveTextContent(/Continue with/i);
97+
});
98+
});
99+
100+
it('should return null when no strategies are enabled', async () => {
101+
const { wrapper } = await createFixtures();
102+
103+
const { container } = render(
104+
<CardStateProvider>
105+
<SocialButtons
106+
{...defaultProps}
107+
enableOAuthProviders={false}
108+
enableWeb3Providers={false}
109+
enableAlternativePhoneCodeProviders={false}
110+
/>
111+
</CardStateProvider>,
112+
{ wrapper },
113+
);
114+
115+
expect(container.firstChild).toBeNull();
116+
});
117+
});
118+
119+
describe('With last authentication strategy', () => {
120+
it('should show "Continue with" prefix for single strategy', async () => {
121+
const { wrapper, fixtures } = await createFixtures(f => {
122+
f.withSocialProvider({ provider: 'google' });
123+
});
124+
125+
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
126+
127+
render(
128+
<CardStateProvider>
129+
<SocialButtons
130+
{...defaultProps}
131+
showLastAuthenticationStrategy
132+
/>
133+
</CardStateProvider>,
134+
{ wrapper },
135+
);
136+
137+
const button = screen.getByRole('button', { name: /google/i });
138+
expect(button).toHaveTextContent('Continue with Google');
139+
expect(button).toHaveTextContent('Last used');
140+
});
141+
142+
it('should show "Continue with" prefix for both buttons when two strategies exist', async () => {
143+
const { wrapper, fixtures } = await createFixtures(f => {
144+
f.withSocialProvider({ provider: 'google' });
145+
f.withSocialProvider({ provider: 'github' });
146+
});
147+
148+
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
149+
150+
render(
151+
<CardStateProvider>
152+
<SocialButtons
153+
{...defaultProps}
154+
showLastAuthenticationStrategy
155+
/>
156+
</CardStateProvider>,
157+
{ wrapper },
158+
);
159+
160+
const googleButton = screen.getByRole('button', { name: /google/i });
161+
const githubButton = screen.getByRole('button', { name: /github/i });
162+
163+
// Both should show "Continue with" when last auth is present
164+
expect(googleButton).toHaveTextContent('Continue with Google');
165+
expect(githubButton).toHaveTextContent('Continue with GitHub');
166+
});
167+
168+
it('should show "Last used" badge on last auth strategy button', async () => {
169+
const { wrapper, fixtures } = await createFixtures(f => {
170+
f.withSocialProvider({ provider: 'google' });
171+
f.withSocialProvider({ provider: 'github' });
172+
});
173+
174+
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
175+
176+
render(
177+
<CardStateProvider>
178+
<SocialButtons
179+
{...defaultProps}
180+
showLastAuthenticationStrategy
181+
/>
182+
</CardStateProvider>,
183+
{ wrapper },
184+
);
185+
186+
const googleButton = screen.getByRole('button', { name: /google/i });
187+
expect(googleButton).toHaveTextContent('Last used');
188+
189+
const badge = screen.getByText('Last used');
190+
expect(badge).toBeInTheDocument();
191+
});
192+
193+
it('should show "Continue with" prefix when last auth strategy is alone in its row', async () => {
194+
const { wrapper, fixtures } = await createFixtures(f => {
195+
f.withSocialProvider({ provider: 'google' });
196+
f.withSocialProvider({ provider: 'github' });
197+
f.withSocialProvider({ provider: 'apple' });
198+
});
199+
200+
fixtures.clerk.client.lastAuthenticationStrategy = 'oauth_google';
201+
202+
render(
203+
<CardStateProvider>
204+
<SocialButtons
205+
{...defaultProps}
206+
showLastAuthenticationStrategy
207+
/>
208+
</CardStateProvider>,
209+
{ wrapper },
210+
);
211+
212+
// Google (last auth) should be in its own row and show "Continue with"
213+
const googleButton = screen.getByRole('button', { name: /google/i });
214+
expect(googleButton).toHaveTextContent('Continue with Google');
215+
});
216+
217+
it('should handle SAML strategies converted to OAuth', async () => {
218+
const { wrapper, fixtures } = await createFixtures(f => {
219+
f.withSocialProvider({ provider: 'google' });
220+
});
221+
222+
// SAML strategy should be converted to OAuth
223+
fixtures.clerk.client.lastAuthenticationStrategy = 'saml_google' as any;
224+
225+
render(
226+
<CardStateProvider>
227+
<SocialButtons
228+
{...defaultProps}
229+
showLastAuthenticationStrategy
230+
/>
231+
</CardStateProvider>,
232+
{ wrapper },
233+
);
234+
235+
const googleButton = screen.getByRole('button', { name: /google/i });
236+
expect(googleButton).toHaveTextContent('Continue with Google');
237+
});
238+
});
239+
});

0 commit comments

Comments
 (0)