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/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx index aad2de21a64..14edfa8b7fd 100644 --- a/packages/swingset/src/stories/menu.component.mdx +++ b/packages/swingset/src/stories/menu.component.mdx @@ -23,31 +23,38 @@ import { Menu } from '@clerk/ui/mosaic/components/menu'; - + - - Add workspace + + + + Add workspace - - Sign out + + + + Sign out - - Delete user + + + + Delete user - + ; ``` -`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write. +`Menu.Popup` renders the portal and the floating positioner itself — neither is a part you compose +— so items are the only children you write. ### Trigger @@ -63,21 +70,80 @@ props (ARIA attributes, click and keyboard handlers) to spread. ### Items -`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon -and text together as children. Use `color='negative'` for destructive actions; the color is -inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick` -never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open. +`label` names the item for typeahead and for assistive technology. What the row shows is composed +from `Menu.Media` and `Menu.Label`, and those children are required: text dropped straight into the +item is not a flex child the row can size, so it neither lines up with the other rows nor +truncates. A row with nothing to lead it is `Menu.Label` alone. Use +`color='negative'` for destructive actions; the color is inherited by the children. `disabled` items +are skipped by keyboard navigation and their `onClick` never fires. Activating an item closes the +menu; pass `closeOnClick={false}` to keep it open. ```tsx + + Revoke + + - - Delete + + + + Delete ``` +### Media + +`Menu.Media` is a square leading column that centers whatever it holds — an icon, an image, an +avatar. Items that lead with marks of differing widths need it: without it each row sets its own +text start, and the labels no longer line up. Leave it empty on an item that leads with nothing and +that row keeps the column. It renders a `span`, since the item it sits in is a button. + +`size` is the column's width: `sm` (the default) fits an icon or an avatar, `xs` a bare glyph. The +row has no height of its own, so it takes whatever the media asks for. That makes the size a +per-menu decision rather than a per-item one: give every item in one menu the same value, or their +text no longer starts on one line. + +```tsx + + + +``` + +### Label + +`Menu.Label` takes the space between the media and whatever trails it, and truncates its text to one +line. Items whose text is a name — an account, a workspace — need it most: a long name would widen +the menu instead of ellipsing. It is also what pushes a trailing mark to the end of the row. Use it +for every composed item, so rows built from parts all read the same. + +The two together are the whole of a row that leads with an avatar and trails with a check: + +```tsx + + + + C + + + colin@clerk.dev + + +``` + + + ### Placement `Menu.Root` takes `placement` and `sideOffset`; the popup flips and shifts automatically to stay in @@ -92,6 +158,22 @@ view, and its `max-height` tracks the available space so long menus scroll rathe ; ``` +`sideOffset` also takes `{ x, y }`, one gap per axis, for a menu that can flip between the two: what +it has to clear sideways is not what it has to clear above. `fallbackPlacements` names where it goes +when `placement` does not fit, in the order it tries them, instead of the opposite side. A menu +opened from inside another floating surface wants both — the opposite side is that surface, so it +has to be given somewhere else to land. + +```tsx + + … +; +``` + ### Controlled ```tsx @@ -107,13 +189,15 @@ const [open, setOpen] = useState(false); ## Parts -| Part | Slot | Description | -| ---------------- | -------------------------------- | --------------------------------------------------------------------- | -| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. | -| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. | -| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. | -| `Menu.Item` | `menu-item` | A single action whose content is composed through children. | -| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. | +| Part | Slot | Description | +| ---------------- | -------------------------------- | ------------------------------------------------------------------------------- | +| `Menu.Root` | — | State provider; owns open/close, placement, and keyboard navigation. | +| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. | +| `Menu.Popup` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. | +| `Menu.Item` | `menu-item` | A single action whose content is composed through children. | +| `Menu.Media` | `menu-media` | Square leading column that centers an item's icon, image, or avatar. | +| `Menu.Label` | `menu-label` | The item's text. Fills the row between media and trailing marks, and truncates. | +| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. | ## Styling diff --git a/packages/swingset/src/stories/menu.component.stories.tsx b/packages/swingset/src/stories/menu.component.stories.tsx index 276f205c8ca..172bc72dec9 100644 --- a/packages/swingset/src/stories/menu.component.stories.tsx +++ b/packages/swingset/src/stories/menu.component.stories.tsx @@ -1,3 +1,4 @@ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Icon } from '@clerk/ui/mosaic/components/icon'; import { Menu } from '@clerk/ui/mosaic/components/menu'; @@ -17,23 +18,66 @@ export function Default() { return ( - + - - Add workspace + + + + Add workspace - - Sign out + + + + Sign out - - Delete user + + + + Delete user - + + + ); +} + +const accounts = [ + { active: true, identifier: 'colin@clerk.dev', initial: 'C' }, + { active: false, identifier: 'braden.wiggins@a-very-long-domain.example', initial: 'B' }, +]; + +export function Accounts() { + return ( + + Switch account + + {accounts.map(account => ( + + + + {account.initial} + + + {account.identifier} + {account.active ? ( + + ) : null} + + ))} + ); } diff --git a/packages/swingset/src/stories/menu.mdx b/packages/swingset/src/stories/menu.mdx index 1160502ad58..9a4a8b53243 100644 --- a/packages/swingset/src/stories/menu.mdx +++ b/packages/swingset/src/stories/menu.mdx @@ -82,13 +82,19 @@ props. `Menu.Portal` is optional. ### `Menu.Root` -| Prop | Type | Default | Description | -| ------------------------- | ------------------------------------ | --------------------------- | --------------------------------------------------------------- | -| open | boolean | — | Controlled open state | -| defaultOpen | boolean | false | Initial open state (uncontrolled) | -| onOpenChange | (open: boolean) => void | — | Called when the open state changes | -| placement | Placement | 'bottom-start' | Placement relative to the trigger (`'right-start'` when nested) | -| sideOffset | number | 4 | Gap in px between the trigger and the popup (`0` when nested) | +| Prop | Type | Default | Description | +| ------------------------------- | ------------------------------------ | --------------------------- | --------------------------------------------------------------- | +| open | boolean | — | Controlled open state | +| defaultOpen | boolean | false | Initial open state (uncontrolled) | +| onOpenChange | (open: boolean) => void | — | Called when the open state changes | +| placement | Placement | 'bottom-start' | Placement relative to the trigger (`'right-start'` when nested) | +| sideOffset | SideOffset | 4 | Gap in px between the trigger and the popup (`0` when nested) | +| fallbackPlacements | Placement[] | opposite side | Where the popup goes when `placement` does not fit, in order | + +`SideOffset` is `number | { x: number; y: number }`. One number covers every placement; +`{ x, y }` gives the horizontal and vertical placements a gap each, which a popup that can flip +between the two axes wants. `fallbackPlacements` is for a menu opened from inside another floating +surface: the opposite side is that surface, so it has to be given somewhere else to land. ### `Menu.Item` 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. ) { void }) { return render( - + - - Add workspace + + + + Add workspace - + > + Sign out + + , ); } @@ -48,9 +52,11 @@ describe('Mosaic Menu', () => { > Actions - - - + + + Add workspace + + , ); const trigger = screen.getByRole('button', { name: 'Actions' }); @@ -91,13 +97,15 @@ describe('Mosaic Menu', () => { render( - + - + > + Sign out + + , ); @@ -112,20 +120,22 @@ describe('Mosaic Menu', () => { render( - + - - Delete user + + + + Delete user - + , ); expect(screen.getByRole('menuitem', { name: 'Delete user' })).toHaveAttribute('data-color', 'negative'); - expect(screen.getByTestId('delete-icon').parentElement).toHaveClass('cl-menu-item'); + expect(screen.getByTestId('delete-icon').closest('.cl-menu-item')).toBeInTheDocument(); }); it('merges consumer className and style onto the popup and items', async () => { @@ -133,15 +143,17 @@ describe('Mosaic Menu', () => { render( - - + > + Sign out + + , ); @@ -153,14 +165,116 @@ describe('Mosaic Menu', () => { expect(screen.getByRole('menuitem', { name: 'Sign out' })).toHaveClass('cl-menu-item', 'my-item'); }); + it('renders the media slot as a span, so it is valid inside the item button', () => { + render( + + + + + + + + Add workspace + + + , + ); + + const media = screen.getByTestId('add-icon').parentElement; + expect(media?.tagName).toBe('SPAN'); + expect(media).toHaveClass('cl-menu-media'); + expect(media?.parentElement).toHaveClass('cl-menu-item'); + }); + + it('holds the media column even where an item leads with nothing', () => { + render( + + + + + + Sign out + + + , + ); + + // The empty slot must not name the item, or every unillustrated row would read differently. + expect(screen.getByRole('menuitem', { name: 'Sign out' }).querySelector('.cl-menu-media')).toBeInTheDocument(); + }); + + it('forwards the media ref and swaps its element via render', () => { + const ref = React.createRef(); + render( + + + + + } + /> + Sign out + + + , + ); + + expect(ref.current?.tagName).toBe('I'); + expect(ref.current).toHaveClass('cl-menu-media'); + }); + + it('renders the label as a span inside the item, and names it', () => { + render( + + + + + + colin@clerk.dev + + + , + ); + + const label = screen.getByRole('menuitem', { name: 'colin@clerk.dev' }).querySelector('.cl-menu-label'); + expect(label?.tagName).toBe('SPAN'); + expect(label).toHaveTextContent('colin@clerk.dev'); + }); + + it('sizes the media to sm by default, and reflects the size it is given', () => { + render( + + + + + + Sign out + + + + colin@clerk.dev + + + , + ); + + const mediaOf = (name: string) => screen.getByRole('menuitem', { name }).querySelector('.cl-menu-media'); + + expect(mediaOf('Sign out')).toHaveAttribute('data-size', 'sm'); + expect(mediaOf('colin@clerk.dev')).toHaveAttribute('data-size', 'xs'); + }); + it('forwards the trigger ref', () => { const ref = React.createRef(); render( - - - + + + Sign out + + , ); expect(ref.current).toBe(screen.getByRole('button')); diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx index ecff3266ff5..838e7f3dc78 100644 --- a/packages/ui/src/mosaic/components/menu/menu.tsx +++ b/packages/ui/src/mosaic/components/menu/menu.tsx @@ -1,11 +1,12 @@ import type { MenuItemProps as PrimitiveMenuItemProps, - MenuPopupProps, + MenuPopupProps as PrimitiveMenuPopupProps, MenuPortalProps, MenuProps, MenuSeparatorProps, } from '@clerk/headless/menu'; import { Menu as Primitive } from '@clerk/headless/menu'; +import { useRender } from '@clerk/headless/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -14,7 +15,8 @@ import { mergeStyleProps, themeProps } from '../../props'; import { Button } from '../button'; import { Icon } from '../icon'; import { reset } from '../reset.styles'; -import { styles } from './menu.styles'; +import { truncationStyles } from '../typography.styles'; +import * as slots from './menu.styles'; export type { MenuProps, MenuSeparatorProps }; @@ -51,24 +53,27 @@ export const MenuTrigger = React.forwardRef ); }); -export interface MenuContentProps extends MenuPopupProps { +export interface MenuPopupProps extends PrimitiveMenuPopupProps { /** Container the menu portals into. Defaults to `document.body`. */ portalRoot?: MenuPortalProps['root']; } -/** The floating surface: portals, positions, and renders the menu items. */ -export const MenuContent = React.forwardRef(function MosaicMenuContent( +/** + * The floating surface: portals, positions, and renders the menu items. The portal and the + * positioner are not parts a consumer composes, so they stay out of the public API. + */ +export const MenuPopup = React.forwardRef(function MosaicMenuPopup( { portalRoot, className, style, children, ...rest }, ref, ) { return ( {children} @@ -78,12 +83,88 @@ export const MenuContent = React.forwardRef(fu ); }); +/** The width of the media column, and so the height of the row it sits in. */ +export type MenuMediaSize = 'xs' | 'sm'; + +/** `span`, not `div`: the item this sits in is a button, so its children are phrasing content. */ +export type MenuMediaProps = MosaicComponentProps<'span'> & { + /** + * Column width: `sm` fits an icon or an avatar, `xs` a bare glyph. The row takes its height + * from this, so every item in one menu wants the same value or their text no longer starts on + * one line. + * + * @default 'sm' + */ + size?: MenuMediaSize; +}; + +/** + * Square leading column that centers its media (icon, image, or avatar), so every item's + * text starts on the same line whatever each one leads with. + */ +export const MenuMedia = React.forwardRef(function MosaicMenuMedia( + { size = 'sm', render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('menu-media', { size }), + stylex.props(reset.base, slots.media.base, slots.media[size]), + className, + style, + ), + ...rest, + }, + }); +}); + +/** `span`, not `div`: the item this sits in is a button, so its children are phrasing content. */ +export type MenuLabelProps = MosaicComponentProps<'span'>; + +/** + * The item's text. Takes the space between the media and whatever trails it, and truncates to one + * line rather than pushing the menu wide. `Menu.Item` wraps its own `label` in this, so only a row + * built from the parts has to write it. + */ +export const MenuLabel = React.forwardRef(function MosaicMenuLabel( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'span', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('menu-label'), + stylex.props(reset.base, slots.label.base, truncationStyles.singleLine), + className, + style, + ), + ...rest, + }, + }); +}); + export interface MenuItemProps extends PrimitiveMenuItemProps { /** Semantic color of the action. */ color?: 'neutral' | 'negative'; + /** + * The row's content, composed from `Menu.Media` and `Menu.Label`. Required, and text goes in + * `Menu.Label` rather than straight in here: a bare text node is not a flex item the row can + * size, so it neither lines up with the other rows nor truncates. + */ + children: React.ReactNode; } -/** A single menu action. `label` drives typeahead and, unless `children` is given, the visible text. */ +/** + * A single menu action. `label` names it for typeahead and for assistive technology; what the row + * shows is whatever `Menu.Media` and `Menu.Label` are composed into it. + */ export const MenuItem = React.forwardRef(function MosaicMenuItem( { color = 'neutral', label, className, style, children, ...rest }, ref, @@ -94,13 +175,13 @@ export const MenuItem = React.forwardRef(funct label={label} {...mergeStyleProps( themeProps('menu-item', { color }), - stylex.props(reset.base, styles.item, color === 'negative' && styles.itemNegative), + stylex.props(reset.base, slots.item.base, color === 'negative' && slots.item.negative), className, style, )} {...rest} > - {children ?? label} + {children} ); }); @@ -109,7 +190,12 @@ export const MenuItem = React.forwardRef(funct export function MenuSeparator({ className, style, ...rest }: MenuSeparatorProps): React.ReactElement { return ( ); @@ -120,7 +206,9 @@ export const Menu = { // alongside the styled parts. Root: Primitive.Root, Trigger: MenuTrigger, - Content: MenuContent, + Popup: MenuPopup, Item: MenuItem, + Media: MenuMedia, + Label: MenuLabel, Separator: MenuSeparator, }; diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 71f4e41bfb4..5ac60c165a3 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -272,6 +272,13 @@ const LogOut = glyph( />, ); +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.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..8676b9228c6 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. */ @@ -429,16 +427,18 @@ function ActionMenu({ label, actions, disabled }: { label: string; actions: RowA aria-label={label} disabled={disabled} /> - + {actions.map(a => ( + > + {a.label} + ))} - + ); @@ -684,27 +684,122 @@ 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 +864,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 +883,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 +950,8 @@ function Footer() { <> - {actions.map(action => ( - + {rows.map(r => ( + {r.node} ))} @@ -998,7 +1057,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 +1066,6 @@ export function UserButtonPopup(): ReactElement {
-