Skip to content

Commit dc7fab3

Browse files
fix(ui): Compose the Mosaic Menu popup out of the shared ScrollArea (#9596)
1 parent cfc5c4e commit dc7fab3

6 files changed

Lines changed: 192 additions & 10 deletions

File tree

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

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,21 @@ has to be given somewhere else to land.
174174
</Menu.Root>;
175175
```
176176

177+
### Scrolling
178+
179+
A menu with more items than fit gets the shared ScrollArea treatment rather than a bare scrollbar:
180+
the rows fade out at whichever edge still has content past it, and the thumb lifts as the pointer
181+
enters. Nothing to opt into — `Menu.Popup` composes it.
182+
183+
The popup itself only supplies the chrome and the height cap; the rows scroll in a `menu-viewport`
184+
inside it. They cannot be one element, because the fade is a `mask-image` and a mask on the popup
185+
would clip its own background and drop shadow along with the overflowing rows.
186+
187+
<Story
188+
name='Overflowing'
189+
storyModule={MenuStories}
190+
/>
191+
177192
### Controlled
178193

179194
```tsx
@@ -194,6 +209,7 @@ const [open, setOpen] = useState(false);
194209
| `Menu.Root` || State provider; owns open/close, placement, and keyboard navigation. |
195210
| `Menu.Trigger` | `menu-trigger` | Opens the menu. Defaults to a square ghost `Button` with an ellipsis. |
196211
| `Menu.Popup` | `menu-positioner` / `menu-popup` | Portals, positions, and renders the popup surface. |
212+
| ↳ viewport | `menu-viewport` | The scrolling box inside the popup. Carries the ScrollArea fade and scrollbar. |
197213
| `Menu.Item` | `menu-item` | A single action whose content is composed through children. |
198214
| `Menu.Media` | `menu-media` | Square leading column that centers an item's icon, image, or avatar. |
199215
| `Menu.Label` | `menu-label` | The item's text. Fills the row between media and trailing marks, and truncates. |

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,97 @@ export function Accounts() {
8181
</Menu.Root>
8282
);
8383
}
84+
85+
const workspaces = [
86+
'Ashgrove',
87+
'Bramblewood',
88+
'Cedar Hollow',
89+
'Dunmore Flats',
90+
'Elderbrook',
91+
'Fernwick',
92+
'Glasswater',
93+
'Hallowfield',
94+
'Ironvale',
95+
'Juniper Reach',
96+
'Kestrel Point',
97+
'Larkspur Mill',
98+
'Marrowden',
99+
'Northgate',
100+
'Oakenshire',
101+
'Pinecrest',
102+
'Quarryfield',
103+
'Redhollow',
104+
'Stonebridge',
105+
'Thornbury',
106+
'Underhill',
107+
'Vellamere',
108+
'Westmarch',
109+
'Yarrowdale',
110+
'Alderwick',
111+
'Blackthorn',
112+
'Coldspring',
113+
'Dovecote',
114+
'Eastfen',
115+
'Foxglove',
116+
'Greyholt',
117+
'Harrowgate',
118+
'Inglewood',
119+
'Jessamine',
120+
'Kirkstall',
121+
'Lindenfell',
122+
'Mossbank',
123+
'Nettlefold',
124+
'Oxbow Landing',
125+
'Peartree Row',
126+
'Quillhaven',
127+
'Ravensmoor',
128+
'Saltmarsh',
129+
'Tanglewood',
130+
'Ullswater',
131+
'Vinequarter',
132+
'Wrenfield',
133+
'Yewbarrow',
134+
'Amberlyn',
135+
'Brookhaven',
136+
'Chalkhill',
137+
'Dryden Cross',
138+
'Ellerby',
139+
'Fallowmere',
140+
'Gorsecliff',
141+
'Hazelmoor',
142+
'Ivybridge',
143+
'Jackdaw Lane',
144+
'Kilnwood',
145+
'Longmeadow',
146+
];
147+
148+
/**
149+
* More rows than the popup can show. The popup caps at the available height and the rows scroll
150+
* inside it, so this is the story where the ScrollArea treatment is visible: the fade at whichever
151+
* edge still has content past it, and the thumb that lifts as the pointer enters.
152+
*/
153+
export function Overflowing() {
154+
return (
155+
<Menu.Root>
156+
<Menu.Trigger>Switch workspace</Menu.Trigger>
157+
<Menu.Popup>
158+
{workspaces.map(workspace => (
159+
<Menu.Item
160+
key={workspace}
161+
label={workspace}
162+
>
163+
<Menu.Media>
164+
<Avatar.Root
165+
shape='square'
166+
size='fit'
167+
>
168+
<Avatar.Fallback>{workspace[0]}</Avatar.Fallback>
169+
</Avatar.Root>
170+
</Menu.Media>
171+
<Menu.Label>{workspace}</Menu.Label>
172+
</Menu.Item>
173+
))}
174+
</Menu.Popup>
175+
</Menu.Root>
176+
);
177+
}

packages/ui/src/mosaic/components/menu/menu.styles.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,13 @@ export const positioner = stylex.create({
1313

1414
export const popup = stylex.create({
1515
base: {
16-
padding: space['0.5'],
1716
borderRadius: radiusVars['--cl-radius-lg'],
18-
gap: space['0.5'],
1917
outline: 'none',
2018
backgroundColor: colorVars['--cl-color-card'],
2119
boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent),
2220
0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent),
2321
0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`,
2422
color: colorVars['--cl-color-card-foreground'],
25-
display: 'flex',
26-
flexDirection: 'column',
2723
opacity: {
2824
default: 1,
2925
':is([data-ending-style])': 0,
@@ -48,8 +44,22 @@ export const popup = stylex.create({
4844
// reaches the width it has to truncate at.
4945
maxWidth: 'min(18rem, calc(100vw - 2rem))',
5046
minWidth: '12.5rem',
51-
overflowX: 'visible',
52-
overflowY: 'auto',
47+
},
48+
});
49+
50+
// The popup supplies the chrome and the height cap; this scrolls inside it. They cannot be
51+
// one element: `scrollAreaViewport()` carries a `mask-image` for the fade, and a mask clips
52+
// the element's whole rendering — so on the popup it would eat the background and the drop
53+
// shadow along with the overflowing rows. Same split the Dialog panel makes.
54+
export const viewport = stylex.create({
55+
base: {
56+
// The inset belongs to the scrolling box, not the popup: `Menu.Separator` bleeds through it
57+
// with a negative margin, and from inside a viewport that clips its inline axis a bleed past
58+
// the popup's own padding would be cut off instead.
59+
padding: space['0.5'],
60+
gap: space['0.5'],
61+
display: 'flex',
62+
flexDirection: 'column',
5363
},
5464
});
5565

@@ -70,6 +80,9 @@ export const item = stylex.create({
7080
},
7181
cursor: { default: 'pointer', ':is([data-disabled])': 'not-allowed' },
7282
display: 'flex',
83+
// The viewport is a height-capped flex column: without this the rows squash to fit the cap
84+
// instead of overflowing it, and the menu silently loses both its row height and its scroll.
85+
flexShrink: 0,
7386
fontFamily: fontFamilyVars['--cl-font-family-sans'],
7487
fontSize: typeScaleVars['--cl-text-sm-size'],
7588
fontWeight: fontWeightVars['--cl-font-medium'],
@@ -83,7 +96,7 @@ export const item = stylex.create({
8396
'@media (prefers-reduced-motion: reduce)': '0.01ms',
8497
},
8598
transitionProperty: 'background-color',
86-
height: space['7'],
99+
height: space['8'],
87100
width: '100%',
88101
'::before': {
89102
insetBlock: `calc(-1 * ${space['0.5']})`,
@@ -130,10 +143,11 @@ export const label = stylex.create({
130143

131144
export const separator = stylex.create({
132145
base: {
133-
// Full-bleed across the popup: cancel the popup's inline padding.
146+
// Full-bleed across the popup: cancel the viewport's inline padding.
134147
marginBlock: space['0.5'],
135148
marginInline: `calc(-1 * ${space['0.5']})`,
136149
backgroundColor: colorVars['--cl-color-border'],
137150
blockSize: '1px',
151+
flexShrink: 0,
138152
},
139153
});

packages/ui/src/mosaic/components/menu/menu.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1+
import * as stylex from '@stylexjs/stylex';
12
import { render, screen } from '@testing-library/react';
23
import userEvent from '@testing-library/user-event';
34
import React from 'react';
45
import { describe, expect, it, vi } from 'vitest';
56

7+
import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area';
68
import { Menu } from './menu';
79

10+
const scrollClasses = stylex.props(...scrollAreaViewport()).className?.split(' ') ?? [];
11+
// The popup shares the root's atoms (both are `min-height: 0` flex columns), so only what the
12+
// viewport adds on top of them — the mask, the overflow, the scrollbar — distinguishes the two.
13+
const rootClasses = stylex.props(scrollAreaRoot).className?.split(' ') ?? [];
14+
// StyleX atoms are content-hashed, so an identical declaration here resolves to the same atom the
15+
// styles module emits. jsdom has no stylesheet, so the class is the only thing there is to assert on.
16+
// The debug class alongside it is derived from the source filename, so only the atoms can match.
17+
const noShrinkClass = (stylex.props(stylex.create({ base: { flexShrink: 0 } }).base).className ?? '')
18+
.split(' ')
19+
.filter(name => /^x[a-z0-9]+$/.test(name));
20+
const viewportOnlyClasses = scrollClasses.filter(name => !rootClasses.includes(name));
21+
822
function renderMenu(props?: { onSignOut?: () => void }) {
923
return render(
1024
<Menu.Root>
@@ -79,6 +93,35 @@ describe('Mosaic Menu', () => {
7993
expect(screen.getByTestId('add-icon')).toBeInTheDocument();
8094
});
8195

96+
it('scrolls the items in a viewport inside the popup, not on the popup itself', async () => {
97+
const user = userEvent.setup();
98+
renderMenu();
99+
100+
await user.click(screen.getByRole('button'));
101+
102+
const popup = screen.getByRole('menu').querySelector('.cl-menu-popup');
103+
const viewport = popup?.querySelector('.cl-menu-viewport');
104+
expect(viewport).toBeInTheDocument();
105+
expect(viewport).toHaveClass(...scrollClasses);
106+
// The chrome must NOT carry them: `scrollAreaViewport()` masks the element it lands on, and a
107+
// mask on the popup would clip its own background and drop shadow.
108+
expect(viewportOnlyClasses).not.toHaveLength(0);
109+
expect(viewportOnlyClasses.filter(name => popup?.classList.contains(name))).toEqual([]);
110+
expect(screen.getByRole('menuitem', { name: 'Sign out' }).closest('.cl-menu-viewport')).toBe(viewport);
111+
});
112+
113+
it('holds row height inside the capped viewport rather than letting flex squash it', async () => {
114+
const user = userEvent.setup();
115+
renderMenu();
116+
117+
await user.click(screen.getByRole('button'));
118+
119+
// The viewport is a height-capped flex column. With flex's default shrink the rows compress to
120+
// fit the cap instead of overflowing it, so the menu loses both its row height and its scroll.
121+
expect(screen.getByRole('menuitem', { name: 'Sign out' })).toHaveClass(...noShrinkClass);
122+
expect(screen.getByRole('separator')).toHaveClass(...noShrinkClass);
123+
});
124+
82125
it('calls an item handler and closes the menu on click', async () => {
83126
const user = userEvent.setup();
84127
const onSignOut = vi.fn();

packages/ui/src/mosaic/components/menu/menu.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { reset } from '../../utils/reset.styles';
1717
import { truncationStyles } from '../../utils/typography.styles';
1818
import { Button } from '../button';
1919
import { Icon } from '../icon';
20+
import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area';
2021
import * as slots from './menu.styles';
2122

2223
export type { MenuProps, MenuSeparatorProps };
@@ -74,10 +75,22 @@ export const MenuPopup = React.forwardRef<HTMLDivElement, MenuPopupProps>(functi
7475
>
7576
<Primitive.Popup
7677
ref={ref}
77-
{...mergeStyleProps(themeProps('menu-popup'), stylex.props(reset.base, slots.popup.base), className, style)}
78+
{...mergeStyleProps(
79+
themeProps('menu-popup'),
80+
stylex.props(reset.base, scrollAreaRoot, slots.popup.base),
81+
className,
82+
style,
83+
)}
7884
{...rest}
7985
>
80-
{children}
86+
<div
87+
{...mergeStyleProps(
88+
themeProps('menu-viewport'),
89+
stylex.props(reset.base, ...scrollAreaViewport(), slots.viewport.base),
90+
)}
91+
>
92+
{children}
93+
</div>
8194
</Primitive.Popup>
8295
</Primitive.Positioner>
8396
</Primitive.Portal>

0 commit comments

Comments
 (0)