Skip to content
19 changes: 19 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,22 @@ 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,
subMenuRows: 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
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.
61 changes: 60 additions & 1 deletion e2e/tests/components/navbar/navbar.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class StoryPage extends AbstractStoryPage {
mobileMenuButton: Locator;
servicesTrigger: Locator;
appTrigger: Locator;
guidesTrigger: Locator;
servicesMobileTrigger: Locator;
closeButton: Locator;
drawer: Locator;
Expand All @@ -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"
),
Expand All @@ -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",
Expand Down Expand Up @@ -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 }) => {
Expand Down
6 changes: 5 additions & 1 deletion src/menu/menu-content.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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});

Expand Down
3 changes: 3 additions & 0 deletions src/menu/menu-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const MenuContent = ({
"data-testid": testId = "menu-content",
overflow,
maxHeight,
maxWidth,
...otherProps
}: MenuContentProps): JSX.Element => {
// =============================================================================
Expand All @@ -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,
});
// =============================================================================
Expand Down
6 changes: 6 additions & 0 deletions src/menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export interface MenuContentProps
| ReactElement<typeof MenuSection>
| ReactElement<typeof MenuSection>[];
"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
Expand Down
52 changes: 38 additions & 14 deletions src/navbar/navbar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -133,19 +134,30 @@ export const NavbarItems = <T,>({
// =============================================================================
const renderDesktopSubMenu = (
subMenu: NavItemCommonProps<T>[],
subMenuId: string
subMenuId: string,
columns?: number,
rows?: number
) => (
<DesktopMenu.Content id={subMenuId}>
<DesktopMenu.Section showDivider={false}>
{subMenu.map((item, subIndex) => (
<DesktopMenu.Link
key={`${item.id}-${subIndex}`}
href={item.href}
>
{item.children}
</DesktopMenu.Link>
))}
</DesktopMenu.Section>
<DesktopMenu.Content
id={subMenuId}
maxWidth={
columns && rows ? getSubMenuGridMaxWidth(columns) : undefined
}
>
{columns && rows ? (
<SubMenuGrid items={subMenu} columns={columns} rows={rows} />
) : (
<DesktopMenu.Section showDivider={false}>
{subMenu.map((item, subIndex) => (
<DesktopMenu.Link
key={`${item.id}-${subIndex}`}
href={item.href}
>
{item.children}
</DesktopMenu.Link>
))}
</DesktopMenu.Section>
)}
</DesktopMenu.Content>
);

Expand All @@ -158,7 +170,14 @@ export const NavbarItems = <T,>({
);

const renderLinkItem = (item: NavItemLinkProps<T>, index: number) => {
const { children, options, subMenu, ...otherItemAttrs } = item;
const {
children,
options,
subMenu,
subMenuColumns: _subMenuColumns,
subMenuRows: _subMenuRows,
...otherItemAttrs
} = item;

const hasSubMenu = !!subMenu?.length;

Expand Down Expand Up @@ -276,7 +295,12 @@ export const NavbarItems = <T,>({
<DesktopMenu
position={isLastItem ? "bottom-end" : "bottom"}
customOffset={0}
menuContent={renderDesktopSubMenu(subMenu!, subMenuId)}
menuContent={renderDesktopSubMenu(
subMenu!,
subMenuId,
item.subMenuColumns,
item.subMenuRows
)}
triggerOnFocus
isModal={false}
onPopoverAppear={() => {
Expand Down
44 changes: 44 additions & 0 deletions src/navbar/submenu-grid.styles.ts
Original file line number Diff line number Diff line change
@@ -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 `<ul>` becomes
// the direct grid parent of its `<li>` 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;
}
`;
Loading
Loading