diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md
new file mode 100644
index 00000000000..973e148bd3d
--- /dev/null
+++ b/.changeset/mosaic-user-button-header.md
@@ -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.
diff --git a/packages/mosaic/src/components/item/item.styles.ts b/packages/mosaic/src/components/item/item.styles.ts
index 46aaf947b39..899ecdfb531 100644
--- a/packages/mosaic/src/components/item/item.styles.ts
+++ b/packages/mosaic/src/components/item/item.styles.ts
@@ -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'],
@@ -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'] },
});
diff --git a/packages/mosaic/src/components/menu/menu.styles.ts b/packages/mosaic/src/components/menu/menu.styles.ts
index 9c8ca11b06f..5af8a02a728 100644
--- a/packages/mosaic/src/components/menu/menu.styles.ts
+++ b/packages/mosaic/src/components/menu/menu.styles.ts
@@ -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',
@@ -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
diff --git a/packages/mosaic/src/components/menu/menu.test.tsx b/packages/mosaic/src/components/menu/menu.test.tsx
index eb0060c0c2a..e7711c47dbd 100644
--- a/packages/mosaic/src/components/menu/menu.test.tsx
+++ b/packages/mosaic/src/components/menu/menu.test.tsx
@@ -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(
+
+
+
+
+ Add workspace
+
+
+ ,
+ );
+ 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(
diff --git a/packages/mosaic/src/components/menu/menu.tsx b/packages/mosaic/src/components/menu/menu.tsx
index 70026fce4ce..2332b06668b 100644
--- a/packages/mosaic/src/components/menu/menu.tsx
+++ b/packages/mosaic/src/components/menu/menu.tsx
@@ -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(function MosaicMenuTrigger(
- { render, xstyle, children, ...rest },
+ { render, xstyle, children, focusableWhenDisabled, ...rest },
ref,
) {
const trigger: MenuTriggerProps['render'] =
@@ -39,6 +47,7 @@ export const MenuTrigger = React.forwardRef
variant='ghost'
size='sm'
shape={children ? 'default' : 'square'}
+ focusableWhenDisabled={focusableWhenDisabled}
{...props}
/>
));
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx
index 6dbc4ea32fe..a8a4f556214 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.integration.test.tsx
@@ -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();
@@ -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 () => {
@@ -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();
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts b/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts
index 9dfe95e4a52..675b81c8d85 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.layout.test.ts
@@ -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 = {},
- modePriority: UserButtonModePriority = 'organization',
-) {
- return resolveUserButtonLayout(mode, modePriority, {
+function resolve(mode: UserButtonMode, data: Partial = {}) {
+ return resolveUserButtonLayout(mode, {
activeSession: alice,
activeOrganization: foundry,
hasOrganizations: true,
@@ -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'],
});
@@ -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(['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', () => {
@@ -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');
- });
-});
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx
index 4c17845117f..3a8f4092bba 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.model.test.tsx
@@ -462,7 +462,12 @@ describe('useUserButtonModel', () => {
render();
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({
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx
index c42c24c7ff8..b0ffeecc8e8 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx
@@ -72,14 +72,21 @@ const popup = () => screen.getByRole('dialog', { name: 'Account' });
// The `cl-` slot classes are Mosaic's public theming hooks, so they are a stable handle on the
// popup's sections rather than an implementation detail.
-const groups = () => Array.from(popup().querySelectorAll('.cl-item-group'));
+const groups = () => Array.from(popup().querySelectorAll('.cl-user-button-group'));
+const header = () => {
+ const node = popup().querySelector('.cl-user-button-header');
+ if (!node) {
+ throw new Error('The popup has no header');
+ }
+ return node;
+};
const labels = (group: HTMLElement | undefined) =>
- Array.from(group?.querySelectorAll(".cl-item-label[data-variant='default']") ?? []).map(
+ Array.from(group?.querySelectorAll(".cl-user-button-item-label[data-variant='default']") ?? []).map(
node => node.textContent ?? '',
);
const row = (group: HTMLElement | undefined, label: string) =>
- Array.from(group?.querySelectorAll('.cl-item') ?? []).find(
- node => node.querySelector(".cl-item-label[data-variant='default']")?.textContent === label,
+ Array.from(group?.querySelectorAll('.cl-user-button-item') ?? []).find(
+ node => node.querySelector(".cl-user-button-item-label[data-variant='default']")?.textContent === label,
);
const scrollClasses = stylex.props(...scrollAreaViewport('auto')).className?.split(' ') ?? [];
@@ -109,17 +116,15 @@ describe('UserButtonView, user mode', () => {
it('names the account in the header, never the organization that is active', () => {
renderUserMode();
- const header = groups()[0];
- expect(within(header).getByText('Alice Smith')).toBeInTheDocument();
- expect(within(header).getByText('alice@example.com')).toBeInTheDocument();
+ expect(within(header()).getByText('Alice Smith')).toBeInTheDocument();
+ expect(within(header()).getByText('alice@example.com')).toBeInTheDocument();
expect(screen.queryByText('Foundry')).toBeNull();
});
it('drops the identifier line when it would only repeat the label', () => {
renderUserMode({ activeSession: { ...alice, name: 'alice@example.com' } });
- const header = groups()[0];
- expect(within(header).getAllByText('alice@example.com')).toHaveLength(1);
+ expect(within(header()).getAllByText('alice@example.com')).toHaveLength(1);
});
it('lists no workspaces at all, and offers no way to make one', () => {
@@ -135,19 +140,20 @@ describe('UserButtonView, user mode', () => {
const onSignOutSession = vi.fn();
renderUserMode({ onSignOutSession });
- expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' }));
- expect(onSignOutSession).toHaveBeenCalledWith('sess_1');
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header');
});
it('spins the header sign-out while it is in flight', () => {
- renderUserMode({ pendingKey: userButtonBusyKeys.signOutSession('sess_1') });
+ renderUserMode({ pendingKey: userButtonBusyKeys.signOutSession('sess_1', 'header') });
const button = screen.getByRole('button', { name: 'Sign out' });
- expect(button).toBeDisabled();
- expect(button.querySelector('.cl-spinner')).not.toBeNull();
+ expect(button).toHaveAttribute('type', 'button');
+ expect(button).toHaveAttribute('aria-busy', 'true');
+ expect(within(button).getByRole('progressbar')).toBeInTheDocument();
});
it('opens the accounts from the foot rather than listing them inline', async () => {
@@ -184,18 +190,18 @@ describe('UserButtonView, organization mode', () => {
it('heads the surface with the active organization and what can be done to it', () => {
renderOrganizationMode();
- const header = groups()[0];
- expect(within(header).getByText('Foundry')).toBeInTheDocument();
- expect(within(header).getByText('24 members · Pro')).toBeInTheDocument();
+ expect(within(header()).getByText('Foundry')).toBeInTheDocument();
+ expect(within(header()).getByText('Pro · 24 members')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('falls back to the account in the header where no organization is active', () => {
renderOrganizationMode({ activeOrganization: null });
- expect(within(groups()[0]).getByText('Alice Smith')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
+ expect(within(header()).getByText('Alice Smith')).toBeInTheDocument();
+ expect(within(header()).getByRole('button', { name: 'Settings' })).toBeInTheDocument();
+ expect(within(header()).getByRole('button', { name: 'Sign out' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
});
@@ -203,7 +209,7 @@ describe('UserButtonView, organization mode', () => {
it('names no organization selected where personal is hidden and none is active', () => {
renderOrganizationMode({ hidePersonal: true, activeOrganization: null });
- expect(within(groups()[0]).getByText('No organization selected')).toBeInTheDocument();
+ expect(within(header()).getByText('No organization selected')).toBeInTheDocument();
expect(screen.queryByText('Alice Smith')).toBeNull();
expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
@@ -215,8 +221,8 @@ describe('UserButtonView, organization mode', () => {
it('offers to invite while the membership list is still in flight', () => {
renderOrganizationMode({ memberships: [], hasOrganizations: false, organizationsLoading: true });
- expect(within(groups()[0]).getByText('Foundry')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument();
+ expect(within(header()).getByText('Foundry')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument();
});
@@ -260,16 +266,9 @@ describe('UserButtonView, combined mode', () => {
renderCombined();
// The subtitle is the header's alone; the row below it carries only a label.
- expect(screen.getByText('24 members · Pro')).toBeInTheDocument();
+ expect(screen.getByText('Pro · 24 members')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument();
- });
-
- it('heads the surface with the account where the user takes priority', () => {
- renderCombined({ modePriority: 'user' });
-
- expect(screen.queryByText('24 members · Pro')).toBeNull();
- expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
});
it('heads the workspace list with the active account and its own actions', async () => {
@@ -280,10 +279,9 @@ describe('UserButtonView, combined mode', () => {
await act.click(screen.getByRole('button', { name: 'Actions for alice@example.com' }));
expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument();
- expect(screen.getByRole('menuitem', { name: 'Create organization' })).toBeInTheDocument();
await act.click(screen.getByRole('menuitem', { name: 'Sign out' }));
- expect(onSignOutSession).toHaveBeenCalledWith('sess_1');
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'organizationsHeading');
});
it('trails the workspaces with "Create organization", below the last of them', async () => {
@@ -291,7 +289,7 @@ describe('UserButtonView, combined mode', () => {
renderCombined({ onCreateOrganization });
// Not `labels`: the row is an action rather than a workspace, so its label is the interactive one.
- const rows = Array.from(workspaceList()?.querySelectorAll('.cl-item-label') ?? []);
+ const rows = Array.from(workspaceList()?.querySelectorAll('.cl-user-button-item-label') ?? []);
expect(rows.at(-1)?.textContent).toBe('Create organization');
await userEvent.setup().click(screen.getByRole('button', { name: 'Create organization' }));
@@ -360,10 +358,10 @@ describe('UserButtonView, the workspace list', () => {
}
// Accepting an invitation joins; a suggestion only files a request, so invitations lead.
- it('leads with the invitations, then the suggestions, then the workspaces held', () => {
+ it('lists the workspaces held, then the invitations, then the suggestions', () => {
renderList({ invitations: [gamma], suggestions: [beta] });
- expect(labels(workspaceList())).toEqual(['Gamma', 'Beta', 'Personal account', 'Foundry']);
+ expect(labels(workspaceList())).toEqual(['Personal account', 'Foundry', 'Gamma', 'Beta']);
});
// `auto` rather than `stable`: a reserved gutter would inset short lists off the edge the header
@@ -472,7 +470,7 @@ describe('UserButtonView, the workspace list', () => {
it('lists them with no memberships to list them beside', () => {
renderList({ hasOrganizations: false, activeOrganization: null, memberships: [], invitations: [gamma] });
- expect(labels(workspaceList())).toEqual(['Gamma', 'Personal account']);
+ expect(labels(workspaceList())).toEqual(['Personal account', 'Gamma']);
expect(screen.getByRole('button', { name: 'Accept' })).toBeInTheDocument();
});
@@ -588,7 +586,7 @@ describe('UserButtonView, the foot', () => {
/** The foot's rows, in the order it lists them. It is the last group in the popup. */
const footActions = () =>
- Array.from(groups().at(-1)?.querySelectorAll(".cl-item-label[data-variant='interactive']") ?? []).map(
+ Array.from(groups().at(-1)?.querySelectorAll(".cl-user-button-item-label[data-variant='interactive']") ?? []).map(
node => node.textContent ?? '',
);
@@ -679,11 +677,16 @@ describe('UserButtonView, the foot', () => {
expect(within(popup()).queryByRole('link', { name: 'Clerk' })).toBeNull();
});
- // "All accounts" is one account, and the account's own row already signs out of it.
- it('withholds "Sign out of all accounts" where there is no second account', () => {
- renderView({ additionalSessions: [] });
+ // "All accounts" is one account, so the foot keeps its sign-out row and signs out of just that one.
+ it('signs out of the one account at the foot where there is no second', async () => {
+ const onSignOutSession = vi.fn();
+ renderView({ additionalSessions: [], onSignOutSession });
expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull();
+ const rows = within(groups().at(-1) ?? document.body);
+ await userEvent.click(rows.getByRole('button', { name: 'Sign out' }));
+
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'footer');
});
});
@@ -738,14 +741,39 @@ describe('UserButtonView, one action at a time', () => {
// The press leaves focus on the row, so the row is what gets re-read while it works. It takes
// the same pairing as a pending `SubmitButton` — `aria-busy` beside an indicator carrying a name
// of its own — since a row that only stands down `aria-disabled` reads as unavailable instead.
- it('reports the switch on the row that owns it, the way a pending button does', () => {
- render(surface(userButtonBusyKeys.selectOrganization('org_2')));
+ it.each([
+ ['a workspace row', 'Other Co', userButtonBusyKeys.selectOrganization('org_2')],
+ ['an action row', 'Sign out of all accounts', userButtonBusyKeys.signOutAll()],
+ ['the accounts flyout', 'Switch account', userButtonBusyKeys.switchSession('sess_2')],
+ [
+ 'the account menu',
+ 'Actions for alice@example.com',
+ userButtonBusyKeys.signOutSession('sess_1', 'organizationsHeading'),
+ ],
+ ])('reports the action on %s that owns it, in place, the way a pending button does', (_name, label, key) => {
+ const { rerender } = render(surface(null));
+ const row = screen.getByRole('button', { name: label });
- const row = screen.getByRole('button', { name: 'Other Co' });
+ rerender(surface(key));
+
+ expect(screen.getByRole('button', { name: label })).toBe(row);
expect(row).toHaveAttribute('aria-busy', 'true');
expect(within(row).getByRole('progressbar')).toHaveAccessibleName('pending');
});
+ it('spins only the sign-out that was pressed where the surface offers it in several places', () => {
+ render(
+ surface(userButtonBusyKeys.signOutSession('sess_1', 'header'), {
+ activeOrganization: null,
+ additionalSessions: [],
+ }),
+ );
+
+ const spinners = popup().querySelectorAll('.cl-spinner');
+ expect(spinners).toHaveLength(1);
+ expect(within(header()).getByRole('button', { name: 'Sign out' })).toContainElement(spinners[0]);
+ });
+
// The rows waiting on it are not running anything, so they carry the indicator's opposite.
it('leaves the rows standing down beside it with nothing to report', () => {
render(surface(userButtonBusyKeys.selectOrganization('org_2')));
@@ -760,7 +788,7 @@ describe('UserButtonView, one action at a time', () => {
it.each([
['the account menu', 'Actions for alice@example.com'],
['the accounts flyout', 'Switch account'],
- ])('holds %s in place, disabled, while another action runs', (_name, label) => {
+ ])('holds %s in place, aria-disabled and still focusable, while another action runs', (_name, label) => {
const { rerender } = render(surface(null));
const row = screen.getByRole('button', { name: label });
@@ -768,14 +796,8 @@ describe('UserButtonView, one action at a time', () => {
const stoodDown = screen.getByRole('button', { name: label });
expect(stoodDown).toBe(row);
- expect(stoodDown).toBeDisabled();
- });
-
- // The flyout closes on pick, so the row that opened it is what is left to report the switch.
- it('reports a switch on the row that opened the flyout', () => {
- render(surface(userButtonBusyKeys.switchSession('sess_2')));
-
- expect(screen.getByRole('button', { name: 'Switch account' }).querySelector('.cl-spinner')).not.toBeNull();
+ expect(stoodDown).toHaveAttribute('aria-disabled', 'true');
+ expect(stoodDown).toBeEnabled();
});
// `aria-disabled` is advisory, so the row has to drop the press itself.
@@ -843,13 +865,6 @@ describe('UserButtonTrigger', () => {
expect(screen.getByText('Pro')).toBeInTheDocument();
});
- it('names the account in combined mode where the user takes priority', () => {
- renderTrigger({ mode: 'combined', modePriority: 'user' });
-
- expect(screen.getByText('Alice Smith')).toBeInTheDocument();
- expect(screen.queryByText('Pro')).toBeNull();
- });
-
it('renders the avatar alone when the label is off', () => {
renderTrigger({ mode: 'organization', renderTriggerLabel: false });
@@ -911,3 +926,104 @@ describe('UserButtonTrigger', () => {
expect(screen.queryByText('Alice Smith')).toBeNull();
});
});
+
+describe('UserButtonView, the header', () => {
+ function renderHeader(props: Partial = {}) {
+ return renderView({
+ hasOrganizations: true,
+ activeOrganization: foundry,
+ memberships: [foundry, otherCo],
+ ...props,
+ });
+ }
+
+ it('carries the slot classes for the trigger, the popover, and the header', () => {
+ renderHeader();
+
+ expect(screen.getByRole('button', { name: 'Open account menu for Foundry' }).className).toMatch(
+ /^cl-popover-trigger cl-user-button-trigger /,
+ );
+ expect(popup().querySelector('.cl-user-button-popover')?.className).toMatch(
+ /^cl-popover-popup cl-user-button-popover /,
+ );
+ expect(header()).toHaveAttribute('data-layout', 'stacked');
+ expect(header().querySelector('.cl-user-button-header-title')?.textContent).toBe('Foundry');
+ expect(header().querySelector('.cl-user-button-header-description')?.textContent).toBe('Pro · 24 members');
+ expect(header().querySelector('.cl-user-button-header-actions')).not.toBeNull();
+ });
+
+ it('stacks the actions under the workspace as labelled buttons', () => {
+ renderHeader();
+
+ expect(header()).toHaveAttribute('data-layout', 'stacked');
+ const settings = screen.getByRole('button', { name: 'Settings' });
+ expect(settings).toHaveAttribute('data-full-width');
+ expect(settings).not.toHaveAttribute('data-shape', 'square');
+ expect(screen.getByRole('button', { name: 'Invite' })).toHaveAttribute('data-full-width');
+ expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull();
+ });
+
+ it('gives each stacked label a box of its own, so a long one truncates', () => {
+ renderHeader();
+
+ const settings = screen.getByRole('button', { name: 'Settings' });
+ expect(within(settings).getByText('Settings').tagName).toBe('SPAN');
+ });
+
+ it('stacks the account actions the same way', async () => {
+ const onSignOutSession = vi.fn();
+ const onManageAccount = vi.fn();
+ renderHeader({ mode: 'user', onSignOutSession, onManageAccount });
+
+ expect(header()).toHaveAttribute('data-layout', 'stacked');
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Settings' }));
+ expect(onManageAccount).toHaveBeenCalled();
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' }));
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header');
+ });
+
+ it('runs the gear inline as an icon where it is the only action', () => {
+ renderHeader({ hidePersonal: true, activeOrganization: null });
+
+ expect(header()).toHaveAttribute('data-layout', 'inline');
+ expect(screen.getByRole('button', { name: 'Manage account' })).toHaveAttribute('data-shape', 'square');
+ expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
+ });
+
+ it('signs a lone account out from the header in user mode, leaving the foot to add one', async () => {
+ const onSignOutSession = vi.fn();
+ renderHeader({ mode: 'user', additionalSessions: [], onSignOutSession });
+
+ expect(header()).toHaveAttribute('data-layout', 'stacked');
+ expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Sign out' })).toHaveLength(1);
+ await userEvent.setup().click(within(header()).getByRole('button', { name: 'Sign out' }));
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header');
+ });
+
+ it('leads a combined surface with the account where no organization is active', async () => {
+ const onSignOutSession = vi.fn();
+ const onManageAccount = vi.fn();
+ renderHeader({ activeOrganization: null, onSignOutSession, onManageAccount });
+
+ expect(within(header()).getByText('Alice Smith')).toBeInTheDocument();
+ expect(header()).toHaveAttribute('data-layout', 'stacked');
+ await userEvent.setup().click(within(header()).getByRole('button', { name: 'Settings' }));
+ expect(onManageAccount).toHaveBeenCalled();
+ await userEvent.setup().click(within(header()).getByRole('button', { name: 'Sign out' }));
+ expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header');
+ });
+
+ it('falls back to the identifier where no organization is active', () => {
+ renderHeader({ activeOrganization: null });
+
+ expect(within(header()).getByText('alice@example.com')).toBeInTheDocument();
+ });
+
+ it('keeps the identifier on a user surface, whatever organization is active', () => {
+ renderHeader({ mode: 'user' });
+
+ expect(within(header()).getByText('alice@example.com')).toBeInTheDocument();
+ });
+});
diff --git a/packages/mosaic/src/features/user-button/user-button-header.styles.ts b/packages/mosaic/src/features/user-button/user-button-header.styles.ts
new file mode 100644
index 00000000000..e460dc8deeb
--- /dev/null
+++ b/packages/mosaic/src/features/user-button/user-button-header.styles.ts
@@ -0,0 +1,49 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { colorVars, fontWeightVars, space, typeScaleVars } from '../../tokens.stylex';
+
+export const styles = stylex.create({
+ root: {
+ padding: space['3'],
+ gap: space['3'],
+ alignItems: 'center',
+ display: 'flex',
+ flexWrap: 'wrap',
+ },
+
+ content: {
+ display: 'flex',
+ flexBasis: 0,
+ flexDirection: 'column',
+ flexGrow: 1,
+ minWidth: 0,
+ },
+
+ title: {
+ color: colorVars['--cl-color-foreground'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ fontWeight: fontWeightVars['--cl-font-medium'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ },
+
+ description: {
+ color: colorVars['--cl-color-foreground-secondary'],
+ fontSize: typeScaleVars['--cl-text-xs-size'],
+ fontWeight: fontWeightVars['--cl-font-normal'],
+ lineHeight: typeScaleVars['--cl-text-xs-leading'],
+ },
+
+ actions: {
+ gap: space['2'],
+ alignItems: 'center',
+ display: 'flex',
+ flexShrink: 0,
+ },
+
+ actionsStacked: {
+ display: 'grid',
+ flexBasis: '100%',
+ gridAutoColumns: 'minmax(0, 1fr)',
+ gridAutoFlow: 'column',
+ },
+});
diff --git a/packages/mosaic/src/features/user-button/user-button-header.view.tsx b/packages/mosaic/src/features/user-button/user-button-header.view.tsx
new file mode 100644
index 00000000000..81ca8a25cae
--- /dev/null
+++ b/packages/mosaic/src/features/user-button/user-button-header.view.tsx
@@ -0,0 +1,54 @@
+import * as stylex from '@stylexjs/stylex';
+import type { ReactElement, ReactNode } from 'react';
+
+import { mergeStyleProps, themeProps } from '../../props';
+import { reset } from '../../utils/reset.styles';
+import { truncationStyles } from '../../utils/typography.styles';
+import type { UserButtonHeaderLayout } from './user-button.types';
+import { styles } from './user-button-header.styles';
+
+export interface UserButtonHeaderProps {
+ layout: UserButtonHeaderLayout;
+ avatar: ReactNode;
+ title: string;
+ description?: string;
+ actions?: ReactNode;
+}
+
+export function UserButtonHeader({ layout, avatar, title, description, actions }: UserButtonHeaderProps): ReactElement {
+ return (
+
+ {avatar}
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {actions ? (
+
+ {actions}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/mosaic/src/features/user-button/user-button-item.styles.ts b/packages/mosaic/src/features/user-button/user-button-item.styles.ts
new file mode 100644
index 00000000000..d8960326b43
--- /dev/null
+++ b/packages/mosaic/src/features/user-button/user-button-item.styles.ts
@@ -0,0 +1,117 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';
+
+export const styles = stylex.create({
+ root: {
+ // The icon in the media column rides the row's text strength rather than its own, the way
+ // `Button` does it. `Icon` reads the var (`icon.styles.ts`) — StyleX can't emit a descendant
+ // rule, so the value crosses the element boundary as a custom property. It is restated in
+ // `interactive` rather than gaining a hover branch here: StyleX resolves a property to the
+ // last style that declares it, so the two can't merge.
+ '--_cl-icon-color': colorVars['--cl-color-foreground-secondary'],
+ borderRadius: radiusVars['--cl-radius-lg'],
+ gap: space['1.5'],
+ paddingInline: space['1.5'],
+ alignItems: 'center',
+ color: colorVars['--cl-color-foreground-secondary'],
+ display: 'flex',
+ fontFamily: fontFamilyVars['--cl-font-family-sans'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ textAlign: 'start',
+ height: space['8'],
+ width: '100%',
+ },
+
+ // A row rendered as a link or button gains hover and cursor. A row standing down while another
+ // action runs keeps its look rather than dimming, but the pointer still reaches it to show
+ // `not-allowed`, so every hover branch excludes it. Both spellings count: a row that has to stay
+ // focusable while it waits carries `aria-disabled` instead of the native attribute.
+ interactive: {
+ '--_cl-icon-color': {
+ default: colorVars['--cl-color-foreground-secondary'],
+ '@media (hover: hover)': {
+ default: null,
+ ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-foreground'],
+ },
+ },
+ backgroundColor: {
+ default: null,
+ ':active:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-200'],
+ '@media (hover: hover)': {
+ ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-neutral-alpha-100'],
+ },
+ },
+ color: {
+ default: colorVars['--cl-color-foreground-secondary'],
+ '@media (hover: hover)': {
+ default: null,
+ ':hover:not(:disabled, [aria-disabled="true"])': colorVars['--cl-color-foreground'],
+ },
+ },
+ cursor: {
+ default: 'pointer',
+ ':is(:disabled, [aria-disabled="true"])': 'not-allowed',
+ },
+ },
+
+ media: {
+ alignItems: 'center',
+ aspectRatio: '1/1',
+ display: 'flex',
+ flexShrink: 0,
+ justifyContent: 'center',
+ width: space['5'],
+ },
+
+ content: {
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ justifyContent: 'center',
+ minWidth: 0,
+ },
+
+ label: {
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ fontWeight: fontWeightVars['--cl-font-medium'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ },
+
+ labelDefault: {
+ color: colorVars['--cl-color-foreground'],
+ },
+
+ description: {
+ color: colorVars['--cl-color-foreground-secondary'],
+ fontSize: typeScaleVars['--cl-text-xs-size'],
+ fontWeight: fontWeightVars['--cl-font-normal'],
+ lineHeight: typeScaleVars['--cl-text-xs-leading'],
+ },
+
+ // At least as wide as the `⋯` menu button that owns it, so whatever stands in that button's
+ // place — the active check, a spinner — lands on the same center line and the right edge of every
+ // row holds still as rows change state. A labelled button or a note grows it past that.
+ trailing: {
+ gap: space['2'],
+ alignItems: 'center',
+ display: 'flex',
+ flexShrink: 0,
+ justifyContent: 'center',
+ minWidth: space['7'],
+ },
+
+ group: {
+ padding: space['1.5'],
+ width: '100%',
+ },
+
+ separator: {
+ borderStyle: 'none',
+ backgroundColor: colorVars['--cl-color-border-subtle'],
+ flexShrink: 0,
+ height: '1px',
+ width: '100%',
+ },
+});
diff --git a/packages/mosaic/src/features/user-button/user-button-item.view.tsx b/packages/mosaic/src/features/user-button/user-button-item.view.tsx
new file mode 100644
index 00000000000..572788c6264
--- /dev/null
+++ b/packages/mosaic/src/features/user-button/user-button-item.view.tsx
@@ -0,0 +1,118 @@
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import { useRender } from '../../primitives/utils';
+import type { MosaicComponentProps, MosaicElementProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { focusOutline } from '../../utils/focus-outline.styles';
+import { reset } from '../../utils/reset.styles';
+import { truncationStyles } from '../../utils/typography.styles';
+import { styles } from './user-button-item.styles';
+
+export const UserButtonItem = React.forwardRef>(function UserButtonItem(
+ { render, xstyle, ...rest },
+ ref,
+) {
+ const interactive = Boolean(render);
+ return useRender({
+ defaultTagName: 'div',
+ render,
+ ref,
+ props: mergeStyleProps(
+ themeProps('user-button-item', { interactive }),
+ stylex.props(reset.base, focusOutline.visible, styles.root, interactive && styles.interactive, xstyle),
+ rest,
+ ),
+ });
+});
+
+export function UserButtonItemMedia({ xstyle, ...rest }: MosaicElementProps<'div'>) {
+ return (
+
+ );
+}
+
+export function UserButtonItemContent({ xstyle, ...rest }: MosaicElementProps<'div'>) {
+ return (
+
+ );
+}
+
+export type UserButtonItemLabelProps = MosaicElementProps<'div'> & {
+ /**
+ * `default` names the row's subject (a person, an organization) in its own color. `interactive`
+ * takes the row's color, so it brightens with the row on hover. Use it where the text is the row
+ * itself (`Add account`, `Sign out`).
+ *
+ * @default 'default'
+ */
+ variant?: 'default' | 'interactive';
+};
+
+export function UserButtonItemLabel({ variant = 'default', xstyle, ...rest }: UserButtonItemLabelProps) {
+ return (
+
+ );
+}
+
+export function UserButtonItemDescription({ xstyle, ...rest }: MosaicElementProps<'div'>) {
+ return (
+
+ );
+}
+
+export function UserButtonItemTrailing({ xstyle, ...rest }: MosaicElementProps<'div'>) {
+ return (
+
+ );
+}
+
+export function UserButtonGroup({ xstyle, ...rest }: MosaicElementProps<'div'>) {
+ return (
+
+ );
+}
+
+export function UserButtonSeparator({ xstyle, ...rest }: MosaicElementProps<'hr'>) {
+ return (
+
+ );
+}
diff --git a/packages/mosaic/src/features/user-button/user-button.controller.tsx b/packages/mosaic/src/features/user-button/user-button.controller.tsx
index 9af27e07bc0..51badbee97c 100644
--- a/packages/mosaic/src/features/user-button/user-button.controller.tsx
+++ b/packages/mosaic/src/features/user-button/user-button.controller.tsx
@@ -90,7 +90,7 @@ const userButtonMachine = createMachine({
},
});
-export type UserButtonControllerOptions = Pick & UserButtonMenuProps;
+export type UserButtonControllerOptions = Pick & UserButtonMenuProps;
export type UserButtonController =
| { status: 'loading' }
@@ -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
@@ -197,7 +197,6 @@ export function useUserButtonController(
status: 'ready',
...data,
mode,
- modePriority,
customMenuItems: menuItems,
menuItemOrder,
open: context.open,
diff --git a/packages/mosaic/src/features/user-button/user-button.layout.ts b/packages/mosaic/src/features/user-button/user-button.layout.ts
index 106aa7a099a..101e755ecd7 100644
--- a/packages/mosaic/src/features/user-button/user-button.layout.ts
+++ b/packages/mosaic/src/features/user-button/user-button.layout.ts
@@ -1,4 +1,4 @@
-import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './user-button.types';
+import type { UserButtonData, UserButtonHeaderLayout, UserButtonMode } from './user-button.types';
/*
* Which mode puts what where. The surface is three slots deep, in this order, and each mode fills
@@ -6,12 +6,13 @@ import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './u
*
* combined organization user
* ┌────────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────────┐
- * │ Foundry [Invite][⚙] │ │ Foundry [Invite][⚙] │ │ Alice [Sign out][⚙] │ header
+ * │ Foundry │ │ Foundry │ │ Alice │ header
+ * │ [⚙ Settings] [Invite] │ │ [⚙ Settings] [Invite] │ │ [⚙ Settings] [Sign out] │
* ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤
* │ alice@x.com [⋯] │ │ │ │ │ organizationsHeading
- * │ Personal account │ │ Personal account │ │ │ ┐
+ * │ Personal account │ │ Personal account │ │ │ ┐
* │ ✓ Foundry │ │ ✓ Foundry │ │ │ ┘ organization rows
- * │ + Create organization │ │ + Create organization │ │ │ organizationsFooter
+ * │ + Create organization │ │ + Create organization │ │ │ organizationsFooter
* ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤
* │ ⇄ Switch account › │ │ │ │ ⇄ Switch account › │ ┐
* │ ⤴ Sign out of all accounts │ │ │ │ ⤴ Sign out of all accounts │ ┘ footer
@@ -20,6 +21,10 @@ import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './u
* The organizations are listed on the surface, headed by the active account, since they are the
* workspaces that account can switch between. The other signed-in accounts are not: they are one
* row at the foot that opens a flyout of them, so the surface stays about the workspace it is on.
+ *
+ * The header is about whatever leads, not the mode: an organization is managed and invited to, an
+ * account is managed and signed out of. With no organization active, a combined surface leads with
+ * the account.
*/
/** The four places an action can land. Every mode has a header and a footer; the list's two vary. */
@@ -34,49 +39,59 @@ export type UserButtonAction =
| 'manageAccount'
| 'signOut'
| 'signOutAll'
- /** The flyout of signed-in accounts. Collapses to `addAccount` where there is only the one. */
+ /** The flyout of signed-in accounts. */
| 'switchAccount';
-/** One mode's whole surface, top to bottom. */
+/**
+ * What the trigger names and the header leads with. `none` is an organization-led surface with no
+ * organization active and no personal workspace to fall back to.
+ */
+export type UserButtonLead = 'organization' | 'user' | 'none';
+
+const headers = {
+ organization: ['inviteMembers', 'manageLead'],
+ user: ['signOut', 'manageLead'],
+ none: ['manageLead'],
+} as const satisfies Record;
+
+/** One mode's whole surface below the header, top to bottom. */
interface ModeLayout {
- header: readonly UserButtonAction[];
/**
* The workspaces the active account switches between: its own, plus the organizations it is in.
* `false` is a list the mode does not carry at all; `heading: false` runs the rows unheaded.
* `footer` trails the rows, inside the list, since what it offers is one more workspace.
*/
organizations: { heading: readonly UserButtonAction[] | false; footer: readonly UserButtonAction[] } | false;
- footer: readonly UserButtonAction[];
+ /**
+ * With a second account the foot opens onto all of them and signs out of every one. With just the
+ * one there is nothing to switch between or to sign out of "all" of.
+ */
+ footer: { multiSession: readonly UserButtonAction[]; singleSession: readonly UserButtonAction[] };
}
const modes = {
combined: {
- header: ['inviteMembers', 'manageLead'],
- organizations: { heading: ['createOrganization', 'manageAccount', 'signOut'], footer: ['createOrganization'] },
- footer: ['switchAccount', 'signOutAll'],
+ organizations: { heading: ['manageAccount', 'signOut'], footer: ['createOrganization'] },
+ footer: { multiSession: ['switchAccount', 'signOutAll'], singleSession: ['addAccount', 'signOut'] },
},
// Not about the account, so it heads its workspaces with nothing and offers no other account.
organization: {
- header: ['inviteMembers', 'manageLead'],
organizations: { heading: false, footer: ['createOrganization'] },
- footer: [],
+ footer: { multiSession: [], singleSession: [] },
},
- // No workspaces at all, so the header takes the account's own actions and the foot is the
- // accounts flyout and what acts across every one of them.
+ // No workspaces at all. The header already signs the lone account out, so the foot only adds one.
user: {
- header: ['signOut', 'manageLead'],
organizations: false,
- footer: ['switchAccount', 'signOutAll'],
+ footer: { multiSession: ['switchAccount', 'signOutAll'], singleSession: ['addAccount'] },
},
} as const satisfies Record;
/**
- * Where each of the surface's actions landed, resolved once from `mode`, `modePriority` and the
- * data, so no section has to read any of them again.
+ * Where each of the surface's actions landed, resolved once from `mode` and the data, so no section
+ * has to read either of them again.
*/
export interface UserButtonLayout {
- /** Which workspace the trigger names and the header leads with. */
- leadWith: 'organization' | 'user';
+ lead: UserButtonLead;
/** The organization rows: their own workspace, the organizations, and what is on offer. */
showOrganizations: boolean;
/**
@@ -84,15 +99,22 @@ export interface UserButtonLayout {
* still needs somewhere to manage and sign out of itself.
*/
showOrganizationsHeading: boolean;
+ headerLayout: UserButtonHeaderLayout;
/** What each slot carries, in the order it renders. */
actions: Record;
}
-export function resolveUserButtonLayout(
- mode: UserButtonMode,
- modePriority: UserButtonModePriority,
- data: UserButtonData,
-): UserButtonLayout {
+function resolveLead(mode: UserButtonMode, data: UserButtonData): UserButtonLead {
+ if (mode === 'user') {
+ return 'user';
+ }
+ if (data.activeOrganization) {
+ return 'organization';
+ }
+ return data.hidePersonal ? 'none' : 'user';
+}
+
+export function resolveUserButtonLayout(mode: UserButtonMode, data: UserButtonData): UserButtonLayout {
const declared: ModeLayout = modes[mode];
const organizationsHeading = declared.organizations === false ? false : declared.organizations.heading;
const organizationsFooter = declared.organizations === false ? [] : declared.organizations.footer;
@@ -102,39 +124,19 @@ export function resolveUserButtonLayout(
// Loading does not count, so an account with none never opens a list that then disappears.
const hasOrganizations = data.hasOrganizations || data.suggestions.length > 0 || data.invitations.length > 0;
- /** The action this surface actually carries in place of the one declared, or `null` for none. */
- const resolve = (action: UserButtonAction): UserButtonAction | null => {
- switch (action) {
- // Inviting belongs to whichever organization is active, even where the account is what heads
- // the surface.
- case 'inviteMembers':
- return data.activeOrganization ? action : null;
- // "All accounts" is one account. The account's own row already signs out of it, so the foot
- // would be offering the same thing over again, in the plural.
- case 'signOutAll':
- return hasOtherSessions ? action : null;
- // With no second account there is nothing to switch between, so the flyout collapses to the
- // one row it would have opened onto.
- case 'switchAccount':
- return hasOtherSessions ? action : 'addAccount';
- default:
- return action;
- }
- };
-
- const slot = (actions: readonly UserButtonAction[]): UserButtonAction[] =>
- actions.map(resolve).filter((action): action is UserButtonAction => action !== null);
+ const lead = resolveLead(mode, data);
+ const header = [...headers[lead]];
return {
- // Only a combined surface has two things to choose between; the other two are what they are.
- leadWith: mode === 'combined' ? modePriority : mode,
+ lead,
showOrganizations: declared.organizations !== false && hasOrganizations,
showOrganizationsHeading: organizationsHeading !== false,
+ headerLayout: header.some(action => action !== 'manageLead') ? 'stacked' : 'inline',
actions: {
- header: slot(declared.header),
- organizationsHeading: organizationsHeading === false ? [] : slot(organizationsHeading),
- organizationsFooter: slot(organizationsFooter),
- footer: slot(declared.footer),
+ header,
+ organizationsHeading: organizationsHeading === false ? [] : [...organizationsHeading],
+ organizationsFooter: [...organizationsFooter],
+ footer: [...(hasOtherSessions ? declared.footer.multiSession : declared.footer.singleSession)],
},
};
}
diff --git a/packages/mosaic/src/features/user-button/user-button.messages.ts b/packages/mosaic/src/features/user-button/user-button.messages.ts
index 52d8e876d56..b06d7c0008f 100644
--- a/packages/mosaic/src/features/user-button/user-button.messages.ts
+++ b/packages/mosaic/src/features/user-button/user-button.messages.ts
@@ -25,6 +25,7 @@ export const userButtonMessages = {
},
manage: {
invite: 'Invite',
+ settings: 'Settings',
account: 'Manage account',
organization: 'Manage organization',
createOrganization: 'Create organization',
diff --git a/packages/mosaic/src/features/user-button/user-button.model.tsx b/packages/mosaic/src/features/user-button/user-button.model.tsx
index 9631acb8b3d..0fdb7559cc2 100644
--- a/packages/mosaic/src/features/user-button/user-button.model.tsx
+++ b/packages/mosaic/src/features/user-button/user-button.model.tsx
@@ -123,6 +123,8 @@ function toMembership(organization: OrganizationResource): UserButtonMembership
name: organization.name,
imageUrl: organization.imageUrl || undefined,
membersCount: organization.membersCount,
+ // TODO: set `planLabel` once the organization resource carries its billing plan; the view and
+ // types already render it, so today the badge only appears with Swingset's mock data.
};
}
diff --git a/packages/mosaic/src/features/user-button/user-button.styles.ts b/packages/mosaic/src/features/user-button/user-button.styles.ts
index b8276826e11..e8d44765bdd 100644
--- a/packages/mosaic/src/features/user-button/user-button.styles.ts
+++ b/packages/mosaic/src/features/user-button/user-button.styles.ts
@@ -6,6 +6,12 @@ export const styles = stylex.create({
accountIdentifier: {
fontWeight: fontWeightVars['--cl-font-medium'],
},
+ rowAvatar: {
+ '--_cl-avatar-radius': radiusVars['--cl-radius-sm'],
+ fontSize: '0.5rem',
+ height: space['5'],
+ width: space['5'],
+ },
trigger: {
padding: 0,
borderRadius: radiusVars['--cl-radius-md'],
@@ -45,7 +51,7 @@ export const styles = stylex.create({
borderRadius: radiusVars['--cl-radius-full'],
},
- // Matches `Item.Label`, so the trigger names a workspace the same way its row does. Capped,
+ // Matches `UserButtonItemLabel`, so the trigger names a workspace the same way its row does. Capped,
// because the trigger sits in a host app's chrome and a long workspace name would push it apart.
triggerName: {
color: colorVars['--cl-color-foreground'],
@@ -66,12 +72,4 @@ export const styles = stylex.create({
scroll: {
maxHeight: '18rem',
},
-
- // The trailing column is as wide as the `⋯` menu button that owns it, so whatever stands in
- // that button's place — the active check, a spinner — lands on the same center line and the
- // right edge of every row holds still as rows change state.
- trailing: {
- justifyContent: 'center',
- width: space['7'],
- },
});
diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx
index 398a07da2b2..1b79df622d5 100644
--- a/packages/mosaic/src/features/user-button/user-button.tsx
+++ b/packages/mosaic/src/features/user-button/user-button.tsx
@@ -79,12 +79,10 @@ export type UserButtonProps = UserButtonModelOptions &
* ```
*
* @example
- * `mode` narrows the menu to one switcher, and `modePriority` picks which one a combined menu leads
- * with — in its header, and in the trigger beside the avatar. The other one is still listed.
+ * `mode` narrows the menu to one switcher.
* ```tsx
*
*
- *
* ```
*
* @example
@@ -133,7 +131,6 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null {
renderTriggerLabel,
renderTriggerBadge,
mode,
- modePriority,
userProfileProps,
organizationProfileProps,
customMenuItems,
@@ -170,7 +167,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null {
appearance: organizationProfileProps?.appearance,
},
});
- const controller = useUserButtonController(model, { mode, modePriority, customMenuItems, menuItemOrder });
+ const controller = useUserButtonController(model, { mode, customMenuItems, menuItemOrder });
if (controller.status === 'loading') {
return (
diff --git a/packages/mosaic/src/features/user-button/user-button.types.ts b/packages/mosaic/src/features/user-button/user-button.types.ts
index e7a69fbf3b6..1fbaee03411 100644
--- a/packages/mosaic/src/features/user-button/user-button.types.ts
+++ b/packages/mosaic/src/features/user-button/user-button.types.ts
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react';
+import type { UserButtonSlot } from './user-button.layout';
+
// ─── Data contract ──────────────────────────────────────────────────────────
// Session-backed, discriminated resource rows. 1:1 with `useUserButtonModel()`'s output, so the
// model and the view agree on a shape neither one owns.
@@ -92,7 +94,7 @@ export interface UserButtonCallbacks {
onAcceptSuggestion?: (suggestionId: string) => void;
onAcceptInvitation?: (invitationId: string) => void;
onSwitchSession?: (sessionId: string) => void;
- onSignOutSession?: (sessionId: string) => void;
+ onSignOutSession?: (sessionId: string, from: UserButtonSlot) => void;
onSignOutAll?: () => void;
onManageOrganization?: () => void;
onInviteMembers?: () => void;
@@ -109,13 +111,12 @@ export interface UserButtonCallbacks {
export type UserButtonMode = 'combined' | 'organization' | 'user';
/**
- * Which of the two switchers a `combined` surface leads with: the one named in the trigger and
- * headed in the popup. Both are still listed either way. The single-purpose modes have only one
- * thing to lead with, so they ignore it.
+ * How the header carries its actions: `inline` trails the workspace with them, the gear as an icon;
+ * `stacked` runs them under it as full-width labelled buttons.
*/
-export type UserButtonModePriority = 'organization' | 'user';
+export type UserButtonHeaderLayout = 'inline' | 'stacked';
-/** Which switchers the surface carries, and which one it leads with. */
+/** Which switchers the surface carries. */
export interface UserButtonModeProps {
/**
* Which switchers the popup carries: both, organizations alone, or accounts alone.
@@ -123,13 +124,6 @@ export interface UserButtonModeProps {
* @default 'combined'
*/
mode?: UserButtonMode;
- /**
- * Which switcher a `combined` surface leads with in the trigger and the popup's header. The other
- * one is still listed. Ignored by the single-purpose modes, which have only one thing to lead with.
- *
- * @default 'organization'
- */
- modePriority?: UserButtonModePriority;
}
/** Whether the surface signs itself with Clerk's mark. */
@@ -160,9 +154,11 @@ export interface UserButtonBusyState {
*
* `switchAccount` and `addAccount` share a slot: the foot carries the flyout of signed-in accounts
* where there is more than one, and the row it would have opened onto where there is not. Name both
- * to place that slot whichever way it resolves.
+ * to place that slot whichever way it resolves. So do `signOutAll` and `signOut`: the foot signs out
+ * of every account where there is more than one, and, in `combined` mode, of the one account where
+ * there is not.
*/
-export type UserButtonMenuItemId = 'switchAccount' | 'addAccount' | 'signOutAll';
+export type UserButtonMenuItemId = 'switchAccount' | 'addAccount' | 'signOutAll' | 'signOut';
interface UserButtonMenuItemBase {
/** Identifies the row, for ordering. */
diff --git a/packages/mosaic/src/features/user-button/user-button.view.tsx b/packages/mosaic/src/features/user-button/user-button.view.tsx
index 52d5940fd86..697bbb329fb 100644
--- a/packages/mosaic/src/features/user-button/user-button.view.tsx
+++ b/packages/mosaic/src/features/user-button/user-button.view.tsx
@@ -10,7 +10,6 @@ import { Badge } from '../../components/badge';
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
import { Icon } from '../../components/icon';
-import { Item } from '../../components/item';
import { Menu } from '../../components/menu';
import { Popover } from '../../components/popover';
import { scrollAreaViewport } from '../../components/scroll-area';
@@ -20,11 +19,12 @@ import type { MosaicMessages } from '../../localization';
import { fill, plural, useLocale, useMessages } from '../../localization';
import { Button as HeadlessButton } from '../../primitives/button';
import type { PopoverProps } from '../../primitives/popover';
+import { themeProps } from '../../props';
import { applyOrder } from '../../utils/apply-order';
import { focusOutline } from '../../utils/focus-outline.styles';
import { rtl } from '../../utils/rtl.styles';
import { truncationStyles } from '../../utils/typography.styles';
-import type { UserButtonLayout } from './user-button.layout';
+import type { UserButtonAction, UserButtonLayout, UserButtonSlot } from './user-button.layout';
import { resolveUserButtonLayout } from './user-button.layout';
import { styles } from './user-button.styles';
import type {
@@ -32,12 +32,24 @@ import type {
UserButtonBusyState,
UserButtonCallbacks,
UserButtonData,
+ UserButtonHeaderLayout,
UserButtonMembership,
UserButtonMenuItemId,
UserButtonMenuProps,
UserButtonModeProps,
UserButtonSession,
} from './user-button.types';
+import { UserButtonHeader } from './user-button-header.view';
+import {
+ UserButtonGroup,
+ UserButtonItem,
+ UserButtonItemContent,
+ UserButtonItemDescription,
+ UserButtonItemLabel,
+ UserButtonItemMedia,
+ UserButtonItemTrailing,
+ UserButtonSeparator,
+} from './user-button-item.view';
// The data contract, the mode flags, and the menu item shapes live in `user-button.types`; they are
// what the model and the view agree on, so neither file owns them.
@@ -50,7 +62,7 @@ export type * from './user-button.types';
export const userButtonBusyKeys = {
selectOrganization: (organizationId: string | null) => `select-org:${organizationId ?? 'personal'}`,
switchSession: (sessionId: string) => `switch:${sessionId}`,
- signOutSession: (sessionId: string) => `sign-out:${sessionId}`,
+ signOutSession: (sessionId: string, from: UserButtonSlot) => `sign-out:${from}:${sessionId}`,
signOutAll: () => 'sign-out-all',
acceptSuggestion: (suggestionId: string) => `accept-suggestion:${suggestionId}`,
acceptInvitation: (invitationId: string) => `accept-invitation:${invitationId}`,
@@ -105,35 +117,37 @@ type ActiveWorkspace =
type Messages = MosaicMessages['userButton'];
function leadWorkspace(
- { layout, activeOrganization, activeSession, hidePersonal }: UserButtonContextValue,
+ { layout, activeOrganization, activeSession }: UserButtonContextValue,
m: Messages,
): ActiveWorkspace {
- if (layout.leadWith === 'organization') {
- if (activeOrganization) {
- return {
- kind: 'organization',
- name: activeOrganization.name,
- imageUrl: activeOrganization.imageUrl,
- shape: 'square',
- organization: activeOrganization,
- };
- }
- if (hidePersonal) {
- return { kind: 'none', name: m.workspaces.notSelected, shape: 'square' };
- }
+ if (layout.lead === 'organization' && activeOrganization) {
+ return {
+ kind: 'organization',
+ name: activeOrganization.name,
+ imageUrl: activeOrganization.imageUrl,
+ shape: 'square',
+ organization: activeOrganization,
+ };
+ }
+ if (layout.lead === 'none') {
+ return { kind: 'none', name: m.workspaces.notSelected, shape: 'square' };
}
- return { kind: 'user', name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' };
+ return {
+ kind: 'user',
+ name: activeSession.name,
+ imageUrl: activeSession.imageUrl,
+ shape: 'circle',
+ };
+}
+
+function joinDetails(...parts: Array): string {
+ return parts.filter(Boolean).join(' · ');
}
function membershipSubtitle(membership: UserButtonMembership, m: Messages, locale: string): string {
- const parts: string[] = [];
- if (membership.membersCount !== undefined) {
- parts.push(plural(m.workspaces.members, membership.membersCount, locale));
- }
- if (membership.planLabel) {
- parts.push(membership.planLabel);
- }
- return parts.join(' · ');
+ const members =
+ membership.membersCount === undefined ? undefined : plural(m.workspaces.members, membership.membersCount, locale);
+ return joinDetails(membership.planLabel, members);
}
function initials(name: string): string {
@@ -148,9 +162,10 @@ interface RowAvatarProps {
imageUrl?: string;
shape: 'circle' | 'square';
size: AvatarProps['size'];
+ xstyle?: AvatarProps['xstyle'];
}
-function RowAvatar({ name, imageUrl, shape, size }: RowAvatarProps) {
+function RowAvatar({ name, imageUrl, shape, size, xstyle }: RowAvatarProps) {
return (
// Decorative: the same name is always in text alongside. Held at the root so the whole mark
// stays out of the accessible name however the image resolves.
@@ -158,6 +173,7 @@ function RowAvatar({ name, imageUrl, shape, size }: RowAvatarProps) {
aria-hidden
size={size}
shape={shape}
+ xstyle={xstyle}
>
{imageUrl ? (
(
/>
);
-/** A row's trailing column, sized and centered so every state lands on the `⋯` button's center line. */
-function Trailing({ children }: { children: ReactNode }) {
- return {children};
+// Focus stays on the row for the length of the action, so the row is what gets re-read when it
+// changes. A decorative spinner changes nothing there and the wait passes in silence, so the
+// indicator is named in its own right — the pairing `SubmitButton` makes, and the reason its
+// pending state is spoken where this one was not.
+function PendingSpinner() {
+ const m = useMessages('userButton');
+ return (
+
+ );
}
interface SwitcherRowProps {
@@ -226,15 +253,13 @@ function SwitcherRow({
busy,
disabled,
}: SwitcherRowProps) {
- const m = useMessages('userButton');
// Selecting what is already selected does nothing, so the active row is not a button at all. A
// row that is merely waiting stays one, disabled.
const select = active ? undefined : onSelect;
const waiting = Boolean(busy || disabled);
return (
-
-
+
-
-
- {name}
-
+
+
+ {name}
+
{busy ? (
-
-
-
+
+
+
) : trailing ? (
- {trailing}
+ {trailing}
) : active ? (
-
+
-
+
) : null}
-
+
);
}
@@ -307,63 +324,86 @@ function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) {
const { busy, disabled } = useBusy(busyKey);
return (
-
- {busy ? : icon}
-
- {label}
-
-
+ {busy ? : icon}
+
+ {label}
+
+
);
}
// ─── Sections ───────────────────────────────────────────────────────────────
interface HeaderAction {
+ id: UserButtonAction;
label: string;
- /** An icon renders a square, icon-only button that labels itself through `aria-label`. */
- icon?: IconName;
+ icon: IconName;
+ /** Inline, the button is square and shows the icon alone, labelling itself through `aria-label`. */
+ iconOnly?: boolean;
onClick: () => void;
/** Key from `userButtonBusyKeys` when the action is one-shot; omitted for navigations. */
busyKey?: string;
}
// Hooks cannot run inside a `.map`, so each button is its own component to read its own busy state.
-function HeaderActionButton({ label, icon, onClick, busyKey }: HeaderAction) {
+function HeaderActionButton({
+ layout,
+ label,
+ icon,
+ iconOnly,
+ onClick,
+ busyKey,
+}: HeaderAction & { layout: UserButtonHeaderLayout }) {
+ const m = useMessages('userButton');
const { busy, disabled } = useBusy(busyKey);
- // On an icon button the spinner takes the icon's place; on a labelled one it leads the label, so
- // the button keeps its width while the action runs.
- const spinner = busy ? : null;
+ const stacked = layout === 'stacked';
+ const compact = !stacked && iconOnly;
+ const buttonProps = {
+ variant: 'outline',
+ color: 'neutral',
+ size: 'sm',
+ shape: compact ? 'square' : 'default',
+ fullWidth: stacked,
+ 'aria-label': compact ? label : undefined,
+ disabled,
+ onClick,
+ } as const;
+ const leading =
+ stacked || compact ? (
+
+ ) : null;
+ const text = compact ? null : label;
+
+ if (busyKey === undefined) {
+ return (
+
+ );
+ }
return (
-
+ {leading}
+ {text}
+
);
}
@@ -372,71 +412,84 @@ function Header() {
const m = useMessages('userButton');
const locale = useLocale();
const data = useUserButtonContext();
+ const layout = data.layout.headerLayout;
const signOutSession = data.onSignOutSession;
const { sessionId, identifier } = data.activeSession;
const workspace = leadWorkspace(data, m);
- const { name, imageUrl, shape } = workspace;
+ const { name } = workspace;
const organization = workspace.kind === 'organization' ? workspace.organization : undefined;
// An account with no name is titled by its identifier, and repeating it underneath says nothing.
// No selection is not the account, so it carries no identifier line either.
- const accountSubtitle = identifier === name ? '' : identifier;
const subtitle =
workspace.kind === 'organization'
? membershipSubtitle(workspace.organization, m, locale)
- : workspace.kind === 'user'
- ? accountSubtitle
+ : workspace.kind === 'user' && identifier !== name
+ ? identifier
: '';
const actions: HeaderAction[] = [];
for (const action of data.layout.actions.header) {
if (action === 'inviteMembers' && data.onInviteMembers) {
- actions.push({ label: m.manage.invite, onClick: data.onInviteMembers });
+ actions.push({ id: action, label: m.manage.invite, icon: 'users-add-right', onClick: data.onInviteMembers });
}
- // Every other mode hangs "Sign out" off the organization heading. An account-only one has no
- // such heading, so it takes the labelled slot **Invite** occupies elsewhere, left of the gear.
+ // An account that leads takes "Sign out" in the labelled slot **Invite** holds for an organization.
if (action === 'signOut' && signOutSession) {
actions.push({
+ id: action,
label: m.accounts.signOut,
- onClick: () => signOutSession(sessionId),
- busyKey: userButtonBusyKeys.signOutSession(sessionId),
+ icon: 'sign-out',
+ onClick: () => signOutSession(sessionId, 'header'),
+ busyKey: userButtonBusyKeys.signOutSession(sessionId, 'header'),
});
}
// The gear manages whatever the header names, which is settled by the data rather than the mode.
+ // Inline it is the icon alone, named for what it manages; stacked it reads "Settings".
if (action === 'manageLead') {
const manage = organization
? { label: m.manage.organization, onClick: data.onManageOrganization }
: { label: m.manage.account, onClick: data.onManageAccount };
if (manage.onClick) {
- actions.push({ label: manage.label, icon: 'cog-6-teeth', onClick: manage.onClick });
+ actions.push({
+ id: action,
+ label: layout === 'stacked' ? m.manage.settings : manage.label,
+ icon: 'cog-6-teeth',
+ iconOnly: true,
+ onClick: manage.onClick,
+ });
}
}
}
+ // Inline, the gear trails the labelled actions; stacked, it leads them.
+ const ordered =
+ layout === 'stacked'
+ ? [...actions.filter(a => a.id === 'manageLead'), ...actions.filter(a => a.id !== 'manageLead')]
+ : actions;
return (
-
-
-
-
-
-
- {name}
- {subtitle ? {subtitle} : null}
-
-
- {actions.map(a => (
-
- ))}
-
-
-
+
+ }
+ title={name}
+ description={subtitle}
+ actions={
+ ordered.length > 0
+ ? ordered.map(a => (
+
+ ))
+ : undefined
+ }
+ />
);
}
@@ -447,17 +500,38 @@ interface RowAction {
}
/** The `⋯` that hangs off a row's trailing edge. Renders nothing when it would be empty. */
-function ActionMenu({ label, actions, disabled }: { label: string; actions: RowAction[]; disabled?: boolean }) {
+interface ActionMenuProps {
+ label: string;
+ actions: RowAction[];
+ busy?: boolean;
+ disabled?: boolean;
+}
+
+function ActionMenu({ label, actions, busy = false, disabled }: ActionMenuProps) {
+ const m = useMessages('userButton');
if (actions.length === 0) {
return null;
}
return (
-
+ (
+
+ )}
/>
{actions.map(a => (
@@ -472,7 +546,7 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA
))}
-
+
);
}
@@ -487,7 +561,7 @@ function OrganizationsHeading() {
const signOutSession = data.onSignOutSession;
const { identifier, sessionId } = data.activeSession;
// Its actions live in a menu that closes on click, so the row itself carries their spinner.
- const { busy, disabled } = useBusy(userButtonBusyKeys.signOutSession(sessionId));
+ const { busy, disabled } = useBusy(userButtonBusyKeys.signOutSession(sessionId, 'organizationsHeading'));
const actions: RowAction[] = [];
for (const action of data.layout.actions.organizationsHeading) {
@@ -501,28 +575,23 @@ function OrganizationsHeading() {
actions.push({
label: m.accounts.signOut,
color: 'negative',
- onClick: () => signOutSession(sessionId),
+ onClick: () => signOutSession(sessionId, 'organizationsHeading'),
});
}
}
return (
-
-
- {identifier}
-
- {busy ? (
-
-
-
- ) : (
-
- )}
-
+
+
+ {identifier}
+
+
+
);
}
@@ -625,7 +694,7 @@ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: Pe
labelId={labelId}
trailing={
note ? (
- {note}
+ {note}
) : onAccept ? (
// Every other affordance here swaps its icon for a spinner, but this one is a labelled
// button, so the spinner goes inside it rather than taking the row's trailing edge — the
@@ -738,6 +807,7 @@ function SessionMenuItem({ session, active }: { session: UserButtonSession; acti
imageUrl={session.imageUrl}
shape='circle'
size='fit'
+ xstyle={styles.rowAvatar}
/>
{session.identifier}
@@ -779,34 +849,29 @@ function SwitchAccountRow() {
fallbackPlacements={['left-start', 'top-start', 'bottom-start']}
>
}
- />
- }
+ aria-busy={busy || undefined}
+ render={}
>
-
+
{busy ? (
-
+
) : (
)}
-
-
- {m.accounts.switch}
-
-
+
+
+ {m.accounts.switch}
+
+
-
+
{/* The account it is on leads, checked: the flyout is the full set of accounts rather than
@@ -847,14 +912,14 @@ function OrganizationListLoadingRow() {
return (
// Plain text rather than a live region: it mounts with its copy already in it, so there is no
// change for one to report, and the popup it lands in is read on open either way.
-
-
+
+
-
-
- {m.workspaces.loading}
-
-
+
+
+ {m.workspaces.loading}
+
+
);
}
@@ -895,11 +960,11 @@ function OrganizationSection() {
return (
<>
-
+
{/* `auto` rather than `stable`: a reserved gutter insets the rows whether or not the list
overflows, so short lists would sit their avatars and icons off the edge the header and
footer align to. */}
-
+
{showOrganizationsHeading ? : null}
{/* Memberships, invitations and suggestions are three separate requests landing at three
different moments. Rendering each as it arrives walks the list in in stages, so the
@@ -909,19 +974,16 @@ function OrganizationSection() {
) : (
<>
- {/* What is on offer leads the list: an invitation or suggestion is the one row here
- that goes away if it is not acted on, and the workspaces held are not going
- anywhere. This is the order the existing OrganizationSwitcher lists them in. */}
-
+
{data.paging?.hasMore ? : null}
>
))}
{/* Trails the rows rather than sitting at the foot of the surface: what it offers is one
more of the workspaces above it, not an action on the account. */}
{actions}
-
+
>
);
}
@@ -936,6 +998,8 @@ interface FooterRow {
function Footer() {
const m = useMessages('userButton');
const data = useUserButtonContext();
+ const signOutSession = data.onSignOutSession;
+ const { sessionId } = data.activeSession;
const builtIn: FooterRow[] = [];
for (const action of data.layout.actions.footer) {
@@ -961,6 +1025,24 @@ function Footer() {
),
});
}
+ if (action === 'signOut' && signOutSession) {
+ builtIn.push({
+ id: 'signOut',
+ node: (
+
+ }
+ label={m.accounts.signOut}
+ onClick={() => signOutSession(sessionId, 'footer')}
+ busyKey={userButtonBusyKeys.signOutSession(sessionId, 'footer')}
+ />
+ ),
+ });
+ }
if (action === 'signOutAll' && data.onSignOutAll) {
builtIn.push({
id: 'signOutAll',
@@ -968,7 +1050,7 @@ function Footer() {
@@ -996,12 +1078,12 @@ function Footer() {
return (
<>
-
-
+
+
{rows.map(r => (
{r.node}
))}
-
+
>
);
}
@@ -1030,20 +1112,10 @@ export interface UserButtonRootProps
* the data through context.
*/
export function UserButtonRoot(props: UserButtonRootProps): ReactElement {
- const {
- children,
- mode = 'combined',
- modePriority = 'organization',
- open,
- defaultOpen,
- onOpenChange,
- placement,
- sideOffset,
- ...data
- } = props;
+ const { children, mode = 'combined', open, defaultOpen, onOpenChange, placement, sideOffset, ...data } = props;
// Resolved here so the sections below never read `mode` again: which affordance lands in which
// slot is settled once, in one table, rather than re-derived by each part that renders one.
- const layout = resolveUserButtonLayout(mode, modePriority, data);
+ const layout = resolveUserButtonLayout(mode, data);
return (
{renderTriggerLabel ? (
@@ -1125,7 +1198,10 @@ export function UserButtonPopup(): ReactElement {
const { renderBranding } = useUserButtonContext();
return (
-
+
diff --git a/packages/mosaic/src/icons/glyphs/sign-out.tsx b/packages/mosaic/src/icons/glyphs/sign-out.tsx
new file mode 100644
index 00000000000..5e76a05bc67
--- /dev/null
+++ b/packages/mosaic/src/icons/glyphs/sign-out.tsx
@@ -0,0 +1,11 @@
+import { glyph } from '../glyph';
+
+export const SignOut = glyph(
+ ,
+);
diff --git a/packages/mosaic/src/icons/glyphs/switch-account.tsx b/packages/mosaic/src/icons/glyphs/switch-account.tsx
new file mode 100644
index 00000000000..4d4c9c6bdd5
--- /dev/null
+++ b/packages/mosaic/src/icons/glyphs/switch-account.tsx
@@ -0,0 +1,34 @@
+import { glyph } from '../glyph';
+
+export const SwitchAccount = glyph(
+ <>
+
+
+
+
+ >,
+);
diff --git a/packages/mosaic/src/icons/glyphs/users-add-right.tsx b/packages/mosaic/src/icons/glyphs/users-add-right.tsx
new file mode 100644
index 00000000000..7ec176b1683
--- /dev/null
+++ b/packages/mosaic/src/icons/glyphs/users-add-right.tsx
@@ -0,0 +1,25 @@
+import { glyph } from '../glyph';
+
+export const UsersAddRight = glyph(
+ <>
+
+
+
+ >,
+);
diff --git a/packages/mosaic/src/icons/registry.tsx b/packages/mosaic/src/icons/registry.tsx
index f1a0f803a80..5a7edf92941 100644
--- a/packages/mosaic/src/icons/registry.tsx
+++ b/packages/mosaic/src/icons/registry.tsx
@@ -78,12 +78,15 @@ import { Shield } from './glyphs/shield';
import { ShieldCheck } from './glyphs/shield-check';
import { ShieldClose } from './glyphs/shield-close';
import { Sidebar } from './glyphs/sidebar';
+import { SignOut } from './glyphs/sign-out';
import { Spinner } from './glyphs/spinner';
import { Support } from './glyphs/support';
+import { SwitchAccount } from './glyphs/switch-account';
import { Trash } from './glyphs/trash';
import { UserCircle } from './glyphs/user-circle';
import { UserCirclePlus } from './glyphs/user-circle-plus';
import { Users } from './glyphs/users';
+import { UsersAddRight } from './glyphs/users-add-right';
import { X } from './glyphs/x';
import { XCircle } from './glyphs/x-circle';
@@ -307,12 +310,15 @@ export const iconRegistry = {
'shield-check': ShieldCheck,
'shield-close': ShieldClose,
sidebar: Sidebar,
+ 'sign-out': SignOut,
spinner: Spinner,
support: Support,
+ 'switch-account': SwitchAccount,
trash: Trash,
'user-circle': UserCircle,
'user-circle-plus': UserCirclePlus,
users: Users,
+ 'users-add-right': UsersAddRight,
x: X,
'x-circle': XCircle,
} satisfies Record;
diff --git a/packages/swingset/src/app/(clerk)/live-user-button.tsx b/packages/swingset/src/app/(clerk)/live-user-button.tsx
index 140e1c7af14..3ab5d39e44e 100644
--- a/packages/swingset/src/app/(clerk)/live-user-button.tsx
+++ b/packages/swingset/src/app/(clerk)/live-user-button.tsx
@@ -6,7 +6,7 @@ import { MosaicProvider } from '@clerk/mosaic/MosaicProvider';
export function LiveUserButton() {
return (
-
+
);
}
diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx
index 54f60b08a6e..5522789e328 100644
--- a/packages/swingset/src/stories/user-button.mdx
+++ b/packages/swingset/src/stories/user-button.mdx
@@ -6,7 +6,7 @@ The account & organization switcher behind the user avatar. The active organizat
carrying a **gear** and **Invite**. The account's workspaces sit under it: every organization, plus
**suggested** and **invited** ones it can Join. **Switch account** at the foot opens the signed-in
accounts as a flyout — the active one checked — so an account never reads as a workspace. With
-nobody to switch to, that row is **Add account** instead. The foot signs out of everything. `mode` narrows all of it, and `modePriority` picks what heads it;
+nobody to switch to, that row is **Add account** instead. The foot signs out of everything. `mode` narrows all of it;
see [Modes](#modes).
Only the active account's organizations are listed. Not a design choice: org requests are scoped to
@@ -37,16 +37,16 @@ that would navigate (Manage, Invite, Create organization, Add account) only clos
import { UserButtonView } from '@clerk/mosaic/features/user-button/user-button.view';
setActive({ organization })}
onSwitchSession={session => setActive({ session })}
onSignOutAll={() => signOut()}
@@ -73,20 +73,14 @@ Exports are flat (not `UserButton.Trigger`) so each part declares its own `'use
## Trigger
-The active workspace's avatar and what it is called: the org and its plan wherever one heads the
-trigger, no selection when personal is hidden and none is active, the account otherwise.
+The active workspace's avatar and what it is called: the org and its plan where one is active, no
+selection when personal is hidden and none is active, the account otherwise.
-`renderTriggerLabel={false}` leaves the avatar alone.
+`renderTriggerLabel={false}` leaves the avatar alone. A user's avatar on its own rounds the trigger
+fully to match it; see [User only](#user-only).
-
-A user's avatar on its own rounds the trigger fully to match it.
-
-
@@ -97,6 +91,13 @@ A user's avatar on its own rounds the trigger fully to match it.
storyModule={UserButtonStories}
/>
+## Header
+
+The header names whatever leads and carries what you can do about it, stacked under it as
+full-width labelled buttons. An organization is described by its plan and member count, and carries
+**Settings** with **Invite**. An account is described by its identifier, and carries **Settings**
+with **Sign out**. Where the gear is the only action, it trails the workspace inline as an icon.
+
## Modes
`mode` picks which switchers the surface carries — the only prop that changes the popup's shape.
@@ -104,46 +105,74 @@ Everything else is data.
| Mode | Popup | Leads with |
| -------------------- | ------------------------------------ | --------------------------------- |
-| `combined` (default) | Organizations and accounts together. | The org; `modePriority` flips it. |
-| `organization` | Organizations only; no account rows. | The org. |
+| `combined` (default) | Organizations and accounts together. | The active org, else the account. |
+| `organization` | Organizations only; no account rows. | The active org, else the account. |
| `user` | Accounts only; never shows an org. | The account. |
What a mode leads with is named in the trigger and heads the popup — the two always agree.
-**Invite** belongs to the active org wherever there is one; the gear manages whatever leads.
-`combined` carries both switchers, so `modePriority` picks which one leads. Everything is listed
-either way; only the trigger and the header move.
+### Combined
+
+The active org heads it. The workspaces sit under the account's identifier, whose `⋯` manages or
+signs out of the account, with **Create organization** at their foot. **Switch account** opens the
+signed-in accounts, and the foot signs out of all of them.
+
+With one account signed in, the foot is **Add account** and **Sign out**.
+
+
-Off-mode data is not an error. Both examples below take the same props as the one above and differ
-only by `mode`.
+With the personal workspace selected, the account leads, and the header carries **Sign out**.
+
+
-### Organizations
+### Org only
-The org heads it, with **Invite** and the gear (**Manage organization**). **Create organization** trails
-the workspaces the way it does on a combined surface, and nothing else is on offer, so the popup ends
-there. The extra account is in the props, deliberately unrendered.
+The org heads it, with **Settings** and **Invite**. **Create organization** trails the workspaces, and
+nothing else is on offer, so the popup ends there.
-### User
+
+
+### User only
-The account heads it, with **Sign out** in **Invite**'s slot and the gear (**Manage account**). No
-workspaces are listed at all, so **Switch account** at the foot is the whole of it. An org is active;
-this mode ignores it, down to the trigger.
+The account heads it, with **Settings** and **Sign out**. No workspaces are listed, so **Switch
+account** and **Sign out of all accounts** at the foot are the whole of it.
+
+
+With one account signed in, the foot is **Add account** alone.
+
+
+
## Loading
Memberships, invitations and suggestions are three separate requests, and the popup opens before any
@@ -162,9 +191,9 @@ Reopen the popup to watch it again.
An instance in single-session mode holds one account at a time, so there is nothing to switch to and
no second account to add. It hands the view an empty `additionalSessions` and leaves `onAddAccount`
-and `onSignOutAll` off — the surface withholds **Switch account**, **Add account**, and **Sign out of
-all accounts** together. The account's own `⋯` still carries **Sign out**, which is all "all
-accounts" ever meant here.
+and `onSignOutAll` off — the surface withholds **Switch account** and **Add account**. The foot keeps
+its sign-out row, but as **Sign out** of the one account, since that is all "all accounts" ever
+meant here.
;
+
+/** The multi-account frames: NestLabs Creative active, beside Acme, with Clerk on offer. */
+const nestLabsWithSuggestion: Workspaces = {
+ activeOrganizationId: nestLabs.organizationId,
+ memberships: [nestLabs, acme],
+ suggestions: [clerkSuggestion],
+ invitations: [],
};
-// Two accounts with different workspaces, so switching account changes the list under it too.
-const initialAccounts: Account[] = [
- {
- session: colin,
- activeOrganizationId: 'org_clerk_app',
- memberships: [
- {
- kind: 'membership',
- organizationId: 'org_clerk_app',
- name: 'Clerk app',
- membersCount: 24,
- planLabel: 'Pro plan',
- imageUrl: clerkLogo,
- },
- clerkCloud,
- ],
- suggestions: [
- {
- kind: 'suggestion',
- id: 'sug_labs',
- organizationId: 'org_clerk_labs',
- name: 'Clerk Labs',
- status: 'pending',
- imageUrl: defaultOrgLogo,
- },
- ],
- invitations: [],
- },
- {
- session: braden,
- // An organization only Braden is in, so switching to him changes the header, the trigger, and
- // the list under it all at once.
- activeOrganizationId: 'org_clerk_marketing',
- memberships: [
- {
- kind: 'membership',
- organizationId: 'org_clerk_marketing',
- name: 'Clerk Marketing',
- membersCount: 9,
- imageUrl: defaultOrgLogo,
- },
- clerkCloud,
- ],
- suggestions: [],
- invitations: [
- {
- kind: 'invitation',
- id: 'inv_app',
- organizationId: 'org_clerk_app',
- organizationName: 'Clerk app',
- status: 'pending',
- imageUrl: clerkLogo,
- },
- ],
- },
+/** The single-account and organization-only frames: NestLabs Creative active, beside Clerk App and Acme. */
+const nestLabsWithClerkApp: Workspaces = {
+ activeOrganizationId: nestLabs.organizationId,
+ memberships: [nestLabs, clerkApp, acme],
+ suggestions: [],
+ invitations: [],
+};
+
+/** The personal-workspace frame: no organization active, Clerk App and Acme to switch to. */
+const personalWorkspace: Workspaces = {
+ activeOrganizationId: null,
+ memberships: [clerkApp, acme],
+ suggestions: [],
+ invitations: [],
+};
+
+// The other two sign-ins' workspaces, reached only by switching to them.
+const otherAccounts: Account[] = [
+ { session: cameron('sess_cameron_2'), ...nestLabsWithClerkApp },
+ { session: cameron('sess_cameron_3'), ...personalWorkspace },
];
/** Joining is what turns a suggestion or an invitation into a workspace you can switch to. */
@@ -146,19 +136,20 @@ const LATENCY_MS = 800;
* account) have nowhere to go here, so they only close the popover.
*/
function usePrototype({
+ workspaces = nestLabsWithSuggestion,
+ singleSession = false,
hidePersonal = false,
- startWithoutOrganization = false,
}: {
+ workspaces?: Workspaces;
+ singleSession?: boolean;
hidePersonal?: boolean;
- startWithoutOrganization?: boolean;
} = {}): Omit {
const [open, setOpen] = useState(false);
- const [accounts, setAccounts] = useState(() =>
- startWithoutOrganization
- ? initialAccounts.map(a => (a.session.sessionId === colin.sessionId ? { ...a, activeOrganizationId: null } : a))
- : initialAccounts,
- );
- const [activeSessionId, setActiveSessionId] = useState(colin.sessionId);
+ const [accounts, setAccounts] = useState(() => [
+ { session: cameron('sess_cameron_1'), ...workspaces },
+ ...(singleSession ? [] : otherAccounts),
+ ]);
+ const [activeSessionId, setActiveSessionId] = useState('sess_cameron_1');
const [pendingKey, setPendingKey] = useState(null);
const account = accounts.find(a => a.session.sessionId === activeSessionId) ?? accounts[0];
@@ -233,7 +224,8 @@ function usePrototype({
}),
),
onSwitchSession: sessionId => run(userButtonBusyKeys.switchSession(sessionId), () => setActiveSessionId(sessionId)),
- onSignOutSession: sessionId => run(userButtonBusyKeys.signOutSession(sessionId), () => signOutSession(sessionId)),
+ onSignOutSession: (sessionId, from) =>
+ run(userButtonBusyKeys.signOutSession(sessionId, from), () => signOutSession(sessionId)),
// Nothing is left to render once every account is gone, so this one closes too.
onSignOutAll: () => run(userButtonBusyKeys.signOutAll(), close),
onManageOrganization: close,
@@ -244,8 +236,10 @@ function usePrototype({
};
}
+// ─── Mode: Combined ─────────────────────────────────────────────────────────
+
export function Combined(_args: Record) {
- const prototype = usePrototype();
+ const prototype = usePrototype({ hidePersonal: true });
return (
) {
);
}
-export function UserPriority(_args: Record) {
- const prototype = usePrototype();
+export function CombinedIconTrigger(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
return (
);
}
-export function AvatarOnly(_args: Record) {
- const prototype = usePrototype();
+export function CombinedSingleAccount(_args: Record) {
+ const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, singleSession: true, hidePersonal: true });
return (
);
}
-export function UserAvatarOnly(_args: Record) {
- const prototype = usePrototype();
+export function CombinedPersonalWorkspace(_args: Record) {
+ const prototype = usePrototype({ workspaces: personalWorkspace, singleSession: true });
return (
);
}
-export function WithoutTriggerBadge(_args: Record) {
- const prototype = usePrototype();
+// ─── Mode: Org only ─────────────────────────────────────────────────────────
+
+export function Organizations(_args: Record) {
+ const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, hidePersonal: true });
return (
);
}
-export function Organizations(_args: Record) {
- const prototype = usePrototype();
+export function OrganizationsIconTrigger(_args: Record) {
+ const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, hidePersonal: true });
- // Fed the same data as the others, including the additional account it deliberately never shows.
return (
);
}
+// ─── Mode: User only ────────────────────────────────────────────────────────
+
export function User(_args: Record) {
const prototype = usePrototype();
- // Fed the same data too: an active organization and its workspaces, none of which this mode shows.
return (
) {
);
}
+export function UserAvatarTrigger(_args: Record) {
+ const prototype = usePrototype();
+
+ return (
+
+ );
+}
+
+export function UserAvatarSingleSession(_args: Record) {
+ const prototype = usePrototype({ singleSession: true });
+
+ return (
+
+ );
+}
+
+// ─── Beyond the frames ──────────────────────────────────────────────────────
+
+export function WithoutTriggerBadge(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true });
+
+ return (
+
+ );
+}
+
export function NoOrganizationSelected(_args: Record) {
- const prototype = usePrototype({ hidePersonal: true, startWithoutOrganization: true });
+ const prototype = usePrototype({ workspaces: personalWorkspace, hidePersonal: true });
// Personal is withheld and nothing is active, so the lead is no selection — not the account.
// Picking an organization leaves it.
@@ -339,15 +372,14 @@ export function NoOrganizationSelected(_args: Record) {
}
export function SingleSession(_args: Record) {
- const prototype = usePrototype();
+ const prototype = usePrototype({ singleSession: true, hidePersonal: true });
// What an instance in single-session mode hands the view: one account, and neither of the two
- // actions that only make sense with a second one. The account's own `⋯` still signs out of it.
+ // actions that only make sense with a second one. The foot signs out of just that account.
return (
@@ -355,7 +387,7 @@ export function SingleSession(_args: Record) {
}
export function CustomMenuItems(_args: Record) {
- const prototype = usePrototype();
+ const prototype = usePrototype({ singleSession: true, hidePersonal: true });
// The app's own rows join the foot, and `menuItemOrder` puts them wherever it names them. Ids for
// rows the surface does not carry are ignored, so one order can cover every mode.
@@ -365,7 +397,6 @@ export function CustomMenuItems(_args: Record) {
mode='combined'
// One account resolves the accounts row to "Add account", which is the form `menuItemOrder`
// names by either id.
- additionalSessions={[]}
customMenuItems={[
{
id: 'settings',
@@ -414,7 +445,7 @@ const ORGANIZATIONS_LATENCY_MS = 2500;
* nothing to show it. The wait restarts on every open, so it can be watched more than once.
*/
export function LoadingOrganizations(_args: Record) {
- const prototype = usePrototype();
+ const prototype = usePrototype({ hidePersonal: true });
const [organizationsLoading, setOrganizationsLoading] = useState(false);
useEffect(() => {