Skip to content

Commit c38c4e6

Browse files
alexcarpenterprestonwebdevclaude
authored
feat(mosaic): rework UserButton (#10002)
Co-authored-by: Preston Booth <91231503+prestonwebdev@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2b2d642 commit c38c4e6

19 files changed

Lines changed: 403 additions & 497 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
Update `UserButton` to the latest designs. Combined mode always leads with the account, and the `modePriority` prop is removed. **Settings** opens organization and profile settings, and **Sign out of all accounts** moves into the **Switch account** menu.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎integration/tests/mosaic/user-button.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
4747
return u;
4848
}
4949

50-
async function runAccountAction(page: Page, label: 'Manage account' | 'Sign out') {
50+
async function openProfileSettings(page: Page) {
5151
await popup(page)
52-
.getByRole('button', { name: `Actions for ${fakeUser.email}` })
52+
.getByRole('button', { name: /^(Settings|Manage account)$/ })
5353
.click();
54-
await page.getByRole('menuitem', { name: label }).click();
54+
const profileSettings = page.getByRole('menuitem', { name: 'Profile settings' });
55+
await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor();
56+
if (await profileSettings.isVisible()) {
57+
await profileSettings.click();
58+
}
5559
}
5660

5761
test('switches the active organization and keeps it across a reload', async ({ page, context }) => {
@@ -77,7 +81,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
7781
const u = await signIn({ page, context });
7882

7983
await trigger(page).click();
80-
await runAccountAction(page, 'Sign out');
84+
await popup(page).getByRole('button', { name: 'Sign out', exact: true }).click();
8185

8286
await u.po.expect.toBeSignedOut();
8387
await expect(trigger(page)).toHaveCount(0);
@@ -104,7 +108,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
104108
const u = await signIn({ page, context }, '/custom');
105109

106110
await trigger(page).click();
107-
await runAccountAction(page, 'Manage account');
111+
await openProfileSettings(page);
108112
await u.po.userProfile.waitForUserProfileModal();
109113

110114
await page.locator('.cl-userProfile-root').getByText('Custom page').click();

‎packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx‎

Lines changed: 95 additions & 71 deletions
Large diffs are not rendered by default.
Lines changed: 22 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import { describe, expect, it } from 'vitest';
22

33
import { resolveUserButtonLayout } from '../user-button.layout';
4-
import type { UserButtonData, UserButtonMode, UserButtonModePriority } from '../user-button.types';
4+
import type { UserButtonData, UserButtonMode } from '../user-button.types';
55

66
const alice = { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' };
77
const bob = { sessionId: 'sess_2', name: 'Bob Jones', identifier: 'bob@example.com' };
88
const foundry = { kind: 'membership', organizationId: 'org_1', name: 'Foundry' } as const;
99

10-
function resolve(
11-
mode: UserButtonMode,
12-
data: Partial<UserButtonData> = {},
13-
modePriority: UserButtonModePriority = 'organization',
14-
) {
15-
return resolveUserButtonLayout(mode, modePriority, {
10+
function resolve(mode: UserButtonMode, data: Partial<UserButtonData> = {}) {
11+
return resolveUserButtonLayout(mode, {
1612
activeSession: alice,
1713
activeOrganization: foundry,
1814
hasOrganizations: true,
@@ -25,30 +21,27 @@ function resolve(
2521
}
2622

2723
describe('resolveUserButtonLayout, where each action lands', () => {
28-
it('spreads them across all four slots in combined mode', () => {
24+
it('spreads them across all three slots in combined mode', () => {
2925
expect(resolve('combined').actions).toEqual({
3026
header: ['inviteMembers', 'manageLead'],
31-
organizationsHeading: ['manageAccount', 'signOut'],
3227
organizationsFooter: ['createOrganization'],
33-
footer: ['switchAccount', 'signOutAll'],
28+
footer: ['switchAccount', 'signOut'],
3429
});
3530
});
3631

3732
it('carries no account actions at all in organization mode', () => {
3833
expect(resolve('organization').actions).toEqual({
3934
header: ['inviteMembers', 'manageLead'],
40-
organizationsHeading: [],
4135
organizationsFooter: ['createOrganization'],
4236
footer: [],
4337
});
4438
});
4539

4640
it('takes the account actions into the header and the foot in user mode', () => {
4741
expect(resolve('user').actions).toEqual({
48-
header: ['signOut', 'manageLead'],
49-
organizationsHeading: [],
42+
header: ['manageLead'],
5043
organizationsFooter: [],
51-
footer: ['switchAccount', 'signOutAll'],
44+
footer: ['switchAccount', 'signOut'],
5245
});
5346
});
5447
});
@@ -58,7 +51,7 @@ describe('resolveUserButtonLayout, what the data settles', () => {
5851
const layout = resolve('combined', { activeOrganization: null });
5952

6053
expect(layout.lead).toBe('user');
61-
expect(layout.actions.header).toEqual(['signOut', 'manageLead']);
54+
expect(layout.actions.header).toEqual(['manageLead']);
6255
});
6356

6457
it('leads with no selection where no organization is active and personal is hidden', () => {
@@ -69,60 +62,43 @@ describe('resolveUserButtonLayout, what the data settles', () => {
6962
});
7063

7164
// With no second account the flyout would open onto one row, so the foot offers that row instead.
72-
// "All accounts" is that one account too, so the foot signs out of just it, in the singular.
73-
it('collapses the foot to "Add account" and "Sign out" in combined mode where there is one account', () => {
74-
expect(resolve('combined', { additionalSessions: [] }).actions.footer).toEqual(['addAccount', 'signOut']);
75-
});
76-
77-
it('signs a lone account out from the header in user mode, leaving the foot to add one', () => {
78-
const layout = resolve('user', { additionalSessions: [] });
79-
80-
expect(layout.actions.header).toEqual(['signOut', 'manageLead']);
81-
expect(layout.actions.footer).toEqual(['addAccount']);
82-
});
65+
it.each(['combined', 'user'] as const)(
66+
'collapses the foot to "Add account" in %s mode where there is one account',
67+
mode => {
68+
expect(resolve(mode, { additionalSessions: [] }).actions.footer).toEqual(['addAccount', 'signOut']);
69+
},
70+
);
8371
});
8472

85-
describe('resolveUserButtonLayout, a combined surface led by the account', () => {
73+
describe('resolveUserButtonLayout, a combined surface', () => {
8674
it('leads with the account inside its active organization, inviting to that organization', () => {
87-
const layout = resolve('combined', {}, 'user');
75+
const layout = resolve('combined');
8876

8977
expect(layout.lead).toBe('member');
9078
expect(layout.actions.header).toEqual(['inviteMembers', 'manageLead']);
9179
});
9280

93-
it('leads with the account alone where no organization is active', () => {
94-
const layout = resolve('combined', { activeOrganization: null, hidePersonal: true }, 'user');
81+
it('leads with the account alone where no organization is active, even with personal hidden', () => {
82+
const layout = resolve('combined', { activeOrganization: null, hidePersonal: true });
9583

9684
expect(layout.lead).toBe('user');
97-
expect(layout.actions.header).toEqual(['signOut', 'manageLead']);
98-
});
99-
100-
it('is ignored by the single-purpose modes', () => {
101-
expect(resolve('organization', {}, 'user').lead).toBe('organization');
102-
expect(resolve('user', {}, 'user').lead).toBe('user');
85+
expect(layout.actions.header).toEqual(['manageLead']);
10386
});
10487
});
10588

10689
describe('resolveUserButtonLayout, how the header carries its actions', () => {
10790
it('stacks them wherever a labelled action joins the gear', () => {
10891
expect(resolve('combined').headerLayout).toBe('stacked');
10992
expect(resolve('organization').headerLayout).toBe('stacked');
110-
expect(resolve('user').headerLayout).toBe('stacked');
11193
});
11294

11395
it('runs the gear inline where it is the only action', () => {
114-
expect(resolve('combined', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline');
96+
expect(resolve('user').headerLayout).toBe('inline');
97+
expect(resolve('organization', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline');
11598
});
11699
});
117100

118101
describe('resolveUserButtonLayout, which sections render', () => {
119-
it('heads the organizations with the account even where it belongs to none', () => {
120-
const layout = resolve('combined', { hasOrganizations: false, memberships: [], activeOrganization: null });
121-
122-
expect(layout.showOrganizationsHeading).toBe(true);
123-
expect(layout.showOrganizations).toBe(false);
124-
});
125-
126102
it('counts an invitation or a suggestion as something to list', () => {
127103
const invitation = {
128104
kind: 'invitation',
@@ -138,6 +114,6 @@ describe('resolveUserButtonLayout, which sections render', () => {
138114
});
139115

140116
it('carries no organizations in user mode', () => {
141-
expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false });
117+
expect(resolve('user').showOrganizations).toBe(false);
142118
});
143119
});

‎packages/mosaic/src/features/user-button/user-button-avatar.styles.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,19 +77,23 @@ export const styles = stylex.create({
7777
export const sizes = stylex.create({
7878
xs: { height: space['6'], width: space['6'] },
7979
sm: { height: space['8'], width: space['8'] },
80+
md: { height: space['9.5'], width: space['9.5'] },
8081
});
8182

8283
export const leadSizes = stylex.create({
8384
xs: lead(6, 3),
8485
sm: lead(8, 3.5),
86+
md: lead(9.5, 4),
8587
});
8688

8789
export const ringSizes = stylex.create({
8890
xs: ring(6),
8991
sm: ring(8),
92+
md: ring(9.5),
9093
});
9194

9295
export const badgeSizes = stylex.create({
9396
xs: badge(3),
9497
sm: badge(3.5),
98+
md: badge(4),
9599
});

‎packages/mosaic/src/features/user-button/user-button-avatar.view.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export interface UserButtonAvatarProps {
4545
name: string;
4646
imageUrl?: string;
4747
shape: 'circle' | 'square';
48-
size: 'xs' | 'sm';
48+
size: 'xs' | 'sm' | 'md';
4949
badge?: { name: string; imageUrl?: string };
5050
focusRing?: boolean;
5151
}

‎packages/mosaic/src/features/user-button/user-button-header.styles.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ export const styles = stylex.create({
2828

2929
description: {
3030
color: colorVars['--cl-color-foreground-secondary'],
31-
fontSize: typeScaleVars['--cl-text-xs-size'],
31+
fontSize: typeScaleVars['--cl-text-sm-size'],
3232
fontWeight: fontWeightVars['--cl-font-normal'],
33-
lineHeight: typeScaleVars['--cl-text-xs-leading'],
33+
lineHeight: typeScaleVars['--cl-text-sm-leading'],
3434
},
3535

3636
actions: {

‎packages/mosaic/src/features/user-button/user-button.controller.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ const userButtonMachine = createMachine({
9090
},
9191
});
9292

93-
export type UserButtonControllerOptions = Pick<UserButtonModeProps, 'mode' | 'modePriority'> & UserButtonMenuProps;
93+
export type UserButtonControllerOptions = Pick<UserButtonModeProps, 'mode'> & UserButtonMenuProps;
9494

9595
export type UserButtonController =
9696
| { status: 'loading' }
@@ -107,7 +107,7 @@ export function useUserButtonController(
107107
model: UserButtonModel,
108108
options: UserButtonControllerOptions = {},
109109
): UserButtonController {
110-
const { mode: requestedMode, modePriority, customMenuItems, menuItemOrder } = options;
110+
const { mode: requestedMode, customMenuItems, menuItemOrder } = options;
111111
const [{ context }, send] = useMachine(userButtonMachine);
112112

113113
// Every action here is a network round trip, so we can start the
@@ -197,7 +197,6 @@ export function useUserButtonController(
197197
status: 'ready',
198198
...data,
199199
mode,
200-
modePriority,
201200
customMenuItems: menuItems,
202201
menuItemOrder,
203202
open: context.open,

0 commit comments

Comments
 (0)