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} +
    + {children} +
+
); }; 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 ) => ( - + {subMenu.map((item, subIndex) => ( ({ ); const renderLinkItem = (item: NavItemLinkProps, index: number) => { - const { children, options, subMenu, ...otherItemAttrs } = item; + const { + children, + options, + subMenu, + subMenuColumns, + ...otherItemAttrs + } = item; const hasSubMenu = !!subMenu?.length; @@ -276,7 +283,12 @@ export const NavbarItems = ({ { diff --git a/src/navbar/types.ts b/src/navbar/types.ts index 945e15ab93..ea69711d27 100644 --- a/src/navbar/types.ts +++ b/src/navbar/types.ts @@ -11,6 +11,15 @@ import type { TypographyLinkProps } from "../typography"; export interface NavItemLinkProps extends NavItemCommonProps { itemType?: "link"; subMenu?: NavItemCommonProps[] | undefined; + /** + * Renders `subMenu` as a multi-column layout on desktop. Items flow + * vertically (top-to-bottom) within each column before wrapping to the + * next. Has no effect on the mobile drawer, which always renders `subMenu` + * as an expandable vertical list. + * + * @minimum 1 + */ + subMenuColumns?: number | undefined; } /** * Props for a custom component slot navigation item. diff --git a/src/shared/styles/index.ts b/src/shared/styles/index.ts index 37a725e891..476b2ff6e0 100644 --- a/src/shared/styles/index.ts +++ b/src/shared/styles/index.ts @@ -1,3 +1,26 @@ +import { Colour, Radius } from "../../theme/tokens"; + +/** + * Generates CSS for a styled scrollbar. Pass "vertical" for panels that scroll + * vertically and "horizontal" for containers that scroll horizontally. + */ +export const scrollbarCss = (axis: "vertical" | "horizontal") => ` + &::-webkit-scrollbar { + ${axis === "vertical" ? "width" : "height"}: 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; + } +`; + /** * Generates CSS for limiting text to a specified number of lines after which it * is truncated with an ellipsis. diff --git a/stories/menu/menu.mdx b/stories/menu/menu.mdx index 84cb5c0088..47b2aeace3 100644 --- a/stories/menu/menu.mdx +++ b/stories/menu/menu.mdx @@ -31,6 +31,12 @@ import { Menu } from "@lifesg/react-design-system/menu"; +## Grid layout + +`Menu.Section` can display links or items in a multi-column grid. + + + ## Resize and flip As the menu extends [PopoverTrigger](/docs/overlays-popover--docs), resize and diff --git a/stories/menu/menu.stories.tsx b/stories/menu/menu.stories.tsx index 18491437ea..a72e5909ce 100644 --- a/stories/menu/menu.stories.tsx +++ b/stories/menu/menu.stories.tsx @@ -55,6 +55,41 @@ export const MenuContent: StoryObj = { }, }; +export const GridLayout: StoryObj = { + render: (_args) => { + return ( + + + Link 1 + Link 2 + Link 3 + Link 4 + Link 5 + Link 6 + Item 1 + Item 2 + + + Link 1 + Link 2 + Link 3 + Link 4 + Link 5 + Link 6 + + Link 7 (longer) + + Link 8 + + + ); + }, +}; + export const ResizeAndFlip: StoryObj = { args: { menuContent: menuContent, diff --git a/stories/navbar/navbar.mdx b/stories/navbar/navbar.mdx index 1793c574d4..d8aa1abe97 100644 --- a/stories/navbar/navbar.mdx +++ b/stories/navbar/navbar.mdx @@ -77,6 +77,12 @@ A sub menu is shown if the selected nav item has sublinks. +## Sub menu with grid layout + +A sub menu can display links in a multi-column grid by setting `subMenuColumns` on the nav item. + + + ## Secondary branding In this example, a co-brand is displayed next to the primary brand. diff --git a/stories/navbar/navbar.stories.tsx b/stories/navbar/navbar.stories.tsx index 96dd96443c..ca48a0fe50 100644 --- a/stories/navbar/navbar.stories.tsx +++ b/stories/navbar/navbar.stories.tsx @@ -388,6 +388,277 @@ export const SubMenu: StoryObj = { }, }; +export const SubMenuGrid: StoryObj = { + render: (_args) => { + const [selected, setSelected] = useState("lifesg1"); + return ( + { + setSelected((item as NavItemLinkProps).id); + }} + /> + ); + }, +}; + export const SecondaryBranding: StoryObj = { render: (_args) => { return ( diff --git a/tests/menu/menu.spec.tsx b/tests/menu/menu.spec.tsx index f831d8a0c7..3957e5849a 100644 --- a/tests/menu/menu.spec.tsx +++ b/tests/menu/menu.spec.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { Menu } from "src/menu"; describe("Menu", () => { @@ -184,6 +185,31 @@ describe("Menu", () => { expect(event).toBe(true); expect(a).toHaveFocus(); }); + + it("should move focus through grid items in DOM order with Tab", async () => { + const { getByRole } = render( + + + Item 1 + Item 2 + Item 3 + + + ); + + const item1 = getByRole("link", { name: "Item 1" }); + const item2 = getByRole("link", { name: "Item 2" }); + const item3 = getByRole("link", { name: "Item 3" }); + + item1.focus(); + expect(item1).toHaveFocus(); + + await userEvent.tab(); + expect(item2).toHaveFocus(); + + await userEvent.tab(); + expect(item3).toHaveFocus(); + }); }); describe("Menu.Section", () => { @@ -207,5 +233,81 @@ describe("Menu", () => { expect(screen.getByRole("link", { name: "A" })).toBeInTheDocument(); }); + + it("should clamp columns to minimum 1 to avoid invalid CSS for non-positive values", () => { + // columns=-1 is truthy so the grid class is applied, but without the + // Math.max(1, columns) guard gridRows would be negative (invalid CSS). + const { getByTestId } = render( + + Item 1 + Item 2 + Item 3 + + ); + + // Math.max(1, -1) = 1 → gridRows = Math.ceil(3 / 1) = 3, not -3 + expect( + getByTestId("menu-section").style.getPropertyValue( + "--fds-menu-section-grid-rows" + ) + ).toBe("3"); + }); + + it("should set correct gridRows CSS var for normal column counts", () => { + const { getByTestId } = render( + + {Array.from({ length: 7 }, (_, i) => ( + + Item {i + 1} + + ))} + + ); + + // Math.ceil(7 / 3) = 3 + expect( + getByTestId("menu-section").style.getPropertyValue( + "--fds-menu-section-grid-rows" + ) + ).toBe("3"); + }); + }); + + describe("Menu.Content (maxWidth)", () => { + it("should set panel maxWidth CSS var for 3 columns", () => { + // 3 columns: 3 * 383 + 2 * 8 + 2 (border) = 1167px + const { getByTestId } = render( + + + Item 1 + Item 2 + Item 3 + + + ); + + expect( + getByTestId("menu-content").style.getPropertyValue( + "--fds-internal-menu-panel-maxWidth" + ) + ).toBe("1167px"); + }); + + it("should not set panel maxWidth CSS var when no sections have columns", () => { + const { getByTestId } = render( + + + Item 1 + Item 2 + + + ); + + expect( + getByTestId("menu-content").style.getPropertyValue( + "--fds-internal-menu-panel-maxWidth" + ) + ).toBe(""); + }); }); });