Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/mosaic-user-button-organization-lead.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@clerk/mosaic': patch
---

When an organization is active, `UserButton` now leads with the organization. The trigger shows the organization's avatar and name, the popover header shows its avatar badged with the user's, with the user's identifier beneath the name, and the trigger's accessible name still names the user.

`UserButton` and `OrganizationSwitcher` now carry **Settings** and **Invite members** as rows beneath the organization list instead of as buttons in the header. With both an organization and the account to manage, **Settings** opens a menu of **Manage profile** and **Manage organization**. "Create organization" now reads "Add organization", and organizations are listed in the order the Frontend API returns them, rather than with the active one first.

`customMenuItems` now sit in the group with **Settings** and **Invite members**, after them, instead of at the foot of the popover. `menuItemOrder` orders that group, by the ids `settings`, `inviteMembers` and each custom item's `id`. A custom item given `settings` or `inviteMembers` as its `id` replaces that row, in its place. The foot's rows are no longer reorderable, so the `switchAccount`, `addAccount` and `signOut` ids are ignored.

The `manage.organizationSettings`, `manage.profileSettings` and `manage.account` localization keys are replaced by `manage.profile`, and the header no longer renders `.cl-*-header-actions` or a `data-layout` attribute.
6 changes: 2 additions & 4 deletions integration/tests/mosaic/user-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
}

async function openProfileSettings(page: Page) {
await popup(page)
.getByRole('button', { name: /^(Settings|Manage account)$/ })
.click();
const profileSettings = page.getByRole('menuitem', { name: 'Profile settings' });
await popup(page).getByRole('button', { name: 'Settings' }).click();
const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' });
await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor();
if (await profileSettings.isVisible()) {
await profileSettings.click();
Expand Down
5 changes: 0 additions & 5 deletions packages/mosaic/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@
"count": 1
}
},
"src/features/switcher/switcher.view.tsx": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts": {
"@typescript-eslint/no-restricted-imports": {
"count": 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,24 @@ describe('OrganizationSwitcher', () => {
expect(host()).toBeEmptyDOMElement();
});

it('heads the surface with the active organization and what can be done to it', async () => {
await renderSwitcher();
await open();
it('heads the surface with the active organization, and manages it from the rows beneath', async () => {
const { clerk } = await renderSwitcher({}, noOffers());
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await openWithList();

const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup();
expect(within(header).getByText('Acme')).toBeInTheDocument();
expect(within(header).getByText('3 members')).toBeInTheDocument();
expect(within(header).queryAllByRole('button')).toEqual([]);
expect(reading('Add organization', 'Settings', 'Invite members')).toEqual([
'Add organization',
'Settings',
'Invite members',
]);

expect(within(requiredPopup()).getByText('Acme')).toBeInTheDocument();
expect(within(requiredPopup()).getByText('3 members')).toBeInTheDocument();
expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument();
expect(within(requiredPopup()).getByRole('button', { name: 'Settings' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(screen.queryByRole('menu')).toBeNull();
expect(openOrganizationProfile).toHaveBeenCalled();
});

it('falls back to the account, with nothing to manage, where no organization is active', async () => {
Expand All @@ -147,7 +157,7 @@ describe('OrganizationSwitcher', () => {

expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument();
expect(within(requiredPopup()).queryByRole('button', { name: 'Settings' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull();
});

it('names no organization selected where personal is hidden and none is active', async () => {
Expand All @@ -158,19 +168,19 @@ describe('OrganizationSwitcher', () => {

expect(within(requiredPopup()).getByText('No organization selected')).toBeInTheDocument();
expect(within(requiredPopup()).queryByText('Alice Smith')).toBeNull();
expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Manage account' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull();
});

it('carries no account rows, and trails the organizations with create-organization', async () => {
await renderSwitcher({}, noOffers());
await openWithList();

expect(reading('Personal account', 'Acme', 'Other', 'Create organization')).toEqual([
'Acme',
expect(reading('Personal account', 'Acme', 'Other', 'Add organization')).toEqual([
'Personal account',
'Acme',
'Other',
'Create organization',
'Add organization',
]);
for (const name of ['Switch account', 'Add account', 'Sign out', 'Sign out of all accounts']) {
expect(screen.queryByRole('button', { name })).toBeNull();
Expand All @@ -187,13 +197,13 @@ describe('OrganizationSwitcher', () => {
expect(within(trigger()).getByText('Other')).toBeInTheDocument();
});

it('carries custom rows at the foot of the popup, in the order given', async () => {
it('carries custom rows beside Settings, in the order given', async () => {
const terms = { id: 'terms', label: 'Terms of service', href: 'https://example.com/terms' };
const support = { id: 'support', label: 'Support', onClick: vi.fn() };
await renderSwitcher({ customMenuItems: [terms, support], menuItemOrder: ['support', 'terms'] });
await renderSwitcher({ customMenuItems: [terms, support], menuItemOrder: ['support', 'terms', 'settings'] });
await open();

expect(reading('Terms of service', 'Support')).toEqual(['Support', 'Terms of service']);
expect(reading('Settings', 'Terms of service', 'Support')).toEqual(['Support', 'Terms of service', 'Settings']);
});

it('themes its parts through organization-switcher slots', async () => {
Expand Down Expand Up @@ -229,7 +239,7 @@ describe('OrganizationSwitcher', () => {

expect(within(trigger()).getByText('Acme')).toBeInTheDocument();
await userEvent.setup().click(trigger());
expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument();
expect(within(requiredPopup()).getByRole('button', { name: 'Invite members' })).toBeInTheDocument();

memberships.release();
await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@ export const organizationSwitcherMessages = {
pending: 'pending',
},
manage: {
invite: 'Invite',
invite: 'Invite members',
settings: 'Settings',
organizationSettings: 'Organization settings',
profileSettings: 'Profile settings',
account: 'Manage account',
profile: 'Manage profile',
organization: 'Manage organization',
createOrganization: 'Create organization',
createOrganization: 'Add organization',
},
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
*
* @example
* `customPages` adds your own pages to the organization profile; `customMenuItems` adds your own rows
* to the foot of the menu, each one either an `onClick` action or an `href` link.
* beside Settings, each one either an `onClick` action or an `href` link.
* ```tsx
* <OrganizationSwitcher
* organizationProfileProps={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,51 +21,58 @@ function resolve(mode: SwitcherMode, data: Partial<SwitcherData> = {}) {
}

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

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

it('takes the account actions into the header and the foot in user mode', () => {
it('takes the account actions into the manage group and the foot in user mode', () => {
expect(resolve('user').actions).toEqual({
header: ['manageLead'],
organizationsFooter: [],
manage: ['settings'],
footer: ['switchAccount', 'signOut'],
});
});
});

describe('resolveSwitcherLayout, what the data settles', () => {
it('leads with the active organization alone in organization mode', () => {
const layout = resolve('organization');

expect(layout.lead).toBe('organization');
expect(layout.userBadge).toBe(false);
});

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(['manageLead']);
expect(layout.actions.manage).toEqual(['settings']);
});

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([]);
expect(layout.actions.manage).toEqual([]);
});

it('leads with the personal account, managing nothing, where no organization is active in organization mode', () => {
const layout = resolve('organization', { activeOrganization: null });

expect(layout.lead).toBe('personal');
expect(layout.actions.header).toEqual([]);
expect(layout.actions.manage).toEqual([]);
});

// With no second account the flyout would open onto one row, so the foot offers that row instead.
Expand All @@ -78,29 +85,19 @@ describe('resolveSwitcherLayout, what the data settles', () => {
});

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

expect(layout.lead).toBe('member');
expect(layout.actions.header).toEqual(['inviteMembers', 'manageLead']);
expect(layout.lead).toBe('organization');
expect(layout.userBadge).toBe(true);
expect(layout.actions.manage).toEqual(['settings', 'inviteMembers']);
});

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

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

describe('resolveSwitcherLayout, 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');
});

it('runs the gear inline where it is the only action', () => {
expect(resolve('user').headerLayout).toBe('inline');
expect(layout.actions.manage).toEqual(['settings']);
});
});

Expand Down
81 changes: 20 additions & 61 deletions packages/mosaic/src/features/switcher/switcher-avatar.styles.ts
Original file line number Diff line number Diff line change
@@ -1,99 +1,58 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, focusVars, radiusVars, space, spacingVars } from '../../tokens.stylex';
import { space, spacingVars } from '../../tokens.stylex';

const GAP_PX = 1;
const DEFAULT_SPACING_PX = 4;
const BADGE_RADIUS = 0.25;

const step = (multiple: number) => `calc(${spacingVars['--cl-spacing']} * ${multiple})`;

const cutoutPx = (frame: number) => frame * DEFAULT_SPACING_PX;

const cutout = (frame: number) =>
`url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${cutoutPx(frame)} ${cutoutPx(frame)}'><rect width='${cutoutPx(frame)}' height='${cutoutPx(frame)}' rx='${(cutoutPx(frame) - GAP_PX * 2) * BADGE_RADIUS + GAP_PX}'/></svg>")`;
const cutout = `url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 2'><circle cx='1' cy='1' r='1'/></svg>")`;

const square = (multiple: number) => ({
fontSize: `calc(${step(multiple)} * 0.4)`,
height: step(multiple),
width: step(multiple),
fontSize: `calc(${step(multiple)} * 0.4)`,
});

const lead = (box: number, frame: number) => ({
const cutoutOffset = (box: number, badgeSize: number) => `calc(${step(box - badgeSize)} - ${GAP_PX}px)`;

const cutoutSize = (badgeSize: number) => `calc(${step(badgeSize)} + ${GAP_PX * 2}px)`;

const lead = (box: number, badgeSize: number) => ({
...square(box - 1),
maskImage: `linear-gradient(#000 0 0), ${cutout(frame)}`,
maskImage: `linear-gradient(#000 0 0), ${cutout}`,
maskPosition: {
default: `0 0, ${step(box - frame)} ${step(box - frame)}`,
':is([dir="rtl"] *)': `0 0, right ${step(box - frame)} top ${step(box - frame)}`,
default: `0 0, ${cutoutOffset(box, badgeSize)} ${cutoutOffset(box, badgeSize)}`,
':is([dir="rtl"] *)': `0 0, right ${cutoutOffset(box, badgeSize)} top ${cutoutOffset(box, badgeSize)}`,
},
maskSize: `100% 100%, ${step(frame)} ${step(frame)}`,
maskSize: `100% 100%, ${cutoutSize(badgeSize)} ${cutoutSize(badgeSize)}`,
});

const ring = (box: number) => ({
height: step(box - 1),
width: step(box - 1),
});

const badge = (frame: number) => ({
'--_cl-avatar-radius': `${BADGE_RADIUS * 100}%`,
fontSize: `calc((${step(frame)} - ${GAP_PX * 2}px) * 0.4)`,
height: `calc(${step(frame)} - ${GAP_PX * 2}px)`,
width: `calc(${step(frame)} - ${GAP_PX * 2}px)`,
});
const BOX = 10.5;
const BADGE = 4;

export const styles = stylex.create({
root: {
alignItems: 'flex-start',
display: 'inline-flex',
flexShrink: 0,
position: 'relative',
height: space['10.5'],
width: space['10.5'],
},

lead: {
...lead(BOX, BADGE),
maskComposite: 'exclude',
maskRepeat: 'no-repeat',
},

ring: {
borderRadius: radiusVars['--cl-radius-full'],
insetBlockStart: 0,
insetInlineStart: 0,
outlineColor: { default: null, ':is(:focus-visible *)': colorVars['--cl-color-ring'] },
outlineOffset: { default: null, ':is(:focus-visible *)': focusVars['--cl-focus-outline-offset'] },
outlineStyle: { default: null, ':is(:focus-visible *)': focusVars['--cl-focus-outline-style'] },
outlineWidth: { default: null, ':is(:focus-visible *)': focusVars['--cl-focus-outline-width'] },
pointerEvents: 'none',
position: 'absolute',
},

badge: {
display: 'flex',
insetBlockEnd: `${GAP_PX}px`,
insetInlineEnd: `${GAP_PX}px`,
insetBlockEnd: 0,
insetInlineEnd: 0,
position: 'absolute',
},
});

export const sizes = stylex.create({
xs: { height: space['6'], width: space['6'] },
sm: { height: space['8'], width: space['8'] },
md: { height: space['9.5'], width: space['9.5'] },
});

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

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

export const badgeSizes = stylex.create({
xs: badge(3),
sm: badge(3.5),
md: badge(4),
badgeAvatar: square(BADGE),
});
Loading
Loading