From 793a71968cfd338ce1342f1a02789218c8fbf329 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 24 Aug 2026 15:52:01 -0400 Subject: [PATCH 1/6] feat(ui,headless): switch accounts from a flyout in the Mosaic UserButton The popup no longer lists the other signed-in accounts inline under an "Accounts" heading. "Switch account" at the foot opens them as a flyout, the active one checked, with "+ Add account" at its end. Where there is nobody to switch to, that row is "Add account" instead. `Menu.Root` gains `fallbackPlacements` and a per-axis `sideOffset`, which a menu opened from inside a popover needs: the opposite side is the popover itself, and what it has to clear sideways is not what it has to clear above. --- ...saic-user-button-switch-accounts-flyout.md | 2 + .../src/primitives/menu/menu-root.tsx | 23 +- packages/headless/src/utils/css-vars.ts | 6 +- packages/headless/src/utils/index.ts | 1 + .../headless/src/utils/side-offset.test.ts | 19 ++ packages/headless/src/utils/side-offset.ts | 17 ++ packages/swingset/src/stories/user-button.mdx | 29 +- .../src/stories/user-button.stories.tsx | 4 +- .../src/mosaic/components/item/item.styles.ts | 6 +- packages/ui/src/mosaic/icons/registry.tsx | 8 + .../__tests__/user-button.layout.test.ts | 34 +-- .../__tests__/user-button.view.test.tsx | 98 ++++--- .../mosaic/user-button/user-button.layout.ts | 105 +++----- .../user-button/user-button.messages.ts | 3 +- .../mosaic/user-button/user-button.styles.ts | 7 + .../mosaic/user-button/user-button.types.ts | 6 +- .../mosaic/user-button/user-button.view.tsx | 254 +++++++++++------- 17 files changed, 358 insertions(+), 264 deletions(-) create mode 100644 .changeset/mosaic-user-button-switch-accounts-flyout.md create mode 100644 packages/headless/src/utils/side-offset.test.ts create mode 100644 packages/headless/src/utils/side-offset.ts diff --git a/.changeset/mosaic-user-button-switch-accounts-flyout.md b/.changeset/mosaic-user-button-switch-accounts-flyout.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-switch-accounts-flyout.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/menu/menu-root.tsx b/packages/headless/src/primitives/menu/menu-root.tsx index 2afbed329be..8fc8e5448b3 100644 --- a/packages/headless/src/primitives/menu/menu-root.tsx +++ b/packages/headless/src/primitives/menu/menu-root.tsx @@ -28,6 +28,7 @@ import { useControllableState } from '../../hooks/use-controllable-state'; import { useReturnFocus } from '../../hooks/use-return-focus'; import { useTransition } from '../../hooks/use-transition'; import { cssVars } from '../../utils/css-vars'; +import { resolveSideOffset, type SideOffset } from '../../utils/side-offset'; import { MenuContext, type MenuContextValue } from './menu-context'; export interface MenuProps { @@ -35,12 +36,22 @@ export interface MenuProps { defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; placement?: Placement; - sideOffset?: number; + /** + * The gap between the trigger and the menu, in px. `{ x, y }` gives the horizontal and vertical + * placements a gap each, for a menu that can flip between the two axes. + */ + sideOffset?: SideOffset; + /** + * Where the menu goes when `placement` does not fit, in the order it tries them. Defaults to the + * opposite side. A menu opened from inside another floating surface wants this: the opposite side + * is that surface, so it has to be given somewhere else to land. + */ + fallbackPlacements?: Placement[]; children: ReactNode; } function MenuInner(props: MenuProps) { - const { placement: placementProp, sideOffset, children } = props; + const { placement: placementProp, sideOffset, fallbackPlacements, children } = props; const parentContext = useContext(MenuContext); const tree = useFloatingTree(); @@ -74,11 +85,11 @@ function MenuInner(props: MenuProps) { onOpenChange: setOpen, placement: resolvedPlacement, middleware: [ - offset({ - mainAxis: resolvedOffset, + offset(state => ({ + mainAxis: resolveSideOffset(resolvedOffset, state.placement), alignmentAxis: isNested ? -4 : 0, - }), - flip(), + })), + flip({ fallbackPlacements }), shift({ padding: 5 }), arrow({ element: arrowRef }), cssVars({ sideOffset: resolvedOffset }), diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts index b2f51c2259f..d091f3a61dc 100644 --- a/packages/headless/src/utils/css-vars.ts +++ b/packages/headless/src/utils/css-vars.ts @@ -1,5 +1,7 @@ import { detectOverflow, type Middleware } from '@floating-ui/react'; +import { resolveSideOffset, type SideOffset } from './side-offset'; + /** * Positioning middleware that sets CSS custom properties on the floating element: * @@ -12,13 +14,13 @@ import { detectOverflow, type Middleware } from '@floating-ui/react'; * * Place **after** `arrow()` so arrow position data is available for transform-origin. */ -export function cssVars(opts?: { sideOffset?: number }): Middleware { +export function cssVars(opts?: { sideOffset?: SideOffset }): Middleware { return { name: 'cssVars', async fn(state) { const { elements, rects, middlewareData, placement } = state; const style = elements.floating.style; - const sideOffset = opts?.sideOffset ?? 0; + const sideOffset = resolveSideOffset(opts?.sideOffset ?? 0, placement); // Anchor dimensions style.setProperty('--cl-anchor-width', `${rects.reference.width}px`); diff --git a/packages/headless/src/utils/index.ts b/packages/headless/src/utils/index.ts index f54beed344e..d839a1690a1 100644 --- a/packages/headless/src/utils/index.ts +++ b/packages/headless/src/utils/index.ts @@ -2,6 +2,7 @@ export { cssVars } from './css-vars'; export { Freeze, type FreezeProps } from './freeze'; export { isKeyboardEvent, isKeyboardOpen } from './interaction-modality'; export { resetLayoutStyles } from './reset-layout-styles'; +export { resolveSideOffset, type SideOffset } from './side-offset'; export { type ComponentProps, type DefaultProps, diff --git a/packages/headless/src/utils/side-offset.test.ts b/packages/headless/src/utils/side-offset.test.ts new file mode 100644 index 00000000000..d82d73b6631 --- /dev/null +++ b/packages/headless/src/utils/side-offset.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveSideOffset } from './side-offset'; + +describe('resolveSideOffset', () => { + it('takes one number for every placement', () => { + expect(resolveSideOffset(8, 'top-start')).toBe(8); + expect(resolveSideOffset(8, 'right')).toBe(8); + }); + + it('takes x on a horizontal placement and y on a vertical one', () => { + const offset = { x: 16, y: 8 }; + + expect(resolveSideOffset(offset, 'right-start')).toBe(16); + expect(resolveSideOffset(offset, 'left-end')).toBe(16); + expect(resolveSideOffset(offset, 'top-start')).toBe(8); + expect(resolveSideOffset(offset, 'bottom')).toBe(8); + }); +}); diff --git a/packages/headless/src/utils/side-offset.ts b/packages/headless/src/utils/side-offset.ts new file mode 100644 index 00000000000..9ca2a697274 --- /dev/null +++ b/packages/headless/src/utils/side-offset.ts @@ -0,0 +1,17 @@ +import type { Placement } from '@floating-ui/react'; + +/** + * The gap between a floating element and what it is anchored to, in px. One number covers every + * placement. `{ x, y }` gives the horizontal and vertical sides a gap each, which a surface that can + * flip between the two axes wants: what it has to clear sideways is not what it has to clear above. + */ +export type SideOffset = number | { x: number; y: number }; + +/** Picks the gap the placement's own axis asks for. */ +export function resolveSideOffset(offset: SideOffset, placement: Placement): number { + if (typeof offset === 'number') { + return offset; + } + const side = placement.split('-')[0]; + return side === 'left' || side === 'right' ? offset.x : offset.y; +} diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index 2d21d61c0c1..4fe9b0f6478 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -4,9 +4,9 @@ import * as UserButtonStories from './user-button.stories'; The account & organization switcher behind the user avatar. The active organization heads the surface, carrying a **gear** and **Invite**. The account's workspaces sit under it: every organization, plus -**suggested** and **invited** ones it can Join. Every signed-in account sits under an **Accounts** -heading — the active one checked, the rest a click away — so an account never reads as a workspace. -The foot signs out of everything. `mode` narrows all of it, and `modePriority` picks what heads it; +**suggested** and **invited** ones it can Join. **Switch account** at the foot opens the signed-in +accounts as a flyout — the active one checked — so an account never reads as a workspace. With +nobody to switch to, that row is **Add account** instead. The foot signs out of everything. `mode` narrows all of it, and `modePriority` picks what heads it; see [Modes](#modes). Only the active account's organizations are listed. Not a design choice: org requests are scoped to @@ -128,9 +128,8 @@ unrendered. ### User -The account heads it, with **Sign out** in **Invite**'s slot and the gear (**Manage account**). The -header is the active account, so the list holds only the accounts to switch to — no heading over -them, and **Add account** moves to the foot the way **Create organization** does. An org is active; +The account heads it, with **Sign out** in **Invite**'s slot and the gear (**Manage account**). No +workspaces are listed at all, so **Switch account** at the foot is the whole of it. An org is active; this mode ignores it, down to the trigger. ) { , ); +const SwitchHorizontal = glyph( + , +); + const Cog = glyph( { - 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: ['createOrganization', 'manageAccount', 'signOut'], - sessionsHeading: ['addAccount'], - footer: ['signOutAll'], + footer: ['switchAccount', 'signOutAll'], }); }); @@ -38,7 +37,6 @@ describe('resolveUserButtonLayout, where each action lands', () => { expect(resolve('organization').actions).toEqual({ header: ['inviteMembers', 'manageLead'], organizationsHeading: [], - sessionsHeading: [], footer: ['createOrganization'], }); }); @@ -47,8 +45,7 @@ describe('resolveUserButtonLayout, where each action lands', () => { expect(resolve('user').actions).toEqual({ header: ['signOut', 'manageLead'], organizationsHeading: [], - sessionsHeading: [], - footer: ['addAccount', 'signOutAll'], + footer: ['switchAccount', 'signOutAll'], }); }); }); @@ -58,18 +55,14 @@ describe('resolveUserButtonLayout, what the data settles', () => { expect(resolve('combined', { activeOrganization: null }).actions.header).toEqual(['manageLead']); }); - // "All accounts" is one account, and the account's own row already signs out of it. - it('offers no sign-out of all accounts where there is only the one', () => { - expect(resolve('user', { additionalSessions: [] }).actions.footer).toEqual(['addAccount']); - }); - - it('drops "Add account" to the foot where no accounts heading renders to carry it', () => { - const layout = resolve('combined', { additionalSessions: [] }); - - expect(layout.showSessionsHeading).toBe(false); - expect(layout.actions.sessionsHeading).toEqual([]); - expect(layout.actions.footer).toEqual(['addAccount']); - }); + // With no second account the flyout would open onto one row, so the foot offers that row instead. + // "All accounts" is that one account too, and the account's own row already signs out of it. + it.each(['combined', 'user'])( + 'leaves the foot "Add account" alone in %s mode where there is one account', + mode => { + expect(resolve(mode, { additionalSessions: [] }).actions.footer).toEqual(['addAccount']); + }, + ); }); describe('resolveUserButtonLayout, which sections render', () => { @@ -94,11 +87,6 @@ describe('resolveUserButtonLayout, which sections render', () => { expect(resolve('combined', { ...data, invitations: [invitation] }).showOrganizations).toBe(true); }); - it('lists the accounts unheaded in user mode, and not at all in organization mode', () => { - expect(resolve('user')).toMatchObject({ showSessions: true, showSessionsHeading: false }); - expect(resolve('organization')).toMatchObject({ showSessions: false, showSessionsHeading: false }); - }); - it('carries no organizations in user mode', () => { expect(resolve('user')).toMatchObject({ showOrganizations: false, showOrganizationsHeading: false }); }); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index def6553349f..e3e9c3958a4 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -87,9 +87,11 @@ const scrollClasses = stylex.props(...scrollAreaViewport('auto')).className?.spl /** The workspace list: the one group in the popup that scrolls. */ const workspaceList = () => groups().find(group => scrollClasses.every(name => group.classList.contains(name))); -/** The accounts group: the one whose rows are titled by identifier rather than by workspace name. */ -const accountsList = () => - groups().find(group => group !== workspaceList() && labels(group).some(label => label.includes('@'))); +/** Opens the accounts flyout at the foot, and hands back the menu it opens. */ +async function openAccounts(act: ReturnType) { + await act.click(screen.getByRole('button', { name: 'Switch account' })); + return screen.findByRole('menu'); +} describe('UserButtonView, user mode', () => { function renderUserMode(props: Partial = {}) { @@ -148,18 +150,17 @@ describe('UserButtonView, user mode', () => { expect(button.querySelector('.cl-spinner')).not.toBeNull(); }); - it('lists only the accounts to switch to, with no heading above them', () => { + it('opens the accounts from the foot rather than listing them inline', async () => { renderUserMode(); - expect(labels(accountsList())).toEqual(['bob@example.com']); - expect(screen.queryByText('Accounts')).toBeNull(); - }); + expect(screen.queryByRole('button', { name: 'bob@example.com' })).toBeNull(); - it('takes "Add account" at the foot rather than into an account menu', () => { - renderUserMode(); + const items = within(await openAccounts(userEvent.setup())).getAllByRole('menuitem'); - expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Account actions' })).toBeNull(); + expect(items).toHaveLength(3); + expect(items[0]).toHaveAccessibleName('alice@example.com'); + expect(items[1]).toHaveAccessibleName('bob@example.com'); + expect(items[2]).toHaveAccessibleName('Add account'); }); it('signs out of every account at the foot', () => { @@ -217,8 +218,7 @@ describe('UserButtonView, organization mode', () => { it('carries no account rows, not even the one it belongs to', () => { renderOrganizationMode(); - expect(accountsList()).toBeUndefined(); - expect(screen.queryByText('Accounts')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Switch account' })).toBeNull(); expect(screen.queryByRole('button', { name: 'bob@example.com' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Actions for alice@example.com' })).toBeNull(); // Nothing carries "Sign out" either: with no row to hang it off, the header would be the only @@ -281,35 +281,37 @@ describe('UserButtonView, combined mode', () => { expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull(); }); - it('heads the other accounts under "Accounts", listing the one it is on', () => { - renderCombined(); + it('switches account from the flyout, checking the one it is already on', async () => { + const onSwitchSession = vi.fn(); + const act = userEvent.setup(); + renderCombined({ onSwitchSession }); - expect(labels(accountsList())).toEqual(['alice@example.com', 'bob@example.com']); - expect(screen.getByText('Accounts')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'alice@example.com' })).toBeNull(); - }); + const menu = await openAccounts(act); + const active = within(menu).getByRole('menuitem', { name: 'alice@example.com' }); + const other = within(menu).getByRole('menuitem', { name: 'bob@example.com' }); - it('names the account it is on as the current one', () => { - renderCombined(); + expect(active).toHaveAttribute('aria-current', 'true'); + expect(other).not.toHaveAttribute('aria-current'); - expect(row(accountsList(), 'alice@example.com')).toHaveAttribute('aria-current', 'true'); - expect(row(accountsList(), 'bob@example.com')).not.toHaveAttribute('aria-current'); + await act.click(other); + + expect(onSwitchSession).toHaveBeenCalledWith('sess_2'); }); - it('keeps "Add account" in the Accounts heading rather than at the foot', async () => { + it('keeps "Add account" in the flyout rather than at the foot', async () => { renderCombined(); - await userEvent.setup().click(screen.getByRole('button', { name: 'Account actions' })); - - expect(await screen.findByRole('menuitem', { name: 'Add account' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Add account' })).toBeNull(); + + const menu = await openAccounts(userEvent.setup()); + + expect(within(menu).getByRole('menuitem', { name: 'Add account' })).toBeInTheDocument(); }); - it('takes "Add account" at the foot where there is no heading to carry it', () => { + it('takes "Add account" at the foot where there is no second account to switch to', () => { renderCombined({ additionalSessions: [] }); - expect(accountsList()).toBeUndefined(); - expect(screen.queryByText('Accounts')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Switch account' })).toBeNull(); expect(screen.getByRole('button', { name: 'Add account' })).toBeInTheDocument(); }); @@ -567,11 +569,11 @@ describe('UserButtonView, the foot', () => { node => node.textContent ?? '', ); - // The order the existing UserButton lists them in, above "Add account". + // The order the existing UserButton lists them in, above the account rows. it('leads with the custom rows', () => { renderView({ customMenuItems: [action(), support] }); - expect(footActions()).toEqual(['Terms of service', 'Support', 'Sign out of all accounts']); + expect(footActions()).toEqual(['Terms of service', 'Support', 'Switch account', 'Sign out of all accounts']); }); it('runs a custom action on press', async () => { @@ -598,7 +600,7 @@ describe('UserButtonView, the foot', () => { it('orders the rows by the ids it is given', () => { renderView({ customMenuItems: [action(), support], menuItemOrder: ['signOutAll', 'support'] }); - expect(footActions()).toEqual(['Sign out of all accounts', 'Support', 'Terms of service']); + expect(footActions()).toEqual(['Sign out of all accounts', 'Support', 'Terms of service', 'Switch account']); }); // Only some of the built-in actions are rows at all, and which of those a surface carries depends @@ -606,13 +608,17 @@ describe('UserButtonView, the foot', () => { it('drops an id no row answers to', () => { renderView({ customMenuItems: [action()], menuItemOrder: ['manageAccount', 'signOutAll', 'nonsense'] }); - expect(footActions()).toEqual(['Sign out of all accounts', 'Terms of service']); + expect(footActions()).toEqual(['Sign out of all accounts', 'Terms of service', 'Switch account']); }); - it('orders "Add account" where the foot is what carries it', () => { - renderView({ additionalSessions: [], customMenuItems: [action()], menuItemOrder: ['addAccount', 'terms'] }); + // The accounts slot answers to both ids, so an order set once places it whichever way it resolves. + it.each([ + ['Switch account', [bob]], + ['Add account', []], + ])('orders the accounts slot ahead of a custom row as "%s"', (label, additionalSessions) => { + renderView({ additionalSessions, customMenuItems: [action()], menuItemOrder: ['switchAccount', 'addAccount'] }); - expect(footActions()).toEqual(['Add account', 'Terms of service']); + expect(footActions()[0]).toBe(label); }); it('carries the custom rows on an org-only surface too', () => { @@ -693,7 +699,6 @@ describe('UserButtonView, one action at a time', () => { it.each([ ['a workspace row', 'Other Co'], ['the personal row', 'Personal account'], - ['an account row', 'bob@example.com'], ['an action row', 'Sign out of all accounts'], ])('holds %s in place, aria-disabled and still focusable, while another action runs', (_name, label) => { const { rerender } = render(surface(null)); @@ -731,7 +736,7 @@ describe('UserButtonView, one action at a time', () => { // the row would drop its trailing edge for the length of the action and get it back after. it.each([ ['the account menu', 'Actions for alice@example.com'], - ['the accounts menu', 'Account actions'], + ['the accounts flyout', 'Switch account'], ])('holds %s in place, disabled, while another action runs', (_name, label) => { const { rerender } = render(surface(null)); const row = screen.getByRole('button', { name: label }); @@ -743,11 +748,18 @@ describe('UserButtonView, one action at a time', () => { expect(stoodDown).toBeDisabled(); }); + // The flyout closes on pick, so the row that opened it is what is left to report the switch. + it('reports a switch on the row that opened the flyout', () => { + render(surface(userButtonBusyKeys.switchSession('sess_2'))); + + expect(screen.getByRole('button', { name: 'Switch account' }).querySelector('.cl-spinner')).not.toBeNull(); + }); + // `aria-disabled` is advisory, so the row has to drop the press itself. it('ignores a press on a row that is standing down', async () => { - const onSwitchSession = vi.fn(); - render(surface(userButtonBusyKeys.selectOrganization('org_2'), { onSwitchSession })); - const row = screen.getByRole('button', { name: 'bob@example.com' }); + const onSelectOrganization = vi.fn(); + render(surface(userButtonBusyKeys.switchSession('sess_9'), { onSelectOrganization })); + const row = screen.getByRole('button', { name: 'Other Co' }); // The popup takes its own initial focus a frame after it opens. Waiting for that lets the row // hold the focus it takes next, rather than losing it to a steal that lands mid-press. @@ -756,7 +768,7 @@ describe('UserButtonView, one action at a time', () => { row.focus(); await userEvent.click(row); - expect(onSwitchSession).not.toHaveBeenCalled(); + expect(onSelectOrganization).not.toHaveBeenCalled(); expect(row).toHaveFocus(); }); }); diff --git a/packages/ui/src/mosaic/user-button/user-button.layout.ts b/packages/ui/src/mosaic/user-button/user-button.layout.ts index 4378bd65ab3..20fde2707f1 100644 --- a/packages/ui/src/mosaic/user-button/user-button.layout.ts +++ b/packages/ui/src/mosaic/user-button/user-button.layout.ts @@ -1,7 +1,7 @@ import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './user-button.types'; /* - * Which mode puts what where. The surface is four slots deep, in this order, and each mode fills + * Which mode puts what where. The surface is three slots deep, in this order, and each mode fills * them differently: * * combined organization user @@ -12,21 +12,17 @@ import type { UserButtonData, UserButtonMode, UserButtonModePriority } from './u * │ Personal account │ │ Personal account │ │ │ ┐ * │ ✓ Foundry │ │ ✓ Foundry │ │ │ ┘ organization rows * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ - * │ Accounts [⋯] │ │ │ │ │ sessionsHeading - * │ ✓ alice@x.com │ │ │ │ │ ┐ - * │ bob@x.com │ │ │ │ bob@x.com │ ┘ session rows - * ├────────────────────────────┤ ├──────────────────────────┤ ├────────────────────────────┤ - * │ ⤴ Sign out of all accounts │ │ + Create organization │ │ + Add account │ ┐ - * │ │ │ │ │ ⤴ Sign out of all accounts │ ┘ footer + * │ ⇄ Switch account › │ │ + Create organization │ │ ⇄ Switch account › │ ┐ + * │ ⤴ Sign out of all accounts │ │ │ │ ⤴ Sign out of all accounts │ ┘ footer * └────────────────────────────┘ └──────────────────────────┘ └────────────────────────────┘ * - * Both lists read the same way: a heading that carries the list's actions behind a `⋯`, then the - * rows. The organizations are headed by the active account, since they are the workspaces that - * account can switch between; the sessions are headed by the word "Accounts". + * 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 four places an action can land. Every mode has a header and a footer; the headings vary. */ -export type UserButtonSlot = 'header' | 'organizationsHeading' | 'sessionsHeading' | 'footer'; +/** The three places an action can land. Every mode has a header and a footer; the heading varies. */ +export type UserButtonSlot = 'header' | 'organizationsHeading' | 'footer'; export type UserButtonAction = | 'addAccount' @@ -36,20 +32,18 @@ export type UserButtonAction = | 'manageLead' | 'manageAccount' | 'signOut' - | 'signOutAll'; - -/** A list the surface can carry. `heading: false` runs the rows unheaded. */ -interface ListLayout { - heading: readonly UserButtonAction[] | false; -} + | 'signOutAll' + /** The flyout of signed-in accounts. Collapses to `addAccount` where there is only the one. */ + | 'switchAccount'; -/** One mode's whole surface, top to bottom. `false` is a list the mode does not carry at all. */ +/** One mode's whole surface, top to bottom. */ interface ModeLayout { header: readonly UserButtonAction[]; - /** The workspaces the active account switches between: its own, plus the organizations it is in. */ - organizations: ListLayout | false; - /** The other signed-in accounts. */ - sessions: ListLayout | false; + /** + * 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. + */ + organizations: { heading: readonly UserButtonAction[] | false } | false; footer: readonly UserButtonAction[]; } @@ -57,23 +51,20 @@ const modes = { combined: { header: ['inviteMembers', 'manageLead'], organizations: { heading: ['createOrganization', 'manageAccount', 'signOut'] }, - sessions: { heading: ['addAccount'] }, - footer: ['signOutAll'], + footer: ['switchAccount', 'signOutAll'], }, - // Not about the account, so it heads its workspaces with nothing and lists no other account. + // Not about the account, so it heads its workspaces with nothing and offers no other account. organization: { header: ['inviteMembers', 'manageLead'], organizations: { heading: false }, - sessions: false, footer: ['createOrganization'], }, - // No workspaces to head the accounts against, so they stand unheaded and the header takes the - // account's own actions. + // No workspaces at all, so the header takes the account's own actions and the foot is the + // accounts flyout and what acts across every one of them. user: { header: ['signOut', 'manageLead'], organizations: false, - sessions: { heading: false }, - footer: ['addAccount', 'signOutAll'], + footer: ['switchAccount', 'signOutAll'], }, } as const satisfies Record; @@ -91,10 +82,6 @@ export interface UserButtonLayout { * still needs somewhere to manage and sign out of itself. */ showOrganizationsHeading: boolean; - /** The other signed-in accounts. */ - showSessions: boolean; - /** The "Accounts" row above them. Pointless with no accounts under it, so it follows the rows. */ - showSessionsHeading: boolean; /** What each slot carries, in the order it renders. */ actions: Record; } @@ -106,56 +93,44 @@ export function resolveUserButtonLayout( ): UserButtonLayout { const declared: ModeLayout = modes[mode]; const organizationsHeading = declared.organizations === false ? false : declared.organizations.heading; - const sessionsHeading = declared.sessions === false ? false : declared.sessions.heading; const hasOtherSessions = data.additionalSessions.length > 0; // A pending invitation or suggestion counts: it has to be reachable before there is a membership. // 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 showOrganizations = declared.organizations !== false && hasOrganizations; - const showOrganizationsHeading = organizationsHeading !== false; - const showSessions = declared.sessions !== false && hasOtherSessions; - const showSessionsHeading = showSessions && sessionsHeading !== false; - - const offered = (action: UserButtonAction): boolean => { + /** The action this surface actually carries in place of the one declared, or `null` for none. */ + const resolve = (action: UserButtonAction): UserButtonAction | null => { switch (action) { // Inviting belongs to whichever organization is active, even where the account is what heads // the surface. case 'inviteMembers': - return Boolean(data.activeOrganization); + return data.activeOrganization ? action : null; // "All accounts" is one account. The account's own row already signs out of it, so the foot // would be offering the same thing over again, in the plural. case 'signOutAll': - return hasOtherSessions; + return hasOtherSessions ? action : null; + // With no second account there is nothing to switch between, so the flyout collapses to the + // one row it would have opened onto. + case 'switchAccount': + return hasOtherSessions ? action : 'addAccount'; default: - return true; + return action; } }; - const actions: Record = { - header: declared.header.filter(offered), - organizationsHeading: [], - sessionsHeading: [], - footer: declared.footer.filter(offered), - }; - - if (organizationsHeading !== false) { - actions.organizationsHeading.push(...organizationsHeading.filter(offered)); - } - // The accounts heading follows its rows, so with no other account there is nothing to carry its - // actions and they fall to the footer, which every mode has. - if (sessionsHeading !== false) { - actions[showSessionsHeading ? 'sessionsHeading' : 'footer'].push(...sessionsHeading.filter(offered)); - } + const slot = (actions: readonly UserButtonAction[]): UserButtonAction[] => + actions.map(resolve).filter((action): action is UserButtonAction => action !== null); return { // Only a combined surface has two things to choose between; the other two are what they are. leadWith: mode === 'combined' ? modePriority : mode, - showOrganizations, - showOrganizationsHeading, - showSessions, - showSessionsHeading, - actions, + showOrganizations: declared.organizations !== false && hasOrganizations, + showOrganizationsHeading: organizationsHeading !== false, + actions: { + header: slot(declared.header), + organizationsHeading: organizationsHeading === false ? [] : slot(organizationsHeading), + footer: slot(declared.footer), + }, }; } diff --git a/packages/ui/src/mosaic/user-button/user-button.messages.ts b/packages/ui/src/mosaic/user-button/user-button.messages.ts index f6253a26b87..43a4e25e4e5 100644 --- a/packages/ui/src/mosaic/user-button/user-button.messages.ts +++ b/packages/ui/src/mosaic/user-button/user-button.messages.ts @@ -24,9 +24,8 @@ export const userButtonBase = { pending: 'pending', }, accounts: { - heading: 'Accounts', - menu: 'Account actions', actionsFor: 'Actions for {identifier}', + switch: 'Switch account', add: 'Add account', signOut: 'Sign out', signOutAll: 'Sign out of all accounts', diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index 2da67425590..d8783804d32 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -41,6 +41,13 @@ export const styles = stylex.create({ maxHeight: '18rem', }, + // A menu item lays its children out in one flat row, so the account's identifier is what has to + // take the space between its avatar and the check rather than the menu spacing the three evenly. + accountName: { + flexGrow: 1, + minWidth: 0, + }, + // The trailing column is as wide as the `⋯` menu button that owns it, so whatever stands in // that button's place — the active check, a spinner — lands on the same centre line and the // right edge of every row holds still as rows change state. diff --git a/packages/ui/src/mosaic/user-button/user-button.types.ts b/packages/ui/src/mosaic/user-button/user-button.types.ts index 26f9e5f9d1f..9e07c819de7 100644 --- a/packages/ui/src/mosaic/user-button/user-button.types.ts +++ b/packages/ui/src/mosaic/user-button/user-button.types.ts @@ -156,8 +156,12 @@ 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. + * + * `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. */ -export type UserButtonMenuItemId = 'createOrganization' | 'addAccount' | 'signOutAll'; +export type UserButtonMenuItemId = 'createOrganization' | 'switchAccount' | 'addAccount' | 'signOutAll'; interface UserButtonMenuItemBase { /** Identifies the row, for ordering. */ diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index e75574ad61a..0af3adc2b5f 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -271,8 +271,6 @@ const asAnchor = ); interface ActionRowProps { - /** Identifies the row, for ordering. */ - id: UserButtonMenuItemId | (string & {}); icon?: ReactNode; label: string; /** Where the row goes, for a row that leaves rather than acting. */ @@ -684,27 +682,116 @@ function PendingRows() { } /** - * A signed-in account: a plain row you click to switch to, checked where it is already the active - * one. Its workspaces cannot be listed here — they are scoped to the session that fetches them — - * so switching is all it offers. + * A signed-in account inside the accounts flyout: a menu item you pick to switch to, checked where + * it is already the active one. Its workspaces cannot be listed here — they are scoped to the + * session that fetches them — so switching is all it offers. */ -function SessionRow({ session, active }: { session: UserButtonSession; active?: boolean }) { +function SessionMenuItem({ session, active }: { session: UserButtonSession; active: boolean }) { const data = useUserButtonContext(); const switchSession = data.onSwitchSession; - const { busy, disabled } = useBusy(userButtonBusyKeys.switchSession(session.sessionId)); return ( - switchSession(session.sessionId) : undefined} - busy={busy} - disabled={disabled} - /> + label={session.identifier} + // The check is decorative, so without this the active item reads like the ones you can + // switch to. + aria-current={active ? 'true' : undefined} + // Picking what is already picked does nothing, so the active item only closes the flyout. + onClick={active || !switchSession ? undefined : () => switchSession(session.sessionId)} + > + + {session.identifier} + {active ? ( + + ) : null} + + ); +} + +/** + * 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 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. + */ +function SwitchAccountRow() { + const data = useUserButtonContext(); + const addAccount = data.onAddAccount; + const { pendingKey } = data; + const busy = data.additionalSessions.some(s => pendingKey === userButtonBusyKeys.switchSession(s.sessionId)); + 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. + + } + /> + } + > + + {busy ? ( + + ) : ( + + )} + + + {m.accounts.switch} + + + + {/* 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 => ( + + ))} + {addAccount ? ( + + + {m.accounts.add} + + ) : null} + + ); } @@ -769,66 +856,10 @@ function OrganizationSection() { ); } -/** The heading the session rows sit under, and the actions across every account it carries. */ -function SessionsHeading() { - const data = useUserButtonContext(); - // Everything it opens is a navigation, so it owns no action of its own to spin. It still stands - // down while one runs, the way the organization heading's `⋯` does. - const { disabled } = useBusy(); - - const actions: RowAction[] = []; - for (const action of data.layout.actions.sessionsHeading) { - if (action === 'addAccount' && data.onAddAccount) { - actions.push({ label: m.accounts.add, onClick: data.onAddAccount }); - } - } - - return ( - - - {m.accounts.heading} - - - - ); -} - -/** The other signed-in accounts, under their own heading, so they never read as workspaces. */ -function SessionSection() { - const data = useUserButtonContext(); - - if (!data.layout.showSessions) { - return null; - } - - return ( - <> - - - {data.layout.showSessionsHeading ? ( - <> - - {/* Under a heading the group reads as the full set of accounts, so the one you are on - is listed and checked. Without one it is a list of somewhere else to go. */} - - - ) : null} - {data.additionalSessions.map(s => ( - - ))} - - - ); +/** One row at the foot: whatever it renders, and the id `menuItemOrder` places it by. */ +interface FooterRow { + id: UserButtonMenuItemId | (string & {}); + node: ReactNode; } /** The actions that close out the surface. */ @@ -844,43 +875,66 @@ function Footer() { /> ); - const builtIn: ActionRowProps[] = []; + 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 === 'createOrganization' && data.onCreateOrganization) { builtIn.push({ id: 'createOrganization', - icon: plus, - label: m.manage.createOrganization, - onClick: data.onCreateOrganization, + node: ( + + ), }); } if (action === 'addAccount' && data.onAddAccount) { - builtIn.push({ id: 'addAccount', icon: plus, label: m.accounts.add, onClick: data.onAddAccount }); + builtIn.push({ + id: 'addAccount', + node: ( + + ), + }); } if (action === 'signOutAll' && data.onSignOutAll) { builtIn.push({ id: 'signOutAll', - icon: ( - + } + label={m.accounts.signOutAll} + onClick={data.onSignOutAll} + busyKey={userButtonBusyKeys.signOutAll()} /> ), - label: m.accounts.signOutAll, - onClick: data.onSignOutAll, - busyKey: userButtonBusyKeys.signOutAll(), }); } } + const custom: FooterRow[] = (data.customMenuItems ?? []).map(({ id, ...item }) => ({ + id, + node: , + })); + // Custom rows lead by default, the way the existing UserButton lists them above "Add account". - const actions = applyOrder( - data.menuItemOrder, - [...(data.customMenuItems ?? []), ...builtIn], - r => r.id, - ); + const rows = applyOrder(data.menuItemOrder, [...custom, ...builtIn], r => r.id); - if (actions.length === 0) { + if (rows.length === 0) { return null; } @@ -888,11 +942,8 @@ function Footer() { <> - {actions.map(action => ( - + {rows.map(r => ( + {r.node} ))} @@ -998,7 +1049,7 @@ export function UserButtonTrigger({ ); } -/** The popover surface: header, organizations, other accounts, and footer. */ +/** The popover surface: header, organizations, and footer. */ export function UserButtonPopup(): ReactElement { const { renderBranding } = useUserButtonContext(); @@ -1007,7 +1058,6 @@ export function UserButtonPopup(): ReactElement {
-