diff --git a/e2e/nextjs-app/src/app/components/local-nav/dropdown.e2e.tsx b/e2e/nextjs-app/src/app/components/local-nav/dropdown.e2e.tsx
new file mode 100644
index 0000000000..72846587e0
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/local-nav/dropdown.e2e.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { LocalNavDropdown } from "@lifesg/react-design-system/local-nav";
+import { Typography } from "@lifesg/react-design-system/typography";
+import { useRef, useState } from "react";
+
+const ITEMS = [
+ { id: "section-1", title: "Title 1" },
+ { id: "section-2", title: "Title 2" },
+ { id: "section-3", title: "Title 3" },
+ { id: "section-4", title: "Title 4" },
+];
+
+const LOREM_PARAGRAPH =
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum vestibulum. Cras venenatis euismod malesuada. Integer sit amet lacus ac risus dapibus dictum. Suspendisse potenti. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris in erat justo. Nullam ac urna eu felis dapibus condimentum sit amet a augue. Sed non neque elit. Sed ut imperdiet nisi. Proin condimentum fermentum nunc.";
+
+const TopContent = () => (
+
+ Top content
+ {LOREM_PARAGRAPH}
+
+);
+
+const Content = () => (
+
+ {ITEMS.map((item) => (
+
+
+ {item.title}
+
+ {LOREM_PARAGRAPH}
+
+ ))}
+
+);
+
+export default function Story() {
+ const [selectedItemIndex] = useState(-1);
+ const dropdownRef = useRef(null);
+
+ return (
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/local-nav/menu.e2e.tsx b/e2e/nextjs-app/src/app/components/local-nav/menu.e2e.tsx
new file mode 100644
index 0000000000..2d62aa8b14
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/local-nav/menu.e2e.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { LocalNavMenu } from "@lifesg/react-design-system/local-nav";
+import { useState } from "react";
+
+const ITEMS = [
+ { id: "section-1", title: "Title 1" },
+ { id: "section-2", title: "Title 2" },
+ { id: "section-3", title: "Title 3" },
+ { id: "section-4", title: "Title 4" },
+];
+
+export default function Story() {
+ const [selectedItemIndex, setSelectedItemIndex] = useState(0);
+
+ return (
+ setSelectedItemIndex(index)}
+ />
+ );
+}
diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Default--mount.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Default--mount.png
new file mode 100644
index 0000000000..101dd85f45
Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Default--mount.png differ
diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Default-dark-mode---mount.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Default-dark-mode---mount.png
new file mode 100644
index 0000000000..5e98efb5c8
Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Default-dark-mode---mount.png differ
diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Sticky-on-scroll--sticky-open.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Sticky-on-scroll--sticky-open.png
new file mode 100644
index 0000000000..be0840f44b
Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-Sticky-on-scroll--sticky-open.png differ
diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-dark-mode---mount.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-dark-mode---mount.png
new file mode 100644
index 0000000000..5e98efb5c8
Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-dark-mode---mount.png differ
diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-dark-mode---sticky-open.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-dark-mode---sticky-open.png
new file mode 100644
index 0000000000..c7a7d736a1
Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Dropdown-dark-mode---sticky-open.png differ
diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-Default--mount.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-Default--mount.png
new file mode 100644
index 0000000000..f55c3f5db3
Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-Default--mount.png differ
diff --git a/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-dark-mode---mount.png b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-dark-mode---mount.png
new file mode 100644
index 0000000000..be71789115
Binary files /dev/null and b/e2e/tests/components/local-nav/__screenshots__/chromium/Local-nav-Menu-dark-mode---mount.png differ
diff --git a/e2e/tests/components/local-nav/local-nav.e2e.spec.ts b/e2e/tests/components/local-nav/local-nav.e2e.spec.ts
new file mode 100644
index 0000000000..8e9d742b39
--- /dev/null
+++ b/e2e/tests/components/local-nav/local-nav.e2e.spec.ts
@@ -0,0 +1,156 @@
+import { test as base, expect, Locator, Page } from "@playwright/test";
+import { AbstractStoryPage, compareScreenshot } from "../../utils";
+
+class StoryPage extends AbstractStoryPage {
+ protected readonly component = "local-nav";
+
+ public readonly locators: {
+ menu: Locator;
+ menuItems: Locator;
+ dropdown: Locator;
+ dropdownLabel: Locator;
+ dropdownList: Locator;
+ contentAfter: Locator;
+ };
+
+ constructor(page: Page) {
+ super(page);
+
+ this.locators = {
+ menu: page.getByTestId("local-nav-menu"),
+ menuItems: page.getByTestId("local-nav-menu").getByRole("link"),
+ dropdown: page.getByTestId("local-nav-dropdown"),
+ dropdownLabel: page.getByTestId("local-nav-dropdown-label"),
+ dropdownList: page.getByTestId("local-nav-dropdown-dropdown-list"),
+ contentAfter: page.getByTestId("content-after"),
+ };
+ }
+}
+
+const test = base.extend<{ story: StoryPage }>({
+ story: async ({ page }, use) => {
+ const story = new StoryPage(page);
+ await use(story);
+ },
+});
+
+test.describe("Local nav", () => {
+ test.describe("Menu", () => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("menu");
+ });
+
+ test("Default", async ({ story }) => {
+ await compareScreenshot(story, "mount", {
+ locator: story.locators.menu,
+ });
+ });
+
+ test("Accessibility", async ({ story }) => {
+ await expect(story.locators.menu).toMatchAriaSnapshot(`
+ - list:
+ - listitem:
+ - link "Title 1"
+ - listitem:
+ - link "Title 2"
+ - listitem:
+ - link "Title 3"
+ - listitem:
+ - link "Title 4"
+ `);
+ });
+ });
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("menu", { mode: "dark" });
+ });
+
+ test("Menu (dark mode)", async ({ story }) => {
+ await compareScreenshot(story, "mount", {
+ locator: story.locators.menu,
+ });
+ });
+ });
+ test.describe("Dropdown", () => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("dropdown");
+ });
+
+ test("Default", async ({ story }) => {
+ await compareScreenshot(story, "mount", {
+ locator: story.locators.dropdown,
+ });
+ await expect(story.locators.dropdownLabel).toHaveAttribute(
+ "aria-expanded",
+ "false"
+ );
+ await expect(story.locators.dropdownList).not.toBeVisible();
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("dropdown", { mode: "dark" });
+ });
+
+ test("Default (dark mode)", async ({ story }) => {
+ await compareScreenshot(story, "mount", {
+ locator: story.locators.dropdown,
+ });
+ });
+ });
+
+ test("Accessibility", async ({ story }) => {
+ await expect(story.locators.dropdown).toMatchAriaSnapshot(`
+ - menu:
+ - menuitem "Title 1"
+ - menuitem "Title 2"
+ - menuitem "Title 3"
+ - menuitem "Title 4"
+ `);
+ });
+
+ test("Sticky on scroll", async ({ story }) => {
+ await test.step("Select an item", async () => {
+ await story.locators.dropdownLabel.click();
+ await story.locators.dropdownList
+ .getByRole("menuitem", { name: "Title 2" })
+ .click();
+ });
+
+ await test.step("Scroll until sticky", async () => {
+ await story.scrollWithWheelUntil({
+ scrollTarget: story.locators.contentAfter,
+ until: async () => {
+ const text =
+ await story.locators.dropdownLabel.textContent();
+ return (text ?? "").includes("Title 2");
+ },
+ });
+ });
+
+ await test.step("Open dropdown when stickied shows backdrop", async () => {
+ await story.locators.dropdownLabel.click();
+ await expect(story.locators.dropdownList).toBeVisible();
+ await compareScreenshot(story, "sticky-open", {
+ fullscreen: true,
+ });
+ });
+ });
+ });
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("dropdown", { mode: "dark" });
+ });
+
+ test("Dropdown (dark mode)", async ({ story }) => {
+ await compareScreenshot(story, "mount", {
+ locator: story.locators.dropdown,
+ });
+ await story.locators.dropdownLabel.click();
+ await expect(story.locators.dropdownList).toBeVisible();
+ await compareScreenshot(story, "sticky-open", {
+ fullscreen: true,
+ });
+ });
+ });
+});
diff --git a/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts b/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts
new file mode 100644
index 0000000000..e453985f12
--- /dev/null
+++ b/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts
@@ -0,0 +1,153 @@
+import { css } from "@linaria/core";
+
+import { Border, Colour, Font, Motion, Radius, Spacing } from "../../theme";
+
+export const tokens = {
+ navWrapper: {
+ stickyOffset: "--fds-internal-localNavDropdown-navWrapper-stickyOffset",
+ sideMargin: "--fds-internal-localNavDropdown-navWrapper-sideMargin",
+ },
+ navItemList: {
+ viewportHeight:
+ "--fds-internal-localNavDropdown-navItemList-viewportHeight",
+ },
+};
+
+// -----------------------------------------------------------------------------
+// NAV SELECT
+// -----------------------------------------------------------------------------
+
+export const navSelectIcon = css`
+ color: ${Colour["icon"]};
+ transition: transform ${Motion["duration-250"]} ${Motion["ease-default"]};
+ transform: rotate(0deg);
+`;
+
+export const navSelectIconExpanded = css`
+ transform: rotate(180deg);
+`;
+
+export const navSelect = css`
+ cursor: pointer;
+ background: ${Colour["bg"]};
+ padding: ${Spacing["spacing-12"]} ${Spacing["spacing-16"]};
+ overflow: hidden;
+ box-shadow: 0 0 ${Border["width-010"]} ${Border["width-010"]}
+ ${Colour["border"]};
+ border-radius: ${Radius["sm"]};
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: all ${Motion["duration-250"]} ${Motion["ease-default"]};
+ transition-property: background, border-radius, box-shadow, transform;
+
+ &:focus-visible {
+ outline: 2px solid ${Colour["focus-ring"]};
+ outline-offset: 2px;
+ border-radius: ${Radius["sm"]};
+ }
+`;
+
+export const navSelectExpanded = css`
+ border-bottom-left-radius: ${Radius["none"]};
+ border-bottom-right-radius: ${Radius["none"]};
+`;
+
+// -----------------------------------------------------------------------------
+// NAV ITEMS
+// -----------------------------------------------------------------------------
+
+export const navItem = css`
+ padding: ${Spacing["spacing-12"]} ${Spacing["spacing-8"]}
+ ${Spacing["spacing-12"]} ${Spacing["spacing-32"]};
+ background: ${Colour["bg"]};
+ /* Ensures that the tick mark is positioned relative to the selected item */
+ position: relative;
+ display: flex;
+ /* Vertically align text and tick */
+ align-items: center;
+
+ &:focus-visible {
+ outline: 2px solid ${Colour["focus-ring"]};
+ outline-offset: 0px;
+ border-radius: ${Radius["sm"]};
+ }
+`;
+
+export const navItemSelected = css`
+ padding: ${Spacing["spacing-12"]} ${Spacing["spacing-8"]}
+ ${Spacing["spacing-12"]} 0;
+ background: ${Colour["bg-primary-subtlest"]};
+`;
+
+export const navItemList = css`
+ ${tokens.navItemList.viewportHeight}: initial;
+ transition: all ${Motion["duration-250"]} ${Motion["ease-default"]};
+ transform-origin: top;
+ list-style-type: none;
+ padding: 0 ${Spacing["spacing-8"]};
+ margin: 0;
+ background: ${Colour["bg"]};
+ cursor: pointer;
+ box-shadow: 0 0 ${Border["width-010"]} ${Border["width-010"]}
+ ${Colour["border"]};
+ border-bottom-right-radius: ${Radius["sm"]};
+ border-bottom-left-radius: ${Radius["sm"]};
+ /* Enables vertical scrolling */
+ overflow-y: auto;
+ /* Set a max height for the dropdown list */
+ max-height: var(${tokens.navItemList.viewportHeight});
+`;
+
+export const navItemLabel = css`
+ ${Font["body-baseline-regular"]}
+ color: ${Colour["text"]};
+`;
+
+export const navItemLabelSelected = css`
+ color: ${Colour["text-selected"]};
+`;
+
+export const tickIcon = css`
+ color: ${Colour["icon-selected"]};
+ margin: 0 ${Spacing["spacing-8"]};
+`;
+
+// -----------------------------------------------------------------------------
+// MAIN
+// -----------------------------------------------------------------------------
+
+export const backdrop = css`
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ background-color: ${Colour["overlay-strong"]};
+ z-index: -1;
+`;
+
+export const navWrapper = css`
+ ${tokens.navWrapper.stickyOffset}: initial;
+ ${tokens.navWrapper.sideMargin}: initial;
+ display: block;
+ position: sticky;
+ top: var(${tokens.navWrapper.stickyOffset});
+ width: 100%;
+ z-index: 10;
+`;
+
+export const navWrapperStickied = css`
+ .${navSelect} {
+ margin: 0 calc(var(${tokens.navWrapper.sideMargin} * -1, 0px));
+ padding: ${Spacing["spacing-12"]} ${Spacing["spacing-16"]};
+ border-radius: ${Radius["none"]};
+ }
+
+ .${navItemList} {
+ margin-left: calc(var(${tokens.navWrapper.sideMargin} * -1, 0px));
+ margin-right: calc(var(${tokens.navWrapper.sideMargin} * -1, 0px));
+ border-radius-bottom-left: ${Radius.sm};
+ border-radius-bottom-right: ${Radius.sm};
+ }
+`;
diff --git a/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.tsx b/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.tsx
deleted file mode 100644
index b800fbae30..0000000000
--- a/src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import { ChevronDownIcon } from "@lifesg/react-icons/chevron-down";
-import { TickIcon } from "@lifesg/react-icons/tick";
-import styled, { css } from "styled-components";
-
-import {
- V3_Border,
- V3_Colour,
- V3_Font,
- V3_Motion,
- V3_Radius,
- V3_Spacing,
-} from "../../v3_theme";
-
-// =============================================================================
-// STYLE INTERFACES, transient props are denoted with $
-// See more https://styled-components.com/docs/api#transient-props
-// =============================================================================
-interface DropdownNavStyleProps {
- $isStickied?: boolean;
- $stickyOffset: number;
- $sideMargin?: number;
-}
-
-interface NavItemListStyleProps {
- $viewportHeight?: number;
-}
-
-interface NavItemStyleProps {
- $isSelected?: boolean;
-}
-interface DropdownExpandedProps {
- $isDropdownExpanded: boolean;
-}
-
-interface NavIconStyleProps extends DropdownExpandedProps {}
-interface NavLabelStyleProps extends DropdownExpandedProps {}
-
-// =============================================================================
-// STYLING
-// =============================================================================
-
-// -----------------------------------------------------------------------------
-// NAV SELECT
-// -----------------------------------------------------------------------------
-
-export const NavSelectIcon = styled(ChevronDownIcon)`
- color: ${V3_Colour["icon"]};
- transition: transform ${V3_Motion["duration-250"]}
- ${V3_Motion["ease-default"]};
- transform: rotate(${(props) => (props.$isDropdownExpanded ? 180 : 0)}deg);
-`;
-
-export const NavSelect = styled.div`
- cursor: pointer;
- background: ${V3_Colour["bg"]};
- padding: ${V3_Spacing["spacing-12"]} ${V3_Spacing["spacing-16"]};
- overflow: hidden;
- box-shadow: 0 0 ${V3_Border["width-010"]} ${V3_Border["width-010"]}
- ${V3_Colour["border"]};
- border-radius: ${V3_Radius["sm"]};
- ${(props) =>
- props.$isDropdownExpanded &&
- css`
- border-bottom-left-radius: ${V3_Radius["none"]};
- border-bottom-right-radius: ${V3_Radius["none"]};
- `}
- display: flex;
- justify-content: space-between;
- align-items: center;
- transition: all ${V3_Motion["duration-250"]} ${V3_Motion["ease-default"]};
- transition-property: background, border-radius, box-shadow, transform;
-
- &:focus-visible {
- outline: 2px solid ${V3_Colour["focus-ring"]};
- outline-offset: 2px;
- border-radius: ${V3_Radius["sm"]};
- }
-`;
-
-// -----------------------------------------------------------------------------
-// NAV ITEMS
-// -----------------------------------------------------------------------------
-
-export const NavItem = styled.li`
- padding: ${(props) =>
- props.$isSelected
- ? css`
- ${V3_Spacing["spacing-12"]} ${V3_Spacing["spacing-8"]}
- ${V3_Spacing["spacing-12"]} 0
- `
- : css`
- ${V3_Spacing["spacing-12"]} ${V3_Spacing["spacing-8"]}
- ${V3_Spacing["spacing-12"]} ${V3_Spacing["spacing-32"]}
- `};
- background: ${(props) =>
- props.$isSelected ? V3_Colour["bg-primary-subtlest"] : V3_Colour["bg"]};
- /* Ensures that the tick mark is positioned relative to the selected item */
- position: relative;
- display: flex;
- /* Vertically align text and tick */
- align-items: center;
-
- &:focus-visible {
- outline: 2px solid ${V3_Colour["focus-ring"]};
- outline-offset: 0px;
- border-radius: ${V3_Radius["sm"]};
- }
-`;
-
-export const NavItemList = styled.ul`
- transition: all ${V3_Motion["duration-250"]} ${V3_Motion["ease-default"]};
- transform-origin: top;
- list-style-type: none;
- padding: 0 ${V3_Spacing["spacing-8"]};
- margin: 0;
- background: ${V3_Colour["bg"]};
- cursor: pointer;
- box-shadow: 0 0 ${V3_Border["width-010"]} ${V3_Border["width-010"]}
- ${V3_Colour["border"]};
- border-bottom-right-radius: ${V3_Radius["sm"]};
- border-bottom-left-radius: ${V3_Radius["sm"]};
- /* Enables vertical scrolling */
- overflow-y: auto;
- /* Set a max height for the dropdown list */
- max-height: ${(props) => props.$viewportHeight}px;
-`;
-
-export const NavItemLabel = styled.div`
- ${V3_Font["body-baseline-regular"]}
- color: ${(props) =>
- props.$isSelected ? V3_Colour["text-selected"] : V3_Colour["text"]};
-`;
-
-export const StyledTickIcon = styled(TickIcon)`
- color: ${V3_Colour["icon-selected"]};
- margin: 0 ${V3_Spacing["spacing-8"]};
-`;
-
-// -----------------------------------------------------------------------------
-// MAIN
-// -----------------------------------------------------------------------------
-
-export const Backdrop = styled.div`
- position: fixed;
- top: 0;
- right: 0;
- left: 0;
- bottom: 0;
- background-color: ${V3_Colour["overlay-strong"]};
- z-index: -1;
-`;
-
-export const NavWrapper = styled.nav`
- display: block;
- position: sticky;
- top: ${(props) => props.$stickyOffset}px;
- width: 100%;
- z-index: 10;
-
- ${({ $isStickied, $sideMargin }) =>
- $isStickied &&
- css`
- ${NavSelect} {
- ${$sideMargin && `margin: 0 -${$sideMargin}px;`}
- padding: ${V3_Spacing["spacing-12"]} ${V3_Spacing[
- "spacing-16"
- ]};
- border-radius: ${V3_Radius["none"]};
- }
-
- ${NavItemList} {
- ${$sideMargin && `margin-left: -${$sideMargin}px;`}
- ${$sideMargin && `margin-right: -${$sideMargin}px;`}
- border-radius-bottom-left: ${V3_Radius.sm};
- border-radius-bottom-right: ${V3_Radius.sm};
- }
- `}
-`;
diff --git a/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx b/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx
index e4f9fe118f..138f6c82bc 100644
--- a/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx
+++ b/src/local-nav/local-nav-dropdown/local-nav-dropdown.tsx
@@ -1,19 +1,14 @@
+import { ChevronDownIcon } from "@lifesg/react-icons/chevron-down";
+import { TickIcon } from "@lifesg/react-icons/tick";
+import clsx from "clsx";
import React, { useEffect, useImperativeHandle, useRef, useState } from "react";
+import { useApplyStyle } from "../../theme";
import { Typography } from "../../typography";
import { useId } from "../../util";
import type { LocalNavDropdownItemComponentProps } from "../internal-types";
import type { LocalNavDropdownProps, LocalNavItemProps } from "../types";
-import {
- Backdrop,
- NavItem,
- NavItemLabel,
- NavItemList,
- NavSelect,
- NavSelectIcon,
- NavWrapper,
- StyledTickIcon,
-} from "./local-nav-dropdown.styles";
+import * as styles from "./local-nav-dropdown.styles";
const Component = (
{
@@ -35,6 +30,7 @@ const Component = (
const detectStickyRef = useRef(null);
const dropdownRef = useRef(null);
const navWrapperRef = useRef(null);
+ const navItemListRef = useRef(null);
const listItemRefs = useRef<(HTMLLIElement | null)[]>([]);
const [isStickied, setIsStickied] = useState(false);
const [isDropdownExpanded, setIsDropdownExpanded] =
@@ -48,6 +44,17 @@ const Component = (
useImperativeHandle(ref, () => navWrapperRef.current!);
+ useApplyStyle(navWrapperRef, {
+ [styles.tokens.navWrapper.stickyOffset]: `${stickyOffset}px`,
+ [styles.tokens.navWrapper.sideMargin]: `${dynamicMargin}px`,
+ });
+
+ useApplyStyle(navItemListRef, {
+ [styles.tokens.navItemList.viewportHeight]: `${
+ viewportHeight - dropdowntHeight - stickyOffset
+ }px`,
+ });
+
const labelText =
typeof selectedItemIndex === "number" &&
selectedItemIndex >= 0 &&
@@ -301,11 +308,14 @@ const Component = (
}
return (
- handleNavItemKeyDown(e, handleClick)}
aria-current={isSelected ? true : undefined}
@@ -314,31 +324,42 @@ const Component = (
listItemRefs.current[index] = el as HTMLLIElement;
}}
>
- {isSelected && }
- {title}
-
+ {isSelected && }
+
+ {title}
+
+
);
};
return (
<>
-
-
{labelText}
-
-
+
+
{isDropdownExpanded && (
-
{items.map((item, i) =>
renderDropdownNavItem({
@@ -369,12 +394,15 @@ const Component = (
index: i,
})
)}
-
+
)}
{isDropdownExpanded && isStickied && (
-
+
)}
-
+
>
);
};
diff --git a/src/local-nav/local-nav-menu/local-nav-menu.styles.ts b/src/local-nav/local-nav-menu/local-nav-menu.styles.ts
new file mode 100644
index 0000000000..c4fbe5ddf0
--- /dev/null
+++ b/src/local-nav/local-nav-menu/local-nav-menu.styles.ts
@@ -0,0 +1,55 @@
+import { css } from "@linaria/core";
+
+import { Colour, Radius, Spacing } from "../../theme";
+
+export const nav = css`
+ list-style-type: none;
+ padding: 0;
+ margin-top: 0;
+`;
+
+export const textLabel = css`
+ margin: 0;
+`;
+
+export const navItem = css`
+ display: block;
+ position: relative;
+ margin: 0;
+ padding: 0;
+ cursor: pointer;
+
+ &::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ top: 0;
+ background-color: ${Colour["bg-primary-subtler"]};
+ transition: all 250ms linear;
+ }
+
+ &:hover,
+ &:focus-within {
+ background-color: ${Colour["bg-hover-subtle"]};
+ }
+`;
+
+export const navItemSelected = css`
+ &::before {
+ background-color: ${Colour["bg-primary"]};
+ }
+`;
+
+export const navItemContent = css`
+ display: block;
+ padding: ${Spacing["spacing-16"]};
+ padding-left: ${Spacing["spacing-20"]};
+
+ &:focus-visible {
+ outline: 2px solid ${Colour["focus-ring"]};
+ outline-offset: 2px;
+ border-radius: ${Radius["sm"]};
+ }
+`;
diff --git a/src/local-nav/local-nav-menu/local-nav-menu.styles.tsx b/src/local-nav/local-nav-menu/local-nav-menu.styles.tsx
deleted file mode 100644
index b55d7a95ff..0000000000
--- a/src/local-nav/local-nav-menu/local-nav-menu.styles.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import styled from "styled-components";
-
-import { Typography } from "../../typography";
-import { V3_Colour, V3_Radius, V3_Spacing } from "../../v3_theme";
-
-// =============================================================================
-// STYLE INTERFACES, transient props are denoted with $
-// See more https://styled-components.com/docs/api#transient-props
-// =============================================================================
-interface NavItemStyleProps {
- $isSelected?: boolean;
-}
-
-// =============================================================================
-// STYLING
-// =============================================================================
-
-export const Nav = styled.ul`
- list-style-type: none;
- padding: 0;
- margin-top: 0;
-`;
-
-export const TextLabel = styled(Typography.BodyBL)`
- margin: 0;
-`;
-
-export const NavItem = styled.li`
- display: block;
- position: relative;
- margin: 0;
- padding: 0;
- cursor: pointer;
-
- &::before {
- content: "";
- position: absolute;
- left: 0;
- width: 4px;
- height: 100%;
- top: 0;
- background-color: ${(props) =>
- props.$isSelected
- ? V3_Colour["bg-primary"]
- : V3_Colour["bg-primary-subtler"]};
- transition: all 250ms linear;
- }
-
- &:hover,
- &:focus-within {
- background-color: ${V3_Colour["bg-hover-subtle"]};
- }
-`;
-
-export const NavItemContent = styled.div`
- display: block;
- padding: ${V3_Spacing["spacing-16"]};
- padding-left: ${V3_Spacing["spacing-20"]};
-
- &:focus-visible {
- outline: 2px solid ${V3_Colour["focus-ring"]};
- outline-offset: 2px;
- border-radius: ${V3_Radius["sm"]};
- }
-`;
diff --git a/src/local-nav/local-nav-menu/local-nav-menu.tsx b/src/local-nav/local-nav-menu/local-nav-menu.tsx
index 4faaac1b72..95a47f822c 100644
--- a/src/local-nav/local-nav-menu/local-nav-menu.tsx
+++ b/src/local-nav/local-nav-menu/local-nav-menu.tsx
@@ -1,13 +1,10 @@
+import clsx from "clsx";
import React from "react";
+import { Typography } from "../../typography";
import type { LocalNavMenuItemComponentProps } from "../internal-types";
import type { LocalNavMenuProps } from "../types";
-import {
- Nav,
- NavItem,
- NavItemContent,
- TextLabel,
-} from "./local-nav-menu.styles";
+import * as styles from "./local-nav-menu.styles";
/**
* A sidebar navigation element. The currently visible section will be highlighted.
@@ -67,15 +64,26 @@ const Component = (
return renderItem(item, { selected: isSelected });
}
return (
-
+
{title}
-
+
);
};
return (
-
-
+ handleNavItemKeyDown(e, handleClick)}
@@ -83,15 +91,15 @@ const Component = (
aria-current={isSelected ? true : undefined}
>
{renderTitle()}
-
-
+
+
);
};
return (
-
+
);
};
diff --git a/tests/local-nav/local-nav.spec.tsx b/tests/local-nav/local-nav.spec.tsx
index f3eeaedcd4..13edce7a8e 100644
--- a/tests/local-nav/local-nav.spec.tsx
+++ b/tests/local-nav/local-nav.spec.tsx
@@ -78,6 +78,40 @@ describe("LocalNav", () => {
expect(screen.getByText("Custom: Section 1")).toBeInTheDocument();
});
+
+ it("should handle keyboard navigation and selection", async () => {
+ const user = userEvent.setup();
+ const mockOnSelect = jest.fn();
+
+ render(
+
+ );
+
+ const items = screen.getAllByRole("link");
+
+ await user.keyboard("{Tab}");
+ expect(items[0]).toHaveFocus();
+
+ await user.keyboard("{Escape}");
+ expect(items[0]).toHaveFocus();
+
+ await user.keyboard("{Tab}");
+ expect(items[1]).toHaveFocus();
+
+ await user.keyboard("{Shift>}{Tab}{/Shift}");
+ expect(items[0]).toHaveFocus();
+
+ for (let i = 0; i < items.length; i++) {
+ await user.keyboard("{Tab}");
+ }
+
+ items.forEach((item) => {
+ expect(item).not.toHaveFocus();
+ });
+ });
});
describe("LocalNavDropdown", () => {
@@ -254,6 +288,13 @@ describe("LocalNav", () => {
await user.keyboard(" ");
expect(screen.getByRole("menu")).toBeInTheDocument();
+
+ await user.keyboard("{Escape}");
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+
+ await user.keyboard("{ArrowDown}");
+ expect(screen.getByRole("menu")).toBeInTheDocument();
+
expect(
screen.getByRole("menuitem", { name: "Section 1" })
).toHaveFocus();