Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
308d850
feat(ui): add a user-button-header slot and layouts to the Mosaic Use…
alexcarpenter Sep 17, 2026
acd31e5
feat(ui): stack the nested organization avatar on a grid
alexcarpenter Sep 17, 2026
987c136
feat(ui): size the user button row avatars at 20px
alexcarpenter Sep 17, 2026
639de3e
feat(ui): round the user button row avatars at 4px
alexcarpenter Sep 17, 2026
b622068
feat(ui): set the xs Item row to 32px
alexcarpenter Sep 17, 2026
ccc2a09
fix(ui): split the stacked avatar grid placement into longhands
alexcarpenter Sep 17, 2026
f857eaf
fix(ui): group the user button slot classes with the popover ones
alexcarpenter Sep 17, 2026
8a051ad
feat(ui): tighten the xs Item and Menu item gap and inline padding to…
alexcarpenter Sep 17, 2026
635cccd
feat(ui): size the Menu sm and Item xs media columns at 20px and drop…
alexcarpenter Sep 17, 2026
681b52b
fix(ui): keep the Menu label padding and size the item gap around it
alexcarpenter Sep 17, 2026
050530a
feat(ui): derive the user button header layout from its actions
alexcarpenter Sep 17, 2026
4bb97cf
feat(ui): size the user button header avatar at 32px
alexcarpenter Sep 17, 2026
c0d5b3a
fix(ui): size the nested avatar by its lead avatar
alexcarpenter Sep 17, 2026
159eb57
chore(ui): note the missing plan label source in the user button model
alexcarpenter Sep 17, 2026
d333654
fix(ui): size the nested workspace avatar at 32px overall
alexcarpenter Sep 17, 2026
4103e74
fix(ui): keep the user button header content beside its avatar
alexcarpenter Sep 17, 2026
7c344b6
fix(ui): let the user button header action labels truncate
alexcarpenter Sep 17, 2026
f19c295
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-butt…
alexcarpenter Sep 17, 2026
a40d154
test(mosaic): guard the user button header label truncation
alexcarpenter Sep 17, 2026
aa5c327
chore(mosaic): shorten the user button header changeset
alexcarpenter Sep 17, 2026
38a1e79
chore(mosaic): trim the user button header changeset
alexcarpenter Sep 17, 2026
276db84
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-butt…
alexcarpenter Sep 17, 2026
e024447
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-butt…
alexcarpenter Sep 17, 2026
bf19eb7
verbose npm logging
dstaley Sep 17, 2026
c63148a
set log level in script
dstaley Sep 17, 2026
26f3e90
feat(mosaic): keep a sign-out row at the foot of the user button in s…
alexcarpenter Sep 17, 2026
2ea5cb6
feat(mosaic): apply the new user button designs and drop modePriority
alexcarpenter Sep 25, 2026
478e411
feat(mosaic): give the user button its own item slots and keep its me…
alexcarpenter Sep 25, 2026
caab588
Merge remote-tracking branch 'origin/main' into carp/mosaic-user-butt…
alexcarpenter Sep 25, 2026
70a3d73
chore(repo): drop verbose npm logging from the snapshot release
alexcarpenter Sep 25, 2026
7a5c7c5
feat(mosaic): use the design icons for invite and switch account in t…
alexcarpenter Sep 25, 2026
141fbb2
feat(mosaic): use the design sign-out icon in the user button
alexcarpenter Sep 25, 2026
9387688
docs(swingset): mark the UserButton as stable
alexcarpenter Sep 25, 2026
69a374b
fix(mosaic): announce pending user button actions and keep the accoun…
alexcarpenter Sep 25, 2026
5d0cee6
Apply suggestion from @alexcarpenter
alexcarpenter Sep 25, 2026
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
7 changes: 7 additions & 0 deletions .changeset/mosaic-user-button-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/mosaic': patch
---

Remove the `modePriority` prop from `UserButton`. The header now follows what it leads with: an active organization offers Settings and Invite, and an account offers Settings and Sign out.

The rows in the `UserButton` popup are now themed through their own `.cl-user-button-item` slots (`-media`, `-content`, `-label`, `-description`, `-trailing`), plus `.cl-user-button-group` and `.cl-user-button-separator`, instead of the shared `.cl-item` slots.
6 changes: 3 additions & 3 deletions packages/mosaic/src/components/item/item.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ export const item = stylex.create({
},

xs: {
gap: space['2'],
height: space['9'],
gap: space['1.5'],
height: space['8'],
},
md: {
gap: space['3'],
Expand All @@ -87,7 +87,7 @@ export const media = stylex.create({
justifyContent: 'center',
},

xs: { width: space['6'] },
xs: { width: space['5'] },
md: { width: space['10'] },
lg: { width: space['10'] },
});
Expand Down
6 changes: 3 additions & 3 deletions packages/mosaic/src/components/menu/menu.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ export const item = stylex.create({
base: {
borderRadius: '0.375rem',
borderStyle: 'none',
gap: space['1'],
gap: space['0.5'],
outline: 'none',
paddingInline: space['1'],
paddingInline: space['1.5'],
alignItems: 'center',
backgroundColor: {
default: 'transparent',
Expand Down Expand Up @@ -139,7 +139,7 @@ export const media = stylex.create({
justifyContent: 'center',
},
xs: { width: space['4'] },
sm: { width: space['6'] },
sm: { width: space['5'] },
});

// The item lays its children out in one flat row, so the label is what has to take the space
Expand Down
29 changes: 29 additions & 0 deletions packages/mosaic/src/components/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ describe('Mosaic Menu', () => {
expect(trigger).toHaveAttribute('aria-haspopup', 'menu');
});

it('keeps a disabled default trigger focusable and closed when focusableWhenDisabled is set', async () => {
render(
<Menu.Root>
<Menu.Trigger
aria-label='Actions'
disabled
focusableWhenDisabled
/>
<Menu.Popup>
<Menu.Item label='Add workspace'>
<Menu.Label>Add workspace</Menu.Label>
</Menu.Item>
</Menu.Popup>
</Menu.Root>,
);
const trigger = screen.getByRole('button', { name: 'Actions' });

expect(trigger).toBeEnabled();
expect(trigger).toHaveAttribute('aria-disabled', 'true');

const act = userEvent.setup();
await act.tab();
expect(trigger).toHaveFocus();

await act.keyboard('{Enter}');
await act.click(trigger);
expect(screen.queryByRole('menu')).toBeNull();
});

it('renders a consumer trigger passed via render', () => {
render(
<Menu.Root>
Expand Down
13 changes: 11 additions & 2 deletions packages/mosaic/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,22 @@ import * as slots from './menu.styles';

export type { MenuProps };

export type MenuTriggerProps = MosaicComponentProps<'button'>;
export type MenuTriggerProps = MosaicComponentProps<'button'> & {
/**
* Keeps a disabled default trigger in the tab order, marked `aria-disabled`. Applies to the
* default `Button` only; a `render` trigger handles its own disabled state.
*
* @default false
*/
focusableWhenDisabled?: boolean;
};

/**
* Opens the menu. Renders a ghost `Button` holding an ellipsis glyph by default;
* pass `children` for a labelled trigger, or `render` to supply your own element.
*/
export const MenuTrigger = React.forwardRef<HTMLButtonElement, MenuTriggerProps>(function MosaicMenuTrigger(
{ render, xstyle, children, ...rest },
{ render, xstyle, children, focusableWhenDisabled, ...rest },
ref,
) {
const trigger: MenuTriggerProps['render'] =
Expand All @@ -39,6 +47,7 @@ export const MenuTrigger = React.forwardRef<HTMLButtonElement, MenuTriggerProps>
variant='ghost'
size='sm'
shape={children ? 'default' : 'square'}
focusableWhenDisabled={focusableWhenDisabled}
{...props}
/>
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ describe('UserButton (connected)', () => {
renderUserButton();
const act = await open();

await accountAction(act, 'Create organization');
await act.click(screen.getByRole('button', { name: 'Create organization' }));

expect(openCreateOrganization).toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
Expand All @@ -544,21 +544,20 @@ describe('UserButton (connected)', () => {
renderUserButton({ createOrganizationUrl: '/new-org' });
const act = await open();

await accountAction(act, 'Create organization');
await act.click(screen.getByRole('button', { name: 'Create organization' }));

expect(navigate).toHaveBeenCalledWith('/new-org');
expect(openCreateOrganization).not.toHaveBeenCalled();
await waitFor(() => expect(popup()).toBeNull());
});

it('leaves create-organization out of the account menu for a user who cannot open one', async () => {
it('leaves "Create organization" out of the list for a user who cannot open one', async () => {
user = { ...(user as FakeUser), createOrganizationEnabled: false };
renderUserButton();
const act = await open();
await act.click(accountMenu());
await open();

expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Create organization' })).toBeNull();
expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull();
});

it('spins the clicked affordance and stands every other one down while an action is in flight', async () => {
Expand All @@ -576,7 +575,7 @@ describe('UserButton (connected)', () => {
// keeps its place in the tab order. Dropping it to a static row would remount it, and with it
// the avatar it carries.
expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true');
expect(screen.getByRole('button', { name: 'Switch account' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Switch account' })).toHaveAttribute('aria-disabled', 'true');
expect(popup()).toBeInTheDocument();

deferred.resolve();
Expand Down
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 @@ -28,7 +24,7 @@ describe('resolveUserButtonLayout, where each action lands', () => {
it('spreads them across all four slots in combined mode', () => {
expect(resolve('combined').actions).toEqual({
header: ['inviteMembers', 'manageLead'],
organizationsHeading: ['createOrganization', 'manageAccount', 'signOut'],
organizationsHeading: ['manageAccount', 'signOut'],
organizationsFooter: ['createOrganization'],
footer: ['switchAccount', 'signOutAll'],
});
Expand All @@ -54,18 +50,44 @@ describe('resolveUserButtonLayout, where each action lands', () => {
});

describe('resolveUserButtonLayout, what the data settles', () => {
it('offers no invitation where no organization is active', () => {
expect(resolve('combined', { activeOrganization: null }).actions.header).toEqual(['manageLead']);
it('leads with the account where no organization is active', () => {
const layout = resolve('combined', { activeOrganization: null });

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

it('leads with no selection where no organization is active and personal is hidden', () => {
const layout = resolve('organization', { activeOrganization: null, hidePersonal: true });

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

// 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, and the account's own row already signs out of it.
it.each<UserButtonMode>(['combined', 'user'])(
'leaves the foot "Add account" alone in %s mode where there is one account',
mode => {
expect(resolve(mode, { additionalSessions: [] }).actions.footer).toEqual(['addAccount']);
},
);
// "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']);
});
});

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');
});
});

describe('resolveUserButtonLayout, which sections render', () => {
Expand Down Expand Up @@ -94,12 +116,3 @@ describe('resolveUserButtonLayout, which sections render', () => {
expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false });
});
});

describe('resolveUserButtonLayout, what the surface leads with', () => {
it('takes the priority only where there are two things to choose between', () => {
expect(resolve('combined', {}, 'user').leadWith).toBe('user');
expect(resolve('combined', {}, 'organization').leadWith).toBe('organization');
expect(resolve('organization', {}, 'user').leadWith).toBe('organization');
expect(resolve('user', {}, 'organization').leadWith).toBe('user');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,12 @@ describe('useUserButtonModel', () => {
render(<Harness />);

const rows = memberships();
expect(rows[0]).toMatchObject({ kind: 'membership', organizationId: 'org_1', name: 'Acme', membersCount: 3 });
expect(rows[0]).toMatchObject({
kind: 'membership',
organizationId: 'org_1',
name: 'Acme',
membersCount: 3,
});

const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]');
expect(suggestions[0]).toMatchObject({
Expand Down
Loading
Loading