Skip to content

Commit 8726fd9

Browse files
feat(ui,headless): switch accounts from a flyout in the Mosaic UserButton (#9534)
1 parent 6b18758 commit 8726fd9

28 files changed

Lines changed: 906 additions & 448 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/headless/src/primitives/menu/menu-root.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,30 @@ import { useControllableState } from '../../hooks/use-controllable-state';
2828
import { useReturnFocus } from '../../hooks/use-return-focus';
2929
import { useTransition } from '../../hooks/use-transition';
3030
import { cssVars } from '../../utils/css-vars';
31+
import { resolveSideOffset, type SideOffset } from '../../utils/side-offset';
3132
import { MenuContext, type MenuContextValue } from './menu-context';
3233

3334
export interface MenuProps {
3435
open?: boolean;
3536
defaultOpen?: boolean;
3637
onOpenChange?: (open: boolean) => void;
3738
placement?: Placement;
38-
sideOffset?: number;
39+
/**
40+
* The gap between the trigger and the menu, in px. `{ x, y }` gives the horizontal and vertical
41+
* placements a gap each, for a menu that can flip between the two axes.
42+
*/
43+
sideOffset?: SideOffset;
44+
/**
45+
* Where the menu goes when `placement` does not fit, in the order it tries them. Defaults to the
46+
* opposite side. A menu opened from inside another floating surface wants this: the opposite side
47+
* is that surface, so it has to be given somewhere else to land.
48+
*/
49+
fallbackPlacements?: Placement[];
3950
children: ReactNode;
4051
}
4152

4253
function MenuInner(props: MenuProps) {
43-
const { placement: placementProp, sideOffset, children } = props;
54+
const { placement: placementProp, sideOffset, fallbackPlacements, children } = props;
4455

4556
const parentContext = useContext(MenuContext);
4657
const tree = useFloatingTree();
@@ -74,11 +85,11 @@ function MenuInner(props: MenuProps) {
7485
onOpenChange: setOpen,
7586
placement: resolvedPlacement,
7687
middleware: [
77-
offset({
78-
mainAxis: resolvedOffset,
88+
offset(state => ({
89+
mainAxis: resolveSideOffset(resolvedOffset, state.placement),
7990
alignmentAxis: isNested ? -4 : 0,
80-
}),
81-
flip(),
91+
})),
92+
flip({ fallbackPlacements }),
8293
shift({ padding: 5 }),
8394
arrow({ element: arrowRef }),
8495
cssVars({ sideOffset: resolvedOffset }),

packages/headless/src/utils/css-vars.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { detectOverflow, type Middleware } from '@floating-ui/react';
22

3+
import { resolveSideOffset, type SideOffset } from './side-offset';
4+
35
/**
46
* Positioning middleware that sets CSS custom properties on the floating element:
57
*
@@ -12,13 +14,13 @@ import { detectOverflow, type Middleware } from '@floating-ui/react';
1214
*
1315
* Place **after** `arrow()` so arrow position data is available for transform-origin.
1416
*/
15-
export function cssVars(opts?: { sideOffset?: number }): Middleware {
17+
export function cssVars(opts?: { sideOffset?: SideOffset }): Middleware {
1618
return {
1719
name: 'cssVars',
1820
async fn(state) {
1921
const { elements, rects, middlewareData, placement } = state;
2022
const style = elements.floating.style;
21-
const sideOffset = opts?.sideOffset ?? 0;
23+
const sideOffset = resolveSideOffset(opts?.sideOffset ?? 0, placement);
2224

2325
// Anchor dimensions
2426
style.setProperty('--cl-anchor-width', `${rects.reference.width}px`);

packages/headless/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { cssVars } from './css-vars';
22
export { Freeze, type FreezeProps } from './freeze';
33
export { isKeyboardEvent, isKeyboardOpen } from './interaction-modality';
44
export { resetLayoutStyles } from './reset-layout-styles';
5+
export { resolveSideOffset, type SideOffset } from './side-offset';
56
export {
67
type ComponentProps,
78
type DefaultProps,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { resolveSideOffset } from './side-offset';
4+
5+
describe('resolveSideOffset', () => {
6+
it('takes one number for every placement', () => {
7+
expect(resolveSideOffset(8, 'top-start')).toBe(8);
8+
expect(resolveSideOffset(8, 'right')).toBe(8);
9+
});
10+
11+
it('takes x on a horizontal placement and y on a vertical one', () => {
12+
const offset = { x: 16, y: 8 };
13+
14+
expect(resolveSideOffset(offset, 'right-start')).toBe(16);
15+
expect(resolveSideOffset(offset, 'left-end')).toBe(16);
16+
expect(resolveSideOffset(offset, 'top-start')).toBe(8);
17+
expect(resolveSideOffset(offset, 'bottom')).toBe(8);
18+
});
19+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Placement } from '@floating-ui/react';
2+
3+
/**
4+
* The gap between a floating element and what it is anchored to, in px. One number covers every
5+
* placement. `{ x, y }` gives the horizontal and vertical sides a gap each, which a surface that can
6+
* flip between the two axes wants: what it has to clear sideways is not what it has to clear above.
7+
*/
8+
export type SideOffset = number | { x: number; y: number };
9+
10+
/** Picks the gap the placement's own axis asks for. */
11+
export function resolveSideOffset(offset: SideOffset, placement: Placement): number {
12+
if (typeof offset === 'number') {
13+
return offset;
14+
}
15+
const side = placement.split('-')[0];
16+
return side === 'left' || side === 'right' ? offset.x : offset.y;
17+
}

packages/swingset/src/stories/menu.component.mdx

Lines changed: 106 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,31 +23,38 @@ import { Menu } from '@clerk/ui/mosaic/components/menu';
2323

2424
<Menu.Root>
2525
<Menu.Trigger />
26-
<Menu.Content>
26+
<Menu.Popup>
2727
<Menu.Item label='Add workspace'>
28-
<Icon name='plus' />
29-
Add workspace
28+
<Menu.Media>
29+
<Icon name='plus' />
30+
</Menu.Media>
31+
<Menu.Label>Add workspace</Menu.Label>
3032
</Menu.Item>
3133
<Menu.Item
3234
label='Sign out'
3335
onClick={signOut}
3436
>
35-
<Icon name='log-out' />
36-
Sign out
37+
<Menu.Media>
38+
<Icon name='log-out' />
39+
</Menu.Media>
40+
<Menu.Label>Sign out</Menu.Label>
3741
</Menu.Item>
3842
<Menu.Item
3943
label='Delete user'
4044
color='negative'
4145
onClick={deleteUser}
4246
>
43-
<Icon name='close' />
44-
Delete user
47+
<Menu.Media>
48+
<Icon name='close' />
49+
</Menu.Media>
50+
<Menu.Label>Delete user</Menu.Label>
4551
</Menu.Item>
46-
</Menu.Content>
52+
</Menu.Popup>
4753
</Menu.Root>;
4854
```
4955

50-
`Menu.Content` composes the portal, positioner, and popup, so items are the only children you write.
56+
`Menu.Popup` renders the portal and the floating positioner itself — neither is a part you compose
57+
— so items are the only children you write.
5158

5259
### Trigger
5360

@@ -63,21 +70,80 @@ props (ARIA attributes, click and keyboard handlers) to spread.
6370

6471
### Items
6572

66-
`label` drives typeahead and is used as the visible text when `children` is omitted. Render an icon
67-
and text together as children. Use `color='negative'` for destructive actions; the color is
68-
inherited by the children. `disabled` items are skipped by keyboard navigation and their `onClick`
69-
never fires. Activating an item closes the menu; pass `closeOnClick={false}` to keep it open.
73+
`label` names the item for typeahead and for assistive technology. What the row shows is composed
74+
from `Menu.Media` and `Menu.Label`, and those children are required: text dropped straight into the
75+
item is not a flex child the row can size, so it neither lines up with the other rows nor
76+
truncates. A row with nothing to lead it is `Menu.Label` alone. Use
77+
`color='negative'` for destructive actions; the color is inherited by the children. `disabled` items
78+
are skipped by keyboard navigation and their `onClick` never fires. Activating an item closes the
79+
menu; pass `closeOnClick={false}` to keep it open.
7080

7181
```tsx
82+
<Menu.Item
83+
label='Revoke'
84+
color='negative'
85+
>
86+
<Menu.Label>Revoke</Menu.Label>
87+
</Menu.Item>
88+
7289
<Menu.Item
7390
label='Delete'
7491
color='negative'
7592
>
76-
<Icon name='close' />
77-
Delete
93+
<Menu.Media>
94+
<Icon name='close' />
95+
</Menu.Media>
96+
<Menu.Label>Delete</Menu.Label>
7897
</Menu.Item>
7998
```
8099

100+
### Media
101+
102+
`Menu.Media` is a square leading column that centers whatever it holds — an icon, an image, an
103+
avatar. Items that lead with marks of differing widths need it: without it each row sets its own
104+
text start, and the labels no longer line up. Leave it empty on an item that leads with nothing and
105+
that row keeps the column. It renders a `span`, since the item it sits in is a button.
106+
107+
`size` is the column's width: `sm` (the default) fits an icon or an avatar, `xs` a bare glyph. The
108+
row has no height of its own, so it takes whatever the media asks for. That makes the size a
109+
per-menu decision rather than a per-item one: give every item in one menu the same value, or their
110+
text no longer starts on one line.
111+
112+
```tsx
113+
<Menu.Media size='xs'>
114+
<Icon name='check' />
115+
</Menu.Media>
116+
```
117+
118+
### Label
119+
120+
`Menu.Label` takes the space between the media and whatever trails it, and truncates its text to one
121+
line. Items whose text is a name — an account, a workspace — need it most: a long name would widen
122+
the menu instead of ellipsing. It is also what pushes a trailing mark to the end of the row. Use it
123+
for every composed item, so rows built from parts all read the same.
124+
125+
The two together are the whole of a row that leads with an avatar and trails with a check:
126+
127+
```tsx
128+
<Menu.Item label='colin@clerk.dev'>
129+
<Menu.Media>
130+
<Avatar.Root
131+
shape='circle'
132+
size='fit'
133+
>
134+
<Avatar.Fallback>C</Avatar.Fallback>
135+
</Avatar.Root>
136+
</Menu.Media>
137+
<Menu.Label>colin@clerk.dev</Menu.Label>
138+
<Icon name='check' size='sm' />
139+
</Menu.Item>
140+
```
141+
142+
<Story
143+
name='Accounts'
144+
storyModule={MenuStories}
145+
/>
146+
81147
### Placement
82148

83149
`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
92158
</Menu.Root>;
93159
```
94160

161+
`sideOffset` also takes `{ x, y }`, one gap per axis, for a menu that can flip between the two: what
162+
it has to clear sideways is not what it has to clear above. `fallbackPlacements` names where it goes
163+
when `placement` does not fit, in the order it tries them, instead of the opposite side. A menu
164+
opened from inside another floating surface wants both — the opposite side is that surface, so it
165+
has to be given somewhere else to land.
166+
167+
```tsx
168+
<Menu.Root
169+
placement='right-start'
170+
sideOffset={{ x: 16, y: 8 }}
171+
fallbackPlacements={['left-start', 'top-start', 'bottom-start']}
172+
>
173+
174+
</Menu.Root>;
175+
```
176+
95177
### Controlled
96178

97179
```tsx
@@ -107,13 +189,15 @@ const [open, setOpen] = useState(false);
107189

108190
## Parts
109191

110-
| Part | Slot | Description |
111-
| ---------------- | -------------------------------- | --------------------------------------------------------------------- |
112-
| `Menu.Root` || State provider; owns open/close, placement, and keyboard navigation. |
113-
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
114-
| `Menu.Content` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
115-
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
116-
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |
192+
| Part | Slot | Description |
193+
| ---------------- | -------------------------------- | ------------------------------------------------------------------------------- |
194+
| `Menu.Root` || State provider; owns open/close, placement, and keyboard navigation. |
195+
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
196+
| `Menu.Popup` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
197+
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
198+
| `Menu.Media` | `menu-media` | Square leading column that centers an item's icon, image, or avatar. |
199+
| `Menu.Label` | `menu-label` | The item's text. Fills the row between media and trailing marks, and truncates. |
200+
| `Menu.Separator` | `menu-separator` | Full-bleed divider between groups of items. |
117201

118202
## Styling
119203

packages/swingset/src/stories/menu.component.stories.tsx

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
12
import { Icon } from '@clerk/ui/mosaic/components/icon';
23
import { Menu } from '@clerk/ui/mosaic/components/menu';
34

@@ -17,23 +18,66 @@ export function Default() {
1718
return (
1819
<Menu.Root>
1920
<Menu.Trigger />
20-
<Menu.Content>
21+
<Menu.Popup>
2122
<Menu.Item label='Add workspace'>
22-
<Icon name='plus' />
23-
Add workspace
23+
<Menu.Media>
24+
<Icon name='plus' />
25+
</Menu.Media>
26+
<Menu.Label>Add workspace</Menu.Label>
2427
</Menu.Item>
2528
<Menu.Item label='Sign out'>
26-
<Icon name='log-out' />
27-
Sign out
29+
<Menu.Media>
30+
<Icon name='log-out' />
31+
</Menu.Media>
32+
<Menu.Label>Sign out</Menu.Label>
2833
</Menu.Item>
2934
<Menu.Item
3035
label='Delete user'
3136
color='negative'
3237
>
33-
<Icon name='close' />
34-
Delete user
38+
<Menu.Media>
39+
<Icon name='close' />
40+
</Menu.Media>
41+
<Menu.Label>Delete user</Menu.Label>
3542
</Menu.Item>
36-
</Menu.Content>
43+
</Menu.Popup>
44+
</Menu.Root>
45+
);
46+
}
47+
48+
const accounts = [
49+
{ active: true, identifier: 'colin@clerk.dev', initial: 'C' },
50+
{ active: false, identifier: 'braden.wiggins@a-very-long-domain.example', initial: 'B' },
51+
];
52+
53+
export function Accounts() {
54+
return (
55+
<Menu.Root>
56+
<Menu.Trigger>Switch account</Menu.Trigger>
57+
<Menu.Popup>
58+
{accounts.map(account => (
59+
<Menu.Item
60+
key={account.identifier}
61+
label={account.identifier}
62+
>
63+
<Menu.Media>
64+
<Avatar.Root
65+
shape='circle'
66+
size='fit'
67+
>
68+
<Avatar.Fallback>{account.initial}</Avatar.Fallback>
69+
</Avatar.Root>
70+
</Menu.Media>
71+
<Menu.Label>{account.identifier}</Menu.Label>
72+
{account.active ? (
73+
<Icon
74+
name='check'
75+
size='sm'
76+
/>
77+
) : null}
78+
</Menu.Item>
79+
))}
80+
</Menu.Popup>
3781
</Menu.Root>
3882
);
3983
}

0 commit comments

Comments
 (0)