diff --git a/.changeset/mosaic-user-button-combined-settings.md b/.changeset/mosaic-user-button-combined-settings.md new file mode 100644 index 00000000000..14ab61f911e --- /dev/null +++ b/.changeset/mosaic-user-button-combined-settings.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Update `UserButton` to the latest designs. Combined mode always leads with the account, and the `modePriority` prop is removed. **Settings** opens organization and profile settings, and **Sign out of all accounts** moves into the **Switch account** menu. diff --git a/.changeset/user-button-design-tweaks.md b/.changeset/user-button-design-tweaks.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-button-design-tweaks.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts index 60f625f6f3d..2d5ea62443b 100644 --- a/integration/tests/mosaic/user-button.test.ts +++ b/integration/tests/mosaic/user-button.test.ts @@ -47,11 +47,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User return u; } - async function runAccountAction(page: Page, label: 'Manage account' | 'Sign out') { + async function openProfileSettings(page: Page) { await popup(page) - .getByRole('button', { name: `Actions for ${fakeUser.email}` }) + .getByRole('button', { name: /^(Settings|Manage account)$/ }) .click(); - await page.getByRole('menuitem', { name: label }).click(); + const profileSettings = page.getByRole('menuitem', { name: 'Profile settings' }); + await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor(); + if (await profileSettings.isVisible()) { + await profileSettings.click(); + } } test('switches the active organization and keeps it across a reload', async ({ page, context }) => { @@ -77,7 +81,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User const u = await signIn({ page, context }); await trigger(page).click(); - await runAccountAction(page, 'Sign out'); + await popup(page).getByRole('button', { name: 'Sign out', exact: true }).click(); await u.po.expect.toBeSignedOut(); await expect(trigger(page)).toHaveCount(0); @@ -104,7 +108,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User const u = await signIn({ page, context }, '/custom'); await trigger(page).click(); - await runAccountAction(page, 'Manage account'); + await openProfileSettings(page); await u.po.userProfile.waitForUserProfileModal(); await page.locator('.cl-userProfile-root').getByText('Custom page').click(); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index f731379b977..08657d42c66 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -76,7 +76,7 @@ const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); const popup = () => screen.queryByRole('dialog', { name: 'Account' }); const waiting = () => Array.from(popup()?.querySelectorAll('button') ?? []).some(button => button.getAttribute('aria-disabled') === 'true'); -const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice' }); +const signOut = () => within(requiredPopup()).getByRole('button', { name: 'Sign out' }); async function open() { const user = userEvent.setup(); @@ -85,8 +85,8 @@ async function open() { return user; } -async function accountAction(user: User, label: string) { - await user.click(accountMenu()); +async function openSettings(user: User, label: string) { + await user.click(within(requiredPopup()).getByRole('button', { name: 'Settings' })); await user.click(await screen.findByRole('menuitem', { name: label })); } @@ -177,7 +177,7 @@ describe('UserButton', () => { await open(); expect(await screen.findByRole('button', { name: 'Other' })).toBeInTheDocument(); - expect(accountMenu()).toBeInTheDocument(); + expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); }); describe('switching workspace', () => { @@ -214,7 +214,7 @@ describe('UserButton', () => { expect(screen.queryByText('Personal account')).toBeNull(); }); - it('names no organization selected when the instance forces one and none is active', async () => { + it('leads with the account, and lists no personal workspace, when the instance forces an organization and none is active', async () => { await renderUserButton( {}, signedIn({ @@ -223,12 +223,12 @@ describe('UserButton', () => { }), ); - expect(screen.getByRole('button', { name: /No organization selected/ })).toBeInTheDocument(); - await open(); + expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); + await openWithList(); - expect(within(requiredPopup()).getByText('No organization selected')).toBeInTheDocument(); + expect(screen.queryByText('No organization selected')).toBeNull(); expect(screen.queryByText('Personal account')).toBeNull(); - expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); }); it('lists no organizations on an account-only surface', async () => { @@ -258,7 +258,7 @@ describe('UserButton', () => { const { fapi, navigate } = await renderUserButton(); const user = await open(); - await accountAction(user, 'Sign out'); + await user.click(signOut()); await waitFor(() => expect(navigate).toHaveBeenCalledWith('/after-single-sign-out')); expect(fapi.client.sessions.map(session => session.id)).toEqual(['sess_2']); @@ -268,7 +268,7 @@ describe('UserButton', () => { const { clerk, fapi, navigate } = await renderUserButton({}, signedIn({ client: fapiClient([aliceSession]) })); const user = await open(); - await accountAction(user, 'Sign out'); + await user.click(signOut()); await waitFor(() => expect(navigate).toHaveBeenCalledWith('/')); await waitFor(() => expect(host()).toBeEmptyDOMElement()); @@ -286,7 +286,8 @@ describe('UserButton', () => { const { clerk, fapi, navigate } = await renderUserButton(); const user = await open(); - await user.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); + const menu = await openAccounts(user); + await user.click(within(menu).getByRole('menuitem', { name: 'Sign out of all accounts' })); await waitFor(() => expect(navigate).toHaveBeenCalledWith('/')); await waitFor(() => expect(host()).toBeEmptyDOMElement()); @@ -308,13 +309,11 @@ describe('UserButton', () => { client: fapiClient([aliceSession]), }), ); - const user = await open(); + await open(); - expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); - expect(screen.queryByLabelText('Account actions')).toBeNull(); - await user.click(accountMenu()); - expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: 'Add account' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Switch account' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull(); + expect(signOut()).toBeInTheDocument(); }); }); @@ -382,7 +381,7 @@ describe('UserButton', () => { const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); - await accountAction(user, 'Manage account'); + await openSettings(user, 'Profile settings'); expect(openUserProfile).toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); @@ -396,7 +395,7 @@ describe('UserButton', () => { const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); - await accountAction(user, 'Manage account'); + await openSettings(user, 'Profile settings'); expect(openUserProfile.mock.calls[0]?.[0]?.getContainer?.()).toBe(container); }); @@ -419,7 +418,7 @@ describe('UserButton', () => { const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); - await accountAction(user, 'Manage account'); + await openSettings(user, 'Profile settings'); await waitFor(() => expect(popup()).toBeNull()); const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; @@ -445,7 +444,7 @@ describe('UserButton', () => { const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); - await accountAction(user, 'Manage account'); + await openSettings(user, 'Profile settings'); await waitFor(() => expect(popup()).toBeNull()); const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; @@ -493,7 +492,7 @@ describe('UserButton', () => { const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); - await accountAction(user, 'Manage account'); + await openSettings(user, 'Profile settings'); expect(navigate).toHaveBeenCalledWith('/account'); expect(openUserProfile).not.toHaveBeenCalled(); @@ -504,7 +503,7 @@ describe('UserButton', () => { const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await open(); - await user.click(screen.getByRole('button', { name: 'Settings' })); + await openSettings(user, 'Organization settings'); expect(navigate).toHaveBeenCalledWith('/org'); expect(openOrganizationProfile).not.toHaveBeenCalled(); @@ -557,7 +556,7 @@ describe('UserButton', () => { await user.click(screen.getByRole('button', { name: 'Other' })); expect(waiting()).toBe(true); - expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); + expect(signOut()).toHaveAttribute('aria-disabled', 'true'); expect(screen.getByRole('button', { name: 'Switch account' })).toHaveAttribute('aria-disabled', 'true'); expect(popup()).toBeInTheDocument(); @@ -583,7 +582,7 @@ describe('UserButton', () => { await waitFor(() => expect(navigate).toHaveBeenCalledWith('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/org/org_9')); expect(screen.queryByTestId('fallback')).toBeNull(); - expect(within(requiredPopup()).getAllByText('Acme')).toHaveLength(2); + expect(current()).toEqual([expect.stringContaining('Acme')]); act(() => arrive()); await waitFor(() => expect(popup()).toBeNull()); @@ -602,7 +601,7 @@ describe('UserButton', () => { await waitFor(() => expect(waiting()).toBe(false), { timeout: 2000 }); expect(popup()).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); + expect(signOut()).toBeEnabled(); }); it('still shows the action when reopened before it settles, and starts no second one', async () => { @@ -659,7 +658,7 @@ describe('UserButton', () => { await user.click(trigger()); expect(waiting()).toBe(false); - expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); + expect(signOut()).toBeEnabled(); }); }); @@ -674,6 +673,20 @@ describe('UserButton', () => { expect(await screen.findByRole('button', { name: 'Org 15' })).toBeInTheDocument(); }); + it('leads with the active organization before the page holding it has loaded, and lists it once', async () => { + const many = Array.from({ length: 15 }, (_, i) => fapiOrganization({ id: `org_p${i + 1}`, name: `Org ${i + 1}` })); + await renderUserButton({}, signedIn({ memberships: [...many.map(o => fapiMembership(o)), fapiMembership(acme)] })); + await open(); + + const last = await screen.findByRole('button', { name: 'Org 9' }); + expect(reading('Acme', 'Personal account', 'Org 1')).toEqual(['Acme', 'Personal account', 'Org 1']); + + last.scrollIntoView(); + await screen.findByRole('button', { name: 'Org 15' }); + + expect(reading('Acme', 'Personal account', 'Org 15')).toEqual(['Acme', 'Personal account', 'Org 15']); + }); + describe('in user mode', () => { it('heads the surface with the account, never the active organization', async () => { await renderUserButton({ mode: 'user' }); @@ -704,30 +717,26 @@ describe('UserButton', () => { expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); }); - it('signs out from the header, beside the settings', async () => { + it('heads the surface with the account and its settings, and signs out at the foot', async () => { await renderUserButton({ mode: 'user' }); await open(); - expect(within(requiredPopup()).getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); expect(within(requiredPopup()).getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + expect(reading('Switch account', 'Sign out')).toEqual(['Switch account', 'Sign out']); expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); }); - it('lists every account in the flyout, then the way to add one', async () => { + it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => { await renderUserButton({ mode: 'user' }); const user = await open(); const items = within(await openAccounts(user)).getAllByRole('menuitem'); - expect(items.map(item => item.textContent)).toEqual([ - expect.stringContaining('alice'), - expect.stringContaining('bob@example.com'), - 'Add account', - ]); - expect(reading('Switch account', 'Add account', 'Sign out of all accounts')).toEqual([ - 'Switch account', - 'Sign out of all accounts', - ]); + expect(items).toHaveLength(4); + expect(items[0]).toHaveAccessibleName('alice'); + expect(items[1]).toHaveAccessibleName('bob@example.com'); + expect(items[2]).toHaveAccessibleName('Add account'); + expect(items[3]).toHaveAccessibleName('Sign out of all accounts'); }); }); @@ -771,26 +780,20 @@ describe('UserButton', () => { expect(reading('Personal account', 'Acme', 'Other', 'Create organization')).toEqual([ 'Acme', - 'Personal account', 'Acme', + 'Personal account', 'Other', 'Create organization', ]); - for (const name of [ - 'Switch account', - 'Add account', - 'Sign out', - 'Sign out of all accounts', - 'Actions for alice', - ]) { + for (const name of ['Switch account', 'Add account', 'Sign out', 'Sign out of all accounts']) { expect(screen.queryByRole('button', { name })).toBeNull(); } }); }); describe('in combined mode', () => { - it('heads the surface with the account where the user takes priority, managing its active organization', async () => { - const { clerk } = await renderUserButton({ modePriority: 'user' }); + it('heads the surface with the account, managing its active organization', async () => { + const { clerk } = await renderUserButton(); const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); @@ -798,15 +801,18 @@ describe('UserButton', () => { expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); expect(within(requiredPopup()).getByRole('button', { name: 'Invite' })).toBeInTheDocument(); - await user.click(within(requiredPopup()).getByRole('button', { name: 'Settings' })); + await openSettings(user, 'Organization settings'); expect(openOrganizationProfile).toHaveBeenCalled(); }); - it('keeps sign-out off the header, since the account row carries it', async () => { + it('trails the foot with sign-out, behind the flyout of accounts', async () => { await renderUserButton(); await open(); - expect(within(requiredPopup()).queryByRole('button', { name: 'Sign out' })).toBeNull(); + expect(reading('Switch account', 'Add account', 'Sign out', 'Sign out of all accounts')).toEqual([ + 'Switch account', + 'Sign out', + ]); }); it('trails the workspaces with create-organization', async () => { @@ -825,6 +831,7 @@ describe('UserButton', () => { expect(within(menu).getByRole('menuitem', { name: 'alice' })).toHaveAttribute('aria-current', 'true'); expect(within(menu).getByRole('menuitem', { name: 'bob@example.com' })).not.toHaveAttribute('aria-current'); expect(within(menu).getByRole('menuitem', { name: 'Add account' })).toBeInTheDocument(); + expect(within(menu).getByRole('menuitem', { name: 'Sign out of all accounts' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull(); }); @@ -837,7 +844,7 @@ describe('UserButton', () => { expect(reading('Switch account', 'Add account', 'Sign out of all accounts')).toEqual(['Add account']); }); - it('keeps the account row for an account with no organizations', async () => { + it('heads an account with no organizations with the account alone', async () => { const loner = fapiUser({ ...alice, organization_memberships: [] }); await renderUserButton( {}, @@ -845,7 +852,8 @@ describe('UserButton', () => { ); await open(); - expect(screen.getByRole('button', { name: 'Actions for alice' })).toBeInTheDocument(); + expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); expect(screen.queryByText('Personal account')).toBeNull(); }); }); @@ -890,19 +898,18 @@ describe('UserButton', () => { await renderUserButton({}, signedInAs(overrides)); await open(); - expect(screen.getByRole('button', { name: `Actions for ${identifier}` })).toBeInTheDocument(); + expect(within(requiredPopup()).getByText(identifier)).toBeInTheDocument(); }); }); describe('the workspace list', () => { - it('lists the workspaces held, then the invitations, then the suggestions', async () => { + it('lists the active workspace first, then the rest held, then the invitations, then the suggestions', async () => { await renderUserButton(); await openWithList(); expect(reading('Gamma', 'Beta', 'Personal account', 'Acme', 'Other')).toEqual([ 'Acme', 'Personal account', - 'Acme', 'Other', 'Gamma', 'Beta', @@ -963,7 +970,6 @@ describe('UserButton', () => { expect(reading('Gamma', 'Beta', 'Personal account', 'Acme', 'Other')).toEqual([ 'Acme', 'Personal account', - 'Acme', 'Other', ]); }); @@ -995,7 +1001,7 @@ describe('UserButton', () => { expect(await screen.findByText('Loading organizations…')).toBeInTheDocument(); expect(reading('Personal account', 'Other')).toEqual([]); - expect(screen.getByRole('button', { name: 'Actions for alice' })).toBeInTheDocument(); + expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); invitations.release(); await waitFor(() => expect(screen.queryByText('Loading organizations…')).toBeNull()); @@ -1011,9 +1017,12 @@ describe('UserButton', () => { await renderUserButton({ customMenuItems: [terms, support] }); await open(); - expect( - reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out of all accounts'), - ).toEqual(['Terms of service', 'Support', 'Switch account', 'Sign out of all accounts']); + expect(reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out')).toEqual([ + 'Terms of service', + 'Support', + 'Switch account', + 'Sign out', + ]); expect(screen.getByRole('link', { name: 'Support' })).toHaveAttribute('href', '/support'); }); @@ -1027,13 +1036,16 @@ describe('UserButton', () => { it('orders the rows by the ids it is given, dropping ids no row answers to', async () => { await renderUserButton({ customMenuItems: [terms, support], - menuItemOrder: ['signOutAll', 'manageAccount', 'support', 'nonsense'], + menuItemOrder: ['signOut', 'manageAccount', 'support', 'nonsense'], }); await open(); - expect( - reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out of all accounts'), - ).toEqual(['Sign out of all accounts', 'Support', 'Terms of service', 'Switch account']); + expect(reading('Terms of service', 'Support', 'Switch account', 'Add account', 'Sign out')).toEqual([ + 'Sign out', + 'Support', + 'Terms of service', + 'Switch account', + ]); }); it.each([ @@ -1073,7 +1085,7 @@ describe('UserButton', () => { }); describe('while an action is in flight', () => { - it.each(['Other', 'Personal account', 'Sign out of all accounts'])( + it.each(['Other', 'Personal account', 'Sign out'])( 'holds "%s" in place, aria-disabled and still focusable', async label => { await renderUserButton({}, noOffers()); @@ -1108,7 +1120,7 @@ describe('UserButton', () => { const idle = screen.getByRole('button', { name: 'Personal account' }); expect(idle).not.toHaveAttribute('aria-busy'); expect(within(idle).queryByRole('progressbar')).toBeNull(); - expect(screen.getByRole('button', { name: 'Actions for alice' })).toHaveAttribute('aria-disabled', 'true'); + expect(signOut()).toHaveAttribute('aria-disabled', 'true'); touch.release(); await waitFor(() => expect(popup()).toBeNull()); @@ -1144,9 +1156,8 @@ describe('UserButton', () => { describe('the trigger', () => { it.each([ [{ mode: 'organization' as const }, 'Acme'], - [{ mode: 'combined' as const }, 'Acme'], + [{ mode: 'combined' as const }, 'Alice Smith'], [{ mode: 'user' as const }, 'Alice Smith'], - [{ mode: 'combined' as const, modePriority: 'user' as const }, 'Alice Smith'], ])('with %o names %s beside the avatar', async (props, name) => { await renderUserButton(props); @@ -1154,6 +1165,19 @@ describe('UserButton', () => { expect(popup()).toBeNull(); }); + it('names the active organization beneath the account', async () => { + await renderUserButton(); + + expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); + expect(within(trigger()).getByText('Acme')).toBeInTheDocument(); + }); + + it('names no organization beneath the account in user mode', async () => { + await renderUserButton({ mode: 'user' }); + + expect(within(trigger()).queryByText('Acme')).toBeNull(); + }); + it('still names the account in user mode where personal is hidden and none is active', async () => { await renderUserButton({ mode: 'user', hidePersonal: true }, signedIn({ client: fapiClient([personalSession]) })); 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 0cf08e8e22d..3742988c16c 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, @@ -25,19 +21,17 @@ function resolve( } describe('resolveUserButtonLayout, where each action lands', () => { - it('spreads them across all four slots in combined mode', () => { + it('spreads them across all three slots in combined mode', () => { expect(resolve('combined').actions).toEqual({ header: ['inviteMembers', 'manageLead'], - organizationsHeading: ['manageAccount', 'signOut'], organizationsFooter: ['createOrganization'], - footer: ['switchAccount', 'signOutAll'], + footer: ['switchAccount', 'signOut'], }); }); it('carries no account actions at all in organization mode', () => { expect(resolve('organization').actions).toEqual({ header: ['inviteMembers', 'manageLead'], - organizationsHeading: [], organizationsFooter: ['createOrganization'], footer: [], }); @@ -45,10 +39,9 @@ describe('resolveUserButtonLayout, where each action lands', () => { it('takes the account actions into the header and the foot in user mode', () => { expect(resolve('user').actions).toEqual({ - header: ['signOut', 'manageLead'], - organizationsHeading: [], + header: ['manageLead'], organizationsFooter: [], - footer: ['switchAccount', 'signOutAll'], + footer: ['switchAccount', 'signOut'], }); }); }); @@ -58,7 +51,7 @@ describe('resolveUserButtonLayout, what the data settles', () => { const layout = resolve('combined', { activeOrganization: null }); expect(layout.lead).toBe('user'); - expect(layout.actions.header).toEqual(['signOut', 'manageLead']); + expect(layout.actions.header).toEqual(['manageLead']); }); it('leads with no selection where no organization is active and personal is hidden', () => { @@ -69,37 +62,27 @@ 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, 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 header in user mode, leaving the foot to add one', () => { - const layout = resolve('user', { additionalSessions: [] }); - - expect(layout.actions.header).toEqual(['signOut', 'manageLead']); - expect(layout.actions.footer).toEqual(['addAccount']); - }); + it.each(['combined', 'user'] as const)( + 'collapses the foot to "Add account" in %s mode where there is one account', + mode => { + expect(resolve(mode, { additionalSessions: [] }).actions.footer).toEqual(['addAccount', 'signOut']); + }, + ); }); -describe('resolveUserButtonLayout, a combined surface led by the account', () => { +describe('resolveUserButtonLayout, a combined surface', () => { it('leads with the account inside its active organization, inviting to that organization', () => { - const layout = resolve('combined', {}, 'user'); + const layout = resolve('combined'); expect(layout.lead).toBe('member'); expect(layout.actions.header).toEqual(['inviteMembers', 'manageLead']); }); - it('leads with the account alone where no organization is active', () => { - const layout = resolve('combined', { activeOrganization: null, hidePersonal: true }, 'user'); + 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(['signOut', 'manageLead']); - }); - - it('is ignored by the single-purpose modes', () => { - expect(resolve('organization', {}, 'user').lead).toBe('organization'); - expect(resolve('user', {}, 'user').lead).toBe('user'); + expect(layout.actions.header).toEqual(['manageLead']); }); }); @@ -107,22 +90,15 @@ describe('resolveUserButtonLayout, 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'); - expect(resolve('user').headerLayout).toBe('stacked'); }); it('runs the gear inline where it is the only action', () => { - expect(resolve('combined', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline'); + expect(resolve('user').headerLayout).toBe('inline'); + expect(resolve('organization', { activeOrganization: null, hidePersonal: true }).headerLayout).toBe('inline'); }); }); describe('resolveUserButtonLayout, which sections render', () => { - it('heads the organizations with the account even where it belongs to none', () => { - const layout = resolve('combined', { hasOrganizations: false, memberships: [], activeOrganization: null }); - - expect(layout.showOrganizationsHeading).toBe(true); - expect(layout.showOrganizations).toBe(false); - }); - it('counts an invitation or a suggestion as something to list', () => { const invitation = { kind: 'invitation', @@ -138,6 +114,6 @@ describe('resolveUserButtonLayout, which sections render', () => { }); it('carries no organizations in user mode', () => { - expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false }); + expect(resolve('user').showOrganizations).toBe(false); }); }); diff --git a/packages/mosaic/src/features/user-button/user-button-avatar.styles.ts b/packages/mosaic/src/features/user-button/user-button-avatar.styles.ts index 42109b4f163..784829da4a5 100644 --- a/packages/mosaic/src/features/user-button/user-button-avatar.styles.ts +++ b/packages/mosaic/src/features/user-button/user-button-avatar.styles.ts @@ -77,19 +77,23 @@ export const styles = stylex.create({ 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), }); diff --git a/packages/mosaic/src/features/user-button/user-button-avatar.view.tsx b/packages/mosaic/src/features/user-button/user-button-avatar.view.tsx index 0a9624ad8cd..9055f6fa881 100644 --- a/packages/mosaic/src/features/user-button/user-button-avatar.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button-avatar.view.tsx @@ -45,7 +45,7 @@ export interface UserButtonAvatarProps { name: string; imageUrl?: string; shape: 'circle' | 'square'; - size: 'xs' | 'sm'; + size: 'xs' | 'sm' | 'md'; badge?: { name: string; imageUrl?: string }; focusRing?: boolean; } diff --git a/packages/mosaic/src/features/user-button/user-button-header.styles.ts b/packages/mosaic/src/features/user-button/user-button-header.styles.ts index e460dc8deeb..a71d0a2cf64 100644 --- a/packages/mosaic/src/features/user-button/user-button-header.styles.ts +++ b/packages/mosaic/src/features/user-button/user-button-header.styles.ts @@ -28,9 +28,9 @@ export const styles = stylex.create({ description: { color: colorVars['--cl-color-foreground-secondary'], - fontSize: typeScaleVars['--cl-text-xs-size'], + fontSize: typeScaleVars['--cl-text-sm-size'], fontWeight: fontWeightVars['--cl-font-normal'], - lineHeight: typeScaleVars['--cl-text-xs-leading'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], }, actions: { 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 a3d467dbf91..bb3743f7f1c 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,41 +6,38 @@ import type { * * combined organization user * ┌────────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────────┐ - * │ Foundry │ │ Foundry │ │ Alice │ header - * │ [⚙ Settings] [Invite] │ │ [⚙ Settings] [Invite] │ │ [⚙ Settings] [Sign out] │ + * │ Alice ᶠ │ │ Foundry │ │ Alice ⚙ │ header + * │ [⚙ Settings ▾] [Invite] │ │ [⚙ Settings] [Invite] │ │ │ * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ - * │ 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 + * │ ⤴ Sign out │ │ │ │ ⤴ Sign out │ ┘ footer * └────────────────────────────┘ └──────────────────────────┘ └────────────────────────────┘ * - * 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 organizations are listed on the surface, since they are the workspaces the active 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. A combined surface can lead with the account while an organization is active too, and - * then the account is badged with that organization, and invites to and manages it. + * 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 four places an action can land. Every mode has a header and a footer; the list's two vary. */ -export type UserButtonSlot = 'header' | 'organizationsHeading' | 'organizationsFooter' | 'footer'; +/** The three places an action can land. Every mode has a header and a footer; the list's varies. */ +export type UserButtonSlot = 'header' | 'organizationsFooter' | 'footer'; export type UserButtonAction = | 'addAccount' | 'createOrganization' | 'inviteMembers' - /** The gear. Manages the header's organization, whether it leads or badges the account, else the account. */ + /** The gear. Manages the header's organization and, where it badges the account, the account too. */ | 'manageLead' - | 'manageAccount' | 'signOut' - | 'signOutAll' - /** The flyout of signed-in accounts. */ + /** The flyout of signed-in accounts, adding one, and signing out of all of them. */ | 'switchAccount'; /** @@ -58,7 +50,7 @@ export type UserButtonLead = 'organization' | 'member' | 'user' | 'none'; const headers = { organization: ['inviteMembers', 'manageLead'], member: ['inviteMembers', 'manageLead'], - user: ['signOut', 'manageLead'], + user: ['manageLead'], none: ['manageLead'], } as const satisfies Record; @@ -66,12 +58,12 @@ const headers = { interface ModeLayout { /** * 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. + * `false` is a list the mode does not carry at all. `footer` trails the rows, inside the list, + * since what it offers is one more workspace. */ - organizations: { heading: readonly UserButtonAction[] | false; footer: readonly UserButtonAction[] } | false; + organizations: { footer: readonly UserButtonAction[] } | false; /** - * With a second account the foot opens onto all of them and signs out of every one. With just the + * With a second account the foot switches between them, or 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[] }; @@ -79,44 +71,39 @@ interface ModeLayout { const modes = { combined: { - organizations: { heading: ['manageAccount', 'signOut'], footer: ['createOrganization'] }, - footer: { multiSession: ['switchAccount', 'signOutAll'], singleSession: ['addAccount', 'signOut'] }, + organizations: { footer: ['createOrganization'] }, + footer: { multiSession: ['switchAccount', 'signOut'], singleSession: ['addAccount', 'signOut'] }, }, - // Not about the account, so it heads its workspaces with nothing and offers no other account. + // Not about the account, so it offers no other account. organization: { - organizations: { heading: false, footer: ['createOrganization'] }, + organizations: { footer: ['createOrganization'] }, footer: { multiSession: [], singleSession: [] }, }, - // No workspaces at all. The header already signs the lone account out, so the foot only adds one. + // No workspaces at all, so the foot is the whole of it. user: { organizations: false, - footer: { multiSession: ['switchAccount', 'signOutAll'], singleSession: ['addAccount'] }, + footer: { multiSession: ['switchAccount', 'signOut'], singleSession: ['addAccount', 'signOut'] }, }, } 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 again. */ export interface UserButtonLayout { lead: UserButtonLead; /** The organization rows: their own workspace, the organizations, and what is on offer. */ showOrganizations: boolean; - /** - * The active account's row above them. Not gated on the rows: an account with no organizations - * still needs somewhere to manage and sign out of itself. - */ - showOrganizationsHeading: boolean; headerLayout: UserButtonHeaderLayout; /** What each slot carries, in the order it renders. */ actions: Record; } -function resolveLead(mode: UserButtonMode, modePriority: UserButtonModePriority, data: UserButtonData): UserButtonLead { +function resolveLead(mode: UserButtonMode, data: UserButtonData): UserButtonLead { if (mode === 'user') { return 'user'; } - if (mode === 'combined' && modePriority === 'user') { + if (mode === 'combined') { return data.activeOrganization ? 'member' : 'user'; } if (data.activeOrganization) { @@ -125,13 +112,8 @@ function resolveLead(mode: UserButtonMode, modePriority: UserButtonModePriority, return data.hidePersonal ? 'none' : 'user'; } -export function resolveUserButtonLayout( - mode: UserButtonMode, - modePriority: UserButtonModePriority, - data: UserButtonData, -): UserButtonLayout { +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; const hasOtherSessions = data.additionalSessions.length > 0; @@ -139,17 +121,15 @@ 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; - const lead = resolveLead(mode, modePriority, data); + const lead = resolveLead(mode, data); const header = [...headers[lead]]; return { lead, showOrganizations: declared.organizations !== false && hasOrganizations, - showOrganizationsHeading: organizationsHeading !== false, headerLayout: header.some(action => action !== 'manageLead') ? 'stacked' : 'inline', actions: { header, - 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.messages.ts b/packages/mosaic/src/features/user-button/user-button.messages.ts index b06d7c0008f..10f236ce4f9 100644 --- a/packages/mosaic/src/features/user-button/user-button.messages.ts +++ b/packages/mosaic/src/features/user-button/user-button.messages.ts @@ -17,7 +17,6 @@ export const userButtonMessages = { pending: 'pending', }, accounts: { - actionsFor: 'Actions for {identifier}', switch: 'Switch account', add: 'Add account', signOut: 'Sign out', @@ -26,6 +25,8 @@ export const userButtonMessages = { manage: { invite: 'Invite', settings: 'Settings', + organizationSettings: 'Organization settings', + profileSettings: 'Profile settings', account: 'Manage account', organization: 'Manage organization', createOrganization: 'Create organization', 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 9f413496991..7b58a96458b 100644 --- a/packages/mosaic/src/features/user-button/user-button.styles.ts +++ b/packages/mosaic/src/features/user-button/user-button.styles.ts @@ -3,9 +3,6 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, durationVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; export const styles = stylex.create({ - accountIdentifier: { - fontWeight: fontWeightVars['--cl-font-medium'], - }, rowAvatar: { '--_cl-avatar-radius': radiusVars['--cl-radius-sm'], fontSize: '0.5rem', @@ -57,6 +54,13 @@ export const styles = stylex.create({ borderRadius: radiusVars['--cl-radius-full'], }, + triggerText: { + display: 'flex', + flexDirection: 'column', + textAlign: 'start', + minWidth: 0, + }, + // 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: { @@ -67,6 +71,13 @@ 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'], @@ -78,4 +89,8 @@ export const styles = stylex.create({ scroll: { maxHeight: '18rem', }, + + popup: { + width: 'min(21.875rem, calc(100vw - 2rem))', + }, }); diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx index 398a07da2b2..22ae79b18f5 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 @@ -124,7 +122,7 @@ export type UserButtonProps = UserButtonModelOptions & * { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }, * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, * ]} - * menuItemOrder={['docs', 'support', 'addAccount', 'signOutAll']} + * menuItemOrder={['docs', 'support', 'addAccount', 'signOut']} * /> * ``` */ @@ -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 696f0d9567a..4919aa512d9 100644 --- a/packages/mosaic/src/features/user-button/user-button.types.ts +++ b/packages/mosaic/src/features/user-button/user-button.types.ts @@ -116,14 +116,7 @@ export type UserButtonMode = 'combined' | 'organization' | 'user'; */ export type UserButtonHeaderLayout = 'inline' | 'stacked'; -/** - * Which of the two switchers a `combined` surface leads with: the one named in the trigger and - * headed in the popup. Both are still listed either way. The single-purpose modes have only one - * thing to lead with, so they ignore it. - */ -export type UserButtonModePriority = 'organization' | 'user'; - -/** Which switchers the surface carries, and which one it leads with. */ +/** Which switchers the surface carries. */ export interface UserButtonModeProps { /** * Which switchers the popup carries: both, organizations alone, or accounts alone. @@ -131,14 +124,6 @@ export interface UserButtonModeProps { * @default 'combined' */ mode?: UserButtonMode; - /** - * Which switcher a `combined` surface leads with in the trigger and the popup's header. Leading - * with the account while an organization is active badges the account's avatar with it. Ignored - * by the single-purpose modes, which have only one thing to lead with. - * - * @default 'organization' - */ - modePriority?: UserButtonModePriority; } /** Whether the surface signs itself with Clerk's mark. */ @@ -164,16 +149,14 @@ export interface UserButtonBusyState { /** * 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 or behind a `⋯`, where there is no - * list for an order to run in. + * 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. So do `signOutAll` and `signOut`: the foot signs out - * of every account where there is more than one, and, in `combined` mode, of the one account where - * there is not. + * to place that slot whichever way it resolves. `signOut` signs out of the active account. */ -export type UserButtonMenuItemId = 'switchAccount' | 'addAccount' | 'signOutAll' | 'signOut'; +export type UserButtonMenuItemId = 'switchAccount' | 'addAccount' | 'signOut'; interface UserButtonMenuItemBase { /** Identifies the row, for ordering. */ 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 7eeca04448f..e9685953c98 100644 --- a/packages/mosaic/src/features/user-button/user-button.view.tsx +++ b/packages/mosaic/src/features/user-button/user-button.view.tsx @@ -140,6 +140,14 @@ function leadWorkspace( }; } +/** The organization a workspace shows: the one it is, or the one badging the account. */ +function workspaceOrganization(workspace: ActiveWorkspace): UserButtonMembership | undefined { + if (workspace.kind === 'organization') { + return workspace.organization; + } + return workspace.kind === 'user' ? workspace.badge : undefined; +} + function joinDetails(...parts: Array): string { return parts.filter(Boolean).join(' · '); } @@ -307,13 +315,23 @@ function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) { // ─── Sections ─────────────────────────────────────────────────────────────── +interface HeaderMenuItem { + label: string; + icon: IconName; + onClick: () => void; +} + interface HeaderAction { id: UserButtonAction; label: string; icon: IconName; /** Inline, the button is square and shows the icon alone, labelling itself through `aria-label`. */ iconOnly?: boolean; - onClick: () => void; + /** 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 `userButtonBusyKeys` when the action is one-shot; omitted for navigations. */ busyKey?: string; } @@ -324,7 +342,9 @@ function HeaderActionButton({ label, icon, iconOnly, + leadingIcon, onClick, + items, busyKey, }: HeaderAction & { layout: UserButtonHeaderLayout }) { const m = useMessages('userButton'); @@ -339,10 +359,9 @@ function HeaderActionButton({ fullWidth: stacked, 'aria-label': compact ? label : undefined, disabled, - onClick, } as const; const leading = - stacked || compact ? ( + stacked || compact || leadingIcon ? ( + }> + {leading} + {text} + + + {items.map(item => ( + + + + + {item.label} + + ))} + + + ); + } + if (busyKey === undefined) { return ( - @@ -362,6 +412,7 @@ function HeaderActionButton({ return ( signOutSession(sessionId, 'header'), - busyKey: userButtonBusyKeys.signOutSession(sessionId, 'header'), - }); - } - // The gear manages the header's organization, named or badged, and otherwise the account. - // Inline it is the icon alone, named for what it manages; stacked it reads "Settings". + // 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 manage = organization - ? { label: m.manage.organization, onClick: data.onManageOrganization } - : { label: m.manage.account, onClick: data.onManageAccount }; - if (manage.onClick) { + const settings: Array = []; + if (organization && data.onManageOrganization) { + settings.push({ + name: m.manage.organization, + label: m.manage.organizationSettings, + icon: 'building', + onClick: data.onManageOrganization, + }); + } + if (workspace.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' ? m.manage.settings : manage.label, + label: layout === 'stacked' || userLed ? m.manage.settings : only.name, icon: 'cog-6-teeth', - iconOnly: true, - onClick: manage.onClick, + iconOnly: !userLed, + leadingIcon: userLed, + onClick: only.onClick, }); } } @@ -444,7 +500,7 @@ function Header() { name={workspace.name} imageUrl={workspace.imageUrl} shape={workspace.shape} - size='sm' + size='md' badge={workspace.kind === 'user' ? workspace.badge : undefined} /> } @@ -465,108 +521,6 @@ function Header() { ); } -interface RowAction { - label: string; - onClick: () => void; - color?: 'negative'; -} - -/** The `⋯` that hangs off a row's trailing edge. Renders nothing when it would be empty. */ -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; - } - - return ( - - - ( - - )} - /> - - {actions.map(a => ( - - {a.label} - - ))} - - - - ); -} - -/** - * Heads the organization list, named by the active account's identifier: these are the workspaces - * that account can switch between. Carries the account-wide actions, the way the "Accounts" heading - * below carries the ones that act on every account. - */ -function OrganizationsHeading() { - const m = useMessages('userButton'); - const data = useUserButtonContext(); - 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, 'organizationsHeading')); - - const actions: RowAction[] = []; - for (const action of data.layout.actions.organizationsHeading) { - if (action === 'createOrganization' && data.onCreateOrganization) { - actions.push({ label: m.manage.createOrganization, onClick: data.onCreateOrganization }); - } - if (action === 'manageAccount' && data.onManageAccount) { - actions.push({ label: m.manage.account, onClick: data.onManageAccount }); - } - if (action === 'signOut' && signOutSession) { - actions.push({ - label: m.accounts.signOut, - color: 'negative', - onClick: () => signOutSession(sessionId, 'organizationsHeading'), - }); - } - } - - return ( - - - {identifier} - - - - ); -} - interface MembershipRowProps { membership: UserButtonMembership; active: boolean; @@ -621,21 +575,43 @@ function PersonalRow() { ); } -/** The organizations the active account belongs to. Its own workspace is the row above. */ +/** The active organization, which leads the list whichever page of memberships it is on. */ +function ActiveMembershipRow() { + const data = useUserButtonContext(); + 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. */ function MembershipRows() { const data = useUserButtonContext(); const selectOrganization = data.onSelectOrganization; + const activeId = data.activeOrganization?.organizationId; return ( <> - {data.memberships.map(m => ( - selectOrganization(m.organizationId) : undefined} - active={m.organizationId === data.activeOrganization?.organizationId} - /> - ))} + {data.memberships + .filter(m => m.organizationId !== activeId) + .map(m => ( + selectOrganization(m.organizationId) : undefined} + active={false} + /> + ))} ); } @@ -796,18 +772,20 @@ function SessionMenuItem({ session, active }: { session: UserButtonSession; acti } /** - * The accounts affordance at the foot: a row that opens a flyout of every signed-in account, and - * of the way to add one more. + * 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, the way the - * organizations heading carries the spinner for what its own `⋯` opens. + * The flyout closes on pick, so the row itself carries the switch's spinner. */ function SwitchAccountRow() { const m = useMessages('userButton'); const data = useUserButtonContext(); const addAccount = data.onAddAccount; + const signOutAll = data.onSignOutAll; const { pendingKey } = data; - const busy = data.additionalSessions.some(s => pendingKey === userButtonBusyKeys.switchSession(s.sessionId)); + const busy = + pendingKey === userButtonBusyKeys.signOutAll() || + data.additionalSessions.some(s => pendingKey === userButtonBusyKeys.switchSession(s.sessionId)); const { disabled } = useBusy(); return ( @@ -873,6 +851,24 @@ function SwitchAccountRow() { {m.accounts.add} ) : null} + {signOutAll ? ( + <> + + + + + + {m.accounts.signOutAll} + + + ) : null} ); @@ -895,17 +891,11 @@ function OrganizationListLoadingRow() { ); } -/** - * The workspaces the active account can switch between, under the account's own heading. This is - * the group that scrolls. - * - * The heading can render without the list: an account with no organizations still needs somewhere - * to manage and sign out of itself. - */ +/** The workspaces the active account can switch between. This is the group that scrolls. */ function OrganizationSection() { const m = useMessages('userButton'); const data = useUserButtonContext(); - const { showOrganizations, showOrganizationsHeading } = data.layout; + const { showOrganizations } = data.layout; const actions: ReactNode[] = []; for (const action of data.layout.actions.organizationsFooter) { @@ -926,7 +916,7 @@ function OrganizationSection() { } } - if (!showOrganizations && !showOrganizationsHeading && actions.length === 0) { + if (!showOrganizations && actions.length === 0) { return null; } @@ -937,7 +927,6 @@ function OrganizationSection() { 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 placeholder stands in for all of them until the last one is in. */} @@ -946,6 +935,7 @@ function OrganizationSection() { ) : ( <> + @@ -1015,25 +1005,6 @@ function Footer() { ), }); } - if (action === 'signOutAll' && data.onSignOutAll) { - builtIn.push({ - id: 'signOutAll', - node: ( - - } - label={m.accounts.signOutAll} - onClick={data.onSignOutAll} - busyKey={userButtonBusyKeys.signOutAll()} - /> - ), - }); - } } const custom: FooterRow[] = (data.customMenuItems ?? []).map(({ id, ...item }) => ({ @@ -1084,20 +1055,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 (