Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4c1c68f
[MOL-22399][FN] feat: add submenu rows and columns prop types
fauzannaufan-gws Sep 15, 2026
299a2a4
[MOL-22399][FN] feat: build submenugrid internal component
fauzannaufan-gws Sep 15, 2026
d5d6f03
[MOL-22399][FN] feat: wire submenugrid into navbar items
fauzannaufan-gws Sep 15, 2026
f80009f
[MOL-22399][FN] test: unit test for navbar submenu grid
fauzannaufan-gws Sep 15, 2026
6a570ec
[MOL-22399][FN] feat: add story for sub menu grid
fauzannaufan-gws Sep 15, 2026
0ef1afb
[MOL-22399][FN] test: add e2e for navbar sumenu grid
fauzannaufan-gws Sep 17, 2026
4dee769
[MOL-22399][FN] fix: address code review findings
fauzannaufan-gws Sep 17, 2026
9309d18
[MOL-22399][FN] test: address testing gaps findings
fauzannaufan-gws Sep 17, 2026
aeb1293
[MOL-22399][FN] feat: move multi column grid behavior into Menu.Section
fauzannaufan-gws Sep 18, 2026
a783cd8
[MOL-22399][FN] fix: address code review findings
fauzannaufan-gws Sep 18, 2026
e13a6ea
[MOL-22399][FN] test: address testing gaps
fauzannaufan-gws Sep 18, 2026
d166d9f
[MOL-22399][FN] fix: merge columns and rows into 1 props, utilize use…
fauzannaufan-gws Sep 22, 2026
e00a844
[MOL-22399][FN] fix: change orientation for the menu section grid
fauzannaufan-gws Sep 22, 2026
320db90
[MOL-22399][FN] chore: move max width calculation to menu-content
fauzannaufan-gws Sep 22, 2026
fd235fb
[MOL-22399][FN] fix: address PR comments
fauzannaufan-gws Sep 22, 2026
a6304ea
[MOL-22399][FN] chore: update storybook
fauzannaufan-gws Sep 22, 2026
df8e19d
[MOL-22399][FN] test: adjust unit test and e2e
fauzannaufan-gws Sep 22, 2026
8a5b71a
[MOL-22399][FN] fix: address code review findings
fauzannaufan-gws Sep 22, 2026
5bcbbd6
[MOL-22399][FN] chore: update e2e screenshot for failed items
fauzannaufan-gws Sep 22, 2026
9aa925d
[MOL-22399][FN] feat: change approach to support number of columns only
fauzannaufan-gws Sep 24, 2026
508f3a3
[MOL-22399][FN] chore: update tests and stories
fauzannaufan-gws Sep 24, 2026
071e883
[MOL-22399][FN] fix: address code review, update e2e screenshots
fauzannaufan-gws Sep 24, 2026
afc5346
[MOL-22399][FN] fix: resolve PR comments
fauzannaufan-gws Sep 25, 2026
e6ac288
[MOL-22399][FN] test: fix failed e2e
fauzannaufan-gws Sep 25, 2026
180ed04
[MISC][RL] Simplify menu docs
qroll Sep 24, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { Menu } from "@lifesg/react-design-system/menu";

export default function Story() {
return (
<div className="story-column-container">
<Menu.Content data-testid="menu-content">
<Menu.Section
showDivider={false}
columns={2}
label="A very long section label that should wrap across multiple lines when the container is not wide enough to display it all on a single line without wrapping"
data-testid="menu-section"
>
{Array.from({ length: 6 }, (_, i) => (
<Menu.Link key={i} href={`#link-${i + 1}`}>
Link {i + 1}
</Menu.Link>
))}
</Menu.Section>
</Menu.Content>
</div>
);
}
23 changes: 23 additions & 0 deletions e2e/nextjs-app/src/app/components/menu/grid-layout.e2e.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { Menu } from "@lifesg/react-design-system/menu";

export default function Story() {
return (
<div className="story-column-container">
<Menu.Content data-testid="menu-content">
<Menu.Section
showDivider={false}
columns={2}
data-testid="menu-section"
>
{Array.from({ length: 8 }, (_, i) => (
<Menu.Link key={i} href={`#link-${i + 1}`}>
Link {i + 1}
</Menu.Link>
))}
</Menu.Section>
</Menu.Content>
</div>
);
}
2 changes: 1 addition & 1 deletion e2e/nextjs-app/src/app/components/menu/overflow.e2e.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Menu } from "@lifesg/react-design-system/menu";
export default function Story() {
return (
<div className="story-column-container">
<Menu.Content maxHeight={160} overflow="auto">
<Menu.Content maxHeight={181} overflow="auto">
<Menu.Section showDivider={false} label="Overflow list">
<Menu.Link href="#item-1">Overflow item 1</Menu.Link>
<Menu.Link href="#item-2">Overflow item 2</Menu.Link>
Expand Down
18 changes: 18 additions & 0 deletions e2e/nextjs-app/src/app/components/navbar/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,21 @@ export const navItemsWithSubmenu: NavItemProps<undefined>[] = [
],
},
];

export const navItemsWithSubmenuGrid: NavItemProps<undefined>[] = [
{
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",
})),
},
];
16 changes: 16 additions & 0 deletions e2e/nextjs-app/src/app/components/navbar/submenu-grid.e2e.tsx
Comment thread
fauzannaufan-gws marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";

import { Navbar } from "@lifesg/react-design-system/navbar";

import { downloadActionButtons, navItemsWithSubmenuGrid } from "./common";

export default function Story() {
return (
<Navbar
data-testid="navbar-submenu-grid"
items={{ desktop: navItemsWithSubmenuGrid }}
actionButtons={{ desktop: downloadActionButtons }}
onItemClick={() => {}}
/>
);
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 59 additions & 2 deletions e2e/tests/components/menu/menu.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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"
Expand Down Expand Up @@ -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,
});
});
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 39 additions & 1 deletion e2e/tests/components/navbar/navbar.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
}),
Expand All @@ -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",
Expand Down Expand Up @@ -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 }) => {
Expand Down
23 changes: 8 additions & 15 deletions src/menu/menu-content.styles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { css } from "@linaria/core";

import { scrollbarCss } from "../shared/styles";
import {
Border,
Breakpoint,
Expand All @@ -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",
},
Expand All @@ -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
Expand All @@ -43,26 +46,16 @@ 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});

&:focus {
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")}
`;
29 changes: 27 additions & 2 deletions src/menu/menu-content.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -30,9 +37,27 @@ export const MenuContent = ({
// =============================================================================
const panelRef = useRef<HTMLDivElement>(null);

const gridMaxWidth = useMemo(() => {
const maxColumns = Children.toArray(children)
.filter(
(child): child is ReactElement<MenuSectionProps> =>
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,
});
// =============================================================================
Expand Down
2 changes: 1 addition & 1 deletion src/menu/menu-item.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion src/menu/menu-link.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]};

Expand Down
Loading
Loading