-
Notifications
You must be signed in to change notification settings - Fork 21
[BOOKINGSG-9311][RYN] migrate local nav component #1178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: pre-release/v4
Are you sure you want to change the base?
Changes from all commits
176a62d
48ac5aa
faa00d5
5321efb
295ff3d
aac1999
0420b2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ); | ||
| } |
| 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)} | ||
| /> | ||
| ); | ||
| } |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
| 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 }) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. check the |
||
| - menuitem "Title 1" | ||
| - menuitem "Title 2" | ||
| - menuitem "Title 3" | ||
| - menuitem "Title 4" | ||
| `); | ||
| }); | ||
|
|
||
| test("Sticky on scroll", async ({ story }) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hover the hover states |
||
| await test.step("Select an item", async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
| 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}; | ||
| } | ||
| `; |
There was a problem hiding this comment.
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