diff --git a/e2e/nextjs-app/src/app/components/navbar/common.ts b/e2e/nextjs-app/src/app/components/navbar/common.ts index 1e8936f1a1..d3b2b9a622 100644 --- a/e2e/nextjs-app/src/app/components/navbar/common.ts +++ b/e2e/nextjs-app/src/app/components/navbar/common.ts @@ -72,3 +72,22 @@ export const navItemsWithSubmenu: NavItemProps[] = [ ], }, ]; + +export const navItemsWithSubmenuGrid: NavItemProps[] = [ + { + id: "home", + children: "Home", + href: "https://www.life.gov.sg", + }, + { + id: "guides", + children: "Guides", + subMenuColumns: 3, + subMenuRows: 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/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..4cd1151ad1 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-Grid-Scroll-reveals-items-beyond-the-visible-grid-rows--scrolled.png b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Scroll-reveals-items-beyond-the-visible-grid-rows--scrolled.png new file mode 100644 index 0000000000..5773122f11 Binary files /dev/null and b/e2e/tests/components/navbar/__screenshots__/chromium/Navbar-Submenu-Grid-Scroll-reveals-items-beyond-the-visible-grid-rows--scrolled.png differ diff --git a/e2e/tests/components/navbar/navbar.e2e.spec.ts b/e2e/tests/components/navbar/navbar.e2e.spec.ts index 7014ad3f6b..50abedb5dd 100644 --- a/e2e/tests/components/navbar/navbar.e2e.spec.ts +++ b/e2e/tests/components/navbar/navbar.e2e.spec.ts @@ -22,6 +22,7 @@ class StoryPage extends AbstractStoryPage { mobileMenuButton: Locator; servicesTrigger: Locator; appTrigger: Locator; + guidesTrigger: Locator; servicesMobileTrigger: Locator; closeButton: Locator; drawer: Locator; @@ -47,6 +48,7 @@ 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" ), @@ -61,7 +63,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 +260,62 @@ test.describe("Navbar", () => { }); }); + test.describe("Submenu Grid", () => { + 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("Scroll reveals items beyond the visible grid rows", async ({ + story, + }) => { + await story.locators.internal.guidesTrigger.click(); + + const overflowItem = + story.locators.internal.submenuLink("Guides item 12"); + + await test.step("Overflow item starts outside the visible grid area", async () => { + await expect(overflowItem).not.toBeInViewport(); + }); + + await test.step("Scrolling brings the overflow item into view", async () => { + await overflowItem.scrollIntoViewIfNeeded(); + await expect(overflowItem).toBeInViewport(); + + await compareScreenshot(story, "scrolled", { + fullscreen: true, + }); + }); + }); + + test("Keyboard users can Tab through grid items in order", async ({ + story, + }) => { + await story.locators.internal.guidesTrigger.click(); + + await story.page.keyboard.press("Tab"); + await expect( + story.locators.internal.submenuLink("Guides item 1") + ).toBeFocused(); + + await story.page.keyboard.press("Tab"); + await expect( + story.locators.internal.submenuLink("Guides item 2") + ).toBeFocused(); + + await story.page.keyboard.press("Tab"); + await expect( + story.locators.internal.submenuLink("Guides item 3") + ).toBeFocused(); + }); + }); + 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..12afe632b9 100644 --- a/src/menu/menu-content.styles.ts +++ b/src/menu/menu-content.styles.ts @@ -13,6 +13,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", }, @@ -43,7 +44,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}); diff --git a/src/menu/menu-content.tsx b/src/menu/menu-content.tsx index 10d47df3f5..2e86b2b068 100644 --- a/src/menu/menu-content.tsx +++ b/src/menu/menu-content.tsx @@ -23,6 +23,7 @@ export const MenuContent = ({ "data-testid": testId = "menu-content", overflow, maxHeight, + maxWidth, ...otherProps }: MenuContentProps): JSX.Element => { // ============================================================================= @@ -33,6 +34,8 @@ export const MenuContent = ({ useApplyStyle(panelRef, { [styles.tokens.panel.maxHeight]: maxHeight !== undefined ? `${maxHeight}px` : null, + [styles.tokens.panel.maxWidth]: + maxWidth !== undefined ? `${maxWidth}px` : null, [styles.tokens.panel.overflow]: overflow || null, }); // ============================================================================= diff --git a/src/menu/types.ts b/src/menu/types.ts index 0f9c59f391..3b9b216c7b 100644 --- a/src/menu/types.ts +++ b/src/menu/types.ts @@ -45,6 +45,12 @@ export interface MenuContentProps | ReactElement | ReactElement[]; "data-testid"?: string | undefined; + /** + * Maximum width of the panel in pixels. + * + * @default 24rem (384px at the default 16px root font size) + */ + maxWidth?: number | undefined; } // @storybookSkipProps diff --git a/src/navbar/navbar-items.tsx b/src/navbar/navbar-items.tsx index abe86eb02f..08f59f6c31 100644 --- a/src/navbar/navbar-items.tsx +++ b/src/navbar/navbar-items.tsx @@ -9,6 +9,7 @@ import type { TypographyWeight } from "../typography"; import { useId } from "../util"; import { Menu as MobileMenu } from "./menu"; import * as styles from "./navbar-items.styles"; +import { getSubMenuGridMaxWidth, SubMenuGrid } from "./submenu-grid"; import type { NavItemCommonProps, NavItemLinkProps, @@ -133,19 +134,30 @@ export const NavbarItems = ({ // ============================================================================= const renderDesktopSubMenu = ( subMenu: NavItemCommonProps[], - subMenuId: string + subMenuId: string, + columns?: number, + rows?: number ) => ( - - - {subMenu.map((item, subIndex) => ( - - {item.children} - - ))} - + + {columns && rows ? ( + + ) : ( + + {subMenu.map((item, subIndex) => ( + + {item.children} + + ))} + + )} ); @@ -158,7 +170,14 @@ export const NavbarItems = ({ ); const renderLinkItem = (item: NavItemLinkProps, index: number) => { - const { children, options, subMenu, ...otherItemAttrs } = item; + const { + children, + options, + subMenu, + subMenuColumns: _subMenuColumns, + subMenuRows: _subMenuRows, + ...otherItemAttrs + } = item; const hasSubMenu = !!subMenu?.length; @@ -276,7 +295,12 @@ export const NavbarItems = ({ { diff --git a/src/navbar/submenu-grid.styles.ts b/src/navbar/submenu-grid.styles.ts new file mode 100644 index 0000000000..6b0a629f7e --- /dev/null +++ b/src/navbar/submenu-grid.styles.ts @@ -0,0 +1,44 @@ +import { css } from "@linaria/core"; + +import { Colour, Radius, Spacing } from "../theme"; + +export const tokens = { + grid: { + columns: "--fds-navbarItems-subMenuGrid-columns", + maxHeight: "--fds-navbarItems-subMenuGrid-maxHeight", + }, +} as const; + +// removes the wrapper from the layout tree so `Menu.Section`'s `
    ` becomes +// the direct grid parent of its `
  • ` children +export const measureWrapper = css` + display: contents; +`; + +export const grid = css` + ${tokens.grid.columns}: initial; + ${tokens.grid.maxHeight}: initial; + + display: grid; + grid-template-columns: repeat(var(${tokens.grid.columns}), minmax(0, 1fr)); + column-gap: ${Spacing["spacing-8"]}; + row-gap: ${Spacing["spacing-4"]}; + + max-height: var(${tokens.grid.maxHeight}); + overflow-y: auto; + + &::-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; + } +`; diff --git a/src/navbar/submenu-grid.tsx b/src/navbar/submenu-grid.tsx new file mode 100644 index 0000000000..74d2401f59 --- /dev/null +++ b/src/navbar/submenu-grid.tsx @@ -0,0 +1,121 @@ +import { useRef } from "react"; +import { useResizeDetector } from "react-resize-detector"; + +import { Menu as DesktopMenu } from "../menu"; +import { useIsomorphicLayoutEffect } from "../util"; +import * as styles from "./submenu-grid.styles"; +import type { NavItemCommonProps } from "./types"; + +// comfortable minimum per grid column, and the gap used between columns +const SUBMENU_GRID_COLUMN_WIDTH_PX = 383; +const SUBMENU_GRID_COLUMN_GAP_PX = 8; + +/** + * Recommended `Menu.Content` `maxWidth` for a `columns`-wide `SubMenuGrid` — + * the default 24rem panel cap is sized for a single-column list and gets + * cramped once more than 1-2 columns are requested. + */ +export const getSubMenuGridMaxWidth = (columns: number): number => + columns * SUBMENU_GRID_COLUMN_WIDTH_PX + + (columns - 1) * SUBMENU_GRID_COLUMN_GAP_PX; + +interface Props { + items: NavItemCommonProps[]; + columns: number; + rows: number; +} + +/** + * Lays `items` out in a fixed-column CSS grid, capping the visible height to + * `rows` and enabling vertical scroll for the remainder. + * + * Why DOM measurement (not a fixed row height): items line-clamp to 2 lines, + * so row heights vary with label length. We measure the offset of the first + * item past the visible cutoff rather than assume a uniform row height. + * + * Why a wrapper `div` (not a `ref` on `Menu.Section`): `Menu.Section` doesn't + * forward refs, so its rendered `
      ` is read via `firstElementChild` on a + * `display: contents` wrapper instead of changing the shared `Menu` component. + */ +export const SubMenuGrid = ({ + items, + columns, + rows, +}: Props): JSX.Element | null => { + // ========================================================================= + // CONST, STATE, REF + // ========================================================================= + const wrapperRef = useRef(null); + // ResizeObserver target: the actual grid
        , not wrapperRef — wrapperRef + // is `display: contents` and never reports a real size to observe. + const gridRef = useRef(null); + + // ========================================================================= + // HELPER FUNCTIONS + // ========================================================================= + const applyGridLayout = () => { + const grid = wrapperRef.current + ?.firstElementChild as HTMLElement | null; + if (!grid) return; + gridRef.current = grid; + + grid.style.setProperty(styles.tokens.grid.columns, String(columns)); + + const visibleCount = rows * columns; + if (items.length <= visibleCount) { + grid.style.removeProperty(styles.tokens.grid.maxHeight); + return; + } + + const boundaryItem = grid.children[visibleCount] as + | HTMLElement + | undefined; + if (!boundaryItem) return; + + const rowGap = parseFloat(getComputedStyle(grid).rowGap) || 0; + const visibleHeight = boundaryItem.offsetTop - rowGap / 2; + + grid.style.setProperty( + styles.tokens.grid.maxHeight, + `${visibleHeight}px` + ); + }; + + // ========================================================================= + // EFFECTS + // ========================================================================= + useIsomorphicLayoutEffect(() => { + applyGridLayout(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, columns, rows]); + + useResizeDetector({ + handleWidth: true, + handleHeight: false, + skipOnMount: true, + refreshMode: "throttle", + targetRef: gridRef, + onResize: applyGridLayout, + }); + + // ========================================================================= + // RENDER FUNCTIONS + // ========================================================================= + // guards against callers bypassing navbar-items.tsx's own columns/rows check + if (columns < 1 || rows < 1) return null; + + return ( +
        + + {items.map((item, index) => ( + + {item.children} + + ))} + +
        + ); +}; diff --git a/src/navbar/types.ts b/src/navbar/types.ts index 945e15ab93..0d9bbf0a41 100644 --- a/src/navbar/types.ts +++ b/src/navbar/types.ts @@ -11,6 +11,24 @@ import type { TypographyLinkProps } from "../typography"; export interface NavItemLinkProps extends NavItemCommonProps { itemType?: "link"; subMenu?: NavItemCommonProps[] | undefined; + /** + * Number of columns to lay `subMenu` out in on desktop, as a grid instead + * of a single vertical list. Must be set together with `subMenuRows` — + * if either is omitted, `subMenu` renders as a standard vertical list. + * + * Has no effect on the mobile drawer, which always renders `subMenu` as + * an expandable vertical list. + * + * @minimum 1 + */ + subMenuColumns?: number | undefined; + /** + * Number of grid rows of `subMenu` visible on desktop before the panel + * becomes vertically scrollable. See `subMenuColumns`. + * + * @minimum 1 + */ + subMenuRows?: number | undefined; } /** * Props for a custom component slot navigation item. diff --git a/stories/navbar/navbar.stories.tsx b/stories/navbar/navbar.stories.tsx index 96dd96443c..d4d966e62e 100644 --- a/stories/navbar/navbar.stories.tsx +++ b/stories/navbar/navbar.stories.tsx @@ -388,6 +388,279 @@ 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/jest-setup.ts b/tests/jest-setup.ts index b7c980f84c..511a66f807 100644 --- a/tests/jest-setup.ts +++ b/tests/jest-setup.ts @@ -8,6 +8,22 @@ if (typeof globalThis.PointerEvent === "undefined") { (globalThis as any).PointerEvent = MouseEvent; } +// Polyfill ResizeObserver for jsdom (used by react-resize-detector, e.g. SubMenuGrid) +if (typeof globalThis.ResizeObserver === "undefined") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- globalThis lacks ResizeObserver type in jsdom + (globalThis as any).ResizeObserver = class { + public observe() { + // noop for jsdom + } + public unobserve() { + // noop for jsdom + } + public disconnect() { + // noop for jsdom + } + }; +} + const getFallbackThemeContext = (): ThemeContextValue => ({ theme: "lifesg", mode: "light", diff --git a/tests/menu/menu.spec.tsx b/tests/menu/menu.spec.tsx index f831d8a0c7..7541e4f7fc 100644 --- a/tests/menu/menu.spec.tsx +++ b/tests/menu/menu.spec.tsx @@ -2,6 +2,7 @@ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { Menu } from "src/menu"; +import { tokens as menuContentTokens } from "src/menu/menu-content.styles"; describe("Menu", () => { describe("Menu.Content", () => { @@ -16,6 +17,32 @@ describe("Menu", () => { expect(screen.getByText("hello")).toBeInTheDocument(); }); + it("should set the maxWidth CSS variable on the panel when provided", () => { + render( + +
        hello
        +
        + ); + + const panel = screen.getByTestId("menu-content"); + expect( + panel.style.getPropertyValue(menuContentTokens.panel.maxWidth) + ).toBe("640px"); + }); + + it("should not set the maxWidth CSS variable when not provided", () => { + render( + +
        hello
        +
        + ); + + const panel = screen.getByTestId("menu-content"); + expect( + panel.style.getPropertyValue(menuContentTokens.panel.maxWidth) + ).toBe(""); + }); + it("should move focus forward with ArrowDown", () => { render( diff --git a/tests/navbar/navbar.spec.tsx b/tests/navbar/navbar.spec.tsx index a4ee8a2148..46280489ef 100644 --- a/tests/navbar/navbar.spec.tsx +++ b/tests/navbar/navbar.spec.tsx @@ -1,6 +1,7 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Navbar } from "src/navbar"; +import * as subMenuGridStyles from "src/navbar/submenu-grid.styles"; describe("Navbar", () => { describe("Basic functions", () => { @@ -178,6 +179,74 @@ describe("Navbar", () => { ); }); }); + + describe("Submenu grid", () => { + it("should render all subMenu item labels even when the visible cutoff is exceeded", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("link__1")); + + for (const label of MOCK_SUBMENU_LABELS) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it("should apply the grid layout when subMenuColumns and subMenuRows are both set", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("link__1")); + + expect( + document.body.getElementsByClassName(subMenuGridStyles.grid) + ).toHaveLength(1); + }); + + it("should fall back to the vertical list when only one of subMenuColumns/subMenuRows is set", async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId("link__1")); + + for (const label of MOCK_SUBMENU_LABELS) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect( + document.body.getElementsByClassName(subMenuGridStyles.grid) + ).toHaveLength(0); + }); + + it("should move focus between grid items with ArrowDown", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("link__1")); + + const panel = screen.getByTestId("menu-content"); + const first = screen.getByRole("link", { name: "Sub item 1" }); + const second = screen.getByRole("link", { name: "Sub item 2" }); + + first.focus(); + expect(first).toHaveFocus(); + + fireEvent.keyDown(panel, { key: "ArrowDown" }); + expect(second).toHaveFocus(); + }); + }); }); // ============================================================================= @@ -210,3 +279,27 @@ function MOCK_COLLAPSIBLE_BUTTON() { args: { children: "Uncollapsible" }, }; } + +function MOCK_SUBMENU_ITEMS() { + return [ + { id: "sub-1", children: "Sub item 1", href: "#" }, + { id: "sub-2", children: "Sub item 2", href: "#" }, + { id: "sub-3", children: "Sub item 3", href: "#" }, + { id: "sub-4", children: "Sub item 4", href: "#" }, + { id: "sub-5", children: "Sub item 5", href: "#" }, + ]; +} + +const MOCK_SUBMENU_LABELS = MOCK_SUBMENU_ITEMS().map((item) => item.children); + +function MOCK_GRID_ITEMS() { + return [ + { + id: "guides", + children: "Guides", + subMenuColumns: 2, + subMenuRows: 2, + subMenu: MOCK_SUBMENU_ITEMS(), + }, + ]; +}