Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/mosaic-user-button-combined-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

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.
2 changes: 2 additions & 0 deletions .changeset/user-button-design-tweaks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
14 changes: 9 additions & 5 deletions integration/tests/mosaic/user-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
return u;
}

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

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

await trigger(page).click();
await runAccountAction(page, 'Sign out');
await popup(page).getByRole('button', { name: 'Sign out', exact: true }).click();

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

await trigger(page).click();
await runAccountAction(page, 'Manage account');
await openProfileSettings(page);
await u.po.userProfile.waitForUserProfileModal();

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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { describe, expect, it } from 'vitest';

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

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

function resolve(
mode: UserButtonMode,
data: Partial<UserButtonData> = {},
modePriority: UserButtonModePriority = 'organization',
) {
return resolveUserButtonLayout(mode, modePriority, {
function resolve(mode: UserButtonMode, data: Partial<UserButtonData> = {}) {
return resolveUserButtonLayout(mode, {
activeSession: alice,
activeOrganization: foundry,
hasOrganizations: true,
Expand All @@ -25,30 +21,27 @@ function resolve(
}

describe('resolveUserButtonLayout, where each action lands', () => {
it('spreads them across all four slots in combined mode', () => {
it('spreads them across all three slots in combined mode', () => {
expect(resolve('combined').actions).toEqual({
header: ['inviteMembers', 'manageLead'],
organizationsHeading: ['manageAccount', 'signOut'],
organizationsFooter: ['createOrganization'],
footer: ['switchAccount', 'signOutAll'],
footer: ['switchAccount', 'signOut'],
});
});

it('carries no account actions at all in organization mode', () => {
expect(resolve('organization').actions).toEqual({
header: ['inviteMembers', 'manageLead'],
organizationsHeading: [],
organizationsFooter: ['createOrganization'],
footer: [],
});
});

it('takes the account actions into the header and the foot in user mode', () => {
expect(resolve('user').actions).toEqual({
header: ['signOut', 'manageLead'],
organizationsHeading: [],
header: ['manageLead'],
organizationsFooter: [],
footer: ['switchAccount', 'signOutAll'],
footer: ['switchAccount', 'signOut'],
});
});
});
Expand All @@ -58,7 +51,7 @@ describe('resolveUserButtonLayout, what the data settles', () => {
const layout = resolve('combined', { activeOrganization: null });

expect(layout.lead).toBe('user');
expect(layout.actions.header).toEqual(['signOut', 'manageLead']);
expect(layout.actions.header).toEqual(['manageLead']);
});

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

// With no second account the flyout would open onto one row, so the foot offers that row instead.
// "All accounts" is that one account too, so the foot signs out of just it, in the singular.
it('collapses the foot to "Add account" and "Sign out" in combined mode where there is one account', () => {
expect(resolve('combined', { additionalSessions: [] }).actions.footer).toEqual(['addAccount', 'signOut']);
});

it('signs a lone account out from the header in user mode, leaving the foot to add one', () => {
const layout = resolve('user', { additionalSessions: [] });

expect(layout.actions.header).toEqual(['signOut', 'manageLead']);
expect(layout.actions.footer).toEqual(['addAccount']);
});
it.each(['combined', 'user'] as const)(
'collapses the foot to "Add account" in %s mode where there is one account',
mode => {
expect(resolve(mode, { additionalSessions: [] }).actions.footer).toEqual(['addAccount', 'signOut']);
},
);
});

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

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

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

expect(layout.lead).toBe('user');
expect(layout.actions.header).toEqual(['signOut', 'manageLead']);
});

it('is ignored by the single-purpose modes', () => {
expect(resolve('organization', {}, 'user').lead).toBe('organization');
expect(resolve('user', {}, 'user').lead).toBe('user');
expect(layout.actions.header).toEqual(['manageLead']);
});
});

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

it('runs the gear inline where it is the only action', () => {
expect(resolve('combined', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline');
expect(resolve('user').headerLayout).toBe('inline');
expect(resolve('organization', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline');
});
});

describe('resolveUserButtonLayout, which sections render', () => {
it('heads the organizations with the account even where it belongs to none', () => {
const layout = resolve('combined', { hasOrganizations: false, memberships: [], activeOrganization: null });

expect(layout.showOrganizationsHeading).toBe(true);
expect(layout.showOrganizations).toBe(false);
});

it('counts an invitation or a suggestion as something to list', () => {
const invitation = {
kind: 'invitation',
Expand All @@ -138,6 +114,6 @@ describe('resolveUserButtonLayout, which sections render', () => {
});

it('carries no organizations in user mode', () => {
expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false });
expect(resolve('user').showOrganizations).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,23 @@ export const styles = stylex.create({
export const sizes = stylex.create({
xs: { height: space['6'], width: space['6'] },
sm: { height: space['8'], width: space['8'] },
md: { height: space['9.5'], width: space['9.5'] },
});

export const leadSizes = stylex.create({
xs: lead(6, 3),
sm: lead(8, 3.5),
md: lead(9.5, 4),
});

export const ringSizes = stylex.create({
xs: ring(6),
sm: ring(8),
md: ring(9.5),
});

export const badgeSizes = stylex.create({
xs: badge(3),
sm: badge(3.5),
md: badge(4),
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface UserButtonAvatarProps {
name: string;
imageUrl?: string;
shape: 'circle' | 'square';
size: 'xs' | 'sm';
size: 'xs' | 'sm' | 'md';
badge?: { name: string; imageUrl?: string };
focusRing?: boolean;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export const styles = stylex.create({

description: {
color: colorVars['--cl-color-foreground-secondary'],
fontSize: typeScaleVars['--cl-text-xs-size'],
fontSize: typeScaleVars['--cl-text-sm-size'],
fontWeight: fontWeightVars['--cl-font-normal'],
lineHeight: typeScaleVars['--cl-text-xs-leading'],
lineHeight: typeScaleVars['--cl-text-sm-leading'],
},

actions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const userButtonMachine = createMachine({
},
});

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

export type UserButtonController =
| { status: 'loading' }
Expand All @@ -107,7 +107,7 @@ export function useUserButtonController(
model: UserButtonModel,
options: UserButtonControllerOptions = {},
): UserButtonController {
const { mode: requestedMode, modePriority, customMenuItems, menuItemOrder } = options;
const { mode: requestedMode, customMenuItems, menuItemOrder } = options;
const [{ context }, send] = useMachine(userButtonMachine);

// Every action here is a network round trip, so we can start the
Expand Down Expand Up @@ -197,7 +197,6 @@ export function useUserButtonController(
status: 'ready',
...data,
mode,
modePriority,
customMenuItems: menuItems,
menuItemOrder,
open: context.open,
Expand Down
Loading
Loading