Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/components/src/Menu/Menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ export const CustomWidth: Story = {
render: CustomWidthExample,
};

export const ScrollableLongMenu: Story = {
name: "Scrollable Long Menu",
render: ScrollableLongMenuExample,
};

function ControlledExample() {
const [controlledOpen, setControlledOpen] = useState(false);

Expand Down Expand Up @@ -376,6 +381,72 @@ function CustomWidthExample() {
);
}

function ScrollableLongMenuExample() {
const longMenuItems = Array.from({ length: 16 }, (_, index) => ({
label: `Saved view ${index + 1}`,
value: `saved-view-${index + 1}`,
}));

return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
<div
style={{
display: "flex",
gap: "16px",
alignItems: "flex-start",
flexWrap: "wrap",
}}
>
<Menu>
<Menu.Trigger ariaLabel="Open default long menu">
Default Scroll
</Menu.Trigger>
<Menu.Content>
<Menu.Group>
<Menu.GroupLabel>Saved Views</Menu.GroupLabel>
{longMenuItems.map(item => (
<Menu.Item
key={item.value}
onClick={() => action("default-long-menu")(item.value)}
textValue={item.label}
>
<Menu.ItemLabel>{item.label}</Menu.ItemLabel>
</Menu.Item>
))}
</Menu.Group>
</Menu.Content>
</Menu>

<Menu>
<Menu.Trigger ariaLabel="Open custom height long menu">
Custom Desktop Length
</Menu.Trigger>
<Menu.Content style={{ maxHeight: "240px" }}>
<Menu.Group>
<Menu.GroupLabel>Saved Views</Menu.GroupLabel>
{longMenuItems.map(item => (
<Menu.Item
key={`${item.value}-custom`}
onClick={() => action("custom-long-menu")(item.value)}
textValue={item.label}
>
<Menu.ItemLabel>{item.label}</Menu.ItemLabel>
</Menu.Item>
))}
</Menu.Group>
</Menu.Content>
</Menu>
</div>
</div>
);
}

function StoryExampleLayout(props: { readonly children: React.ReactNode }) {
return <>{props.children}</>;
}
Expand Down
21 changes: 19 additions & 2 deletions packages/components/src/Menu/MenuBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ function getTextContent(node: React.ReactNode): string {
);
}

function getBottomSheetContentStyle(style: React.CSSProperties | undefined) {
if (!style) {
return undefined;
}

const safeStyle = { ...style };

delete safeStyle.height;
delete safeStyle.maxHeight;
delete safeStyle.minHeight;
delete safeStyle.width;

return safeStyle;
}

// Bottom sheet menu interaction helpers. These adapt Drawer semantics into the
// menu-style keyboard and focus behavior we want for the mobile shell.
function getNavigableMenuItems(target: HTMLElement) {
Expand Down Expand Up @@ -375,6 +390,7 @@ function BottomSheetContent({
UNSAFE_className,
UNSAFE_style,
});
const bottomSheetStyle = getBottomSheetContentStyle(resolvedBaseProps.style);

return (
<BottomSheet.Popup
Expand All @@ -390,7 +406,7 @@ function BottomSheetContent({
composableMenuStyles.bottomSheetMenuContent,
resolvedBaseProps.className,
)}
style={resolvedBaseProps.style}
style={bottomSheetStyle}
>
{children}
</BottomSheetMenuSurface>
Expand Down Expand Up @@ -735,6 +751,7 @@ function BottomSheetSubmenuContent({
UNSAFE_className,
UNSAFE_style,
});
const bottomSheetStyle = getBottomSheetContentStyle(resolvedBaseProps.style);

return (
<BottomSheet.Popup
Expand All @@ -744,7 +761,7 @@ function BottomSheetSubmenuContent({
composableMenuStyles.bottomSheetSubmenuPopup,
)}
>
<BottomSheet.Content style={resolvedBaseProps.style}>
<BottomSheet.Content style={bottomSheetStyle}>
{title ? (
<BaseDrawer.Title className={composableMenuStyles.screenReaderOnly}>
{title}
Expand Down
15 changes: 12 additions & 3 deletions packages/components/src/Menu/MenuDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
sharedMenuGroupLabelClassName,
} from "./menuComposableShared";
import styles from "./Menu.module.css";
import { MENU_OFFSET } from "./constants";
import { MENU_MAX_HEIGHT_PERCENTAGE, MENU_OFFSET } from "./constants";
import { Icon } from "../Icon";
import { OverlaySeparator } from "../primitives";
import { Menu as BaseMenu } from "../unstyledPrimitives";
Expand Down Expand Up @@ -55,6 +55,13 @@ function getFloatingLayerStyle(style: React.CSSProperties | undefined) {
return { zIndex: style.zIndex };
}

function getMenuPopupStyle(style: React.CSSProperties | undefined) {
return {
maxHeight: `min(${MENU_MAX_HEIGHT_PERCENTAGE}vh, max(0px, calc(var(--available-height) - var(--space-base))))`,
...style,
};
}

function MenuDropdownRoot({
children,
onOpenChange,
Expand Down Expand Up @@ -140,6 +147,7 @@ function MenuDropdownContent({
const [side = "bottom", align = "start"] =
preferredPlacement?.split(" ") ?? [];
const floatingLayerStyle = getFloatingLayerStyle(resolvedBaseProps.style);
const popupStyle = getMenuPopupStyle(resolvedBaseProps.style);

return (
<BaseMenu.Portal>
Expand All @@ -156,7 +164,7 @@ function MenuDropdownContent({
styles.ariaMenu,
resolvedBaseProps.className,
)}
style={resolvedBaseProps.style}
style={popupStyle}
>
{children}
</BaseMenu.Popup>
Expand Down Expand Up @@ -417,6 +425,7 @@ function MenuDropdownSubmenuContent({
UNSAFE_style,
});
const floatingLayerStyle = getFloatingLayerStyle(resolvedBaseProps.style);
const popupStyle = getMenuPopupStyle(resolvedBaseProps.style);

return (
<BaseMenu.Portal>
Expand All @@ -433,7 +442,7 @@ function MenuDropdownSubmenuContent({
styles.ariaMenu,
resolvedBaseProps.className,
)}
style={resolvedBaseProps.style}
style={popupStyle}
>
{children}
</BaseMenu.Popup>
Expand Down
73 changes: 73 additions & 0 deletions packages/components/src/Menu/__tests__/Menu.composable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,23 @@ describe("Menu (composable API)", () => {
}
});

it("caps desktop menu height so long menus can scroll", async () => {
const restoreDesktop = await mockViewport({ width: 800, height: 900 });

try {
render(<TestLongMenu />);

await POM.openWithClick("Menu");

expect(screen.getByRole("menu")).toHaveStyle({
maxHeight:
"min(72vh, max(0px, calc(var(--available-height) - var(--space-base))))",
});
} finally {
await restoreDesktop();
}
});

it("preserves menu item semantics in the mobile bottom sheet shell", async () => {
const restoreMobile = await mockViewport({ width: 375, height: 812 });

Expand Down Expand Up @@ -337,6 +354,21 @@ describe("Menu (composable API)", () => {
}
});

it("ignores custom desktop height overrides in the mobile bottom sheet shell", async () => {
const restoreMobile = await mockViewport({ width: 375, height: 812 });

try {
render(<TestMobileMenuWithCustomHeight />);

await POM.openWithClick("Menu");

expect(screen.getByRole("menu")).not.toHaveStyle("max-height: 240px");
expect(screen.getByRole("menu")).not.toHaveStyle("height: 240px");
} finally {
await restoreMobile();
}
});

it("focuses the first item when the mobile bottom sheet opens via keyboard", async () => {
const restoreMobile = await mockViewport({ width: 375, height: 812 });

Expand Down Expand Up @@ -767,6 +799,47 @@ function TestMobileSemanticsMenu() {
);
}

function TestLongMenu() {
const items = Array.from({ length: 20 }, (_, index) => `Item ${index + 1}`);

return (
<Menu>
<Menu.Trigger ariaLabel="Menu">
<Button label="Menu" />
</Menu.Trigger>
<Menu.Content>
<Menu.Group>
{items.map(item => (
<Menu.Item key={item} textValue={item}>
<Menu.ItemLabel>{item}</Menu.ItemLabel>
</Menu.Item>
))}
</Menu.Group>
</Menu.Content>
</Menu>
);
}

function TestMobileMenuWithCustomHeight() {
return (
<Menu>
<Menu.Trigger ariaLabel="Menu">
<Button label="Menu" />
</Menu.Trigger>
<Menu.Content style={{ maxHeight: "240px", height: "240px" }}>
<Menu.Group>
<Menu.Item textValue="One">
<Menu.ItemLabel>One</Menu.ItemLabel>
</Menu.Item>
<Menu.Item textValue="Two">
<Menu.ItemLabel>Two</Menu.ItemLabel>
</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu>
);
}

function TestSubmenuMenu() {
return (
<Menu>
Expand Down
Loading