diff --git a/e2e/nextjs-app/src/app/components/menu/grid-layout-with-label.e2e.tsx b/e2e/nextjs-app/src/app/components/menu/grid-layout-with-label.e2e.tsx
new file mode 100644
index 0000000000..87c0e524b1
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/menu/grid-layout-with-label.e2e.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { Menu } from "@lifesg/react-design-system/menu";
+
+export default function Story() {
+ return (
+
+
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+ Link {i + 1}
+
+ ))}
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/menu/grid-layout.e2e.tsx b/e2e/nextjs-app/src/app/components/menu/grid-layout.e2e.tsx
new file mode 100644
index 0000000000..4efe796c5a
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/menu/grid-layout.e2e.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { Menu } from "@lifesg/react-design-system/menu";
+
+export default function Story() {
+ return (
+
+
+
+ {Array.from({ length: 8 }, (_, i) => (
+
+ Link {i + 1}
+
+ ))}
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/menu/overflow.e2e.tsx b/e2e/nextjs-app/src/app/components/menu/overflow.e2e.tsx
index 490d646a08..883d460940 100644
--- a/e2e/nextjs-app/src/app/components/menu/overflow.e2e.tsx
+++ b/e2e/nextjs-app/src/app/components/menu/overflow.e2e.tsx
@@ -5,7 +5,7 @@ import { Menu } from "@lifesg/react-design-system/menu";
export default function Story() {
return (
-
+
Overflow item 1
Overflow item 2
diff --git a/e2e/nextjs-app/src/app/components/navbar/common.ts b/e2e/nextjs-app/src/app/components/navbar/common.ts
index 1e8936f1a1..2aa5888eef 100644
--- a/e2e/nextjs-app/src/app/components/navbar/common.ts
+++ b/e2e/nextjs-app/src/app/components/navbar/common.ts
@@ -72,3 +72,21 @@ export const navItemsWithSubmenu: NavItemProps[] = [
],
},
];
+
+export const navItemsWithSubmenuGrid: NavItemProps[] = [
+ {
+ id: "home",
+ children: "Home",
+ href: "https://www.life.gov.sg",
+ },
+ {
+ id: "guides",
+ children: "Guides",
+ subMenuColumns: 3,
+ subMenu: Array.from({ length: 12 }, (_, index) => ({
+ id: `guides-item-${index + 1}`,
+ children: `Guides item ${index + 1}`,
+ href: "https://www.life.gov.sg",
+ })),
+ },
+];
diff --git a/e2e/nextjs-app/src/app/components/navbar/submenu-grid.e2e.tsx b/e2e/nextjs-app/src/app/components/navbar/submenu-grid.e2e.tsx
new file mode 100644
index 0000000000..0ea9f12c82
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/navbar/submenu-grid.e2e.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import { Navbar } from "@lifesg/react-design-system/navbar";
+
+import { downloadActionButtons, navItemsWithSubmenuGrid } from "./common";
+
+export default function Story() {
+ return (
+ {}}
+ />
+ );
+}
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mobile--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mobile--state.png
index 0fbd58fd08..c01f873744 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mobile--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mobile--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mount--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mount--state.png
index 9752a92497..edb0222746 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mount--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Default-Mount--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-Collapses-to-single-column-on-mobile--mobile.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-Collapses-to-single-column-on-mobile--mobile.png
new file mode 100644
index 0000000000..4396ce4189
Binary files /dev/null and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-Collapses-to-single-column-on-mobile--mobile.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-Desktop-visual--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-Desktop-visual--state.png
new file mode 100644
index 0000000000..1bf9002bf4
Binary files /dev/null and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-Desktop-visual--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-With-section-label--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-With-section-label--state.png
new file mode 100644
index 0000000000..786627f0c5
Binary files /dev/null and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Grid-layout-With-section-label--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Overflow--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Overflow--state.png
index 4521121e2b..c623adc867 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Overflow--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Overflow--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom--state.png
index dcb12f98e2..6e5090442a 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-end--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-end--state.png
index a2a93f6c6b..4cd165b832 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-end--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-end--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-start--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-start--state.png
index 03e01d4133..70a3cbdb67 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-start--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-bottom-start--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-left--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-left--state.png
index 31094f05a2..8b71c16955 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-left--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-left--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-right--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-right--state.png
index 474aff0062..7fc4725949 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-right--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-right--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-top--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-top--state.png
index b05b60619c..8c9a7f15db 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-top--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Placements-position-top--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item--state.png
index cb8cbb5367..867ffba6dd 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item--state.png differ
diff --git a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item-Dark-Mode--state.png b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item-Dark-Mode--state.png
index 2d9b585141..379afbbb87 100644
Binary files a/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item-Dark-Mode--state.png and b/e2e/tests/components/menu/__screenshots__/chromium/Menu-Variants-Section-and-Item-Dark-Mode--state.png differ
diff --git a/e2e/tests/components/menu/menu.e2e.spec.ts b/e2e/tests/components/menu/menu.e2e.spec.ts
index b4c14ab38a..3d9be6a154 100644
--- a/e2e/tests/components/menu/menu.e2e.spec.ts
+++ b/e2e/tests/components/menu/menu.e2e.spec.ts
@@ -94,8 +94,8 @@ test.describe("Menu", () => {
- listitem:
- paragraph: Jane Doe
- paragraph: jane.doe@example.gov.sg
+ - paragraph: Actions
- list "Actions":
- - paragraph: Actions
- listitem: Settings
- listitem:
- link "First link":
@@ -106,8 +106,8 @@ test.describe("Menu", () => {
- listitem:
- link "Third link":
- /url: "#third-link"
+ - paragraph: Resources
- list "Resources":
- - paragraph: Resources
- listitem:
- link "This is a long menu link title that should clamp across lines when the menu has limited width":
- /url: "#long-link"
@@ -215,4 +215,61 @@ test.describe("Menu", () => {
});
}
});
+
+ test.describe("Grid layout", () => {
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("grid-layout");
+ });
+
+ test("Desktop visual", async ({ story }) => {
+ const content = story.page.getByTestId("menu-content");
+ const section = story.page.getByTestId("menu-section");
+
+ await expect(content).toBeVisible();
+ await expect(
+ section.getByRole("link", { name: "Link 1" })
+ ).toBeVisible();
+ await expect(
+ section.getByRole("link", { name: "Link 8" })
+ ).toBeVisible();
+
+ await compareScreenshot(story, "state", {
+ locator: content,
+ });
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("grid-layout", { size: "mobile" });
+ });
+
+ test("Collapses to single column on mobile", async ({ story }) => {
+ const content = story.page.getByTestId("menu-content");
+
+ await expect(content).toBeVisible();
+
+ await compareScreenshot(story, "mobile", {
+ locator: content,
+ });
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("grid-layout-with-label");
+ });
+
+ test("With section label", async ({ story }) => {
+ const content = story.page.getByTestId("menu-content");
+
+ await expect(content).toBeVisible();
+
+ await compareScreenshot(story, "state", {
+ locator: content,
+ });
+ });
+ });
+ });
});
diff --git a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-imda-theme--mount.png b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-imda-theme--mount.png
index 50942db51d..169cb4ea85 100644
Binary files a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-imda-theme--mount.png and b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-imda-theme--mount.png differ
diff --git a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-sportsg-orange-theme--mount.png b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-sportsg-orange-theme--mount.png
index 1e9e909cd6..4b8db5c0ac 100644
Binary files a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-sportsg-orange-theme--mount.png and b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Default-dark-mode-sportsg-orange-theme--mount.png differ
diff --git a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Mobile--mobile-submenu-open.png b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Mobile--mobile-submenu-open.png
new file mode 100644
index 0000000000..160a6df337
Binary files /dev/null and b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Mobile--mobile-submenu-open.png differ
diff --git a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Open--state.png b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Open--state.png
new file mode 100644
index 0000000000..9638314bac
Binary files /dev/null and b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Open--state.png differ
diff --git a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open--state.png b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open--state.png
index f1b90cd6ef..1317fffac0 100644
Binary files a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open--state.png and b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open--state.png differ
diff --git a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open-last-item--state.png b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open-last-item--state.png
index 88efa36f1f..9773d8aac2 100644
Binary files a/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open-last-item--state.png and b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Open-last-item--state.png differ
diff --git a/e2e/tests/components/navbar/navbar.e2e.spec.ts b/e2e/tests/components/navbar/navbar.e2e.spec.ts
index 7014ad3f6b..1afae85aae 100644
--- a/e2e/tests/components/navbar/navbar.e2e.spec.ts
+++ b/e2e/tests/components/navbar/navbar.e2e.spec.ts
@@ -22,7 +22,9 @@ class StoryPage extends AbstractStoryPage {
mobileMenuButton: Locator;
servicesTrigger: Locator;
appTrigger: Locator;
+ guidesTrigger: Locator;
servicesMobileTrigger: Locator;
+ guidesMobileTrigger: Locator;
closeButton: Locator;
drawer: Locator;
drawerBrand: Locator;
@@ -47,9 +49,13 @@ class StoryPage extends AbstractStoryPage {
mobileMenuButton: page.getByTestId("button__mobile-menu"),
servicesTrigger: page.getByRole("button", { name: "Services" }),
appTrigger: page.getByRole("button", { name: "LifeSG app" }),
+ guidesTrigger: page.getByRole("button", { name: "Guides" }),
servicesMobileTrigger: page.getByTestId(
"link__mobile-2-expand-collapse-button"
),
+ guidesMobileTrigger: page.getByTestId(
+ "link__mobile-2-expand-collapse-button"
+ ),
closeButton: page.getByRole("button", {
name: "Close nav menu",
}),
@@ -61,7 +67,8 @@ class StoryPage extends AbstractStoryPage {
page.getByTestId(`menu__mobile-${index}`),
mobileNavLink: (index: number) =>
page.getByTestId(`link__mobile-${index}`),
- submenuLink: (name: string) => page.getByRole("link", { name }),
+ submenuLink: (name: string) =>
+ page.getByRole("link", { name, exact: true }),
downloadButton: page.getByTestId("action-button__download"),
mobileNav: page.getByRole("navigation", {
name: "Mobile navigation menu",
@@ -257,6 +264,37 @@ test.describe("Navbar", () => {
});
});
+ test.describe("Submenu Grid", () => {
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("submenu-grid");
+ });
+
+ test("Open", async ({ story }) => {
+ await story.locators.internal.guidesTrigger.click();
+ await compareScreenshot(story, "state", {
+ fullscreen: true,
+ });
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("submenu-grid", { size: "mobile" });
+ });
+
+ test("Mobile", async ({ story }) => {
+ await story.openMobileDrawer();
+ await story.locators.internal.servicesMobileTrigger.click();
+ await story.page.mouse.move(0, 0);
+
+ await compareScreenshot(story, "mobile-submenu-open", {
+ fullscreen: true,
+ });
+ });
+ });
+ });
+
test.describe("Mobile", () => {
test.describe(() => {
test.beforeEach(async ({ story }) => {
diff --git a/src/menu/menu-content.styles.ts b/src/menu/menu-content.styles.ts
index 5c26d08aa6..389c3336d4 100644
--- a/src/menu/menu-content.styles.ts
+++ b/src/menu/menu-content.styles.ts
@@ -1,5 +1,6 @@
import { css } from "@linaria/core";
+import { scrollbarCss } from "../shared/styles";
import {
Border,
Breakpoint,
@@ -13,6 +14,7 @@ export const tokens = {
panel: {
availableWidth: "--fds-internal-menu-panel-availableWidth",
maxHeight: "--fds-internal-menu-panel-maxHeight",
+ maxWidth: "--fds-internal-menu-panel-maxWidth",
overflow: "--fds-internal-menu-panel-overflow",
xSpacing: "--fds-internal-menu-panel-xSpacing",
},
@@ -25,6 +27,7 @@ export const panel = css`
box-shadow: ${Shadow["md-subtle"]};
${tokens.panel.maxHeight}: initial;
+ ${tokens.panel.maxWidth}: initial;
${tokens.panel.overflow}: initial;
${tokens.panel.xSpacing}: 0px;
${tokens.panel.availableWidth}: calc(100vw - var(${tokens.panel
@@ -43,7 +46,10 @@ export const panel = css`
}
min-width: min(15rem, var(${tokens.panel.availableWidth}));
- max-width: min(24rem, var(${tokens.panel.availableWidth}));
+ max-width: min(
+ var(${tokens.panel.maxWidth}, 24rem),
+ var(${tokens.panel.availableWidth})
+ );
max-height: var(${tokens.panel.maxHeight});
overflow-y: var(${tokens.panel.overflow});
@@ -51,18 +57,5 @@ export const panel = css`
outline: none;
}
- &::-webkit-scrollbar {
- width: 14px;
- }
-
- &::-webkit-scrollbar-track {
- background: transparent;
- }
-
- &::-webkit-scrollbar-thumb {
- background: ${Colour["bg-inverse-subtlest"]};
- border: 5px solid transparent;
- border-radius: ${Radius["full"]};
- background-clip: padding-box;
- }
+ ${scrollbarCss("vertical")}
`;
diff --git a/src/menu/menu-content.tsx b/src/menu/menu-content.tsx
index 10d47df3f5..cf29ea7abc 100644
--- a/src/menu/menu-content.tsx
+++ b/src/menu/menu-content.tsx
@@ -1,10 +1,17 @@
import clsx from "clsx";
import type React from "react";
-import { useRef } from "react";
+import {
+ Children,
+ isValidElement,
+ type ReactElement,
+ useMemo,
+ useRef,
+} from "react";
import { useApplyStyle } from "../theme";
import * as styles from "./menu-content.styles";
-import type { MenuContentProps } from "./types";
+import * as sectionStyles from "./menu-section.styles";
+import type { MenuContentProps, MenuSectionProps } from "./types";
// =============================================================================
// HELPERS
@@ -30,9 +37,27 @@ export const MenuContent = ({
// =============================================================================
const panelRef = useRef(null);
+ const gridMaxWidth = useMemo(() => {
+ const maxColumns = Children.toArray(children)
+ .filter(
+ (child): child is ReactElement =>
+ isValidElement(child) &&
+ !!(child.props as MenuSectionProps).columns
+ )
+ .reduce((max, child) => Math.max(max, child.props.columns ?? 0), 0);
+ if (!maxColumns) return undefined;
+ return (
+ maxColumns * sectionStyles.GRID_COLUMN_WIDTH_PX +
+ (maxColumns - 1) * sectionStyles.GRID_COLUMN_GAP_PX +
+ 2 // 1px border × 2 sides
+ );
+ }, [children]);
+
useApplyStyle(panelRef, {
[styles.tokens.panel.maxHeight]:
maxHeight !== undefined ? `${maxHeight}px` : null,
+ [styles.tokens.panel.maxWidth]:
+ gridMaxWidth !== undefined ? `${gridMaxWidth}px` : null,
[styles.tokens.panel.overflow]: overflow || null,
});
// =============================================================================
diff --git a/src/menu/menu-item.styles.ts b/src/menu/menu-item.styles.ts
index 6bfd6ca835..5a0402d799 100644
--- a/src/menu/menu-item.styles.ts
+++ b/src/menu/menu-item.styles.ts
@@ -5,7 +5,7 @@ import { Colour, Spacing } from "../theme/tokens";
export const menuItemDiv = css`
display: flex;
flex-direction: column;
- padding: ${Spacing["spacing-8"]} ${Spacing["spacing-16"]};
+ padding: ${Spacing["spacing-12"]} ${Spacing["spacing-16"]};
`;
export const text = css`
diff --git a/src/menu/menu-link.styles.ts b/src/menu/menu-link.styles.ts
index c98a0aca35..cfe7b2baad 100644
--- a/src/menu/menu-link.styles.ts
+++ b/src/menu/menu-link.styles.ts
@@ -14,7 +14,7 @@ export const link = css`
margin: 0 ${Spacing["spacing-8"]};
border: ${Border["solid"]} transparent;
- border-width: ${Spacing["spacing-8"]} ${Spacing["spacing-8"]};
+ border-width: ${Spacing["spacing-12"]} ${Spacing["spacing-8"]};
border-radius: ${Radius["md"]};
diff --git a/src/menu/menu-section.styles.ts b/src/menu/menu-section.styles.ts
index 6a3d3eb854..047730ccd1 100644
--- a/src/menu/menu-section.styles.ts
+++ b/src/menu/menu-section.styles.ts
@@ -1,6 +1,15 @@
import { css } from "@linaria/core";
-import { Border, Colour, Spacing } from "../theme/tokens";
+import { Border, Colour, MediaQuery, Spacing } from "../theme/tokens";
+
+export const gridTokens = {
+ rows: "--fds-menu-section-grid-rows",
+} as const;
+
+// 367px max content width (Figma) + 8px padding per side; used to cap the panel maxWidth in MenuContent
+export const GRID_COLUMN_WIDTH_PX = 383;
+// matches Spacing["spacing-8"]
+export const GRID_COLUMN_GAP_PX = 8;
export const section = css`
padding: ${Spacing["spacing-8"]} 0;
@@ -15,3 +24,18 @@ export const label = css`
margin: 0 ${Spacing["spacing-16"]} ${Spacing["spacing-8"]};
color: ${Colour["text-subtler"]};
`;
+
+export const columns = css`
+ ${gridTokens.rows}: initial;
+
+ display: grid;
+ grid-template-rows: repeat(var(${gridTokens.rows}), auto);
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(0, 1fr);
+ column-gap: ${Spacing["spacing-8"]};
+
+ ${MediaQuery.MaxWidth.lg} {
+ grid-template-rows: none;
+ grid-auto-flow: row;
+ }
+`;
diff --git a/src/menu/menu-section.tsx b/src/menu/menu-section.tsx
index d4c96837fa..5e96f71a6d 100644
--- a/src/menu/menu-section.tsx
+++ b/src/menu/menu-section.tsx
@@ -1,5 +1,7 @@
import clsx from "clsx";
+import { Children, useMemo, useRef } from "react";
+import { useApplyStyle } from "../theme";
import { Typography } from "../typography";
import { useId } from "../util";
import * as styles from "./menu-section.styles";
@@ -10,6 +12,7 @@ export const MenuSection = ({
label,
showDivider = true,
className,
+ columns,
"data-testid": testId = "menu-section",
...otherProps
}: MenuSectionProps): JSX.Element => {
@@ -17,32 +20,57 @@ export const MenuSection = ({
// CONST, STATE, REF
// =============================================================================
const internalId = useId();
+ const ulRef = useRef(null);
+ const gridRows = useMemo(
+ () =>
+ columns !== undefined
+ ? Math.ceil(Children.count(children) / Math.max(1, columns))
+ : undefined,
+ [columns, children]
+ );
+
+ // =========================================================================
+ // EFFECTS
+ // =========================================================================
+ useApplyStyle(
+ ulRef,
+ gridRows !== undefined
+ ? { [styles.gridTokens.rows]: String(gridRows) }
+ : undefined
+ );
// =============================================================================
// RENDER FUNCTIONS
// =============================================================================
+ const labelElement = label ? (
+
+ {label}
+
+ ) : null;
+
return (
-
- {label && (
-
- {label}
-
- )}
- {children}
-
+ {labelElement}
+
+
);
};
diff --git a/src/menu/types.ts b/src/menu/types.ts
index 0f9c59f391..5bb8b26d41 100644
--- a/src/menu/types.ts
+++ b/src/menu/types.ts
@@ -12,7 +12,6 @@ import type {
} from "../popover";
import type { MenuItem } from "./menu-item";
import type { MenuLink } from "./menu-link";
-import type { MenuSection } from "./menu-section";
// @storybookSection Menu
/**
@@ -41,9 +40,7 @@ export interface MenuContentProps
extends HTMLAttributes,
PopoverRenderProps {
/** One or more `Menu.Section` elements that make up the panel body. */
- children:
- | ReactElement
- | ReactElement[];
+ children: ReactElement | ReactElement[];
"data-testid"?: string | undefined;
}
@@ -65,6 +62,15 @@ export interface MenuSectionProps extends HTMLAttributes {
* @default true
*/
showDivider?: boolean | undefined;
+ /**
+ * Renders section children in a grid with the given number of columns
+ * on desktop. Items flow vertically (top-to-bottom) within each column
+ * before wrapping to the next. Has no effect on mobile viewports, which
+ * always render as a single column.
+ *
+ * @minimum 1
+ */
+ columns?: number | undefined;
/**
* Accessible group label rendered above the section items.
*/
diff --git a/src/navbar/navbar-items.tsx b/src/navbar/navbar-items.tsx
index abe86eb02f..4eac38c9dc 100644
--- a/src/navbar/navbar-items.tsx
+++ b/src/navbar/navbar-items.tsx
@@ -133,10 +133,11 @@ export const NavbarItems = ({
// =============================================================================
const renderDesktopSubMenu = (
subMenu: NavItemCommonProps[],
- subMenuId: string
+ subMenuId: string,
+ columns?: number
) => (