diff --git a/.changeset/mosaic-user-button-organization-lead.md b/.changeset/mosaic-user-button-organization-lead.md new file mode 100644 index 00000000000..33fc288082b --- /dev/null +++ b/.changeset/mosaic-user-button-organization-lead.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +`UserButton` now leads with the active organization, and `UserButton` and `OrganizationSwitcher` move **Settings** and **Invite members** from the header into rows below the organization list. diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts index f4342db8139..c4d5e6ed310 100644 --- a/integration/tests/mosaic/user-button.test.ts +++ b/integration/tests/mosaic/user-button.test.ts @@ -47,10 +47,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User } async function openProfileSettings(page: Page) { - await popup(page) - .getByRole('button', { name: /^(Settings|Manage account)$/ }) - .click(); - const profileSettings = page.getByRole('menuitem', { name: 'Profile settings' }); + await popup(page).getByRole('button', { name: 'Settings' }).click(); + const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' }); await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor(); if (await profileSettings.isVisible()) { await profileSettings.click(); diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json index bddb5d0db28..245446609fe 100644 --- a/packages/mosaic/eslint-suppressions.json +++ b/packages/mosaic/eslint-suppressions.json @@ -39,11 +39,6 @@ "count": 1 } }, - "src/features/switcher/switcher.view.tsx": { - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, "src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts": { "@typescript-eslint/no-restricted-imports": { "count": 1 diff --git a/packages/mosaic/src/components/menu/menu.styles.ts b/packages/mosaic/src/components/menu/menu.styles.ts index 5af8a02a728..fe23ca7bd38 100644 --- a/packages/mosaic/src/components/menu/menu.styles.ts +++ b/packages/mosaic/src/components/menu/menu.styles.ts @@ -102,7 +102,7 @@ export const item = stylex.create({ flexShrink: 0, fontFamily: fontFamilyVars['--cl-font-family-sans'], fontSize: typeScaleVars['--cl-text-sm-size'], - fontWeight: fontWeightVars['--cl-font-medium'], + fontWeight: fontWeightVars['--cl-font-normal'], lineHeight: typeScaleVars['--cl-text-sm-leading'], opacity: { default: 1, ':is([data-disabled])': 0.5 }, outlineOffset: 0, diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 71fa19b2a9b..be5e7afe65f 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -131,14 +131,24 @@ describe('OrganizationSwitcher', () => { expect(host()).toBeEmptyDOMElement(); }); - it('heads the surface with the active organization and what can be done to it', async () => { - await renderSwitcher(); - await open(); + it('heads the surface with the active organization, and manages it from the rows beneath', async () => { + const { clerk } = await renderSwitcher({}, noOffers()); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + const user = await openWithList(); + + const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup(); + expect(within(header).getByText('Acme')).toBeInTheDocument(); + expect(within(header).getByText('3 members')).toBeInTheDocument(); + expect(within(header).queryAllByRole('button')).toEqual([]); + expect(reading('Add organization', 'Settings', 'Invite members')).toEqual([ + 'Add organization', + 'Settings', + 'Invite members', + ]); - expect(within(requiredPopup()).getByText('Acme')).toBeInTheDocument(); - expect(within(requiredPopup()).getByText('3 members')).toBeInTheDocument(); - expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument(); - expect(within(requiredPopup()).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Settings' })); + expect(screen.queryByRole('menu')).toBeNull(); + expect(openOrganizationProfile).toHaveBeenCalled(); }); it('falls back to the account, with nothing to manage, where no organization is active', async () => { @@ -147,7 +157,7 @@ describe('OrganizationSwitcher', () => { expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); expect(within(requiredPopup()).queryByRole('button', { name: 'Settings' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull(); }); it('names no organization selected where personal is hidden and none is active', async () => { @@ -158,19 +168,19 @@ describe('OrganizationSwitcher', () => { expect(within(requiredPopup()).getByText('No organization selected')).toBeInTheDocument(); expect(within(requiredPopup()).queryByText('Alice Smith')).toBeNull(); - expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Manage account' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Settings' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Invite members' })).toBeNull(); }); it('carries no account rows, and trails the organizations with create-organization', async () => { await renderSwitcher({}, noOffers()); await openWithList(); - expect(reading('Personal account', 'Acme', 'Other', 'Create organization')).toEqual([ - 'Acme', + expect(reading('Personal account', 'Acme', 'Other', 'Add organization')).toEqual([ 'Personal account', + 'Acme', 'Other', - 'Create organization', + 'Add organization', ]); for (const name of ['Switch account', 'Add account', 'Sign out', 'Sign out of all accounts']) { expect(screen.queryByRole('button', { name })).toBeNull(); @@ -187,13 +197,13 @@ describe('OrganizationSwitcher', () => { expect(within(trigger()).getByText('Other')).toBeInTheDocument(); }); - it('carries custom rows at the foot of the popup, in the order given', async () => { + it('carries custom rows beside Settings, in the order given', async () => { const terms = { id: 'terms', label: 'Terms of service', href: 'https://example.com/terms' }; const support = { id: 'support', label: 'Support', onClick: vi.fn() }; - await renderSwitcher({ customMenuItems: [terms, support], menuItemOrder: ['support', 'terms'] }); + await renderSwitcher({ customMenuItems: [terms, support], menuItemOrder: ['support', 'terms', 'settings'] }); await open(); - expect(reading('Terms of service', 'Support')).toEqual(['Support', 'Terms of service']); + expect(reading('Settings', 'Terms of service', 'Support')).toEqual(['Support', 'Terms of service', 'Settings']); }); it('themes its parts through organization-switcher slots', async () => { @@ -229,7 +239,7 @@ describe('OrganizationSwitcher', () => { expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); await userEvent.setup().click(trigger()); - expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument(); + expect(within(requiredPopup()).getByRole('button', { name: 'Invite members' })).toBeInTheDocument(); memberships.release(); await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull()); diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts b/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts index b2e4d3aca38..42dfee1fba7 100644 --- a/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts +++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts @@ -16,12 +16,10 @@ export const organizationSwitcherMessages = { pending: 'pending', }, manage: { - invite: 'Invite', + invite: 'Invite members', settings: 'Settings', - organizationSettings: 'Organization settings', - profileSettings: 'Profile settings', - account: 'Manage account', + profile: 'Manage profile', organization: 'Manage organization', - createOrganization: 'Create organization', + createOrganization: 'Add organization', }, } as const; diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx index 95881d01740..ace9a4a3f94 100644 --- a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx +++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx @@ -48,7 +48,7 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions & * * @example * `customPages` adds your own pages to the organization profile; `customMenuItems` adds your own rows - * to the foot of the menu, each one either an `onClick` action or an `href` link. + * beside Settings, each one either an `onClick` action or an `href` link. * ```tsx * = {}) { } describe('resolveSwitcherLayout, where each action lands', () => { - it('spreads them across all three slots in combined mode', () => { + it('spreads them across the three slots below the header in combined mode', () => { expect(resolve('combined').actions).toEqual({ - header: ['inviteMembers', 'manageLead'], organizationsFooter: ['createOrganization'], + manage: ['settings', 'inviteMembers'], footer: ['switchAccount', 'signOut'], }); }); it('carries no account actions at all in organization mode', () => { expect(resolve('organization').actions).toEqual({ - header: ['inviteMembers', 'manageLead'], organizationsFooter: ['createOrganization'], + manage: ['settings', 'inviteMembers'], footer: [], }); }); - it('takes the account actions into the header and the foot in user mode', () => { + it('takes the account actions into the manage group and the foot in user mode', () => { expect(resolve('user').actions).toEqual({ - header: ['manageLead'], organizationsFooter: [], + manage: ['settings'], footer: ['switchAccount', 'signOut'], }); }); }); describe('resolveSwitcherLayout, what the data settles', () => { + it('leads with the active organization alone in organization mode', () => { + const layout = resolve('organization'); + + expect(layout.lead).toBe('organization'); + expect(layout.userBadge).toBe(false); + }); + it('leads with the account where no organization is active', () => { const layout = resolve('combined', { activeOrganization: null }); expect(layout.lead).toBe('user'); - expect(layout.actions.header).toEqual(['manageLead']); + expect(layout.actions.manage).toEqual(['settings']); }); it('leads with no selection where no organization is active and personal is hidden', () => { const layout = resolve('organization', { activeOrganization: null, hidePersonal: true }); expect(layout.lead).toBe('none'); - expect(layout.actions.header).toEqual([]); + expect(layout.actions.manage).toEqual([]); }); it('leads with the personal account, managing nothing, where no organization is active in organization mode', () => { const layout = resolve('organization', { activeOrganization: null }); expect(layout.lead).toBe('personal'); - expect(layout.actions.header).toEqual([]); + expect(layout.actions.manage).toEqual([]); }); // With no second account the flyout would open onto one row, so the foot offers that row instead. @@ -78,29 +85,19 @@ describe('resolveSwitcherLayout, what the data settles', () => { }); describe('resolveSwitcherLayout, a combined surface', () => { - it('leads with the account inside its active organization, inviting to that organization', () => { + it('leads with its active organization, badged with the user, inviting to that organization', () => { const layout = resolve('combined'); - expect(layout.lead).toBe('member'); - expect(layout.actions.header).toEqual(['inviteMembers', 'manageLead']); + expect(layout.lead).toBe('organization'); + expect(layout.userBadge).toBe(true); + expect(layout.actions.manage).toEqual(['settings', 'inviteMembers']); }); it('leads with the account alone where no organization is active, even with personal hidden', () => { const layout = resolve('combined', { activeOrganization: null, hidePersonal: true }); expect(layout.lead).toBe('user'); - expect(layout.actions.header).toEqual(['manageLead']); - }); -}); - -describe('resolveSwitcherLayout, how the header carries its actions', () => { - it('stacks them wherever a labelled action joins the gear', () => { - expect(resolve('combined').headerLayout).toBe('stacked'); - expect(resolve('organization').headerLayout).toBe('stacked'); - }); - - it('runs the gear inline where it is the only action', () => { - expect(resolve('user').headerLayout).toBe('inline'); + expect(layout.actions.manage).toEqual(['settings']); }); }); diff --git a/packages/mosaic/src/features/switcher/switcher-avatar.styles.ts b/packages/mosaic/src/features/switcher/switcher-avatar.styles.ts index 784829da4a5..3c63b57946d 100644 --- a/packages/mosaic/src/features/switcher/switcher-avatar.styles.ts +++ b/packages/mosaic/src/features/switcher/switcher-avatar.styles.ts @@ -1,45 +1,35 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, focusVars, radiusVars, space, spacingVars } from '../../tokens.stylex'; +import { space, spacingVars } from '../../tokens.stylex'; const GAP_PX = 1; -const DEFAULT_SPACING_PX = 4; -const BADGE_RADIUS = 0.25; const step = (multiple: number) => `calc(${spacingVars['--cl-spacing']} * ${multiple})`; -const cutoutPx = (frame: number) => frame * DEFAULT_SPACING_PX; - -const cutout = (frame: number) => - `url("data:image/svg+xml,")`; +const cutout = `url("data:image/svg+xml,")`; const square = (multiple: number) => ({ - fontSize: `calc(${step(multiple)} * 0.4)`, height: step(multiple), width: step(multiple), + fontSize: `calc(${step(multiple)} * 0.4)`, }); -const lead = (box: number, frame: number) => ({ +const cutoutOffset = (box: number, badgeSize: number) => `calc(${step(box - badgeSize)} - ${GAP_PX}px)`; + +const cutoutSize = (badgeSize: number) => `calc(${step(badgeSize)} + ${GAP_PX * 2}px)`; + +const lead = (box: number, badgeSize: number) => ({ ...square(box - 1), - maskImage: `linear-gradient(#000 0 0), ${cutout(frame)}`, + maskImage: `linear-gradient(#000 0 0), ${cutout}`, maskPosition: { - default: `0 0, ${step(box - frame)} ${step(box - frame)}`, - ':is([dir="rtl"] *)': `0 0, right ${step(box - frame)} top ${step(box - frame)}`, + default: `0 0, ${cutoutOffset(box, badgeSize)} ${cutoutOffset(box, badgeSize)}`, + ':is([dir="rtl"] *)': `0 0, right ${cutoutOffset(box, badgeSize)} top ${cutoutOffset(box, badgeSize)}`, }, - maskSize: `100% 100%, ${step(frame)} ${step(frame)}`, + maskSize: `100% 100%, ${cutoutSize(badgeSize)} ${cutoutSize(badgeSize)}`, }); -const ring = (box: number) => ({ - height: step(box - 1), - width: step(box - 1), -}); - -const badge = (frame: number) => ({ - '--_cl-avatar-radius': `${BADGE_RADIUS * 100}%`, - fontSize: `calc((${step(frame)} - ${GAP_PX * 2}px) * 0.4)`, - height: `calc(${step(frame)} - ${GAP_PX * 2}px)`, - width: `calc(${step(frame)} - ${GAP_PX * 2}px)`, -}); +const BOX = 10.5; +const BADGE = 4; export const styles = stylex.create({ root: { @@ -47,53 +37,22 @@ export const styles = stylex.create({ display: 'inline-flex', flexShrink: 0, position: 'relative', + height: space['10.5'], + width: space['10.5'], }, lead: { + ...lead(BOX, BADGE), maskComposite: 'exclude', maskRepeat: 'no-repeat', }, - ring: { - borderRadius: radiusVars['--cl-radius-full'], - insetBlockStart: 0, - insetInlineStart: 0, - outlineColor: { default: null, ':is(:focus-visible *)': colorVars['--cl-color-ring'] }, - outlineOffset: { default: null, ':is(:focus-visible *)': focusVars['--cl-focus-outline-offset'] }, - outlineStyle: { default: null, ':is(:focus-visible *)': focusVars['--cl-focus-outline-style'] }, - outlineWidth: { default: null, ':is(:focus-visible *)': focusVars['--cl-focus-outline-width'] }, - pointerEvents: 'none', - position: 'absolute', - }, - badge: { display: 'flex', - insetBlockEnd: `${GAP_PX}px`, - insetInlineEnd: `${GAP_PX}px`, + insetBlockEnd: 0, + insetInlineEnd: 0, position: 'absolute', }, -}); - -export const sizes = stylex.create({ - xs: { height: space['6'], width: space['6'] }, - sm: { height: space['8'], width: space['8'] }, - md: { height: space['9.5'], width: space['9.5'] }, -}); - -export const leadSizes = stylex.create({ - xs: lead(6, 3), - sm: lead(8, 3.5), - md: lead(9.5, 4), -}); - -export const ringSizes = stylex.create({ - xs: ring(6), - sm: ring(8), - md: ring(9.5), -}); -export const badgeSizes = stylex.create({ - xs: badge(3), - sm: badge(3.5), - md: badge(4), + badgeAvatar: square(BADGE), }); diff --git a/packages/mosaic/src/features/switcher/switcher-avatar.view.tsx b/packages/mosaic/src/features/switcher/switcher-avatar.view.tsx index 68360bcca8d..3a292c8f023 100644 --- a/packages/mosaic/src/features/switcher/switcher-avatar.view.tsx +++ b/packages/mosaic/src/features/switcher/switcher-avatar.view.tsx @@ -5,7 +5,7 @@ import type { AvatarProps } from '../../components/avatar'; import { Avatar } from '../../components/avatar'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../styles/reset.styles'; -import { badgeSizes, leadSizes, ringSizes, sizes, styles } from './switcher-avatar.styles'; +import { styles } from './switcher-avatar.styles'; import { useSlot } from './switcher-surface'; function initials(name: string): string { @@ -42,55 +42,33 @@ export function RowAvatar({ name, imageUrl, shape, size, xstyle }: RowAvatarProp ); } -export interface SwitcherAvatarProps { +export interface BadgedAvatarProps { name: string; imageUrl?: string; - shape: 'circle' | 'square'; - size: 'xs' | 'sm' | 'md'; - badge?: { name: string; imageUrl?: string }; - focusRing?: boolean; + badge: { name: string; imageUrl?: string }; } -export function SwitcherAvatar({ - name, - imageUrl, - shape, - size, - badge, - focusRing = false, -}: SwitcherAvatarProps): ReactElement { +export function BadgedAvatar({ name, imageUrl, badge }: BadgedAvatarProps): ReactElement { const slot = useSlot(); - if (!badge) { - return ( - - ); - } - return ( - {focusRing ? : null} diff --git a/packages/mosaic/src/features/switcher/switcher-header.styles.ts b/packages/mosaic/src/features/switcher/switcher-header.styles.ts index a71d0a2cf64..138603713a3 100644 --- a/packages/mosaic/src/features/switcher/switcher-header.styles.ts +++ b/packages/mosaic/src/features/switcher/switcher-header.styles.ts @@ -8,7 +8,6 @@ export const styles = stylex.create({ gap: space['3'], alignItems: 'center', display: 'flex', - flexWrap: 'wrap', }, content: { @@ -32,18 +31,4 @@ export const styles = stylex.create({ fontWeight: fontWeightVars['--cl-font-normal'], lineHeight: typeScaleVars['--cl-text-sm-leading'], }, - - actions: { - gap: space['2'], - alignItems: 'center', - display: 'flex', - flexShrink: 0, - }, - - actionsStacked: { - display: 'grid', - flexBasis: '100%', - gridAutoColumns: 'minmax(0, 1fr)', - gridAutoFlow: 'column', - }, }); diff --git a/packages/mosaic/src/features/switcher/switcher-header.view.tsx b/packages/mosaic/src/features/switcher/switcher-header.view.tsx index e4c350cc371..4e085b4f088 100644 --- a/packages/mosaic/src/features/switcher/switcher-header.view.tsx +++ b/packages/mosaic/src/features/switcher/switcher-header.view.tsx @@ -4,22 +4,19 @@ import type { ReactElement, ReactNode } from 'react'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../styles/reset.styles'; import { truncationStyles } from '../../styles/typography.styles'; -import type { SwitcherHeaderLayout } from './switcher.types'; import { styles } from './switcher-header.styles'; import { useSlot } from './switcher-surface'; export interface SwitcherHeaderProps { - layout: SwitcherHeaderLayout; avatar: ReactNode; title: string; description?: string; - actions?: ReactNode; } -export function SwitcherHeader({ layout, avatar, title, description, actions }: SwitcherHeaderProps): ReactElement { +export function SwitcherHeader({ avatar, title, description }: SwitcherHeaderProps): ReactElement { const slot = useSlot(); return ( -
+
{avatar}
) : null}
- {actions ? ( -
- {actions} -
- ) : null}
); } diff --git a/packages/mosaic/src/features/switcher/switcher-item.styles.ts b/packages/mosaic/src/features/switcher/switcher-item.styles.ts index ee8a7a9735a..705d7c894ed 100644 --- a/packages/mosaic/src/features/switcher/switcher-item.styles.ts +++ b/packages/mosaic/src/features/switcher/switcher-item.styles.ts @@ -75,7 +75,7 @@ export const styles = stylex.create({ label: { fontSize: typeScaleVars['--cl-text-sm-size'], - fontWeight: fontWeightVars['--cl-font-medium'], + fontWeight: fontWeightVars['--cl-font-normal'], lineHeight: typeScaleVars['--cl-text-sm-leading'], }, diff --git a/packages/mosaic/src/features/switcher/switcher.layout.ts b/packages/mosaic/src/features/switcher/switcher.layout.ts index be0429461d8..154789b8dd3 100644 --- a/packages/mosaic/src/features/switcher/switcher.layout.ts +++ b/packages/mosaic/src/features/switcher/switcher.layout.ts @@ -1,17 +1,19 @@ -import type { SwitcherData, SwitcherHeaderLayout, SwitcherMode } from './switcher.types'; +import type { SwitcherData, SwitcherMode } from './switcher.types'; /* - * Which mode puts what where. The surface is three slots deep, in this order, and each mode fills - * them differently: + * Which mode puts what where. Under the header the surface is three slots deep, in this order, and + * each mode fills them differently: * * combined organization user * ┌────────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────────┐ - * │ Alice ᶠ │ │ Foundry │ │ Alice ⚙ │ header - * │ [⚙ Settings ▾] [Invite] │ │ [⚙ Settings] [Invite] │ │ │ + * │ Foundry ᵃ │ │ Foundry │ │ Alice │ header * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ * │ Personal account │ │ Personal account │ │ │ ┐ * │ ✓ Foundry │ │ ✓ Foundry │ │ │ ┘ organization rows - * │ + Create organization │ │ + Create organization │ │ │ organizationsFooter + * │ + Add organization │ │ + Add organization │ │ │ organizationsFooter + * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ + * │ ⚙ Settings › │ │ ⚙ Settings │ │ ⚙ Settings │ ┐ + * │ ⊕ Invite members │ │ ⊕ Invite members │ │ │ ┘ manage * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ * │ ⇄ Switch account › │ │ │ │ ⇄ Switch account › │ ┐ * │ ⤴ Sign out │ │ │ │ ⤴ Sign out │ ┘ footer @@ -21,37 +23,37 @@ import type { SwitcherData, SwitcherHeaderLayout, SwitcherMode } from './switche * 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 organization 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 at the foot. A combined surface always leads with the account. With an - * organization active, the account is badged with it, invites to it, and its Settings opens onto - * both the organization's settings and the account's. + * The header only names whatever leads; what can be done about it is the manage group, which the + * app's own rows join. An + * organization is managed and invited to, an account is managed, and signed out of at the foot. A + * combined surface leads with the account until an organization is active. Then it leads with the + * organization, badged with the account, invites to it, and its Settings opens onto both the + * organization's settings and the account's. */ -/** The three places an action can land. Every mode has a header and a footer; the list's varies. */ -export type SwitcherSlot = 'header' | 'organizationsFooter' | 'footer'; +/** The three places an action can land. Which of them a mode fills varies. */ +export type SwitcherSlot = 'organizationsFooter' | 'manage' | 'footer'; export type SwitcherAction = | 'addAccount' | 'createOrganization' | 'inviteMembers' - /** The gear. Manages the header's organization and, where it badges the account, the account too. */ - | 'manageLead' + /** Settings. Manages the lead's organization and, where it badges the account, the account too. */ + | 'settings' | 'signOut' /** The flyout of signed-in accounts, adding one, and signing out of all of them. */ | 'switchAccount'; /** - * What the trigger names and the header leads with. `member` is the account, badged with the - * organization it is active in. `personal` and `none` are an organization-led surface with no - * organization active, falling back to the personal account or to nothing. Neither has an - * organization to manage, and an organization-led surface never manages the account. + * What the trigger names and the header leads with. `personal` and `none` are an organization-led + * surface with no organization active, falling back to the personal account or to nothing. Neither + * has an organization to manage, and an organization-led surface never manages the account. */ -export type SwitcherLead = 'organization' | 'member' | 'user' | 'personal' | 'none'; +export type SwitcherLead = 'organization' | 'user' | 'personal' | 'none'; -const headers = { - organization: ['inviteMembers', 'manageLead'], - member: ['inviteMembers', 'manageLead'], - user: ['manageLead'], +const manage = { + organization: ['settings', 'inviteMembers'], + user: ['settings'], personal: [], none: [], } as const satisfies Record; @@ -94,9 +96,10 @@ const modes = { */ export interface SwitcherLayout { lead: SwitcherLead; + /** The organization lead is badged with the user, and its gear manages both. */ + userBadge: boolean; /** The organization rows: the personal account, the organizations, and what is on offer. */ showOrganizations: boolean; - headerLayout: SwitcherHeaderLayout; /** What each slot carries, in the order it renders. */ actions: Record; } @@ -106,7 +109,7 @@ function resolveLead(mode: SwitcherMode, data: SwitcherData): SwitcherLead { return 'user'; } if (mode === 'combined') { - return data.activeOrganization ? 'member' : 'user'; + return data.activeOrganization ? 'organization' : 'user'; } if (data.activeOrganization) { return 'organization'; @@ -124,15 +127,14 @@ export function resolveSwitcherLayout(mode: SwitcherMode, data: SwitcherData): S const hasOrganizations = data.hasOrganizations || data.suggestions.length > 0 || data.invitations.length > 0; const lead = resolveLead(mode, data); - const header = [...headers[lead]]; return { lead, + userBadge: mode === 'combined' && lead === 'organization', showOrganizations: declared.organizations !== false && hasOrganizations, - headerLayout: header.some(action => action !== 'manageLead') ? 'stacked' : 'inline', actions: { - header, organizationsFooter: [...organizationsFooter], + manage: [...manage[lead]], footer: [...(hasOtherSessions ? declared.footer.multiSession : declared.footer.singleSession)], }, }; diff --git a/packages/mosaic/src/features/switcher/switcher.styles.ts b/packages/mosaic/src/features/switcher/switcher.styles.ts index 81336829670..2b104bae86e 100644 --- a/packages/mosaic/src/features/switcher/switcher.styles.ts +++ b/packages/mosaic/src/features/switcher/switcher.styles.ts @@ -23,7 +23,8 @@ export const styles = stylex.create({ }, triggerLabelled: { - padding: space['1'], + padding: space['1.5'], + borderRadius: `calc(${radiusVars['--cl-radius-md']} + ${space['1']})`, gap: space['1.5'], backgroundColor: { default: 'transparent', @@ -46,23 +47,11 @@ export const styles = stylex.create({ transitionProperty: 'opacity', }, - triggerRinglessAvatar: { - outlineStyle: 'none', - }, - triggerRound: { borderRadius: radiusVars['--cl-radius-full'], }, - triggerText: { - display: 'flex', - flexDirection: 'column', - textAlign: 'start', - minWidth: 0, - }, - - // Matches `SwitcherItemLabel`, so the trigger names an organization the same way its row does. Capped, - // because the trigger sits in a host app's chrome and a long organization name would push it apart. + // Capped, because the trigger sits in a host app's chrome and a long organization name would push it apart. triggerName: { color: colorVars['--cl-color-foreground'], fontSize: typeScaleVars['--cl-text-sm-size'], @@ -71,16 +60,9 @@ export const styles = stylex.create({ maxWidth: '12rem', }, - triggerOrganization: { - color: colorVars['--cl-color-foreground-secondary'], - fontSize: typeScaleVars['--cl-text-xs-size'], - lineHeight: typeScaleVars['--cl-text-xs-leading'], - maxWidth: '12rem', - }, - triggerCaret: { '--_cl-icon-color': colorVars['--cl-color-foreground-secondary'], - marginInlineEnd: space['1'], + marginInline: space['1'], }, // The organization list scrolls; the header and footer stay put. The scroll area carries the diff --git a/packages/mosaic/src/features/switcher/switcher.types.ts b/packages/mosaic/src/features/switcher/switcher.types.ts index be4041d094c..c5434acc472 100644 --- a/packages/mosaic/src/features/switcher/switcher.types.ts +++ b/packages/mosaic/src/features/switcher/switcher.types.ts @@ -105,12 +105,6 @@ export interface SwitcherCallbacks { export type SwitcherMode = 'combined' | 'user' | 'organization'; -/** - * How the header carries its actions: `inline` trails the lead with them, the gear as an icon; - * `stacked` runs them under it as full-width labelled buttons. - */ -export type SwitcherHeaderLayout = 'inline' | 'stacked'; - /** Whether the surface signs itself with Clerk's mark. */ export interface SwitcherBrandingProps { /** @@ -133,15 +127,11 @@ export interface SwitcherBusyState { // ─── Menu items ───────────────────────────────────────────────────────────── /** - * A built-in action the foot of the popup lists as a row of its own, named by the id `menuItemOrder` - * knows it by. The surface's other actions live in its header, where there is no list for an order - * to run in. - * - * `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. `signOut` signs out of the active account. + * A built-in row of the group your own rows join, named by the id `menuItemOrder` knows it by. + * `settings` manages the lead, and `inviteMembers` invites to an organization that leads. The + * organizations above and the account rows at the foot keep their own order. */ -export type SwitcherMenuItemId = 'switchAccount' | 'addAccount' | 'signOut'; +export type SwitcherMenuItemId = 'settings' | 'inviteMembers'; interface SwitcherMenuItemBase { /** Identifies the row, for ordering. */ @@ -151,13 +141,13 @@ interface SwitcherMenuItemBase { icon?: ReactNode; } -/** An action of your own at the foot of the popup. */ +/** An action of your own, beside Settings. */ export interface SwitcherMenuAction extends SwitcherMenuItemBase { onClick: () => void; href?: never; } -/** A row at the foot of the popup that leaves for somewhere else. */ +/** A row of your own, beside Settings, that leaves for somewhere else. */ export interface SwitcherMenuLink extends SwitcherMenuItemBase { /** Where the row goes. */ href: string; @@ -166,14 +156,17 @@ export interface SwitcherMenuLink extends SwitcherMenuItemBase { export type SwitcherMenuItem = SwitcherMenuAction | SwitcherMenuLink; -/** The app's own actions at the foot of the popup, and the order the foot's rows run in. */ +/** The app's own rows, beside Settings, and the order that group runs in. */ export interface SwitcherMenuProps { - /** Actions and links of your own, added to the foot of the popup ahead of Clerk's own rows. */ + /** + * Actions and links of your own, added to the group with Settings, after Clerk's own rows. An + * item whose `id` is a built-in row's replaces that row, in its place. + */ customMenuItems?: SwitcherMenuItem[]; /** - * The order the foot's rows run in, by id: a built-in row's id, or a custom item's `id`. Anything - * left out follows the rows named here. An id the surface does not carry as a row is ignored, - * since which rows the foot has depends on its mode. + * The order the Settings group runs in, by id: a built-in row's id, or a custom item's `id`. + * Anything left out follows the rows named here. An id the group does not carry as a row is + * ignored, since which rows it has depends on the mode and on what leads. */ menuItemOrder?: (SwitcherMenuItemId | (string & {}))[]; } diff --git a/packages/mosaic/src/features/switcher/switcher.view.tsx b/packages/mosaic/src/features/switcher/switcher.view.tsx index 88efd22afd7..39f42764398 100644 --- a/packages/mosaic/src/features/switcher/switcher.view.tsx +++ b/packages/mosaic/src/features/switcher/switcher.view.tsx @@ -5,7 +5,7 @@ import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import { Badge } from '../../components/badge'; -import { Button, SubmitButton } from '../../components/button'; +import { SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; import { Icon } from '../../components/icon'; import { Menu } from '../../components/menu'; @@ -21,7 +21,7 @@ import { focusOutline } from '../../styles/focus-outline.styles'; import { rtl } from '../../styles/rtl.styles'; import { truncationStyles } from '../../styles/typography.styles'; import { applyOrder } from '../../utils/apply-order'; -import type { SwitcherAction, SwitcherLayout, SwitcherSlot } from './switcher.layout'; +import type { SwitcherLayout, SwitcherSlot } from './switcher.layout'; import { resolveSwitcherLayout } from './switcher.layout'; import { styles } from './switcher.styles'; import type { @@ -29,14 +29,13 @@ import type { SwitcherBusyState, SwitcherCallbacks, SwitcherData, - SwitcherHeaderLayout, SwitcherMembership, SwitcherMenuItemId, SwitcherMenuProps, SwitcherMode, SwitcherSession, } from './switcher.types'; -import { RowAvatar, SwitcherAvatar } from './switcher-avatar.view'; +import { BadgedAvatar, RowAvatar } from './switcher-avatar.view'; import { SwitcherHeader } from './switcher-header.view'; import { SwitcherGroup, @@ -106,8 +105,9 @@ type LeadSelection = imageUrl?: string; shape: 'square'; organization: SwitcherMembership; + user?: SwitcherSession; } - | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle'; badge?: SwitcherMembership } + | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle' } | { kind: 'none'; name: string; imageUrl?: string; shape: 'square' }; /** @@ -127,26 +127,22 @@ function leadSelection( imageUrl: activeOrganization.imageUrl, shape: 'square', organization: activeOrganization, + user: layout.userBadge ? activeSession : undefined, }; } if (layout.lead === 'none') { return { kind: 'none', name: m.organizations.notSelected, shape: 'square' }; } - return { - kind: 'user', - name: activeSession.name, - imageUrl: activeSession.imageUrl, - shape: 'circle', - badge: layout.lead === 'member' && activeOrganization ? activeOrganization : undefined, - }; + return { kind: 'user', name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' }; } -/** The organization a lead shows: the one it is, or the one badging the account. */ +/** The organization a lead shows. */ function leadOrganization(lead: LeadSelection): SwitcherMembership | undefined { - if (lead.kind === 'organization') { - return lead.organization; - } - return lead.kind === 'user' ? lead.badge : undefined; + return lead.kind === 'organization' ? lead.organization : undefined; +} + +function leadUser(lead: LeadSelection): SwitcherSession | undefined { + return lead.kind === 'organization' ? lead.user : undefined; } function joinDetails(...parts: Array): string { @@ -163,15 +159,12 @@ function membershipSubtitle(membership: SwitcherMembership, m: Messages, locale: function headerSubtitle(lead: LeadSelection, identifier: string, m: Messages, locale: string): string { if (lead.kind === 'organization') { - return membershipSubtitle(lead.organization, m, locale); + return lead.user ? identifier : membershipSubtitle(lead.organization, m, locale); } if (lead.kind === 'none') { // No selection is not the account, so it carries no identifier line. return ''; } - if (lead.badge) { - return lead.badge.name; - } // An account with no name is titled by its identifier, and repeating it underneath says nothing. return identifier === lead.name ? '' : identifier; } @@ -333,201 +326,34 @@ function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) { // ─── Sections ─────────────────────────────────────────────────────────────── -interface HeaderMenuItem { - label: string; - icon: IconName; - onClick: () => void; -} - -interface HeaderAction { - id: SwitcherAction; - label: string; - icon: IconName; - /** Inline, the button is square and shows the icon alone, labelling itself through `aria-label`. */ - iconOnly?: boolean; - /** Shows the icon ahead of the label even inline, where a labelled button otherwise has none. */ - leadingIcon?: boolean; - onClick?: () => void; - /** Opens a menu of these rather than acting itself. */ - items?: HeaderMenuItem[]; - /** Key from `switcherBusyKeys` when the action is one-shot; omitted for navigations. */ - busyKey?: string; -} - -// Hooks cannot run inside a `.map`, so each button is its own component to read its own busy state. -function HeaderActionButton({ - layout, - label, - icon, - iconOnly, - leadingIcon, - onClick, - items, - busyKey, -}: HeaderAction & { layout: SwitcherHeaderLayout }) { - const m = useSwitcherMessages(); - const { busy, disabled } = useBusy(busyKey); - const stacked = layout === 'stacked'; - const compact = !stacked && iconOnly; - const buttonProps = { - variant: 'outline', - color: 'neutral', - size: 'sm', - shape: compact ? 'square' : 'default', - fullWidth: stacked, - 'aria-label': compact ? label : undefined, - disabled, - } as const; - const leading = - stacked || compact || leadingIcon ? ( - - ) : null; - const text = compact ? null : label; - - if (items) { - return ( - - }> - {leading} - {text} - - - {items.map(item => ( - - - - - {item.label} - - ))} - - - ); - } - - if (busyKey === undefined) { - return ( - - ); - } - - return ( - - {leading} - {text} - - ); -} - -/** The lead: who you are signed in as, and what you can do about it. */ +/** The lead: who you are signed in as, or the organization you are on. */ function Header() { const m = useSwitcherMessages(); const locale = useLocale(); const data = useSwitcherContext(); - const layout = data.layout.headerLayout; - const userLed = data.layout.lead === 'user'; - const { identifier } = data.activeSession; const lead = leadSelection(data, m); - const { name } = lead; - const organization = leadOrganization(lead); - const subtitle = headerSubtitle(lead, identifier, m, locale); - - 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-add-right', onClick: data.onInviteMembers }); - } - // The gear manages the header's organization, named or badged, and the account wherever the - // account leads. With both to manage it opens a menu of the two. It reads "Settings" when - // stacked, or when the account leads on its own; otherwise inline it is the icon alone, named - // for what it manages. - if (action === 'manageLead') { - const settings: Array = []; - if (organization && data.onManageOrganization) { - settings.push({ - name: m.manage.organization, - label: m.manage.organizationSettings, - icon: 'building', - onClick: data.onManageOrganization, - }); - } - if (lead.kind !== 'organization' && data.onManageAccount) { - settings.push({ - name: m.manage.account, - label: m.manage.profileSettings, - icon: 'user-circle', - onClick: data.onManageAccount, - }); - } - const [only] = settings; - if (settings.length > 1) { - actions.push({ id: action, label: m.manage.settings, icon: 'cog-6-teeth', iconOnly: true, items: settings }); - } else if (only) { - actions.push({ - id: action, - label: layout === 'stacked' || userLed ? m.manage.settings : only.name, - icon: 'cog-6-teeth', - iconOnly: !userLed, - leadingIcon: userLed, - onClick: only.onClick, - }); - } - } - } - // Inline, the gear trails the labelled actions; stacked, it leads them. - const ordered = - layout === 'stacked' - ? [...actions.filter(a => a.id === 'manageLead'), ...actions.filter(a => a.id !== 'manageLead')] - : actions; + const user = leadUser(lead); return ( - } - title={name} - description={subtitle} - actions={ - ordered.length > 0 - ? ordered.map(a => ( - - )) - : undefined + user ? ( + + ) : ( + + ) } + title={lead.name} + description={headerSubtitle(lead, data.activeSession.identifier, m, locale)} /> ); } @@ -586,26 +412,7 @@ function PersonalRow() { ); } -/** The active organization, which leads the list whichever page of memberships it is on. */ -function ActiveMembershipRow() { - const data = useSwitcherContext(); - const active = data.activeOrganization; - const selectOrganization = data.onSelectOrganization; - - if (!active) { - return null; - } - - return ( - selectOrganization(active.organizationId) : undefined} - active - /> - ); -} - -/** The other organizations the active account belongs to. */ +/** The organizations the active account belongs to, in the order they come. */ function MembershipRows() { const data = useSwitcherContext(); const selectOrganization = data.onSelectOrganization; @@ -613,16 +420,14 @@ function MembershipRows() { return ( <> - {data.memberships - .filter(m => m.organizationId !== activeId) - .map(m => ( - selectOrganization(m.organizationId) : undefined} - active={false} - /> - ))} + {data.memberships.map(m => ( + selectOrganization(m.organizationId) : undefined} + active={m.organizationId === activeId} + /> + ))} ); } @@ -782,31 +587,26 @@ function SessionMenuItem({ session, active }: { session: SwitcherSession; active ); } -/** - * The accounts affordance at the foot: a row that opens a flyout of every signed-in account, the - * way to add one more, and the way to sign out of all of them. - * - * The flyout closes on pick, so the row itself carries the switch's spinner. - */ -function SwitchAccountRow() { - const m = useMessages('userButton'); - const data = useSwitcherContext(); - const addAccount = data.onAddAccount; - const signOutAll = data.onSignOutAll; - const { pendingKey } = data; - const busy = - pendingKey === switcherBusyKeys.signOutAll() || - data.additionalSessions.some(s => pendingKey === switcherBusyKeys.switchSession(s.sessionId)); +interface FlyoutRowProps { + icon: IconName; + label: string; + busy?: boolean; + children: ReactNode; +} + +/** A row that opens a flyout of menu items beside the surface rather than acting itself. */ +function FlyoutRow({ icon, label, busy = false, children }: FlyoutRowProps) { const { disabled } = useBusy(); return ( - // It opens out of the popup, so the opposite side is the popup itself. Where the viewport is - // too narrow for either side — a phone — it goes above the row instead of under the card. - // The row is inset 8px, so the sideways gap clears that before it clears the card's edge. Above - // the row there is nothing to clear, so that gap is the plain one. + // It opens out of the popup, so the opposite side is the popup itself. Where the viewport is too + // narrow for either side — a phone — it goes above the row instead of under the card. Sideways it + // clears the row but not the card, so it overlaps the card's edge by what is left of the row's inset. + // Above the row there is nothing to overlap, so that gap is the plain one. ) : ( )} - {m.accounts.switch} + {label} - - {/* The account it is on leads, checked: the flyout is the full set of accounts rather than + {children} + + ); +} + +/** + * The accounts affordance at the foot: a row that opens a flyout of every signed-in account, the + * way to add one more, and the way to sign out of all of them. + * + * The flyout closes on pick, so the row itself carries the switch's spinner. + */ +function SwitchAccountRow() { + const m = useMessages('userButton'); + const data = useSwitcherContext(); + const addAccount = data.onAddAccount; + const signOutAll = data.onSignOutAll; + const { pendingKey } = data; + const busy = + pendingKey === switcherBusyKeys.signOutAll() || + data.additionalSessions.some(s => pendingKey === switcherBusyKeys.switchSession(s.sessionId)); + + return ( + + {/* The account it is on leads, checked: the flyout is the full set of accounts rather than a list of somewhere else to go. */} + + {data.additionalSessions.map(s => ( - {data.additionalSessions.map(s => ( - - ))} - {addAccount ? ( + ))} + {addAccount ? ( + + + + + {m.accounts.add} + + ) : null} + {signOutAll ? ( + <> + - {m.accounts.add} + {m.accounts.signOutAll} - ) : null} - {signOutAll ? ( - <> - - - - - - {m.accounts.signOutAll} - - - ) : null} - - + + ) : null} + ); } @@ -946,7 +771,6 @@ function OrganizationSection() { ) : ( <> - @@ -962,70 +786,115 @@ function OrganizationSection() { ); } -/** One row at the foot: whatever it renders, and the id `menuItemOrder` places it by. */ -interface FooterRow { +interface SettingsTarget { + label: string; + icon: IconName; + onClick: () => void; +} + +/** Settings: with more than one thing to manage it opens a menu of them, otherwise it opens the one. */ +function SettingsRow({ targets }: { targets: SettingsTarget[] }) { + const m = useSwitcherMessages(); + const [only] = targets; + + if (targets.length === 1 && only) { + return ( + + } + label={m.manage.settings} + onClick={only.onClick} + /> + ); + } + + return ( + + {targets.map(target => ( + + + + + {target.label} + + ))} + + ); +} + +/** What Settings manages: the account wherever the account leads or badges it, and the lead's organization. */ +function settingsTargets(data: SwitcherContextValue, lead: LeadSelection, m: Messages): SettingsTarget[] { + const targets: SettingsTarget[] = []; + if ((lead.kind !== 'organization' || leadUser(lead)) && data.onManageAccount) { + targets.push({ label: m.manage.profile, icon: 'user-circle', onClick: data.onManageAccount }); + } + if (lead.kind === 'organization' && data.onManageOrganization) { + targets.push({ label: m.manage.organization, icon: 'building', onClick: data.onManageOrganization }); + } + return targets; +} + +/** One row the app can order: whatever it renders, and the id `menuItemOrder` places it by. */ +interface MenuRow { id: SwitcherMenuItemId | (string & {}); node: ReactNode; } -/** The actions that close out the surface. */ -function Footer() { - const m = useMessages('userButton'); +/** What can be done about the lead (manage it, and invite to an organization that leads), and the app's own rows. */ +function ManageSection() { + const m = useSwitcherMessages(); const data = useSwitcherContext(); - const signOutSession = data.onSignOutSession; - const { sessionId } = data.activeSession; + const targets = settingsTargets(data, leadSelection(data, m), m); - const builtIn: FooterRow[] = []; - for (const action of data.layout.actions.footer) { - // The only foot row that is not a plain action: it opens rather than doing, so it brings its - // own element instead of an `ActionRow`'s props. - if (action === 'switchAccount') { - builtIn.push({ id: 'switchAccount', node: }); - } - if (action === 'addAccount' && data.onAddAccount) { - builtIn.push({ - id: 'addAccount', - node: ( - - } - label={m.accounts.add} - onClick={data.onAddAccount} - /> - ), - }); + const builtIn: MenuRow[] = []; + for (const action of data.layout.actions.manage) { + if (action === 'settings' && targets.length > 0) { + builtIn.push({ id: 'settings', node: }); } - if (action === 'signOut' && signOutSession) { + if (action === 'inviteMembers' && data.onInviteMembers) { builtIn.push({ - id: 'signOut', + id: 'inviteMembers', node: ( } - label={m.accounts.signOut} - onClick={() => signOutSession(sessionId, 'footer')} - busyKey={switcherBusyKeys.signOutSession(sessionId, 'footer')} + label={m.manage.invite} + onClick={data.onInviteMembers} /> ), }); } } - const custom: FooterRow[] = (data.customMenuItems ?? []).map(({ id, ...item }) => ({ + const custom: MenuRow[] = (data.customMenuItems ?? []).map(({ id, ...item }) => ({ id, node: , })); - // Custom rows lead by default, the way the existing UserButton lists them above "Add account". - const rows = applyOrder(data.menuItemOrder, [...custom, ...builtIn], r => r.id); + const builtInIds = new Set(builtIn.map(r => r.id)); + const rows = applyOrder( + data.menuItemOrder, + [...builtIn.map(r => custom.find(c => c.id === r.id) ?? r), ...custom.filter(r => !builtInIds.has(r.id))], + r => r.id, + ); if (rows.length === 0) { return null; @@ -1043,6 +912,65 @@ function Footer() { ); } +/** The actions that close out the surface. */ +function Footer() { + const m = useMessages('userButton'); + const data = useSwitcherContext(); + const signOutSession = data.onSignOutSession; + const { sessionId } = data.activeSession; + + const rows: ReactNode[] = []; + for (const action of data.layout.actions.footer) { + // The only foot row that is not a plain action: it opens rather than doing, so it brings its + // own element instead of an `ActionRow`'s props. + if (action === 'switchAccount') { + rows.push(); + } + if (action === 'addAccount' && data.onAddAccount) { + rows.push( + + } + label={m.accounts.add} + onClick={data.onAddAccount} + />, + ); + } + if (action === 'signOut' && signOutSession) { + rows.push( + + } + label={m.accounts.signOut} + onClick={() => signOutSession(sessionId, 'footer')} + busyKey={switcherBusyKeys.signOutSession(sessionId, 'footer')} + />, + ); + } + } + + if (rows.length === 0) { + return null; + } + + return ( + <> + + {rows} + + ); +} + // ─── Public parts ─────────────────────────────────────────────────────────── export interface SwitcherRootProps @@ -1084,9 +1012,8 @@ export function SwitcherRoot(props: SwitcherRootProps): ReactElement { export interface SwitcherTriggerProps { /** - * Names the lead beside its avatar — the organization wherever one heads the - * trigger, no selection when personal is hidden and none is active, the account otherwise, with - * its active organization beneath it. Turn it off for the avatar alone. + * Names the lead beside its avatar — the organization wherever one heads the trigger, no + * selection when personal is hidden and none is active, the account otherwise. Turn it off for the avatar alone. * * @default true */ @@ -1111,36 +1038,29 @@ export function SwitcherTrigger({ const lead = leadSelection(data, m); const { name, shape } = lead; const planLabel = renderTriggerBadge ? leadOrganization(lead)?.planLabel : undefined; - const badge = lead.kind === 'user' ? lead.badge : undefined; - const ringsAvatar = !renderTriggerLabel && badge !== undefined; + const user = leadUser(lead); return ( - {renderTriggerLabel ? ( <> - - {name} - {badge ? ( - {badge.name} - ) : null} - + {name} {planLabel ? {planLabel} : null}
+