From 308d8504a84266d9809de3448b8e4f61b40553b5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 10:02:15 -0400 Subject: [PATCH 01/31] feat(ui): add a user-button-header slot and layouts to the Mosaic UserButton --- .changeset/mosaic-user-button-header.md | 2 + packages/swingset/src/stories/user-button.mdx | 31 +++ .../src/stories/user-button.stories.tsx | 26 ++ .../__tests__/user-button.layout.test.ts | 6 + .../__tests__/user-button.model.test.tsx | 27 +- .../__tests__/user-button.view.test.tsx | 135 +++++++++- .../user-button/user-button-header.styles.ts | 48 ++++ .../user-button/user-button-header.view.tsx | 54 ++++ .../user-button/user-button.layout.ts | 2 + .../user-button/user-button.messages.ts | 1 + .../user-button/user-button.model.tsx | 13 +- .../user-button/user-button.styles.ts | 16 ++ .../features/user-button/user-button.tsx | 5 +- .../features/user-button/user-button.types.ts | 8 + .../features/user-button/user-button.view.tsx | 234 ++++++++++++------ 15 files changed, 516 insertions(+), 92 deletions(-) create mode 100644 .changeset/mosaic-user-button-header.md create mode 100644 packages/ui/src/mosaic/features/user-button/user-button-header.styles.ts create mode 100644 packages/ui/src/mosaic/features/user-button/user-button-header.view.tsx diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-header.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index fcde080d165..c567864ce6b 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -97,6 +97,25 @@ A user's avatar on its own rounds the trigger fully to match it. storyModule={UserButtonStories} /> +## Header + +The header names the active workspace and carries what you can do about it. An organization is +described by its plan and member count; an account by the organization it works in and its role +there, or by its identifier where the surface carries no organizations. + +`headerLayout='inline'` (the default) trails the workspace with the actions, the gear as an icon +alone. `headerLayout='stacked'` runs them under it as full-width labelled buttons. + + + + + ## Modes `mode` picks which switchers the surface carries — the only prop that changes the popup's shape. @@ -211,3 +230,15 @@ ignored rather than an error, so one order can cover every mode. | `UserButtonRoot` | Owns the data, callbacks, and `mode`; forwards open state to `Popover.Root`. | | `UserButtonTrigger` | The trigger: the active workspace's avatar, and what it is called. | | `UserButtonPopup` | The popover surface: header, workspace list, footer. | + +## Styling + +| Slot | Attributes | +| ------------------------------------ | --------------------------------- | +| `.cl-user-button-trigger` | | +| `.cl-user-button-popover` | | +| `.cl-user-button-header` | `data-layout="inline \| stacked"` | +| `.cl-user-button-header-content` | | +| `.cl-user-button-header-title` | | +| `.cl-user-button-header-description` | | +| `.cl-user-button-header-actions` | | diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index c69edb070fd..85c569e80c9 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -77,6 +77,7 @@ const initialAccounts: Account[] = [ name: 'Clerk app', membersCount: 24, planLabel: 'Pro plan', + roleLabel: 'Admin', imageUrl: clerkLogo, }, clerkCloud, @@ -267,6 +268,31 @@ export function UserPriority(_args: Record) { ); } +export function StackedHeader(_args: Record) { + const prototype = usePrototype(); + + return ( + + ); +} + +export function UserPriorityStackedHeader(_args: Record) { + const prototype = usePrototype(); + + return ( + + ); +} + export function AvatarOnly(_args: Record) { const prototype = usePrototype(); diff --git a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.layout.test.ts b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.layout.test.ts index 9dfe95e4a52..43c640bd548 100644 --- a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.layout.test.ts +++ b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.layout.test.ts @@ -93,6 +93,12 @@ describe('resolveUserButtonLayout, which sections render', () => { it('carries no organizations in user mode', () => { expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false }); }); + + it('describes the account by its organization only where the surface carries organizations', () => { + expect(resolve('combined').describeAccountByOrganization).toBe(true); + expect(resolve('organization').describeAccountByOrganization).toBe(true); + expect(resolve('user').describeAccountByOrganization).toBe(false); + }); }); describe('resolveUserButtonLayout, what the surface leads with', () => { diff --git a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx index 066341c2421..5d27ff37952 100644 --- a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx +++ b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.model.test.tsx @@ -40,6 +40,7 @@ let isOrgLoaded: boolean; let user: FakeUser | null; let session: { id: string; checkAuthorization: ReturnType } | null; let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; +let organizationMembership: { roleName: string } | null; let userMemberships: FakeList; let userInvitations: FakeList; let userSuggestions: FakeList; @@ -80,7 +81,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useSession: () => ({ isLoaded: isSessionLoaded, session }), - useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization })), + useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization, membership: organizationMembership })), // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather // than that some function did. usePortalRoot: () => getContainer, @@ -124,8 +125,8 @@ function acceptable( }; } -function membership(orgId: string, name: string, membersCount: number) { - return { organization: { id: orgId, name, imageUrl: '', membersCount } }; +function membership(orgId: string, name: string, membersCount: number, roleName = 'Member') { + return { roleName, organization: { id: orgId, name, imageUrl: '', membersCount } }; } function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { @@ -148,6 +149,7 @@ beforeEach(() => { }; session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 }; + organizationMembership = { roleName: 'Admin' }; userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); @@ -304,6 +306,17 @@ function activeOrganization() { } describe('useUserButtonModel', () => { + it('names the role the account holds in the active organization', () => { + render(); + expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme', roleLabel: 'Admin' }); + }); + + it('leaves the role off where the membership has not resolved', () => { + organizationMembership = null; + render(); + expect(activeOrganization()).not.toHaveProperty('roleLabel'); + }); + it('is loading until the user, session, and organization are all loaded', () => { isUserLoaded = false; const { rerender } = render(); @@ -462,7 +475,13 @@ 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, + roleLabel: 'Member', + }); const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); expect(suggestions[0]).toMatchObject({ diff --git a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.view.test.tsx index c42c24c7ff8..fa353d84283 100644 --- a/packages/ui/src/mosaic/features/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/features/user-button/__tests__/user-button.view.test.tsx @@ -73,6 +73,13 @@ 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 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( node => node.textContent ?? '', @@ -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', () => { @@ -184,9 +189,8 @@ 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(); }); @@ -194,7 +198,7 @@ describe('UserButtonView, organization mode', () => { 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(within(header()).getByText('Alice Smith')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); }); @@ -203,7 +207,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,7 +219,7 @@ 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(within(header()).getByText('Foundry')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Manage organization' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Invite' })).toBeInTheDocument(); }); @@ -260,7 +264,7 @@ 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(); }); @@ -268,7 +272,7 @@ describe('UserButtonView, combined mode', () => { it('heads the surface with the account where the user takes priority', () => { renderCombined({ modePriority: 'user' }); - expect(screen.queryByText('24 members · Pro')).toBeNull(); + expect(screen.queryByText('Pro · 24 members')).toBeNull(); expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); }); @@ -911,3 +915,108 @@ describe('UserButtonTrigger', () => { expect(screen.queryByText('Alice Smith')).toBeNull(); }); }); + +describe('UserButtonView, the header', () => { + function renderHeader(props: Partial = {}) { + return renderView({ + hasOrganizations: true, + activeOrganization: { ...foundry, roleLabel: 'Admin' }, + 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' })).toHaveClass('cl-user-button-trigger'); + expect(popup().querySelector('.cl-user-button-popover')).not.toBeNull(); + expect(header()).toHaveAttribute('data-layout', 'inline'); + 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('runs the actions beside the workspace by default, the gear as an icon', () => { + renderHeader(); + + const gear = screen.getByRole('button', { name: 'Manage organization' }); + expect(gear).toHaveAttribute('data-shape', 'square'); + expect(screen.getByRole('button', { name: 'Invite' })).not.toHaveAttribute('data-full-width'); + expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); + }); + + it('stacks the actions under the workspace as labelled buttons', () => { + renderHeader({ headerLayout: 'stacked' }); + + 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('stacks the account actions the same way', async () => { + const onSignOutSession = vi.fn(); + const onManageAccount = vi.fn(); + renderHeader({ mode: 'user', headerLayout: 'stacked', onSignOutSession, onManageAccount }); + + 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'); + }); + + it('names the organization and the role under an account that leads a combined surface', () => { + renderHeader({ modePriority: 'user' }); + + expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); + expect(within(header()).getByText('Foundry · Admin')).toBeInTheDocument(); + expect(within(header()).queryByText('alice@example.com')).toBeNull(); + }); + + it('names the organization alone where the role is unknown', () => { + renderHeader({ modePriority: 'user', activeOrganization: foundry }); + + expect(within(header()).getByText('Foundry')).toBeInTheDocument(); + }); + + it('falls back to the identifier where no organization is active', () => { + renderHeader({ modePriority: 'user', 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(); + expect(screen.queryByText('Foundry · Admin')).toBeNull(); + }); + + it('nests the organization avatar in the account avatar where the account leads', () => { + renderHeader({ modePriority: 'user' }); + + const trigger = screen.getByRole('button', { name: 'Open account menu for Alice Smith' }); + const nestedInTrigger = trigger.querySelector('.cl-avatar .cl-avatar'); + expect(nestedInTrigger).toHaveAttribute('data-shape', 'square'); + expect(nestedInTrigger?.textContent).toBe('F'); + + const nestedInHeader = header().querySelector('.cl-avatar .cl-avatar'); + expect(nestedInHeader).toHaveAttribute('data-shape', 'square'); + }); + + it('nests no avatar where the organization leads, none is active, or the surface carries none', () => { + const nested = (props: Partial) => { + const { unmount } = renderHeader(props); + const found = header().querySelector('.cl-avatar .cl-avatar'); + unmount(); + return found; + }; + + expect(nested({})).toBeNull(); + expect(nested({ modePriority: 'user', activeOrganization: null })).toBeNull(); + expect(nested({ mode: 'user' })).toBeNull(); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-button/user-button-header.styles.ts b/packages/ui/src/mosaic/features/user-button/user-button-header.styles.ts new file mode 100644 index 00000000000..7ecae931f15 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-button/user-button-header.styles.ts @@ -0,0 +1,48 @@ +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', + 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/ui/src/mosaic/features/user-button/user-button-header.view.tsx b/packages/ui/src/mosaic/features/user-button/user-button-header.view.tsx new file mode 100644 index 00000000000..81ca8a25cae --- /dev/null +++ b/packages/ui/src/mosaic/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/ui/src/mosaic/features/user-button/user-button.layout.ts b/packages/ui/src/mosaic/features/user-button/user-button.layout.ts index 106aa7a099a..a2e38c33bb4 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.layout.ts +++ b/packages/ui/src/mosaic/features/user-button/user-button.layout.ts @@ -84,6 +84,7 @@ export interface UserButtonLayout { * still needs somewhere to manage and sign out of itself. */ showOrganizationsHeading: boolean; + describeAccountByOrganization: boolean; /** What each slot carries, in the order it renders. */ actions: Record; } @@ -130,6 +131,7 @@ export function resolveUserButtonLayout( leadWith: mode === 'combined' ? modePriority : mode, showOrganizations: declared.organizations !== false && hasOrganizations, showOrganizationsHeading: organizationsHeading !== false, + describeAccountByOrganization: declared.organizations !== false, actions: { header: slot(declared.header), organizationsHeading: organizationsHeading === false ? [] : slot(organizationsHeading), diff --git a/packages/ui/src/mosaic/features/user-button/user-button.messages.ts b/packages/ui/src/mosaic/features/user-button/user-button.messages.ts index 609e4fcb1c9..9898a0d3437 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.messages.ts +++ b/packages/ui/src/mosaic/features/user-button/user-button.messages.ts @@ -30,6 +30,7 @@ export const userButtonMessages = { }, manage: { invite: 'Invite', + settings: 'Settings', account: 'Manage account', organization: 'Manage organization', createOrganization: 'Create organization', diff --git a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx index 603ec84afa6..94600e371e4 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx @@ -116,13 +116,14 @@ function displayName(user: UserResource): string { return getFullName(user) || getIdentifier(user); } -function toMembership(organization: OrganizationResource): UserButtonMembership { +function toMembership(organization: OrganizationResource, roleLabel?: string): UserButtonMembership { return { kind: 'membership', organizationId: organization.id, name: organization.name, imageUrl: organization.imageUrl || undefined, membersCount: organization.membersCount, + roleLabel, }; } @@ -143,7 +144,11 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); // The active org names the trigger. That is not a request to turn Organizations on. - const { isLoaded: isOrgLoaded, organization } = useOrganization({ + const { + isLoaded: isOrgLoaded, + organization, + membership, + } = useOrganization({ __internal_skipAttemptToEnableOrganizations: true, }); const clerk = useClerk(); @@ -212,7 +217,7 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us const suggestionData = userSuggestions.data ?? []; const invitationData = userInvitations.data ?? []; - const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization)); + const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization, m.roleName)); const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({ kind: 'suggestion', @@ -261,7 +266,7 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us organizationsEnabled, renderBranding: displayConfig.branded, activeSession: toSession(session.id, user), - activeOrganization: organization ? toMembership(organization) : null, + activeOrganization: organization ? toMembership(organization, membership?.roleName) : null, // The user resource settles this before the paginated list answers; the count covers a stale resource. hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0, hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), diff --git a/packages/ui/src/mosaic/features/user-button/user-button.styles.ts b/packages/ui/src/mosaic/features/user-button/user-button.styles.ts index b159bb9c13f..80ed271b0f2 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/features/user-button/user-button.styles.ts @@ -6,6 +6,22 @@ export const styles = stylex.create({ accountIdentifier: { fontWeight: fontWeightVars['--cl-font-medium'], }, + nestedAvatar: { + borderRadius: '2px', + insetBlockEnd: `calc(${space['0.5']} * -1)`, + insetInlineEnd: `calc(${space['0.5']} * -1)`, + position: 'absolute', + }, + nestedAvatarSm: { + fontSize: '0.3125rem', + height: space['2.5'], + width: space['2.5'], + }, + nestedAvatarMd: { + fontSize: '0.375rem', + height: space['3.5'], + width: space['3.5'], + }, trigger: { padding: 0, borderRadius: radiusVars['--cl-radius-md'], diff --git a/packages/ui/src/mosaic/features/user-button/user-button.tsx b/packages/ui/src/mosaic/features/user-button/user-button.tsx index df832fd1b93..ded5d4ba78b 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/features/user-button/user-button.tsx @@ -10,7 +10,7 @@ import { useUserButtonModel } from './user-button.model'; import type { OrganizationProfilePageId } from './user-button.pages'; import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './user-button.pages'; import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; -import type { UserButtonTriggerProps } from './user-button.view'; +import type { UserButtonPopupProps, UserButtonTriggerProps } from './user-button.view'; import { UserButtonView } from './user-button.view'; /** What a profile opened by `` takes beyond the profile component's own props. */ @@ -42,6 +42,7 @@ export interface UserButtonOrganizationProfileProps /** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profiles it opens. */ export type UserButtonProps = UserButtonModelOptions & UserButtonTriggerProps & + UserButtonPopupProps & UserButtonMenuProps & UserButtonModeProps & { /** @@ -132,6 +133,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { const { renderTriggerLabel, renderTriggerBadge, + headerLayout, mode, modePriority, userProfileProps, @@ -194,6 +196,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { {...viewController} renderTriggerLabel={renderTriggerLabel} renderTriggerBadge={renderTriggerBadge} + headerLayout={headerLayout} /> {portals} diff --git a/packages/ui/src/mosaic/features/user-button/user-button.types.ts b/packages/ui/src/mosaic/features/user-button/user-button.types.ts index e7a69fbf3b6..6669431f850 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.types.ts +++ b/packages/ui/src/mosaic/features/user-button/user-button.types.ts @@ -19,6 +19,8 @@ export interface UserButtonMembership { imageUrl?: string; membersCount?: number; planLabel?: string; + /** The role the active account holds in the organization, named for display. */ + roleLabel?: string; } export interface UserButtonSuggestion { @@ -115,6 +117,12 @@ export type UserButtonMode = 'combined' | 'organization' | 'user'; */ export type UserButtonModePriority = 'organization' | 'user'; +/** + * 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 UserButtonHeaderLayout = 'inline' | 'stacked'; + /** Which switchers the surface carries, and which one it leads with. */ export interface UserButtonModeProps { /** diff --git a/packages/ui/src/mosaic/features/user-button/user-button.view.tsx b/packages/ui/src/mosaic/features/user-button/user-button.view.tsx index 33a6f5dfefd..2ba8fa8beef 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/features/user-button/user-button.view.tsx @@ -18,11 +18,12 @@ import { Popover } from '../../components/popover'; import { scrollAreaViewport } from '../../components/scroll-area'; import { Spinner } from '../../components/spinner'; import type { IconName } from '../../icons/registry'; +import { themeProps } from '../../props'; import { applyOrder } from '../../utils/apply-order'; import { focusOutline } from '../../utils/focus-outline.styles'; import { fill, plural } from '../../utils/messages'; import { truncationStyles } from '../../utils/typography.styles'; -import type { UserButtonLayout } from './user-button.layout'; +import type { UserButtonAction, UserButtonLayout } from './user-button.layout'; import { resolveUserButtonLayout } from './user-button.layout'; import { userButtonMessages as m } from './user-button.messages'; import { styles } from './user-button.styles'; @@ -31,12 +32,14 @@ import type { UserButtonBusyState, UserButtonCallbacks, UserButtonData, + UserButtonHeaderLayout, UserButtonMembership, UserButtonMenuItemId, UserButtonMenuProps, UserButtonModeProps, UserButtonSession, } from './user-button.types'; +import { UserButtonHeader } from './user-button-header.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. @@ -94,7 +97,7 @@ type ActiveWorkspace = shape: 'square'; organization: UserButtonMembership; } - | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle' } + | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle'; organization: UserButtonMembership | null } | { kind: 'none'; name: string; imageUrl?: string; shape: 'square' }; /** @@ -121,18 +124,27 @@ function leadWorkspace({ 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', + organization: layout.describeAccountByOrganization ? activeOrganization : null, + }; +} + +function joinDetails(...parts: Array): string { + return parts.filter(Boolean).join(' · '); } function membershipSubtitle(membership: UserButtonMembership): string { - const parts: string[] = []; - if (membership.membersCount !== undefined) { - parts.push(plural(m.workspaces.members, membership.membersCount)); - } - if (membership.planLabel) { - parts.push(membership.planLabel); - } - return parts.join(' · '); + const members = + membership.membersCount === undefined ? undefined : plural(m.workspaces.members, membership.membersCount); + return joinDetails(membership.planLabel, members); +} + +function roleSubtitle(membership: UserButtonMembership): string { + return joinDetails(membership.name, membership.roleLabel); } function initials(name: string): string { @@ -147,9 +159,11 @@ interface RowAvatarProps { imageUrl?: string; shape: 'circle' | 'square'; size: AvatarProps['size']; + xstyle?: AvatarProps['xstyle']; + children?: ReactNode; } -function RowAvatar({ name, imageUrl, shape, size }: RowAvatarProps) { +function RowAvatar({ name, imageUrl, shape, size, xstyle, children }: 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. @@ -157,6 +171,7 @@ function RowAvatar({ name, imageUrl, shape, size }: RowAvatarProps) { aria-hidden size={size} shape={shape} + xstyle={xstyle} > {imageUrl ? ( ) : null} {initials(name)} + {children} ); } +/** The lead workspace's mark. An account working in an organization wears its avatar in the corner. */ +function WorkspaceAvatar({ workspace, size }: { workspace: ActiveWorkspace; size: AvatarProps['size'] }) { + const organization = workspace.kind === 'user' ? workspace.organization : null; + + return ( + + {organization ? ( + + ) : null} + + ); +} + /** * Renders ` ); } From a40d15488edd199a9d289955e2c7fdf477d45ce4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 11:44:08 -0400 Subject: [PATCH 18/31] test(mosaic): guard the user button header label truncation --- .changeset/mosaic-user-button-header.md | 3 +++ .../user-button/__tests__/user-button.view.test.tsx | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md index a845151cc84..fca1bfba1e3 100644 --- a/.changeset/mosaic-user-button-header.md +++ b/.changeset/mosaic-user-button-header.md @@ -1,2 +1,5 @@ --- +'@clerk/mosaic': patch --- + +Lay out the `UserButton` header from what it carries: the actions stack under the workspace as labelled buttons where an organization or a second account gives the gear company, and the gear sits inline as an icon otherwise, with a lone account signing out from the foot. Header and trigger avatars now share one size, an account's nested organization mark stays inside it, and long workspace names truncate instead of wrapping the header or spilling out of its buttons. 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 b03660e61ac..a319c66dd0c 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 @@ -952,6 +952,13 @@ describe('UserButtonView, the header', () => { 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(); From aa5c327ebadeaf6868a10a8acf2fe19ccc15dd26 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 11:46:55 -0400 Subject: [PATCH 19/31] chore(mosaic): shorten the user button header changeset --- .changeset/mosaic-user-button-header.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md index fca1bfba1e3..b24aea35061 100644 --- a/.changeset/mosaic-user-button-header.md +++ b/.changeset/mosaic-user-button-header.md @@ -2,4 +2,4 @@ '@clerk/mosaic': patch --- -Lay out the `UserButton` header from what it carries: the actions stack under the workspace as labelled buttons where an organization or a second account gives the gear company, and the gear sits inline as an icon otherwise, with a lone account signing out from the foot. Header and trigger avatars now share one size, an account's nested organization mark stays inside it, and long workspace names truncate instead of wrapping the header or spilling out of its buttons. +Derive the `UserButton` header layout from its actions: labelled buttons stack under the workspace, and a lone gear sits inline. Header and trigger avatars share one size, and long names truncate instead of wrapping. From 38a1e79536ffc67e74f599d3c31537005d0d53f7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 11:47:24 -0400 Subject: [PATCH 20/31] chore(mosaic): trim the user button header changeset --- .changeset/mosaic-user-button-header.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md index b24aea35061..2ac75a1f28f 100644 --- a/.changeset/mosaic-user-button-header.md +++ b/.changeset/mosaic-user-button-header.md @@ -2,4 +2,4 @@ '@clerk/mosaic': patch --- -Derive the `UserButton` header layout from its actions: labelled buttons stack under the workspace, and a lone gear sits inline. Header and trigger avatars share one size, and long names truncate instead of wrapping. +Derive the `UserButton` header layout from its actions: labelled buttons stack under the workspace, and a lone gear sits inline. From bf19eb744ebb4f1dab0296cebe0a1aa9b3efc8f0 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:07:52 -0500 Subject: [PATCH 21/31] verbose npm logging --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44fc8daf17c..32386c267b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -468,6 +468,7 @@ jobs: if: steps.version-packages.outputs.success == '1' run: pnpm release:snapshot env: + NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_PROVENANCE: true - name: Package info From c63148abb2cfa754a1734fde938632b562fc69b9 Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:14:27 -0500 Subject: [PATCH 22/31] set log level in script --- .github/workflows/release.yml | 1 - package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32386c267b3..44fc8daf17c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -468,7 +468,6 @@ jobs: if: steps.version-packages.outputs.success == '1' run: pnpm release:snapshot env: - NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_PROVENANCE: true - name: Package info diff --git a/package.json b/package.json index 5884e61374c..0870503a494 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "release": "changeset publish && git push --follow-tags && node scripts/wait-for-packages-on-npm.mjs", "release:canary": "changeset publish --tag canary --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", "release:canary-core3": "changeset publish --tag canary-core3 --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", - "release:snapshot": "changeset publish --tag snapshot --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", + "release:snapshot": "NPM_CONFIG_LOGLEVEL=verbose changeset publish --tag snapshot --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", "release:status": "changeset status --output .changeset-status.json", "test": "FORCE_COLOR=1 turbo test --concurrency=${TURBO_CONCURRENCY:-80%}", "test:cache:clear": "FORCE_COLOR=1 turbo test:cache:clear --continue --concurrency=${TURBO_CONCURRENCY:-80%}", From 26f3e90e47b7dc44c701a26e295ff046803abdb3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 17 Sep 2026 17:54:24 -0400 Subject: [PATCH 23/31] feat(mosaic): keep a sign-out row at the foot of the user button in single session --- .../__tests__/user-button.layout.test.ts | 6 +++--- .../__tests__/user-button.view.test.tsx | 11 ++++++++--- .../features/user-button/user-button.layout.ts | 6 ++---- .../features/user-button/user-button.types.ts | 4 ++-- packages/swingset/src/stories/user-button.mdx | 17 +++++++++-------- .../src/stories/user-button.stories.tsx | 2 +- 6 files changed, 25 insertions(+), 21 deletions(-) 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 e02c8773281..ff716c410a8 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 @@ -59,9 +59,9 @@ describe('resolveUserButtonLayout, what the data settles', () => { }); // 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('leaves the foot "Add account" alone in combined mode where there is one account', () => { - expect(resolve('combined', { 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 foot in user mode, leaving the header its gear', () => { 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 a319c66dd0c..7ebd8507713 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 @@ -683,11 +683,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'); }); }); 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 f99521a4040..2f2f155cbd7 100644 --- a/packages/mosaic/src/features/user-button/user-button.layout.ts +++ b/packages/mosaic/src/features/user-button/user-button.layout.ts @@ -118,11 +118,9 @@ export function resolveUserButtonLayout( return data.activeOrganization ? action : null; case 'signOut': return slot === 'header' && !hasOtherSessions ? null : action; - // "All accounts" is one account. Where the account's own row already signs out of it, the foot - // would be offering the same thing over again, in the plural; a user surface has no such row, - // so its foot signs the one account out in the singular instead. + // "All accounts" is one account, so the foot signs out of just that one, in the singular. case 'signOutAll': - return hasOtherSessions ? action : mode === 'user' ? 'signOut' : null; + return hasOtherSessions ? action : 'signOut'; // 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': 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 d11e9d45b3d..6ea54861fb3 100644 --- a/packages/mosaic/src/features/user-button/user-button.types.ts +++ b/packages/mosaic/src/features/user-button/user-button.types.ts @@ -168,8 +168,8 @@ 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. `signOutAll` and `signOut` share one the same way on - * a user surface: it signs out of all accounts where there is more than one, and of the one otherwise. + * 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 of the one account where there is not. */ export type UserButtonMenuItemId = 'switchAccount' | 'addAccount' | 'signOutAll' | 'signOut'; diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index 3e0e46c3e1e..a79edfa8b49 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -190,9 +190,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. ) { const prototype = usePrototype(); // 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 ( Date: Fri, 25 Sep 2026 09:18:32 -0400 Subject: [PATCH 24/31] feat(mosaic): apply the new user button designs and drop modePriority --- .changeset/mosaic-user-button-header.md | 2 + .../user-button.integration.test.tsx | 13 +- .../__tests__/user-button.layout.test.ts | 50 ++-- .../__tests__/user-button.model.test.tsx | 20 +- .../__tests__/user-button.view.test.tsx | 114 +++----- .../user-button/user-button.controller.tsx | 5 +- .../user-button/user-button.layout.ts | 116 ++++----- .../user-button/user-button.model.tsx | 13 +- .../user-button/user-button.styles.ts | 32 --- .../src/features/user-button/user-button.tsx | 7 +- .../features/user-button/user-button.types.ts | 25 +- .../features/user-button/user-button.view.tsx | 195 ++++++-------- packages/swingset/src/stories/user-button.mdx | 119 ++++----- .../src/stories/user-button.stories.tsx | 246 +++++++++--------- 14 files changed, 399 insertions(+), 558 deletions(-) diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md index 2ac75a1f28f..d6185632b65 100644 --- a/.changeset/mosaic-user-button-header.md +++ b/.changeset/mosaic-user-button-header.md @@ -3,3 +3,5 @@ --- Derive the `UserButton` header layout from its actions: labelled buttons stack under the workspace, and a lone gear sits inline. + +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. `UserButtonView`'s `onSignOutSession` now also receives where the sign-out was pressed. 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 38e86877395..d85eb512fb2 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 () => { 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 ff716c410a8..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,8 +50,18 @@ 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. @@ -64,11 +70,11 @@ describe('resolveUserButtonLayout, what the data settles', () => { expect(resolve('combined', { additionalSessions: [] }).actions.footer).toEqual(['addAccount', 'signOut']); }); - it('signs a lone account out from the foot in user mode, leaving the header its gear', () => { + 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(['manageLead']); - expect(layout.actions.footer).toEqual(['addAccount', 'signOut']); + expect(layout.actions.header).toEqual(['signOut', 'manageLead']); + expect(layout.actions.footer).toEqual(['addAccount']); }); }); @@ -80,8 +86,7 @@ describe('resolveUserButtonLayout, how the header carries its actions', () => { }); it('runs the gear inline where it is the only action', () => { - expect(resolve('combined', { activeOrganization: null }).headerLayout).toBe('inline'); - expect(resolve('user', { additionalSessions: [] }).headerLayout).toBe('inline'); + expect(resolve('combined', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline'); }); }); @@ -110,19 +115,4 @@ describe('resolveUserButtonLayout, which sections render', () => { it('carries no organizations in user mode', () => { expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false }); }); - - it('describes the account by its organization only where the surface carries organizations', () => { - expect(resolve('combined').describeAccountByOrganization).toBe(true); - expect(resolve('organization').describeAccountByOrganization).toBe(true); - expect(resolve('user').describeAccountByOrganization).toBe(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 32f18d4462f..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 @@ -40,7 +40,6 @@ let isOrgLoaded: boolean; let user: FakeUser | null; let session: { id: string; checkAuthorization: ReturnType } | null; let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; -let organizationMembership: { roleName: string } | null; let userMemberships: FakeList; let userInvitations: FakeList; let userSuggestions: FakeList; @@ -81,7 +80,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useSession: () => ({ isLoaded: isSessionLoaded, session }), - useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization, membership: organizationMembership })), + useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization })), // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather // than that some function did. usePortalRoot: () => getContainer, @@ -125,8 +124,8 @@ function acceptable( }; } -function membership(orgId: string, name: string, membersCount: number, roleName = 'Member') { - return { roleName, organization: { id: orgId, name, imageUrl: '', membersCount } }; +function membership(orgId: string, name: string, membersCount: number) { + return { organization: { id: orgId, name, imageUrl: '', membersCount } }; } function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { @@ -149,7 +148,6 @@ beforeEach(() => { }; session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 }; - organizationMembership = { roleName: 'Admin' }; userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); @@ -306,17 +304,6 @@ function activeOrganization() { } describe('useUserButtonModel', () => { - it('names the role the account holds in the active organization', () => { - render(); - expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme', roleLabel: 'Admin' }); - }); - - it('leaves the role off where the membership has not resolved', () => { - organizationMembership = null; - render(); - expect(activeOrganization()).not.toHaveProperty('roleLabel'); - }); - it('is loading until the user, session, and organization are all loaded', () => { isUserLoaded = false; const { rerender } = render(); @@ -480,7 +467,6 @@ describe('useUserButtonModel', () => { organizationId: 'org_1', name: 'Acme', membersCount: 3, - roleLabel: 'Member', }); const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); 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 7ebd8507713..453ee2008f9 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 @@ -144,15 +144,16 @@ describe('UserButtonView, user mode', () => { 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 () => { @@ -199,7 +200,8 @@ describe('UserButtonView, organization mode', () => { renderOrganizationMode({ activeOrganization: null }); expect(within(header()).getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Manage account' })).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(); }); @@ -269,13 +271,6 @@ describe('UserButtonView, combined mode', () => { expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); }); - it('heads the surface with the account where the user takes priority', () => { - renderCombined({ modePriority: 'user' }); - - expect(screen.queryByText('Pro · 24 members')).toBeNull(); - expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); - }); - it('heads the workspace list with the active account and its own actions', async () => { const onSignOutSession = vi.fn(); const act = userEvent.setup(); @@ -284,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 () => { @@ -364,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 @@ -476,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(); }); @@ -692,7 +686,7 @@ describe('UserButtonView, the foot', () => { const rows = within(groups().at(-1) ?? document.body); await userEvent.click(rows.getByRole('button', { name: 'Sign out' })); - expect(onSignOutSession).toHaveBeenCalledWith('sess_1'); + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'footer'); }); }); @@ -755,6 +749,19 @@ describe('UserButtonView, one action at a time', () => { 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'))); @@ -852,13 +859,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 }); @@ -925,7 +925,7 @@ describe('UserButtonView, the header', () => { function renderHeader(props: Partial = {}) { return renderView({ hasOrganizations: true, - activeOrganization: { ...foundry, roleLabel: 'Admin' }, + activeOrganization: foundry, memberships: [foundry, otherCo], ...props, }); @@ -973,11 +973,11 @@ describe('UserButtonView, the header', () => { 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'); + expect(onSignOutSession).toHaveBeenCalledWith('sess_1', 'header'); }); it('runs the gear inline as an icon where it is the only action', () => { - renderHeader({ activeOrganization: null }); + renderHeader({ hidePersonal: true, activeOrganization: null }); expect(header()).toHaveAttribute('data-layout', 'inline'); expect(screen.getByRole('button', { name: 'Manage account' })).toHaveAttribute('data-shape', 'square'); @@ -985,34 +985,32 @@ describe('UserButtonView, the header', () => { expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); }); - it('signs a lone account out from the foot, beside "Add account", leaving the header its gear', async () => { + 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', 'inline'); - expect(within(header()).getByRole('button', { name: 'Manage account' })).toHaveAttribute('data-shape', 'square'); - expect(within(header()).queryByRole('button', { name: 'Sign out' })).toBeNull(); + expect(header()).toHaveAttribute('data-layout', 'stacked'); expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' })); - expect(onSignOutSession).toHaveBeenCalledWith('sess_1'); + 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('names the organization and the role under an account that leads a combined surface', () => { - renderHeader({ modePriority: 'user' }); + 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(within(header()).getByText('Foundry · Admin')).toBeInTheDocument(); - expect(within(header()).queryByText('alice@example.com')).toBeNull(); - }); - - it('names the organization alone where the role is unknown', () => { - renderHeader({ modePriority: 'user', activeOrganization: foundry }); - - expect(within(header()).getByText('Foundry')).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({ modePriority: 'user', activeOrganization: null }); + renderHeader({ activeOrganization: null }); expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); }); @@ -1021,31 +1019,5 @@ describe('UserButtonView, the header', () => { renderHeader({ mode: 'user' }); expect(within(header()).getByText('alice@example.com')).toBeInTheDocument(); - expect(screen.queryByText('Foundry · Admin')).toBeNull(); - }); - - it('nests the organization avatar in the account avatar where the account leads', () => { - renderHeader({ modePriority: 'user' }); - - const trigger = screen.getByRole('button', { name: 'Open account menu for Alice Smith' }); - const nestedInTrigger = trigger.querySelector('.cl-user-button-avatar .cl-avatar[data-shape="square"]'); - expect(nestedInTrigger).toHaveAttribute('data-shape', 'square'); - expect(nestedInTrigger?.textContent).toBe('F'); - - const nestedInHeader = header().querySelector('.cl-user-button-avatar .cl-avatar[data-shape="square"]'); - expect(nestedInHeader).toHaveAttribute('data-shape', 'square'); - }); - - it('nests no avatar where the organization leads, none is active, or the surface carries none', () => { - const nested = (props: Partial) => { - const { unmount } = renderHeader(props); - const found = header().querySelector('.cl-user-button-avatar'); - unmount(); - return found; - }; - - expect(nested({})).toBeNull(); - expect(nested({ modePriority: 'user', activeOrganization: null })).toBeNull(); - expect(nested({ mode: 'user' })).toBeNull(); }); }); 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 2f2f155cbd7..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,9 +1,4 @@ -import type { - UserButtonData, - UserButtonHeaderLayout, - 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 @@ -11,12 +6,13 @@ import type { * * 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 @@ -25,6 +21,10 @@ import type { * 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. */ @@ -39,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; /** @@ -89,17 +99,22 @@ export interface UserButtonLayout { * still needs somewhere to manage and sign out of itself. */ showOrganizationsHeading: boolean; - describeAccountByOrganization: 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; @@ -109,44 +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 = (slot: UserButtonSlot, 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; - case 'signOut': - return slot === 'header' && !hasOtherSessions ? null : action; - // "All accounts" is one account, so the foot signs out of just that one, in the singular. - case 'signOutAll': - return hasOtherSessions ? action : 'signOut'; - // 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 = (name: UserButtonSlot, actions: readonly UserButtonAction[]): UserButtonAction[] => - actions.map(action => resolve(name, action)).filter((action): action is UserButtonAction => action !== null); - - const header = slot('header', declared.header); + 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, - describeAccountByOrganization: declared.organizations !== false, headerLayout: header.some(action => action !== 'manageLead') ? 'stacked' : 'inline', actions: { header, - organizationsHeading: organizationsHeading === false ? [] : slot('organizationsHeading', organizationsHeading), - organizationsFooter: slot('organizationsFooter', organizationsFooter), - footer: slot('footer', declared.footer), + 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.model.tsx b/packages/mosaic/src/features/user-button/user-button.model.tsx index 459237b504b..0fdb7559cc2 100644 --- a/packages/mosaic/src/features/user-button/user-button.model.tsx +++ b/packages/mosaic/src/features/user-button/user-button.model.tsx @@ -116,7 +116,7 @@ function displayName(user: UserResource): string { return getFullName(user) || getIdentifier(user); } -function toMembership(organization: OrganizationResource, roleLabel?: string): UserButtonMembership { +function toMembership(organization: OrganizationResource): UserButtonMembership { return { kind: 'membership', organizationId: organization.id, @@ -125,7 +125,6 @@ function toMembership(organization: OrganizationResource, roleLabel?: string): U 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. - roleLabel, }; } @@ -146,11 +145,7 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); // The active org names the trigger. That is not a request to turn Organizations on. - const { - isLoaded: isOrgLoaded, - organization, - membership, - } = useOrganization({ + const { isLoaded: isOrgLoaded, organization } = useOrganization({ __internal_skipAttemptToEnableOrganizations: true, }); const clerk = useClerk(); @@ -219,7 +214,7 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us const suggestionData = userSuggestions.data ?? []; const invitationData = userInvitations.data ?? []; - const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization, m.roleName)); + const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization)); const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({ kind: 'suggestion', @@ -268,7 +263,7 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us organizationsEnabled, renderBranding: displayConfig.branded, activeSession: toSession(session.id, user), - activeOrganization: organization ? toMembership(organization, membership?.roleName) : null, + activeOrganization: organization ? toMembership(organization) : null, // The user resource settles this before the paginated list answers; the count covers a stale resource. hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0, hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), 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 a288ba8357f..22ec3767c15 100644 --- a/packages/mosaic/src/features/user-button/user-button.styles.ts +++ b/packages/mosaic/src/features/user-button/user-button.styles.ts @@ -12,38 +12,6 @@ export const styles = stylex.create({ height: space['5'], width: space['5'], }, - workspaceAvatar: { - display: 'inline-grid', - gridTemplateColumns: `auto ${space['0.5']}`, - gridTemplateRows: `auto ${space['0.5']}`, - }, - workspaceAvatarLead: { - gridColumnStart: '1', - gridRowStart: '1', - }, - workspaceAvatarLeadMd: { - height: space['7.5'], - width: space['7.5'], - }, - nestedAvatar: { - borderRadius: '2px', - alignSelf: 'end', - gridColumnEnd: '-1', - gridColumnStart: '1', - gridRowEnd: '-1', - gridRowStart: '1', - justifySelf: 'end', - }, - nestedAvatarSm: { - fontSize: '0.3125rem', - height: space['2.5'], - width: space['2.5'], - }, - nestedAvatarMd: { - fontSize: '0.375rem', - height: space['3.5'], - width: space['3.5'], - }, trigger: { padding: 0, borderRadius: radiusVars['--cl-radius-md'], 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 6ea54861fb3..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. @@ -19,8 +21,6 @@ export interface UserButtonMembership { imageUrl?: string; membersCount?: number; planLabel?: string; - /** The role the active account holds in the organization, named for display. */ - roleLabel?: string; } export interface UserButtonSuggestion { @@ -94,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; @@ -110,20 +110,13 @@ 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. - */ -export type UserButtonModePriority = 'organization' | 'user'; - /** * 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 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. @@ -131,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. */ @@ -169,7 +155,8 @@ 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. So do `signOutAll` and `signOut`: the foot signs out - * of every account where there is more than one, and of the one account where there is not. + * 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' | 'signOut'; 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 411b58a64d0..5fc9e55b614 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -20,13 +20,12 @@ import { Spinner } from '../../components/spinner'; import type { IconName } from '../../icons/registry'; import type { MosaicMessages } from '../../localization'; import { fill, plural, useLocale, useMessages } from '../../localization'; -import { mergeStyleProps, themeProps } from '../../props'; +import { themeProps } from '../../props'; import { applyOrder } from '../../utils/apply-order'; import { focusOutline } from '../../utils/focus-outline.styles'; -import { reset } from '../../utils/reset.styles'; import { rtl } from '../../utils/rtl.styles'; import { truncationStyles } from '../../utils/typography.styles'; -import type { UserButtonAction, 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 { @@ -54,7 +53,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}`, @@ -99,7 +98,7 @@ type ActiveWorkspace = shape: 'square'; organization: UserButtonMembership; } - | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle'; organization: UserButtonMembership | null } + | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle' } | { kind: 'none'; name: string; imageUrl?: string; shape: 'square' }; /** @@ -109,29 +108,26 @@ 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', - organization: layout.describeAccountByOrganization ? activeOrganization : null, }; } @@ -145,10 +141,6 @@ function membershipSubtitle(membership: UserButtonMembership, m: Messages, local return joinDetails(membership.planLabel, members); } -function roleSubtitle(membership: UserButtonMembership): string { - return joinDetails(membership.name, membership.roleLabel); -} - function initials(name: string): string { const [first = '', second = ''] = name.trim().split(/\s+/); return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase() || '?'; @@ -185,43 +177,6 @@ function RowAvatar({ name, imageUrl, shape, size, xstyle }: RowAvatarProps) { ); } -/** - * The lead workspace's mark. An account working in an organization wears its avatar in the corner, - * on a grid rather than floated, so the overhang takes up room and whatever follows keeps its gap. - */ -function WorkspaceAvatar({ workspace, size }: { workspace: ActiveWorkspace; size: AvatarProps['size'] }) { - const organization = workspace.kind === 'user' ? workspace.organization : null; - if (!organization) { - return ( - - ); - } - - return ( - - - - - ); -} - /** * Renders ` + ); + } return ( - + {text} + ); } @@ -439,20 +409,13 @@ function Header() { const workspace = leadWorkspace(data, m); const { name } = workspace; const organization = workspace.kind === 'organization' ? workspace.organization : undefined; - // An account is described by the organization it works in where the surface carries one, and by - // its identifier otherwise. 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 = - workspace.kind === 'user' && workspace.organization - ? roleSubtitle(workspace.organization) - : identifier === name - ? '' - : identifier; + // 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 subtitle = workspace.kind === 'organization' ? membershipSubtitle(workspace.organization, m, locale) - : workspace.kind === 'user' - ? accountSubtitle + : workspace.kind === 'user' && identifier !== name + ? identifier : ''; const actions: HeaderAction[] = []; @@ -460,15 +423,14 @@ function Header() { if (action === 'inviteMembers' && data.onInviteMembers) { actions.push({ id: action, label: m.manage.invite, icon: 'users', 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, icon: 'log-out', - onClick: () => signOutSession(sessionId), - busyKey: userButtonBusyKeys.signOutSession(sessionId), + 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. @@ -498,8 +460,10 @@ function Header() { } @@ -567,7 +531,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) { @@ -581,7 +545,7 @@ function OrganizationsHeading() { actions.push({ label: m.accounts.signOut, color: 'negative', - onClick: () => signOutSession(sessionId), + onClick: () => signOutSession(sessionId, 'organizationsHeading'), }); } } @@ -990,12 +954,9 @@ 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} ))} @@ -1056,8 +1017,8 @@ function Footer() { /> } label={m.accounts.signOut} - onClick={() => signOutSession(sessionId)} - busyKey={userButtonBusyKeys.signOutSession(sessionId)} + onClick={() => signOutSession(sessionId, 'footer')} + busyKey={userButtonBusyKeys.signOutSession(sessionId, 'footer')} /> ), }); @@ -1131,20 +1092,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 ? ( diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index a79edfa8b49..db9d84a6548 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,27 +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. - - - -An account leading a combined surface nests the organization it works in on its avatar. - - @@ -106,24 +93,10 @@ An account leading a combined surface nests the organization it works in on its ## Header -The header names the active workspace and carries what you can do about it. An organization is -described by its plan and member count; an account by the organization it works in and its role -there, or by its identifier where the surface carries no organizations. - -The actions stack under the workspace as full-width labelled buttons: **Settings** with **Invite** -where an organization leads, **Settings** with **Sign out** where an account does. Where the gear is -the only action, as for an account with no second session, it trails the workspace inline as an -icon alone, and the foot signs the account out beside **Add account**. - - - - +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 @@ -132,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. -Off-mode data is not an error. Both examples below take the same props as the one above and differ -only by `mode`. +With one account signed in, the foot is **Add account** and **Sign out**. -### Organizations + -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. +With the personal workspace selected, the account leads, and the header carries **Sign out**. + + + +### Org only + +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 + -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. +### User only + +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 diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index fae8d5a72a0..60bf9a9a479 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -25,24 +25,48 @@ export const meta: StoryMeta = { source: 'packages/mosaic/src/features/user-button/user-button.view.tsx', }; -// Accounts wear their own photo. Only the flagship workspace carries the Clerk mark; the rest wear +// The data behind the design frames: one account, Cameron Walker, signed in three times over, and +// the workspaces each frame lists. Only the flagship workspaces carry the Clerk mark; the rest wear // the generated mark Clerk gives an organization that has not uploaded a logo. const clerkLogo = 'https://avatars.githubusercontent.com/u/49538330?v=4'; const defaultOrgLogo = 'https://img.clerk.com/eyJ0eXBlIjoiZGVmYXVsdCIsImlpZCI6Imluc18xbHlXRFppb2JyNjAwQUtVZVFEb1NsckVtb00iLCJyaWQiOiJvcmdfMnp6WVh1TURBRTBYWFh5Q1lHN3dyQXRFd0VpIiwiaW5pdGlhbHMiOiJQIn0?width=48'; +const cameronPhoto = 'https://randomuser.me/api/portraits/men/32.jpg'; -const colin: UserButtonSession = { - sessionId: 'sess_colin', - name: 'Colin', - identifier: 'colin@clerk.dev', - imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', +function cameron(sessionId: string): UserButtonSession { + return { sessionId, name: 'Cameron Walker', identifier: 'cameron@clerk.com', imageUrl: cameronPhoto }; +} + +const nestLabs: UserButtonMembership = { + kind: 'membership', + organizationId: 'org_nestlabs', + name: 'NestLabs Creative', + membersCount: 24, + planLabel: 'Pro plan', + imageUrl: defaultOrgLogo, +}; + +const acme: UserButtonMembership = { + kind: 'membership', + organizationId: 'org_acme', + name: 'Acme', + imageUrl: defaultOrgLogo, +}; + +const clerkApp: UserButtonMembership = { + kind: 'membership', + organizationId: 'org_clerk_app', + name: 'Clerk App', + imageUrl: clerkLogo, }; -const braden: UserButtonSession = { - sessionId: 'sess_braden', - name: 'Braden', - identifier: 'braden@clerk.dev', - imageUrl: 'https://avatars.githubusercontent.com/u/64913815?v=4', +const clerkSuggestion: UserButtonSuggestion = { + kind: 'suggestion', + id: 'sug_clerk', + organizationId: 'org_clerk', + name: 'Clerk', + status: 'pending', + imageUrl: clerkLogo, }; /** @@ -57,70 +81,36 @@ interface Account { invitations: UserButtonInvitation[]; } -const clerkCloud: UserButtonMembership = { - kind: 'membership', - organizationId: 'org_clerk_cloud', - name: 'Clerk Cloud', - membersCount: 6, - imageUrl: defaultOrgLogo, +type Workspaces = Omit; + +/** 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: [], +}; + +/** 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: [], }; -// 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', - roleLabel: 'Admin', - 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 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. */ @@ -147,19 +137,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]; @@ -234,7 +225,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, @@ -245,8 +237,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 UserPriorityAvatarOnly(_args: Record) { - const prototype = usePrototype(); +export function CombinedPersonalWorkspace(_args: Record) { + const prototype = usePrototype({ workspaces: personalWorkspace, singleSession: true }); return ( ); } -export function UserAvatarOnly(_args: Record) { - const prototype = usePrototype(); +// ─── Mode: Org only ───────────────────────────────────────────────────────── + +export function Organizations(_args: Record) { + const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, hidePersonal: true }); return ( + ); +} + +export function OrganizationsIconTrigger(_args: Record) { + const prototype = usePrototype({ workspaces: nestLabsWithClerkApp, hidePersonal: true }); + + return ( + ); } -export function WithoutTriggerBadge(_args: Record) { +// ─── Mode: User only ──────────────────────────────────────────────────────── + +export function User(_args: Record) { const prototype = usePrototype(); return ( ); } -export function Organizations(_args: Record) { +export function UserAvatarTrigger(_args: Record) { const prototype = usePrototype(); - // Fed the same data as the others, including the additional account it deliberately never shows. return ( ); } -export function User(_args: Record) { - const prototype = usePrototype(); +export function UserAvatarSingleSession(_args: Record) { + const prototype = usePrototype({ singleSession: true }); - // Fed the same data too: an active organization and its workspaces, none of which this mode shows. return ( ); } -export function NoOrganizationSelected(_args: Record) { - const prototype = usePrototype({ hidePersonal: true, startWithoutOrganization: true }); +// ─── Beyond the frames ────────────────────────────────────────────────────── + +export function WithoutTriggerBadge(_args: Record) { + const prototype = usePrototype({ hidePersonal: true }); - // Personal is withheld and nothing is active, so the lead is no selection — not the account. - // Picking an organization leaves it. return ( ); } -export function UserSingleSession(_args: Record) { - const prototype = usePrototype(); +export function NoOrganizationSelected(_args: Record) { + const prototype = usePrototype({ workspaces: personalWorkspace, hidePersonal: true }); - // One account signed in, so nothing to switch to or sign out of together. The header keeps its - // gear alone and the foot signs the account out beside adding another. + // Personal is withheld and nothing is active, so the lead is no selection — not the account. + // Picking an organization leaves it. return ( ); } 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 foot signs out of just that account. @@ -377,7 +381,6 @@ export function SingleSession(_args: Record) { @@ -385,7 +388,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. @@ -395,7 +398,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', @@ -444,7 +446,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(() => { From 478e4114e472b97e9d89cc3d189ffa7a03612081 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 09:35:25 -0400 Subject: [PATCH 25/31] feat(mosaic): give the user button its own item slots and keep its menu triggers focusable --- .changeset/mosaic-user-button-header.md | 4 + .../mosaic/src/components/menu/menu.test.tsx | 29 ++++ packages/mosaic/src/components/menu/menu.tsx | 13 +- .../user-button.integration.test.tsx | 2 +- .../__tests__/user-button.view.test.tsx | 17 +-- .../user-button/user-button-item.styles.ts | 117 ++++++++++++++++ .../user-button/user-button-item.view.tsx | 118 +++++++++++++++++ .../user-button/user-button.styles.ts | 10 +- .../features/user-button/user-button.view.tsx | 125 +++++++++--------- packages/swingset/src/stories/user-button.mdx | 26 ++-- 10 files changed, 367 insertions(+), 94 deletions(-) create mode 100644 packages/mosaic/src/features/user-button/user-button-item.styles.ts create mode 100644 packages/mosaic/src/features/user-button/user-button-item.view.tsx diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md index d6185632b65..0ba06c8ab3c 100644 --- a/.changeset/mosaic-user-button-header.md +++ b/.changeset/mosaic-user-button-header.md @@ -5,3 +5,7 @@ Derive the `UserButton` header layout from its actions: labelled buttons stack under the workspace, and a lone gear sits inline. 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. `UserButtonView`'s `onSignOutSession` now also receives where the sign-out was pressed. + +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. + +`Menu.Trigger` accepts `focusableWhenDisabled`, which keeps a disabled default trigger in the tab order. The `UserButton` menu triggers now use it, so they keep focus while an action is in flight. 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 95dd54ec77d..0624e5b77a0 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 d85eb512fb2..8cc3bb280ee 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 @@ -575,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.view.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.view.test.tsx index 453ee2008f9..72be9402c81 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,7 +72,7 @@ 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) { @@ -81,12 +81,12 @@ const 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(' ') ?? []; @@ -289,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' })); @@ -586,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 ?? '', ); @@ -776,7 +776,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 }); @@ -784,7 +784,8 @@ describe('UserButtonView, one action at a time', () => { const stoodDown = screen.getByRole('button', { name: label }); expect(stoodDown).toBe(row); - expect(stoodDown).toBeDisabled(); + expect(stoodDown).toHaveAttribute('aria-disabled', 'true'); + expect(stoodDown).toBeEnabled(); }); // The flyout closes on pick, so the row that opened it is what is left to report the switch. 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..1e05a863e3e --- /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 centre 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..d315756a8ef --- /dev/null +++ b/packages/mosaic/src/features/user-button/user-button-item.view.tsx @@ -0,0 +1,118 @@ +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +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.styles.ts b/packages/mosaic/src/features/user-button/user-button.styles.ts index 22ec3767c15..e8d44765bdd 100644 --- a/packages/mosaic/src/features/user-button/user-button.styles.ts +++ b/packages/mosaic/src/features/user-button/user-button.styles.ts @@ -51,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'], @@ -72,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 centre 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.view.tsx b/packages/mosaic/src/features/user-button/user-button.view.tsx index 5fc9e55b614..03dab1a10fe 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -12,7 +12,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'; @@ -41,6 +40,16 @@ import type { 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. @@ -195,11 +204,6 @@ const rowButton = (disabled = false) => ( /> ); -/** A row's trailing column, sized and centred so every state lands on the `⋯` button's centre line. */ -function Trailing({ children }: { children: ReactNode }) { - return {children}; -} - interface SwitcherRowProps { name: string; /** @@ -240,8 +244,7 @@ function SwitcherRow({ const waiting = Boolean(busy || disabled); return ( - - + - - - {name} - + + + {name} + {busy ? ( - + - + ) : trailing ? ( - {trailing} + {trailing} ) : active ? ( - + - + ) : null} - + ); } @@ -315,18 +318,17 @@ function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) { const { busy, disabled } = useBusy(busyKey); return ( - - {busy ? : icon} - - {label} - - + {busy ? : icon} + + {label} + + ); } @@ -497,11 +499,12 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA } return ( - + {actions.map(a => ( @@ -516,7 +519,7 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA ))} - + ); } @@ -551,14 +554,14 @@ function OrganizationsHeading() { } return ( - - - {identifier} - + + + {identifier} + {busy ? ( - + - + ) : ( )} - + ); } @@ -669,7 +672,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 @@ -823,16 +826,8 @@ function SwitchAccountRow() { sideOffset={{ x: 12, y: 8 }} fallbackPlacements={['left-start', 'top-start', 'bottom-start']} > - } - /> - } - > - + }> + {busy ? ( ) : ( @@ -841,17 +836,17 @@ function SwitchAccountRow() { size='sm' /> )} - - - {m.accounts.switch} - - + + + {m.accounts.switch} + + - + {/* The account it is on leads, checked: the flyout is the full set of accounts rather than @@ -892,14 +887,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} + + ); } @@ -940,11 +935,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 @@ -963,7 +958,7 @@ function OrganizationSection() { {/* 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} - + ); } @@ -1058,12 +1053,12 @@ function Footer() { return ( <> - - + + {rows.map(r => ( {r.node} ))} - + ); } diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index db9d84a6548..5522789e328 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -244,12 +244,20 @@ is ignored rather than an error, so one order can cover every mode. ## Styling -| Slot | Attributes | -| ------------------------------------ | --------------------------------- | -| `.cl-user-button-trigger` | | -| `.cl-user-button-popover` | | -| `.cl-user-button-header` | `data-layout="inline \| stacked"` | -| `.cl-user-button-header-content` | | -| `.cl-user-button-header-title` | | -| `.cl-user-button-header-description` | | -| `.cl-user-button-header-actions` | | +| Slot | Attributes | +| ------------------------------------ | --------------------------------------- | +| `.cl-user-button-trigger` | | +| `.cl-user-button-popover` | | +| `.cl-user-button-header` | `data-layout="inline \| stacked"` | +| `.cl-user-button-header-content` | | +| `.cl-user-button-header-title` | | +| `.cl-user-button-header-description` | | +| `.cl-user-button-header-actions` | | +| `.cl-user-button-group` | | +| `.cl-user-button-separator` | | +| `.cl-user-button-item` | `data-interactive` | +| `.cl-user-button-item-media` | | +| `.cl-user-button-item-content` | | +| `.cl-user-button-item-label` | `data-variant="default \| interactive"` | +| `.cl-user-button-item-description` | | +| `.cl-user-button-item-trailing` | | From 70a3d7352ec936e1ab71333d5d214a32ba3faa03 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 09:40:34 -0400 Subject: [PATCH 26/31] chore(repo): drop verbose npm logging from the snapshot release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0870503a494..5884e61374c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "release": "changeset publish && git push --follow-tags && node scripts/wait-for-packages-on-npm.mjs", "release:canary": "changeset publish --tag canary --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", "release:canary-core3": "changeset publish --tag canary-core3 --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", - "release:snapshot": "NPM_CONFIG_LOGLEVEL=verbose changeset publish --tag snapshot --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", + "release:snapshot": "changeset publish --tag snapshot --no-git-tag && node scripts/wait-for-packages-on-npm.mjs", "release:status": "changeset status --output .changeset-status.json", "test": "FORCE_COLOR=1 turbo test --concurrency=${TURBO_CONCURRENCY:-80%}", "test:cache:clear": "FORCE_COLOR=1 turbo test:cache:clear --continue --concurrency=${TURBO_CONCURRENCY:-80%}", From 7a5c7c52c11649c8f4d80ad079f3c963fd82ced8 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 09:46:42 -0400 Subject: [PATCH 27/31] feat(mosaic): use the design icons for invite and switch account in the user button --- .../features/user-button/user-button.view.tsx | 4 +-- .../src/icons/glyphs/switch-account.tsx | 34 +++++++++++++++++++ .../src/icons/glyphs/users-add-right.tsx | 25 ++++++++++++++ packages/mosaic/src/icons/registry.tsx | 4 +++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 packages/mosaic/src/icons/glyphs/switch-account.tsx create mode 100644 packages/mosaic/src/icons/glyphs/users-add-right.tsx 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 f4be5f1b2d4..dfabbc982c4 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -423,7 +423,7 @@ function Header() { const actions: HeaderAction[] = []; for (const action of data.layout.actions.header) { if (action === 'inviteMembers' && data.onInviteMembers) { - actions.push({ id: action, label: m.manage.invite, icon: 'users', onClick: data.onInviteMembers }); + actions.push({ id: action, label: m.manage.invite, icon: 'users-add-right', onClick: data.onInviteMembers }); } // An account that leads takes "Sign out" in the labelled slot **Invite** holds for an organization. if (action === 'signOut' && signOutSession) { @@ -832,7 +832,7 @@ function SwitchAccountRow() { ) : ( )} 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..02972d4b1da 100644 --- a/packages/mosaic/src/icons/registry.tsx +++ b/packages/mosaic/src/icons/registry.tsx @@ -80,10 +80,12 @@ import { ShieldClose } from './glyphs/shield-close'; import { Sidebar } from './glyphs/sidebar'; 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'; @@ -309,10 +311,12 @@ export const iconRegistry = { sidebar: Sidebar, 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; From 141fbb2a39ab48854d517fd9a55e2db239fa8700 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 09:53:12 -0400 Subject: [PATCH 28/31] feat(mosaic): use the design sign-out icon in the user button --- .../src/features/user-button/user-button.view.tsx | 6 +++--- packages/mosaic/src/icons/glyphs/sign-out.tsx | 11 +++++++++++ packages/mosaic/src/icons/registry.tsx | 2 ++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 packages/mosaic/src/icons/glyphs/sign-out.tsx 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 dfabbc982c4..6d77afe088a 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -430,7 +430,7 @@ function Header() { actions.push({ id: action, label: m.accounts.signOut, - icon: 'log-out', + icon: 'sign-out', onClick: () => signOutSession(sessionId, 'header'), busyKey: userButtonBusyKeys.signOutSession(sessionId, 'header'), }); @@ -1007,7 +1007,7 @@ function Footer() { } @@ -1025,7 +1025,7 @@ function Footer() { 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/registry.tsx b/packages/mosaic/src/icons/registry.tsx index 02972d4b1da..5a7edf92941 100644 --- a/packages/mosaic/src/icons/registry.tsx +++ b/packages/mosaic/src/icons/registry.tsx @@ -78,6 +78,7 @@ 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'; @@ -309,6 +310,7 @@ export const iconRegistry = { 'shield-check': ShieldCheck, 'shield-close': ShieldClose, sidebar: Sidebar, + 'sign-out': SignOut, spinner: Spinner, support: Support, 'switch-account': SwitchAccount, From 93876883d3d46e2d90575d1e336c224ab7c6d049 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 09:54:19 -0400 Subject: [PATCH 29/31] docs(swingset): mark the UserButton as stable --- packages/swingset/src/stories/user-button.stories.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index d641f8493c9..b375df48e3c 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -20,8 +20,7 @@ export const meta: StoryMeta = { group: 'User Button', title: 'UserButton', label: 'User button', - status: 'wip', - substatus: 'needs wire-up', + status: 'stable', source: 'packages/mosaic/src/features/user-button/user-button.view.tsx', }; From 69a374b05f7cfcebbe3890cac0027d03b8f85e99 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 10:00:52 -0400 Subject: [PATCH 30/31] fix(mosaic): announce pending user button actions and keep the account menu mounted --- .changeset/mosaic-user-button-header.md | 2 +- .../__tests__/user-button.view.test.tsx | 25 +++--- .../features/user-button/user-button.view.tsx | 81 ++++++++++++------- 3 files changed, 69 insertions(+), 39 deletions(-) diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md index 0ba06c8ab3c..ef167656ea7 100644 --- a/.changeset/mosaic-user-button-header.md +++ b/.changeset/mosaic-user-button-header.md @@ -8,4 +8,4 @@ Remove the `modePriority` prop from `UserButton`. The header now follows what it 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. -`Menu.Trigger` accepts `focusableWhenDisabled`, which keeps a disabled default trigger in the tab order. The `UserButton` menu triggers now use it, so they keep focus while an action is in flight. +`Menu.Trigger` accepts `focusableWhenDisabled`, which keeps a disabled default trigger in the tab order. The `UserButton` menu triggers now use it, so they keep focus while an action is in flight. A row running an action now reports it to assistive tech, busy with a named progress indicator. 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 72be9402c81..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 @@ -741,10 +741,22 @@ 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'); }); @@ -788,13 +800,6 @@ describe('UserButtonView, one action at a time', () => { expect(stoodDown).toBeEnabled(); }); - // 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(); - }); - // `aria-disabled` is advisory, so the row has to drop the press itself. it('ignores a press on a row that is standing down', async () => { const onSelectOrganization = vi.fn(); 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 6d77afe088a..697bbb329fb 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -204,6 +204,22 @@ const rowButton = (disabled = false) => ( /> ); +// 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 { name: string; /** @@ -237,7 +253,6 @@ 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; @@ -267,16 +282,7 @@ function SwitcherRow({ {busy ? ( - + ) : trailing ? ( {trailing} @@ -321,10 +327,11 @@ function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) { - {busy ? : icon} + {busy ? : icon} {label} @@ -493,7 +500,15 @@ 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; } @@ -503,8 +518,20 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA ( + + )} /> {actions.map(a => ( @@ -558,17 +585,12 @@ function OrganizationsHeading() { {identifier} - {busy ? ( - - - - ) : ( - - )} + ); } @@ -826,10 +848,13 @@ function SwitchAccountRow() { sideOffset={{ x: 12, y: 8 }} fallbackPlacements={['left-start', 'top-start', 'bottom-start']} > - }> + } + > {busy ? ( - + ) : ( Date: Fri, 25 Sep 2026 15:10:25 -0400 Subject: [PATCH 31/31] Apply suggestion from @alexcarpenter --- .changeset/mosaic-user-button-header.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.changeset/mosaic-user-button-header.md b/.changeset/mosaic-user-button-header.md index ef167656ea7..973e148bd3d 100644 --- a/.changeset/mosaic-user-button-header.md +++ b/.changeset/mosaic-user-button-header.md @@ -2,10 +2,6 @@ '@clerk/mosaic': patch --- -Derive the `UserButton` header layout from its actions: labelled buttons stack under the workspace, and a lone gear sits inline. - -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. `UserButtonView`'s `onSignOutSession` now also receives where the sign-out was pressed. +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. - -`Menu.Trigger` accepts `focusableWhenDisabled`, which keeps a disabled default trigger in the tab order. The `UserButton` menu triggers now use it, so they keep focus while an action is in flight. A row running an action now reports it to assistive tech, busy with a named progress indicator.