diff --git a/.changeset/mosaic-user-button-mode-priority.md b/.changeset/mosaic-user-button-mode-priority.md new file mode 100644 index 00000000000..6a649ef4608 --- /dev/null +++ b/.changeset/mosaic-user-button-mode-priority.md @@ -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`. 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 675b81c8d85..0cf08e8e22d 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,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 = {}) { - return resolveUserButtonLayout(mode, { +function resolve( + mode: UserButtonMode, + data: Partial = {}, + modePriority: UserButtonModePriority = 'organization', +) { + return resolveUserButtonLayout(mode, modePriority, { activeSession: alice, activeOrganization: foundry, hasOrganizations: true, @@ -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'); 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 b0ffeecc8e8..efd040cb70a 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 @@ -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 }); @@ -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 }); diff --git a/packages/mosaic/src/features/user-button/user-button-avatar.styles.ts b/packages/mosaic/src/features/user-button/user-button-avatar.styles.ts new file mode 100644 index 00000000000..42109b4f163 --- /dev/null +++ b/packages/mosaic/src/features/user-button/user-button-avatar.styles.ts @@ -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,")`; + +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), +}); diff --git a/packages/mosaic/src/features/user-button/user-button-avatar.view.tsx b/packages/mosaic/src/features/user-button/user-button-avatar.view.tsx new file mode 100644 index 00000000000..0a9624ad8cd --- /dev/null +++ b/packages/mosaic/src/features/user-button/user-button-avatar.view.tsx @@ -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. + + {imageUrl ? ( + + ) : null} + {initials(name)} + + ); +} + +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 ( + + ); + } + + return ( + + + {focusRing ? : null} + + + + + ); +} 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 51badbee97c..9af27e07bc0 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, 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 @@ -197,6 +197,7 @@ 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 101e755ecd7..a3d467dbf91 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,9 @@ -import type { UserButtonData, UserButtonHeaderLayout, UserButtonMode } from './user-button.types'; +import type { + UserButtonData, + UserButtonHeaderLayout, + UserButtonMode, + UserButtonModePriority, +} from './user-button.types'; /* * Which mode puts what where. The surface is three slots deep, in this order, and each mode fills @@ -24,7 +29,8 @@ import type { UserButtonData, UserButtonHeaderLayout, UserButtonMode } from './u * * 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 account. A combined surface can lead with the account while an organization is active too, and + * then the account is badged with that organization, and invites to and manages it. */ /** The four places an action can land. Every mode has a header and a footer; the list's two vary. */ @@ -34,7 +40,7 @@ export type UserButtonAction = | 'addAccount' | 'createOrganization' | 'inviteMembers' - /** The gear. Manages whatever the header names: the organization where one leads, else the account. */ + /** The gear. Manages the header's organization, whether it leads or badges the account, else the account. */ | 'manageLead' | 'manageAccount' | 'signOut' @@ -43,13 +49,15 @@ export type UserButtonAction = | 'switchAccount'; /** - * 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. + * What the trigger names and the header leads with. `member` is the account, badged with the + * organization it is active in. `none` is an organization-led surface with no organization active + * and no personal workspace to fall back to. */ -export type UserButtonLead = 'organization' | 'user' | 'none'; +export type UserButtonLead = 'organization' | 'member' | 'user' | 'none'; const headers = { organization: ['inviteMembers', 'manageLead'], + member: ['inviteMembers', 'manageLead'], user: ['signOut', 'manageLead'], none: ['manageLead'], } as const satisfies Record; @@ -87,8 +95,8 @@ const modes = { } as const satisfies Record; /** - * 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. + * 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. */ export interface UserButtonLayout { lead: UserButtonLead; @@ -104,17 +112,24 @@ export interface UserButtonLayout { actions: Record; } -function resolveLead(mode: UserButtonMode, data: UserButtonData): UserButtonLead { +function resolveLead(mode: UserButtonMode, modePriority: UserButtonModePriority, data: UserButtonData): UserButtonLead { if (mode === 'user') { return 'user'; } + if (mode === 'combined' && modePriority === 'user') { + return data.activeOrganization ? 'member' : 'user'; + } if (data.activeOrganization) { return 'organization'; } return data.hidePersonal ? 'none' : 'user'; } -export function resolveUserButtonLayout(mode: UserButtonMode, data: UserButtonData): UserButtonLayout { +export function resolveUserButtonLayout( + mode: UserButtonMode, + modePriority: UserButtonModePriority, + 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; @@ -124,7 +139,7 @@ export function resolveUserButtonLayout(mode: UserButtonMode, data: UserButtonDa // 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; - const lead = resolveLead(mode, data); + const lead = resolveLead(mode, modePriority, data); const header = [...headers[lead]]; return { 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 e8d44765bdd..8368db63a0e 100644 --- a/packages/mosaic/src/features/user-button/user-button.styles.ts +++ b/packages/mosaic/src/features/user-button/user-button.styles.ts @@ -47,6 +47,10 @@ export const styles = stylex.create({ transitionProperty: 'opacity', }, + triggerRinglessAvatar: { + outlineStyle: 'none', + }, + triggerRound: { borderRadius: radiusVars['--cl-radius-full'], }, diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx index 1b79df622d5..398a07da2b2 100644 --- a/packages/mosaic/src/features/user-button/user-button.tsx +++ b/packages/mosaic/src/features/user-button/user-button.tsx @@ -79,10 +79,12 @@ export type UserButtonProps = UserButtonModelOptions & * ``` * * @example - * `mode` narrows the menu to one switcher. + * `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. * ```tsx * * + * * ``` * * @example @@ -131,6 +133,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { renderTriggerLabel, renderTriggerBadge, mode, + modePriority, userProfileProps, organizationProfileProps, customMenuItems, @@ -167,7 +170,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { appearance: organizationProfileProps?.appearance, }, }); - const controller = useUserButtonController(model, { mode, customMenuItems, menuItemOrder }); + const controller = useUserButtonController(model, { mode, modePriority, 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 1fbaee03411..696f0d9567a 100644 --- a/packages/mosaic/src/features/user-button/user-button.types.ts +++ b/packages/mosaic/src/features/user-button/user-button.types.ts @@ -116,7 +116,14 @@ export type UserButtonMode = 'combined' | 'organization' | 'user'; */ export type UserButtonHeaderLayout = 'inline' | 'stacked'; -/** Which switchers the surface carries. */ +/** + * 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. + */ +export type UserButtonModePriority = 'organization' | 'user'; + +/** Which switchers the surface carries, and which one it leads with. */ export interface UserButtonModeProps { /** * Which switchers the popup carries: both, organizations alone, or accounts alone. @@ -124,6 +131,14 @@ export interface UserButtonModeProps { * @default 'combined' */ mode?: UserButtonMode; + /** + * Which switcher a `combined` surface leads with in the trigger and the popup's header. Leading + * with the account while an organization is active badges the account's avatar with it. 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. */ 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 697bbb329fb..7eeca04448f 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -4,8 +4,6 @@ import * as stylex from '@stylexjs/stylex'; import type { ReactElement, ReactNode } from 'react'; import React from 'react'; -import type { AvatarProps } from '../../components/avatar'; -import { Avatar } from '../../components/avatar'; import { Badge } from '../../components/badge'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; @@ -39,6 +37,7 @@ import type { UserButtonModeProps, UserButtonSession, } from './user-button.types'; +import { RowAvatar, UserButtonAvatar } from './user-button-avatar.view'; import { UserButtonHeader } from './user-button-header.view'; import { UserButtonGroup, @@ -107,7 +106,7 @@ type ActiveWorkspace = shape: 'square'; organization: UserButtonMembership; } - | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle' } + | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle'; badge?: UserButtonMembership } | { kind: 'none'; name: string; imageUrl?: string; shape: 'square' }; /** @@ -137,6 +136,7 @@ function leadWorkspace( name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle', + badge: layout.lead === 'member' && activeOrganization ? activeOrganization : undefined, }; } @@ -150,42 +150,8 @@ function membershipSubtitle(membership: UserButtonMembership, m: Messages, local return joinDetails(membership.planLabel, members); } -function initials(name: string): string { - const [first = '', second = ''] = name.trim().split(/\s+/); - return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase() || '?'; -} - // ─── Presentational leaves ────────────────────────────────────────────────── -interface RowAvatarProps { - name: string; - imageUrl?: string; - shape: 'circle' | 'square'; - size: AvatarProps['size']; - xstyle?: AvatarProps['xstyle']; -} - -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. - - {imageUrl ? ( - - ) : null} - {initials(name)} - - ); -} - /** * Renders `