Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 61 additions & 0 deletions e2e/nextjs-app/src/app/components/local-nav/dropdown.e2e.tsx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eh can exclude the custom content and scroll jumping since that's meant to be done by the consumer. it's not relevant to the DS. we only need to set up a basic example that showcases the sticky nav behaviour

Original file line number Diff line number Diff line change
@@ -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 = () => (
<div
data-testid="content-before"
className="story-padding story-background"
>
<Typography.HeadingMD>Top content</Typography.HeadingMD>
<Typography.BodyBL>{LOREM_PARAGRAPH}</Typography.BodyBL>
</div>
);

const Content = () => (
<div data-testid="content-after" className="story-background">
{ITEMS.map((item) => (
<div key={item.id}>
<Typography.HeadingMD id={item.id}>
{item.title}
</Typography.HeadingMD>
<Typography.BodyBL>{LOREM_PARAGRAPH}</Typography.BodyBL>
</div>
))}
</div>
);

export default function Story() {
const [selectedItemIndex] = useState<number>(-1);
const dropdownRef = useRef<HTMLElement>(null);

return (
<div>
<TopContent />
<LocalNavDropdown
ref={dropdownRef}
data-testid="local-nav-dropdown"
defaultLabel="Jump to section"
items={ITEMS}
selectedItemIndex={selectedItemIndex}
stickyOffset={0}
onNavItemSelect={() => {}}
/>
<div className="story-padding">
<Content />
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions e2e/nextjs-app/src/app/components/local-nav/menu.e2e.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<LocalNavMenu
data-testid="local-nav-menu"
items={ITEMS}
selectedItemIndex={selectedItemIndex}
onNavItemSelect={(_e, _item, index) => setSelectedItemIndex(index)}
/>
);
}
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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't look right. the dropdown should be fixed to the top of the page

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.
156 changes: 156 additions & 0 deletions e2e/tests/components/local-nav/local-nav.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include the hover state and non-selected state. currently it only covers the item-selected state

await compareScreenshot(story, "mount", {
locator: story.locators.menu,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exclude the locators by default. we want to check the default width and height behaviour in most cases

});
});

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"
);
Comment on lines +83 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert the aria snapshot of the component on mount instead of checking the individual property

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:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check the expanded state here as well

- menuitem "Title 1"
- menuitem "Title 2"
- menuitem "Title 3"
- menuitem "Title 4"
`);
});

test("Sticky on scroll", async ({ story }) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hover the hover states

await test.step("Select an item", async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include a snapshot for the non-sticky opened dropdown

await story.locators.dropdownLabel.click();
await story.locators.dropdownList
.getByRole("menuitem", { name: "Title 2" })
.click();
});

await test.step("Scroll until sticky", async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include a snapshot for the sticky unopened dropdown

await story.scrollWithWheelUntil({
scrollTarget: story.locators.contentAfter,
until: async () => {
const text =
await story.locators.dropdownLabel.textContent();
return (text ?? "").includes("Title 2");
Comment on lines +124 to +126
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a longer list? I was thinking that since we're only on title 2, it can resulted in zero scrolling or non-sticky result

},
});
});

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", {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to be missing the scrolling part for this to have a sticky state

fullscreen: true,
});
});
});
});
153 changes: 153 additions & 0 deletions src/local-nav/local-nav-dropdown/local-nav-dropdown.styles.ts
Original file line number Diff line number Diff line change
@@ -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};
}
`;
Loading
Loading