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

Filter by extension

Filter by extension

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

Bring back the `modePriority` prop on `UserButton`. With `modePriority='user'`, a combined `UserButton` leads with the account, and its avatar is badged with the active organization in both the trigger and the header. The badged avatar is themed through `.cl-user-button-avatar` and `.cl-user-button-avatar-badge`.
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { describe, expect, it } from 'vitest';

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

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

function resolve(mode: UserButtonMode, data: Partial<UserButtonData> = {}) {
return resolveUserButtonLayout(mode, {
function resolve(
mode: UserButtonMode,
data: Partial<UserButtonData> = {},
modePriority: UserButtonModePriority = 'organization',
) {
return resolveUserButtonLayout(mode, modePriority, {
activeSession: alice,
activeOrganization: foundry,
hasOrganizations: true,
Expand Down Expand Up @@ -78,6 +82,27 @@ describe('resolveUserButtonLayout, what the data settles', () => {
});
});

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

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

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

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

it('is ignored by the single-purpose modes', () => {
expect(resolve('organization', {}, 'user').lead).toBe('organization');
expect(resolve('user', {}, 'user').lead).toBe('user');
});
});

describe('resolveUserButtonLayout, how the header carries its actions', () => {
it('stacks them wherever a labelled action joins the gear', () => {
expect(resolve('combined').headerLayout).toBe('stacked');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,30 @@ describe('UserButtonTrigger', () => {
expect(screen.getByText('Pro')).toBeInTheDocument();
});

it('names the account, badged with its active organization, where the account leads', () => {
renderTrigger({ mode: 'combined', modePriority: 'user' });

const trigger = screen.getByRole('button', { name: 'Open account menu for Alice Smith' });
expect(within(trigger).getByText('Alice Smith')).toBeInTheDocument();
expect(screen.queryByText('Pro')).toBeNull();
expect(trigger.querySelector('.cl-user-button-avatar-badge')?.textContent).toBe('F');
});

it('badges the avatar alone the same way', () => {
renderTrigger({ mode: 'combined', modePriority: 'user', renderTriggerLabel: false });

const trigger = screen.getByRole('button', { name: 'Open account menu for Alice Smith' });
expect(trigger.querySelector('.cl-user-button-avatar-badge')?.textContent).toBe('F');
});

it('badges nothing where the organization or no organization leads', () => {
renderTrigger({ mode: 'combined' });
renderTrigger({ mode: 'combined', modePriority: 'user', activeOrganization: null });
renderTrigger({ mode: 'user', modePriority: 'user' });

expect(document.querySelector('.cl-user-button-avatar-badge')).toBeNull();
});

it('renders the avatar alone when the label is off', () => {
renderTrigger({ mode: 'organization', renderTriggerLabel: false });

Expand Down Expand Up @@ -1015,6 +1039,21 @@ describe('UserButtonView, the header', () => {
expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header');
});

it('leads a combined surface with the account, badged with its active organization, where asked', async () => {
const onManageAccount = vi.fn();
const onManageOrganization = vi.fn();
const onInviteMembers = vi.fn();
renderHeader({ modePriority: 'user', onManageAccount, onManageOrganization, onInviteMembers });

expect(header().querySelector('.cl-user-button-header-title')?.textContent).toBe('Alice Smith');
expect(header().querySelector('.cl-user-button-avatar-badge')?.textContent).toBe('F');
await userEvent.setup().click(within(header()).getByRole('button', { name: 'Settings' }));
expect(onManageOrganization).toHaveBeenCalled();
expect(onManageAccount).not.toHaveBeenCalled();
await userEvent.setup().click(within(header()).getByRole('button', { name: 'Invite' }));
expect(onInviteMembers).toHaveBeenCalled();
});

it('falls back to the identifier where no organization is active', () => {
renderHeader({ activeOrganization: null });

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, focusVars, radiusVars, 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 square = (multiple: number) => ({
fontSize: `calc(${step(multiple)} * 0.4)`,
height: step(multiple),
width: step(multiple),
});

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

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)`,
});

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

lead: {
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`,
position: 'absolute',
},
});

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

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

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

export const badgeSizes = stylex.create({
xs: badge(3),
sm: badge(3.5),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as stylex from '@stylexjs/stylex';
import type { ReactElement } from 'react';

import type { AvatarProps } from '../../components/avatar';
import { Avatar } from '../../components/avatar';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../utils/reset.styles';
import { badgeSizes, leadSizes, ringSizes, sizes, styles } from './user-button-avatar.styles';

function initials(name: string): string {
const [first = '', second = ''] = name.trim().split(/\s+/);
return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase() || '?';
}

export interface RowAvatarProps {
name: string;
imageUrl?: string;
shape: 'circle' | 'square';
size: AvatarProps['size'];
xstyle?: AvatarProps['xstyle'];
}

export function RowAvatar({ name, imageUrl, shape, size, xstyle }: RowAvatarProps): ReactElement {
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.
<Avatar.Root
aria-hidden
size={size}
shape={shape}
xstyle={xstyle}
>
{imageUrl ? (
<Avatar.Image
src={imageUrl}
alt=''
/>
) : null}
<Avatar.Fallback>{initials(name)}</Avatar.Fallback>
</Avatar.Root>
);
}

export interface UserButtonAvatarProps {
name: string;
imageUrl?: string;
shape: 'circle' | 'square';
size: 'xs' | 'sm';
badge?: { name: string; imageUrl?: string };
focusRing?: boolean;
}

export function UserButtonAvatar({
name,
imageUrl,
shape,
size,
badge,
focusRing = false,
}: UserButtonAvatarProps): ReactElement {
if (!badge) {
return (
<RowAvatar
name={name}
imageUrl={imageUrl}
shape={shape}
size={size}
/>
);
}

return (
<span
aria-hidden
{...mergeStyleProps(
themeProps('user-button-avatar', { size }),
stylex.props(reset.base, styles.root, sizes[size]),
)}
>
<RowAvatar
name={name}
imageUrl={imageUrl}
shape={shape}
size='fit'
xstyle={[styles.lead, leadSizes[size]]}
/>
{focusRing ? <span {...stylex.props(reset.base, styles.ring, ringSizes[size])} /> : null}
<span {...mergeStyleProps(themeProps('user-button-avatar-badge'), stylex.props(reset.base, styles.badge))}>
<RowAvatar
name={badge.name}
imageUrl={badge.imageUrl}
shape='square'
size='fit'
xstyle={badgeSizes[size]}
/>
</span>
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const userButtonMachine = createMachine({
},
});

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

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

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