diff --git a/e2e/nextjs-app/src/app/components/dropdown-list/base-variants.e2e.tsx b/e2e/nextjs-app/src/app/components/dropdown-list/base-variants.e2e.tsx new file mode 100644 index 0000000000..e39104da36 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/dropdown-list/base-variants.e2e.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { + DropdownList, + DropdownListState, +} from "@lifesg/react-design-system/shared/dropdown-list"; + +const ITEMS = ["Option A", "Option B", "Option C", "Option D"]; + +export default function Story() { + return ( +
+
+ + + +
+
+ + + +
+
+ ); +} + +export const story = { init: Story }; diff --git a/e2e/nextjs-app/src/app/components/dropdown-list/custom-cta.e2e.tsx b/e2e/nextjs-app/src/app/components/dropdown-list/custom-cta.e2e.tsx new file mode 100644 index 0000000000..51df46cf90 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/dropdown-list/custom-cta.e2e.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { + DropdownList, + DropdownListState, +} from "@lifesg/react-design-system/shared/dropdown-list"; + +const ITEMS = ["Option A", "Option B", "Option C"]; + +export default function Story() { + return ( +
+ + ( + + )} + /> + +
+ ); +} + +export const story = { init: Story }; diff --git a/e2e/nextjs-app/src/app/components/dropdown-list/keyboard-nav.e2e.tsx b/e2e/nextjs-app/src/app/components/dropdown-list/keyboard-nav.e2e.tsx new file mode 100644 index 0000000000..50226d9b3c --- /dev/null +++ b/e2e/nextjs-app/src/app/components/dropdown-list/keyboard-nav.e2e.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useState } from "react"; +import { + DropdownList, + DropdownListState, +} from "@lifesg/react-design-system/shared/dropdown-list"; + +const ITEMS = ["Option A", "Option B", "Option C", "Option D"]; + +export default function Story() { + const [selected, setSelected] = useState([]); + + const handleSelectItem = (item: unknown) => { + setSelected((prev) => (prev.includes(item) ? [] : [item])); + }; + + return ( +
+ + + +
+ ); +} + +export const story = { init: Story }; diff --git a/e2e/nextjs-app/src/app/components/dropdown-list/load-states.e2e.tsx b/e2e/nextjs-app/src/app/components/dropdown-list/load-states.e2e.tsx new file mode 100644 index 0000000000..18e725c5b1 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/dropdown-list/load-states.e2e.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { + DropdownList, + DropdownListState, +} from "@lifesg/react-design-system/shared/dropdown-list"; + +export default function Story() { + const [failRetried, setFailRetried] = useState(false); + + return ( +
+
+ + {}} + /> + +
+
+ + setFailRetried(true)} + /> + +
+ {failRetried && ( +
Retry triggered
+ )} +
+ ); +} + +export const story = { init: Story }; diff --git a/e2e/nextjs-app/src/app/components/dropdown-list/multi-selection.e2e.tsx b/e2e/nextjs-app/src/app/components/dropdown-list/multi-selection.e2e.tsx new file mode 100644 index 0000000000..7e6367ce5c --- /dev/null +++ b/e2e/nextjs-app/src/app/components/dropdown-list/multi-selection.e2e.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { + DropdownList, + DropdownListState, +} from "@lifesg/react-design-system/shared/dropdown-list"; + +const ITEMS = ["Option A", "Option B", "Option C", "Option D"]; + +export default function Story() { + const [selected, setSelected] = useState([]); + + const handleSelectItem = (item: unknown) => { + setSelected((prev) => + prev.includes(item) + ? prev.filter((i) => i !== item) + : [...prev, item] + ); + }; + + const handleSelectAll = () => { + setSelected((prev) => (prev.length === ITEMS.length ? [] : [...ITEMS])); + }; + + return ( +
+ + + +
+ ); +} + +export const story = { init: Story }; diff --git a/e2e/nextjs-app/src/app/components/dropdown-list/search.e2e.tsx b/e2e/nextjs-app/src/app/components/dropdown-list/search.e2e.tsx new file mode 100644 index 0000000000..0c24badb26 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/dropdown-list/search.e2e.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { + DropdownList, + DropdownListState, +} from "@lifesg/react-design-system/shared/dropdown-list"; + +const ITEMS = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]; + +export default function Story() { + return ( +
+ + + +
+ ); +} + +export const story = { init: Story }; diff --git a/e2e/nextjs-app/src/app/components/dropdown-list/single-selection.e2e.tsx b/e2e/nextjs-app/src/app/components/dropdown-list/single-selection.e2e.tsx new file mode 100644 index 0000000000..8626da6b24 --- /dev/null +++ b/e2e/nextjs-app/src/app/components/dropdown-list/single-selection.e2e.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useState } from "react"; +import { + DropdownList, + DropdownListState, +} from "@lifesg/react-design-system/shared/dropdown-list"; + +const ITEMS = ["Option A", "Option B", "Option C", "Option D"]; + +export default function Story() { + const [selected, setSelected] = useState([]); + + const handleSelectItem = (item: unknown) => { + setSelected((prev) => (prev.includes(item) ? [] : [item])); + }; + + return ( +
+ + + +
+ ); +} + +export const story = { init: Story }; diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Clicking-an-item-selects-it-single-selection-selected.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Clicking-an-item-selects-it-single-selection-selected.png new file mode 100644 index 0000000000..d662a41da0 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Clicking-an-item-selects-it-single-selection-selected.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Custom-CTA---dark-mode-custom-cta-dark.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Custom-CTA---dark-mode-custom-cta-dark.png new file mode 100644 index 0000000000..dcc5dd3dab Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Custom-CTA---dark-mode-custom-cta-dark.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Default-and-small-variant---dark-mode-base-variants-dark.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Default-and-small-variant---dark-mode-base-variants-dark.png new file mode 100644 index 0000000000..3231a87f0e Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Default-and-small-variant---dark-mode-base-variants-dark.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Default-and-small-variant-base-variants.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Default-and-small-variant-base-variants.png new file mode 100644 index 0000000000..c6c02d7180 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Default-and-small-variant-base-variants.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Item-hover-activates-hover-state-base-variants-hover-default.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Item-hover-activates-hover-state-base-variants-hover-default.png new file mode 100644 index 0000000000..7ef091880b Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Item-hover-activates-hover-state-base-variants-hover-default.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Item-hover-activates-hover-state-base-variants-hover-small.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Item-hover-activates-hover-state-base-variants-hover-small.png new file mode 100644 index 0000000000..79851b41e0 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Item-hover-activates-hover-state-base-variants-hover-small.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading---dark-mode-load-states-dark.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading---dark-mode-load-states-dark.png new file mode 100644 index 0000000000..4a04bc9b57 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading---dark-mode-load-states-dark.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading-fail-and-retry-callback-load-states-fail.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading-fail-and-retry-callback-load-states-fail.png new file mode 100644 index 0000000000..9bbc616e14 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading-fail-and-retry-callback-load-states-fail.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading-fail-and-retry-callback-load-states.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading-fail-and-retry-callback-load-states.png new file mode 100644 index 0000000000..b8a808b168 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Loading-fail-and-retry-callback-load-states.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Renders-at-the-bottom-of-the-list-custom-cta.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Renders-at-the-bottom-of-the-list-custom-cta.png new file mode 100644 index 0000000000..d349ffff4e Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Renders-at-the-bottom-of-the-list-custom-cta.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Select-all---dark-mode-multi-selection-dark.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Select-all---dark-mode-multi-selection-dark.png new file mode 100644 index 0000000000..07d61875c5 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Select-all---dark-mode-multi-selection-dark.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Typing-clearing-and-no-results-flow-search-filtered.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Typing-clearing-and-no-results-flow-search-filtered.png new file mode 100644 index 0000000000..0a95992b77 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Typing-clearing-and-no-results-flow-search-filtered.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Typing-clearing-and-no-results-flow-search-no-results.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Typing-clearing-and-no-results-flow-search-no-results.png new file mode 100644 index 0000000000..662c086a0f Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-Typing-clearing-and-no-results-flow-search-no-results.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-clicking-items-toggles-their-selection-multi-selection-partial.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-clicking-items-toggles-their-selection-multi-selection-partial.png new file mode 100644 index 0000000000..f7273d49e6 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-clicking-items-toggles-their-selection-multi-selection-partial.png differ diff --git a/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-select-all-then-clear-all-multi-selection-all-selected.png b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-select-all-then-clear-all-multi-selection-all-selected.png new file mode 100644 index 0000000000..ad2b3368d5 Binary files /dev/null and b/e2e/tests/components/dropdown-list/__screenshots__/chromium/DropdownList-select-all-then-clear-all-multi-selection-all-selected.png differ diff --git a/e2e/tests/components/dropdown-list/dropdown-list.e2e.spec.ts b/e2e/tests/components/dropdown-list/dropdown-list.e2e.spec.ts new file mode 100644 index 0000000000..d9207fd731 --- /dev/null +++ b/e2e/tests/components/dropdown-list/dropdown-list.e2e.spec.ts @@ -0,0 +1,407 @@ +import { test as base, expect, Locator, Page } from "@playwright/test"; +import { AbstractStoryPage, compareScreenshot } from "../../utils"; + +class StoryPage extends AbstractStoryPage { + protected readonly component = "dropdown-list"; + + public readonly locators: { + variantDefault: Locator; + variantSmall: Locator; + singleSelectContainer: Locator; + multiSelectContainer: Locator; + searchContainer: Locator; + keyboardNavContainer: Locator; + loadingContainer: Locator; + failContainer: Locator; + customCtaContainer: Locator; + variantDefaultDropdownContainer: Locator; + variantSmallDropdownContainer: Locator; + variantDefaultDropdownList: Locator; + variantSmallDropdownList: Locator; + searchInput: Locator; + searchNoResults: Locator; + loadingState: Locator; + failState: Locator; + customCta: Locator; + ctaButton: Locator; + retryTriggered: Locator; + }; + + constructor(page: Page) { + super(page); + + this.locators = { + variantDefault: page.getByTestId("variant-default"), + variantSmall: page.getByTestId("variant-small"), + singleSelectContainer: page.getByTestId("single-select-container"), + multiSelectContainer: page.getByTestId("multi-select-container"), + searchContainer: page.getByTestId("search-container"), + keyboardNavContainer: page.getByTestId("keyboard-nav-container"), + loadingContainer: page.getByTestId("loading-container"), + failContainer: page.getByTestId("fail-container"), + customCtaContainer: page.getByTestId("custom-cta-container"), + variantDefaultDropdownContainer: page + .getByTestId("variant-default") + .getByTestId("dropdown-container"), + variantSmallDropdownContainer: page + .getByTestId("variant-small") + .getByTestId("dropdown-container"), + variantDefaultDropdownList: page + .getByTestId("variant-default") + .getByTestId("dropdown-list"), + variantSmallDropdownList: page + .getByTestId("variant-small") + .getByTestId("dropdown-list"), + searchInput: page + .getByTestId("search-container") + .getByTestId("search-input"), + searchNoResults: page + .getByTestId("search-container") + .getByTestId("list-no-results"), + loadingState: page + .getByTestId("loading-container") + .getByTestId("list-loading"), + failState: page + .getByTestId("fail-container") + .getByTestId("list-fail"), + customCta: page + .getByTestId("custom-cta-container") + .getByTestId("custom-cta"), + ctaButton: page + .getByTestId("custom-cta-container") + .getByTestId("cta-button"), + retryTriggered: page.getByTestId("retry-triggered"), + }; + } + + public getListItemsIn(container: Locator) { + return container.getByTestId("list-item"); + } +} + +const test = base.extend<{ story: StoryPage }>({ + story: async ({ page }, use) => { + const story = new StoryPage(page); + await use(story); + }, +}); + +test.describe("DropdownList", () => { + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("base-variants"); + }); + + test("Default and small variant", async ({ story }) => { + await expect( + story.locators.variantDefaultDropdownContainer + ).toBeVisible(); + await expect( + story.locators.variantSmallDropdownContainer + ).toBeVisible(); + + await expect(story.locators.variantDefaultDropdownList) + .toMatchAriaSnapshot(` + - group "Default variant": + - listbox: + - option "Option A" + - option "Option B" + - option "Option C" + - option "Option D" + `); + + await expect(story.locators.variantSmallDropdownList) + .toMatchAriaSnapshot(` + - group "Small variant": + - listbox: + - option "Option A" + - option "Option B" + - option "Option C" + - option "Option D" + `); + + await compareScreenshot(story, "base-variants"); + }); + + test("Item hover activates hover state", async ({ story }) => { + const defaultItems = story.getListItemsIn( + story.locators.variantDefault + ); + const smallItems = story.getListItemsIn( + story.locators.variantSmall + ); + + await defaultItems.first().hover(); + await compareScreenshot(story, "base-variants-hover-default", { + locator: story.locators.variantDefault, + }); + + await smallItems.first().hover(); + await compareScreenshot(story, "base-variants-hover-small", { + locator: story.locators.variantSmall, + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("base-variants", { mode: "dark" }); + }); + + test("Default and small variant - dark mode", async ({ story }) => { + await compareScreenshot(story, "base-variants-dark"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("single-selection"); + }); + + test("Clicking an item selects it", async ({ story }) => { + const container = story.locators.singleSelectContainer; + const items = story.getListItemsIn(container); + + await expect(items.first()).toHaveAttribute( + "aria-selected", + "false" + ); + await items.first().click(); + await expect(items.first()).toHaveAttribute( + "aria-selected", + "true" + ); + + await compareScreenshot(story, "single-selection-selected", { + locator: container, + }); + }); + + test("Clicking a selected item deselects it", async ({ story }) => { + const container = story.locators.singleSelectContainer; + const items = story.getListItemsIn(container); + + await items.first().click(); + await expect(items.first()).toHaveAttribute( + "aria-selected", + "true" + ); + + await items.first().click(); + await expect(items.first()).toHaveAttribute( + "aria-selected", + "false" + ); + }); + + test("Selecting a new item deselects the previous", async ({ + story, + }) => { + const container = story.locators.singleSelectContainer; + const items = story.getListItemsIn(container); + + await items.first().click(); + await expect(items.first()).toHaveAttribute( + "aria-selected", + "true" + ); + + await items.nth(1).click(); + await expect(items.first()).toHaveAttribute( + "aria-selected", + "false" + ); + await expect(items.nth(1)).toHaveAttribute("aria-selected", "true"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("multi-selection"); + }); + + test("clicking items toggles their selection", async ({ story }) => { + const container = story.locators.multiSelectContainer; + const items = story.getListItemsIn(container); + + await items.first().click(); + await items.nth(1).click(); + + await expect(items.first()).toHaveAttribute( + "aria-selected", + "true" + ); + await expect(items.nth(1)).toHaveAttribute("aria-selected", "true"); + await expect(items.nth(2)).toHaveAttribute( + "aria-selected", + "false" + ); + + await compareScreenshot(story, "multi-selection-partial", { + locator: container, + }); + }); + + test("select all then clear all", async ({ story }) => { + const container = story.locators.multiSelectContainer; + const items = story.getListItemsIn(container); + + await container.getByRole("button", { name: "Select all" }).click(); + const count = await items.count(); + for (let i = 0; i < count; i++) { + await expect(items.nth(i)).toHaveAttribute( + "aria-selected", + "true" + ); + } + + await compareScreenshot(story, "multi-selection-all-selected", { + locator: container, + }); + + await container.getByRole("button", { name: "Clear all" }).click(); + for (let i = 0; i < count; i++) { + await expect(items.nth(i)).toHaveAttribute( + "aria-selected", + "false" + ); + } + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("multi-selection", { mode: "dark" }); + }); + + test("Select all - dark mode", async ({ story }) => { + await compareScreenshot(story, "multi-selection-dark"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("search"); + }); + + test("Typing, clearing, and no-results flow", async ({ story }) => { + await story.locators.searchInput.fill("an"); + + const items = story.getListItemsIn(story.locators.searchContainer); + await expect(items).toHaveCount(1); + await expect(items.first()).toContainText("Banana"); + + await compareScreenshot(story, "search-filtered", { + locator: story.locators.searchContainer, + }); + + await story.locators.searchContainer + .getByRole("button", { name: "Clear" }) + .click(); + await expect(items).toHaveCount(5); + + await story.locators.searchInput.fill("zzz"); + await expect(story.locators.searchNoResults).toBeVisible(); + + await compareScreenshot(story, "search-no-results", { + locator: story.locators.searchContainer, + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("keyboard-nav"); + }); + + test("Arrow keys, enter, and space", async ({ story }) => { + const items = story.getListItemsIn( + story.locators.keyboardNavContainer + ); + + await items.first().focus(); + await story.page.keyboard.press("ArrowDown"); + await expect(items.nth(1)).toBeFocused(); + + await story.page.keyboard.press("ArrowUp"); + await expect(items.first()).toBeFocused(); + + await story.page.keyboard.press("Enter"); + await expect(items.first()).toHaveAttribute( + "aria-selected", + "true" + ); + + await story.page.keyboard.press("ArrowDown"); + await expect(items.nth(1)).toBeFocused(); + await story.page.keyboard.press("Space"); + await expect(items.nth(1)).toHaveAttribute("aria-selected", "true"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("load-states"); + }); + + test("Loading, fail, and retry callback", async ({ story }) => { + await expect(story.locators.loadingState).toBeVisible(); + + await compareScreenshot(story, "load-states", { + locator: story.locators.loadingContainer, + }); + + await expect(story.locators.failState).toBeVisible(); + await expect( + story.locators.failContainer.getByRole("button", { + name: "Try again.", + }) + ).toBeVisible(); + + await compareScreenshot(story, "load-states-fail", { + locator: story.locators.failContainer, + }); + + await story.locators.failContainer + .getByRole("button", { name: "Try again." }) + .click(); + + await expect(story.locators.retryTriggered).toBeVisible(); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("load-states", { mode: "dark" }); + }); + + test("Loading - dark mode", async ({ story }) => { + await compareScreenshot(story, "load-states-dark"); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("custom-cta"); + }); + + test("Renders at the bottom of the list", async ({ story }) => { + await expect(story.locators.customCta).toBeVisible(); + await expect(story.locators.ctaButton).toBeVisible(); + + await compareScreenshot(story, "custom-cta", { + locator: story.locators.customCtaContainer, + }); + }); + }); + + test.describe(() => { + test.beforeEach(async ({ story }) => { + await story.init("custom-cta", { mode: "dark" }); + }); + + test("Custom CTA - dark mode", async ({ story }) => { + await compareScreenshot(story, "custom-cta-dark"); + }); + }); +}); diff --git a/src/language-switcher/dropdown-variant.style.tsx b/src/language-switcher/dropdown-variant.style.tsx index 63601358e2..a9e4edacf0 100644 --- a/src/language-switcher/dropdown-variant.style.tsx +++ b/src/language-switcher/dropdown-variant.style.tsx @@ -2,11 +2,10 @@ import styled, { css } from "styled-components"; import { ExpandableElement } from "../shared/dropdown-list"; import { - ListItem, - SelectedIndicator, - UnselectedIndicator, + selectedIndicator, + unselectedIndicator, } from "../shared/dropdown-list/dropdown-list.styles"; -import { IconContainer } from "../shared/dropdown-list/expandable-element.styles"; +import { iconContainer } from "../shared/dropdown-list/expandable-element.styles"; import { Border, Colour, Font, Radius, Spacing } from "../theme"; // ============================================================================= @@ -23,7 +22,7 @@ export const StyledExpandableElement = styled(ExpandableElement)` border: ${Border["width-010"]} ${Border["solid"]} ${Colour["border"]}; background: ${Colour["bg"]}; - ${IconContainer} { + .${iconContainer} { margin-left: auto; svg { @@ -68,9 +67,9 @@ export const DropdownList = styled.ul` padding: ${Spacing["spacing-8"]}; `; -export { SelectedIndicator, UnselectedIndicator }; +export { selectedIndicator, unselectedIndicator }; -export const DropdownItem = styled(ListItem)` +export const DropdownItem = styled.li<{ $selected: boolean }>` align-items: center; ${Font["body-md-regular"]} color: ${Colour["text"]}; diff --git a/src/language-switcher/dropdown-variant.tsx b/src/language-switcher/dropdown-variant.tsx index 1cd326bfe4..8746def838 100644 --- a/src/language-switcher/dropdown-variant.tsx +++ b/src/language-switcher/dropdown-variant.tsx @@ -1,7 +1,14 @@ import { LanguageIcon } from "@lifesg/react-icons/language"; +import { TickIcon } from "@lifesg/react-icons/tick"; +import clsx from "clsx"; import type React from "react"; import { useEffect, useRef, useState } from "react"; +import { + listItem, + listItemActive, + listItemActiveSelected, +} from "../shared/dropdown-list/dropdown-list.styles"; import type { DropdownRenderProps } from "../shared/dropdown-wrapper"; import { ElementWithDropdown } from "../shared/dropdown-wrapper"; import { SimpleIdGenerator } from "../util"; @@ -11,9 +18,9 @@ import { DropdownList, DropdownPanel, LanguageIconWrapper, - SelectedIndicator, + selectedIndicator, StyledExpandableElement, - UnselectedIndicator, + unselectedIndicator, } from "./dropdown-variant.style"; import type { VariantInternalProps } from "./internal-types"; import type { LanguageSwitcherCode } from "./types"; @@ -166,18 +173,26 @@ export const DropdownVariant = ({ }} role="option" lang={code} + className={clsx( + listItem, + isFocused && + isSelected && + listItemActiveSelected, + isFocused && !isSelected && listItemActive + )} aria-selected={isSelected} tabIndex={isFocused ? 0 : -1} - $active={isFocused} $selected={isSelected} - $disabled={false} onClick={() => handleItemSelect(code)} data-testid={`${testId}--item-${code}`} > {isSelected ? ( - + ) : ( - +
)} {LANGUAGE_DISPLAY_MAP[code]} diff --git a/src/shared/dropdown-list/dropdown-label.styles.ts b/src/shared/dropdown-list/dropdown-label.styles.ts new file mode 100644 index 0000000000..ee965b1622 --- /dev/null +++ b/src/shared/dropdown-list/dropdown-label.styles.ts @@ -0,0 +1,137 @@ +import { css } from "@linaria/core"; + +import { Colour, Font } from "../../theme"; +import { lineClampDynamicCss } from "../styles"; + +export const tokens = { + primaryText: { + maxLines: "--fds-internal-dropdownLabel-primaryText-maxLines", + }, + secondaryText: { + maxLines: "--fds-internal-dropdownLabel-secondaryText-maxLines", + }, +}; + +// ============================================================================= +// STYLING +// ============================================================================= + +// ----------------------------------------------------------------------------- +// PRIMARY TEXT +// ----------------------------------------------------------------------------- +export const primaryText = css` + ${tokens.primaryText.maxLines}: 2; + font-weight: ${Font.Spec["weight-regular"]}; + color: ${Colour["text"]}; + width: 100%; + overflow-wrap: break-word; +`; + +export const primaryTextBold = css` + font-weight: ${Font.Spec["weight-semibold"]}; +`; + +export const primaryTextSelected = css` + color: ${Colour["text-selected"]}; +`; + +export const primaryTextDisabled = css` + color: ${Colour["text-disabled"]}; +`; + +export const primaryTextTruncateEnd = css` + ${lineClampDynamicCss(tokens.primaryText.maxLines)} +`; + +// ----------------------------------------------------------------------------- +// SECONDARY TEXT +// ----------------------------------------------------------------------------- +export const secondaryText = css` + ${tokens.secondaryText.maxLines}: 2; + color: ${Colour["text-subtlest"]}; + width: 100%; + overflow-wrap: break-word; +`; + +export const secondaryTextTruncateEnd = css` + ${lineClampDynamicCss(tokens.secondaryText.maxLines)} +`; + +export const secondaryTextNextLine = css` + ${Font["body-md-semibold"]} +`; + +export const secondaryTextInline = css` + ${Font["body-baseline-regular"]} +`; + +// ----------------------------------------------------------------------------- +// MATCHED TEXT +// ----------------------------------------------------------------------------- +export const matchedText = css` + font-weight: ${Font.Spec["weight-semibold"]}; +`; + +// ----------------------------------------------------------------------------- +// LABEL +// ----------------------------------------------------------------------------- +export const label = css` + text-align: left; + width: 100%; + overflow: hidden; + overflow-wrap: break-word; +`; + +export const labelVariantDefault = css` + ${Font["body-baseline-regular"]} +`; + +export const labelVariantSmall = css` + ${Font["body-md-regular"]} +`; + +export const labelNextLine = css` + display: flex; + flex-direction: column; +`; + +export const labelInline = css` + .${primaryText} { + display: inline; + } + + .${secondaryText} { + display: inline; + margin-left: 0.5rem; + } +`; + +// ----------------------------------------------------------------------------- +// TRUNCATION +// ----------------------------------------------------------------------------- +export const truncateFirstLine = css` + display: inline-block; + width: 100%; + height: 1lh; + word-break: break-all; + overflow: hidden; +`; + +export const truncateFirstLineSingle = css` + width: 50%; +`; + +export const truncateSecondLine = css` + display: inline-block; + width: 100%; + height: 1lh; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + direction: rtl; + text-align: right; +`; + +export const truncateSecondLineSingle = css` + width: 50%; +`; diff --git a/src/shared/dropdown-list/dropdown-label.styles.tsx b/src/shared/dropdown-list/dropdown-label.styles.tsx deleted file mode 100644 index 8aa624c46a..0000000000 --- a/src/shared/dropdown-list/dropdown-label.styles.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import styled, { css } from "styled-components"; - -import { V3_Colour, V3_Font } from "../../v3_theme"; -import { lineClampDynamicCss } from "../styles"; -import type { - DropdownVariantType, - LabelDisplayType, - TruncateType, -} from "./types"; - -const tokens = { - primaryText: { - maxLines: "--fds-dropdownLabel-primaryText-maxLines", - }, - secondaryText: { - maxLines: "--fds-dropdownLabel-secondaryText-maxLines", - }, -}; - -// ============================================================================= -// STYLE INTERFACE -// ============================================================================= -interface LabelStyleProps { - $labelDisplayType: LabelDisplayType; - $variant: DropdownVariantType; -} - -interface LabelTextStyleProps { - $labelDisplayType?: LabelDisplayType; - $maxLines?: number; - $selected?: boolean; - $disabled?: boolean; - $truncateType?: TruncateType; - $bold?: boolean; -} - -interface MatchedTextStyleProps { - $variant: DropdownVariantType; -} - -// ============================================================================= -// STYLING -// ============================================================================= - -export const PrimaryText = styled.div` - font-weight: ${(props) => - props.$bold - ? V3_Font.Spec["weight-semibold"] - : V3_Font.Spec["weight-regular"]}; - - ${(props) => { - if (props.$disabled) { - return css` - color: ${V3_Colour["text-disabled"]}; - `; - } else if (props.$selected) { - return css` - color: ${V3_Colour["text-selected"]}; - `; - } else { - return css` - color: ${V3_Colour["text"]}; - `; - } - }} - width: 100%; - overflow-wrap: break-word; - - ${(props) => - props.$truncateType === "end" && - lineClampDynamicCss(tokens.primaryText.maxLines)} - ${tokens.primaryText.maxLines}: ${(props) => props.$maxLines || 2}; -`; - -export const SecondaryText = styled.div` - color: ${V3_Colour["text-subtlest"]}; - width: 100%; - overflow-wrap: break-word; - - ${(props) => - props.$truncateType === "end" && - lineClampDynamicCss(tokens.secondaryText.maxLines)} - ${tokens.secondaryText.maxLines}: ${(props) => props.$maxLines || 2}; - - ${(props) => { - switch (props.$labelDisplayType) { - case "next-line": - return css` - ${V3_Font["body-md-semibold"]} - `; - case "inline": - default: - return css` - ${V3_Font["body-baseline-regular"]} - `; - } - }} -`; - -export const MatchedText = styled.span` - font-weight: ${V3_Font.Spec["weight-semibold"]}; -`; - -export const Label = styled.div` - text-align: left; - width: 100%; - overflow: hidden; - overflow-wrap: break-word; - - ${(props) => - props.$variant === "small" - ? V3_Font["body-md-regular"] - : V3_Font["body-baseline-regular"]} - - ${(props) => { - switch (props.$labelDisplayType) { - case "next-line": - return css` - display: flex; - flex-direction: column; - `; - case "inline": - return css` - ${PrimaryText} { - display: inline; - } - - ${SecondaryText} { - display: inline; - margin-left: 0.5rem; - } - `; - } - }} -`; - -export const TruncateFirstLine = styled.div` - display: inline-block; - width: ${(props) => (props.$maxLines === 1 ? 50 : 100)}%; - height: 1lh; - word-break: break-all; - overflow: hidden; -`; - -export const TruncateSecondLine = styled.div` - display: inline-block; - width: ${(props) => (props.$maxLines === 1 ? 50 : 100)}%; - height: 1lh; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - direction: rtl; - text-align: right; -`; diff --git a/src/shared/dropdown-list/dropdown-label.tsx b/src/shared/dropdown-list/dropdown-label.tsx index 909f307663..ce7c5bde65 100644 --- a/src/shared/dropdown-list/dropdown-label.tsx +++ b/src/shared/dropdown-list/dropdown-label.tsx @@ -1,17 +1,11 @@ -import { useCallback, useContext, useMemo } from "react"; +import clsx from "clsx"; +import { useCallback, useMemo, useRef } from "react"; import { useResizeDetector } from "react-resize-detector"; -import { ThemeContext } from "styled-components"; +import { Font } from "../../theme"; +import { useApplyStyle } from "../../theme/utils/use-apply-styles"; import { StringHelper } from "../../util/string-helper"; -import { V3_Font } from "../../v3_theme"; -import { - Label, - MatchedText, - PrimaryText, - SecondaryText, - TruncateFirstLine, - TruncateSecondLine, -} from "./dropdown-label.styles"; +import * as styles from "./dropdown-label.styles"; import type { DropdownVariantType, LabelDisplayType } from "./types"; interface DropdownLabelProps { @@ -39,15 +33,24 @@ export const DropdownLabel = ({ truncationType = "middle", variant = "default", }: DropdownLabelProps): JSX.Element => { - const theme = useContext(ThemeContext); - const fontSize = variant === "small" - ? V3_Font.Spec["body-size-md"]({ theme }) - : V3_Font.Spec["body-size-baseline"]({ theme }); - const fontFamily = V3_Font.Spec["font-family"]({ theme }); + ? Font.Spec["body-size-md"] + : Font.Spec["body-size-baseline"]; + const fontFamily = Font.Spec["font-family"]; const { ref, width } = useResizeDetector(); + const primaryTextRef = useRef(null); + const secondaryTextRef = useRef(null); + + useApplyStyle(primaryTextRef, { + [styles.tokens.primaryText.maxLines]: String(maxLines), + }); + + useApplyStyle(secondaryTextRef, { + [styles.tokens.secondaryText.maxLines]: String(maxLines), + }); + // ========================================================================= // HELPER FUNCTIONS // ========================================================================= @@ -57,16 +60,11 @@ export const DropdownLabel = ({ return false; } - // best-effort attempt to check if multi-line text will overflow, - // rendering an actual element in the DOM might be more accurate - // but might not be performant for large lists const textWidth = StringHelper.getTextWidth( displayText, `${fontSize} '${fontFamily}'` ); - // there's less space than expected due to word breaks, so an - // arbitary offset is applied return textWidth > width * maxLines - 50; }, [width, displayType, fontSize, fontFamily, maxLines] @@ -84,8 +82,6 @@ export const DropdownLabel = ({ [hasExceededContainer, sublabel] ); - // css cannot truncate inline elements so force the display to render - // them separately const itemDisplayType = shouldTruncateTitle || shouldTruncateLabel ? "next-line" : displayType; @@ -108,9 +104,9 @@ export const DropdownLabel = ({ return ( <> {label.slice(0, startIndex)} - + {label.slice(startIndex, endIndex)} - + {label.slice(endIndex)} ); @@ -119,42 +115,74 @@ export const DropdownLabel = ({ const renderTruncatedText = (displayText: string): JSX.Element => { return ( <> - +
{renderMatchInBold(displayText)} - - +
+
{renderMatchInBold(displayText)} - +
); }; return ( -
)} - + ); }; diff --git a/src/shared/dropdown-list/dropdown-list.styles.ts b/src/shared/dropdown-list/dropdown-list.styles.ts new file mode 100644 index 0000000000..ef51db2b1c --- /dev/null +++ b/src/shared/dropdown-list/dropdown-list.styles.ts @@ -0,0 +1,210 @@ +import { css } from "@linaria/core"; + +import { + Border, + Breakpoint, + Colour, + Font, + MediaQuery, + Radius, + Spacing, +} from "../../theme"; + +export const tokens = { + xSpacing: "--fds-internal-dropdown-list-container-x-spacing", + availableWidth: "--fds-internal-dropdown-list-container-available-width", + availableHeight: "--fds-internal-dropdown-list-container-available-height", +} as const; + +// ----------------------------------------------------------------------------- +// MAIN STYLES +// ----------------------------------------------------------------------------- +export const container = css` + ${tokens.availableHeight}: initial; + ${tokens.availableWidth}: initial; + ${tokens.xSpacing}: initial; + + border: ${Border["width-010"]} ${Border["solid"]} ${Colour["border"]}; + border-radius: ${Radius["sm"]}; + background: ${Colour["bg"]}; + + ${tokens.xSpacing}: 0px; + ${tokens.availableWidth}: calc( + 100vw - var(${tokens.xSpacing}) * 2 + ); + + ${MediaQuery.MaxWidth.xxs} { + --fds-internal-dropdown-list-container-x-spacing: ${Breakpoint[ + "xxs-margin" + ]}; + } + + ${MediaQuery.MaxWidth.xs} { + --fds-internal-dropdown-list-container-x-spacing: ${Breakpoint[ + "xs-margin" + ]}; + } + + ${MediaQuery.MaxWidth.sm} { + --fds-internal-dropdown-list-container-x-spacing: ${Breakpoint[ + "sm-margin" + ]}; + max-height: 15rem; + } + + max-width: var(--fds-internal-dropdown-list-container-available-width); + min-width: min( + 23rem, + var(--fds-internal-dropdown-list-container-available-width) + ); + max-height: min( + 27rem, + var(--fds-internal-dropdown-list-container-available-height, 9999px) + ); + overflow: hidden; + 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; + } +`; + +export const containerVariantDefault = css` + ${Font["body-baseline-regular"]} +`; + +export const containerVariantSmall = css` + ${Font["body-md-regular"]} +`; + +export const list = css` + background: transparent; + padding: ${Spacing["spacing-8"]}; +`; + +export const listbox = css` + list-style-type: none; +`; + +// ----------------------------------------------------------------------------- +// LIST ITEM STYLES +// ----------------------------------------------------------------------------- +export const listItem = css` + display: flex; + align-items: flex-start; + gap: ${Spacing["spacing-8"]}; + padding: ${Spacing["spacing-12"]} ${Spacing["spacing-8"]}; + cursor: pointer; + border: none; + border-radius: ${Radius["none"]}; + outline: none; +`; + +export const listItemActive = css` + background: ${Colour["bg-hover-subtle"]}; +`; + +export const listItemActiveSelected = css` + background: ${Colour["bg-hover"]}; +`; + +export const listItemDisabled = css` + cursor: not-allowed; +`; + +export const selectedIndicator = css` + flex-shrink: 0; + height: 1lh; + width: 1rem; + color: ${Colour["icon-selected"]}; +`; + +export const unselectedIndicator = css` + flex-shrink: 0; + height: 1lh; + width: 1rem; +`; + +export const checkboxSelectedIndicator = css` + flex-shrink: 0; + height: 1lh; + width: 1lh; + color: ${Colour["icon-selected"]}; +`; + +export const checkboxUnselectedIndicator = css` + flex-shrink: 0; + height: 1lh; + width: 1lh; + color: ${Colour["icon-primary-subtlest"]}; +`; + +export const checkboxDisabledIndicator = css` + flex-shrink: 0; + height: 1lh; + width: 1lh; + color: ${Colour["icon-disabled-subtle"]}; +`; + +// ----------------------------------------------------------------------------- +// ELEMENT STYLES +// ----------------------------------------------------------------------------- +export const selectAllContainer = css` + width: 100%; + display: flex; + justify-content: flex-end; +`; + +export const selectAllButton = css` + cursor: pointer; + overflow: hidden; + color: ${Colour["text-primary"]}; + font-size: inherit; + ${Font["body-md-semibold"]} + padding: ${Spacing["spacing-8"]}; +`; + +export const tryAgainButton = css` + cursor: pointer; + overflow: hidden; + color: ${Colour["text-primary"]}; + font-size: inherit; + ${Font["body-baseline-semibold"]} +`; + +export const resultStateContainer = css` + width: 100%; + display: flex; + padding: ${Spacing["spacing-12"]} ${Spacing["spacing-16"]}; + align-items: center; +`; + +export const labelIcon = css` + margin-right: ${Spacing["spacing-4"]}; + color: ${Colour["icon-error"]}; + height: 1em; + width: 1em; +`; + +export const spinner = css` + margin-right: ${Spacing["spacing-8"]}; + color: ${Colour["icon"]}; +`; + +export const noResultDescContainer = css` + --horizontal-padding: ${Spacing["spacing-16"]}; + color: ${Colour["text-subtle"]}; + padding: 0 var(--horizontal-padding) ${Spacing["spacing-12"]} + var(--horizontal-padding); +`; diff --git a/src/shared/dropdown-list/dropdown-list.styles.tsx b/src/shared/dropdown-list/dropdown-list.styles.tsx deleted file mode 100644 index 612b496916..0000000000 --- a/src/shared/dropdown-list/dropdown-list.styles.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { ExclamationCircleFillIcon } from "@lifesg/react-icons/exclamation-circle-fill"; -import { SquareIcon } from "@lifesg/react-icons/square"; -import { SquareFillIcon } from "@lifesg/react-icons/square-fill"; -import { SquareTickFillIcon } from "@lifesg/react-icons/square-tick-fill"; -import { TickIcon } from "@lifesg/react-icons/tick"; -import styled, { css } from "styled-components"; - -import { Markup } from "../../markup"; -import { - V3_Border, - V3_Breakpoint, - V3_Colour, - V3_Font, - V3_MediaQuery, - V3_Radius, - V3_Spacing, -} from "../../v3_theme"; -import { ComponentLoadingSpinner } from "../component-loading-spinner"; -import { BasicButton } from "../input-wrapper"; -import type { DropdownVariantType } from "./types"; - -// ============================================================================= -// STYLE INTERFACE -// ============================================================================= -interface ContainerStyleProps { - $width?: number; - $customWidth?: string; - $variant: DropdownVariantType; -} - -export interface ListItemStyleProps { - $active: boolean; - $selected: boolean; - $disabled: boolean; -} - -// ============================================================================= -// STYLING -// ============================================================================= - -// ----------------------------------------------------------------------------- -// MAIN STYLES -// ----------------------------------------------------------------------------- -export const Container = styled.div` - border: ${V3_Border["width-010"]} ${V3_Border["solid"]} - ${V3_Colour["border"]}; - border-radius: ${V3_Radius["sm"]}; - background: ${V3_Colour["bg"]}; - - --x-spacing: 0px; - --available-width: calc(100vw - var(--x-spacing) * 2); - - ${V3_MediaQuery.MaxWidth.sm} { - --x-spacing: ${V3_Breakpoint["sm-margin"]}px; - max-height: 15rem; - } - - ${V3_MediaQuery.MaxWidth.xs} { - --x-spacing: ${V3_Breakpoint["xs-margin"]}px; - } - - ${V3_MediaQuery.MaxWidth.xxs} { - --x-spacing: ${V3_Breakpoint["xxs-margin"]}px; - } - - max-width: var(--available-width); - - ${(props) => { - if (props.$customWidth) return `width: ${props.$customWidth};`; - if (props.$width) - return `width: ${props.$width}px; min-width: min(23rem, var(--available-width));`; - - return "min-width: min(23rem, var(--available-width));"; - }} - - max-height: min(27rem, var(--available-height, infinity * 1px)); - overflow: hidden; - overflow-y: auto; - ${(props) => - props.$variant === "small" - ? V3_Font["body-md-regular"] - : V3_Font["body-baseline-regular"]} - - &::-webkit-scrollbar { - width: 14px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: ${V3_Colour["bg-inverse-subtlest"]}; - border: 5px solid transparent; - border-radius: ${V3_Radius["full"]}; - background-clip: padding-box; - } -`; -export const List = styled.div` - background: transparent; - padding: ${V3_Spacing["spacing-8"]}; -`; - -export const Listbox = styled.ul` - list-style-type: none; -`; - -// ----------------------------------------------------------------------------- -// LIST ITEM STYLES -// ----------------------------------------------------------------------------- - -export const ListItem = styled.li` - display: flex; - align-items: flex-start; - gap: ${V3_Spacing["spacing-8"]}; - padding: ${V3_Spacing["spacing-12"]} ${V3_Spacing["spacing-8"]}; - cursor: pointer; - border: none; - border-radius: ${V3_Radius["none"]}; - outline: none; - - ${(props) => { - if (props.$disabled) { - return css` - cursor: not-allowed; - `; - } else if (props.$active && props.$selected) { - return css` - background: ${V3_Colour["bg-hover"]}; - `; - } else if (props.$active) { - return css` - background: ${V3_Colour["bg-hover-subtle"]}; - `; - } - }} -`; - -export const SelectedIndicator = styled(TickIcon)` - flex-shrink: 0; - height: 1lh; - width: 1rem; - color: ${V3_Colour["icon-selected"]}; -`; - -export const UnselectedIndicator = styled.div` - flex-shrink: 0; - height: 1lh; - width: 1rem; -`; - -export const CheckboxSelectedIndicator = styled(SquareTickFillIcon)` - flex-shrink: 0; - height: 1lh; - width: 1lh; - color: ${V3_Colour["icon-selected"]}; -`; - -export const CheckboxUnselectedIndicator = styled(SquareIcon)` - flex-shrink: 0; - height: 1lh; - width: 1lh; - color: ${V3_Colour["icon-primary-subtlest"]}; -`; - -export const CheckboxDisabledIndicator = styled(SquareFillIcon)` - flex-shrink: 0; - height: 1lh; - width: 1lh; - color: ${V3_Colour["icon-disabled-subtle"]}; -`; - -// ----------------------------------------------------------------------------- -// ELEMENT STYLES -// ----------------------------------------------------------------------------- - -export const SelectAllContainer = styled.div` - width: 100%; - display: flex; - justify-content: flex-end; -`; - -export const DropdownCommonButton = styled(BasicButton)` - cursor: pointer; - overflow: hidden; - color: ${V3_Colour["text-primary"]}; - font-size: inherit; -`; - -export const TryAgainButton = styled(DropdownCommonButton)` - ${V3_Font["body-baseline-semibold"]} -`; - -export const SelectAllButton = styled(DropdownCommonButton)` - ${V3_Font["body-md-semibold"]} - padding: ${V3_Spacing["spacing-8"]} ${V3_Spacing["spacing-8"]}; -`; - -export const ResultStateContainer = styled.div` - width: 100%; - display: flex; - padding: ${V3_Spacing["spacing-12"]} ${V3_Spacing["spacing-16"]}; - align-items: center; -`; - -export const LabelIcon = styled(ExclamationCircleFillIcon)` - margin-right: ${V3_Spacing["spacing-4"]}; - color: ${V3_Colour["icon-error"]}; - height: 1em; - width: 1em; -`; - -export const Spinner = styled(ComponentLoadingSpinner)` - margin-right: ${V3_Spacing["spacing-8"]}; - color: ${V3_Colour["icon"]}; -`; - -export const NoResultDescContainer = styled(Markup)` - color: ${V3_Colour["text-subtle"]}; - padding: 0 ${V3_Spacing["spacing-16"]} ${V3_Spacing["spacing-12"]} - ${V3_Spacing["spacing-16"]}; -`; diff --git a/src/shared/dropdown-list/dropdown-list.tsx b/src/shared/dropdown-list/dropdown-list.tsx index 95f605be37..8ffc33a7a3 100644 --- a/src/shared/dropdown-list/dropdown-list.tsx +++ b/src/shared/dropdown-list/dropdown-list.tsx @@ -1,3 +1,9 @@ +import { ExclamationCircleFillIcon } from "@lifesg/react-icons/exclamation-circle-fill"; +import { SquareIcon } from "@lifesg/react-icons/square"; +import { SquareFillIcon } from "@lifesg/react-icons/square-fill"; +import { SquareTickFillIcon } from "@lifesg/react-icons/square-tick-fill"; +import { TickIcon } from "@lifesg/react-icons/tick"; +import clsx from "clsx"; import find from "lodash/find"; import isEqual from "lodash/isEqual"; import type React from "react"; @@ -13,6 +19,8 @@ import { import type { VirtuosoHandle } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso"; +import { Markup } from "../../markup"; +import { useApplyStyle } from "../../theme"; import { mergeRefs, useCompare, @@ -21,26 +29,11 @@ import { useIsMounted, } from "../../util"; import { VisuallyHidden } from "../accessibility"; +import { ComponentLoadingSpinner } from "../component-loading-spinner"; import { useDropdownRender } from "../dropdown-wrapper"; +import { BasicButton } from "../input-wrapper"; import { DropdownLabel } from "./dropdown-label"; -import { - CheckboxDisabledIndicator, - CheckboxSelectedIndicator, - CheckboxUnselectedIndicator, - Container, - LabelIcon, - List, - Listbox, - ListItem, - NoResultDescContainer, - ResultStateContainer, - SelectAllButton, - SelectAllContainer, - SelectedIndicator, - Spinner, - TryAgainButton, - UnselectedIndicator, -} from "./dropdown-list.styles"; +import * as styles from "./dropdown-list.styles"; import { DropdownListStateContext } from "./dropdown-list-state"; import { DropdownSearch } from "./dropdown-search"; import type { @@ -104,8 +97,12 @@ const DropdownListInner = ( const { focusedIndex, setFocusedIndex } = useContext( DropdownListStateContext ); - const { elementWidth, setFloatingRef, getFloatingProps, styles } = - useDropdownRender(); + const { + elementWidth, + setFloatingRef, + getFloatingProps, + styles: floatingStyles, + } = useDropdownRender(); const [searchValue, setSearchValue] = useState(""); const [displayListItems, setDisplayListItems] = useState(listItems ?? []); const itemsLoadStateChanged = useCompare(itemsLoadState); @@ -122,6 +119,12 @@ const DropdownListInner = ( !!selectedItems && selectedItems?.length === maxSelectable; + const containerWidthStyle: React.CSSProperties = width + ? { width } + : matchElementWidth && elementWidth + ? { width: elementWidth } + : {}; + // ========================================================================= // HELPER FUNCTIONS // ========================================================================= @@ -131,7 +134,6 @@ const DropdownListInner = ( const getItemKey = (item: T, index: number) => { const formattedValue = valueExtractor ? valueExtractor(item) : item; - // This is needed as some items might have the same value return `item_${index}__${formattedValue}`; }; @@ -232,7 +234,6 @@ const DropdownListInner = ( switch (event.code) { case "ArrowDown": event.preventDefault(); - // Cannot go further than last element if (focusedIndex < displayListItems.length - 1) { const upcomingIndex = focusedIndex + 1; listItemRefs.current[upcomingIndex]?.focus(); @@ -241,7 +242,6 @@ const DropdownListInner = ( break; case "ArrowUp": event.preventDefault(); - // Cannot go further than first element if (focusedIndex > 0) { const upcomingIndex = focusedIndex - 1; listItemRefs.current[upcomingIndex]?.focus(); @@ -306,7 +306,6 @@ const DropdownListInner = ( virtuosoRef.current?.scrollTo({ top: 0 }); return; } - // Delay to ensure render is complete const timer = setTimeout(() => { if (!listItems) return; @@ -322,11 +321,8 @@ const DropdownListInner = ( useEffect(() => { if (disableItemFocus) return; - - // skip effect as dependency did not change if (!mounted || !itemsLoadStateChanged) return; - // Reset focus when options are loaded if (itemsLoadState === "success") { if (searchInputRef.current) { setFocusedIndex(-1); @@ -363,7 +359,6 @@ const DropdownListInner = ( useEffect(() => { if (mounted) { - // only run on mount return; } @@ -373,24 +368,20 @@ const DropdownListInner = ( checkListItemSelected(item) ); - // Focus search input if there is one if (searchInputRef.current) { setFocusedIndex(-1); - setTimeout(() => searchInputRef.current?.focus(), 200); // Wait for animation + setTimeout(() => searchInputRef.current?.focus(), 200); } else if (focusedIndex > 0) { - // Else focus on the specified element virtuosoRef.current?.scrollToIndex({ index: focusedIndex, align: "center", }); setTimeout(() => listItemRefs.current[focusedIndex]?.focus(), 200); } else if (index !== -1) { - // Else focus on the selected element virtuosoRef.current?.scrollToIndex({ index, align: "center" }); setFocusedIndex(index); setTimeout(() => listItemRefs.current[index]?.focus(), 200); } else { - // Else focus on the first list item virtuosoRef.current?.scrollToIndex({ index: 0 }); setFocusedIndex(0); setTimeout(() => listItemRefs.current[0]?.focus(), 200); @@ -404,26 +395,46 @@ const DropdownListInner = ( setFocusedIndex, ]); + // ========================================================================= + // APPLY STYLES + // ========================================================================= + + useApplyStyle(nodeRef, { + ...floatingStyles, + ...containerWidthStyle, + }); + // ========================================================================= // RENDER FUNCTIONS // ========================================================================= const renderListItemIcon = (selected: boolean) => { if (multiSelect) { if (hasSelectedMax && !selected) { - return ; + return ( + + ); } return selected ? ( - + ) : ( - + ); } return selected ? ( - + ) : ( - +
); }; @@ -448,11 +459,12 @@ const DropdownListInner = ( if (!onRetry || itemsLoadState === "success") { const selected = checkListItemSelected(item); const active = index === focusedIndex; + const disabled = !selected && hasSelectedMax; return ( - ( }} role="option" tabIndex={active ? 0 : -1} - $active={active} - $selected={selected} - $disabled={!selected && hasSelectedMax} + className={clsx( + styles.listItem, + disabled && styles.listItemDisabled, + active && selected && styles.listItemActiveSelected, + active && !selected && styles.listItemActive + )} > {renderListItem ? ( renderListItem(item, { selected }) @@ -476,7 +491,7 @@ const DropdownListInner = ( {renderDropdownLabel(item, selected)} )} - + ); } }; @@ -507,13 +522,17 @@ const DropdownListInner = ( itemsLoadState === "success" ) { return ( - - +
+ {maxSelectable || selectedItems.length !== 0 ? clearAllButtonLabel : selectAllButtonLabel} - - + +
); } }; @@ -527,14 +546,23 @@ const DropdownListInner = ( ) { return ( <> - - +
+ {noResultsLabel} - +
{noResultsDescription && ( - + {noResultsDescription} - + )} ); @@ -544,10 +572,13 @@ const DropdownListInner = ( const renderLoading = () => { if (onRetry && itemsLoadState === "loading") { return ( - - +
+ Loading... - +
); } }; @@ -555,13 +586,23 @@ const DropdownListInner = ( const renderTryAgain = () => { if (onRetry && itemsLoadState === "fail") { return ( - - +
+ Failed to load.  - + Try again. - - + +
); } }; @@ -570,35 +611,32 @@ const DropdownListInner = ( const isTestEnv = process.env.NODE_ENV === "test"; return ( - +
    renderItem(item, index)} - // disable virtualisation in tests - // https://github.com/petyosi/react-virtuoso/issues/26#issuecomment-1040316576 - // explicitly set the `key` prop to avoid React warning key={isTestEnv ? displayListItems.length : undefined} - // omit the `initialItemCount` prop to resolve NaN error {...(isTestEnv ? { initialItemCount: displayListItems.length, } : {})} /> - +
); }; const renderList = () => { return ( - {renderSearchInput()} {renderSelectAll()} @@ -606,7 +644,7 @@ const DropdownListInner = ( {renderLoading()} {renderTryAgain()} {renderVirtualisedList()} - +
); }; @@ -615,7 +653,6 @@ const DropdownListInner = ( return; } - // FIXME: implement onDismiss handling return (
{renderCustomCallToAction(onDismiss as any, displayListItems)} @@ -624,19 +661,21 @@ const DropdownListInner = ( }; return ( - {ariaLabel} {renderList()} {renderBottomCta()} - +
); }; diff --git a/src/shared/dropdown-list/dropdown-search.styles.ts b/src/shared/dropdown-list/dropdown-search.styles.ts new file mode 100644 index 0000000000..d87902b928 --- /dev/null +++ b/src/shared/dropdown-list/dropdown-search.styles.ts @@ -0,0 +1,62 @@ +import { css } from "@linaria/core"; + +import { Colour, Font, Radius, Spacing } from "../../theme"; + +// ============================================================================= +// STYLING +// ============================================================================= + +export const container = css` + background: ${Colour["bg-strong"]}; + border-radius: ${Radius["sm"]}; + display: flex; + align-items: center; +`; + +export const containerVariantDefault = css` + ${Font["body-baseline-regular"]} +`; + +export const containerVariantSmall = css` + ${Font["body-md-regular"]} +`; + +export const searchBox = css` + flex: 1; + display: flex; + align-items: center; + gap: ${Spacing["spacing-8"]}; + padding: 10px ${Spacing["spacing-8"]}; +`; + +export const searchBoxVariantSmall = css` + padding: ${Spacing["spacing-8"]} ${Spacing["spacing-16"]}; +`; + +export const searchInput = css` + flex: 1; +`; + +export const searchIcon = css` + color: ${Colour["icon"]}; + flex-shrink: 0; + height: 1em; + width: 1em; +`; + +export const clearButton = css` + flex-shrink: 0; + align-self: stretch; + box-sizing: content-box; + padding: ${Spacing["spacing-8"]}; + margin-left: -${Spacing["spacing-8"]}; + color: ${Colour["icon"]}; + cursor: pointer; + font-size: inherit; + + svg { + flex-shrink: 0; + height: 1em; + width: 1em; + } +`; diff --git a/src/shared/dropdown-list/dropdown-search.styles.tsx b/src/shared/dropdown-list/dropdown-search.styles.tsx deleted file mode 100644 index 5a96ff3051..0000000000 --- a/src/shared/dropdown-list/dropdown-search.styles.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { MagnifierIcon } from "@lifesg/react-icons/magnifier"; -import styled, { css } from "styled-components"; - -import { V3_Colour, V3_Font, V3_Radius, V3_Spacing } from "../../v3_theme"; -import { ClickableIcon } from "../clickable-icon"; -import { BasicInput } from "../input-wrapper"; -import type { DropdownVariantType } from "./types"; - -//============================================================================= -// STYLE INTERFACE -//============================================================================= -export interface StyleProps { - $variant: DropdownVariantType | undefined; -} - -//============================================================================= -// STYLING -//============================================================================= -export const Container = styled.div` - background: ${V3_Colour["bg-strong"]}; - border-radius: ${V3_Radius["sm"]}; - display: flex; - align-items: center; - - ${(props) => - props.$variant === "small" - ? V3_Font["body-md-regular"] - : V3_Font["body-baseline-regular"]} -`; - -export const SearchBox = styled.label` - flex: 1; - display: flex; - align-items: center; - gap: ${V3_Spacing["spacing-8"]}; - padding: ${(props) => - props.$variant === "small" - ? css` - ${V3_Spacing["spacing-8"]} ${V3_Spacing["spacing-16"]} - ` - : // TODO: confirm vertical spacing - css`10px ${V3_Spacing["spacing-8"]}`}; -`; - -export const SearchInput = styled(BasicInput)` - flex: 1; -`; - -export const SearchIcon = styled(MagnifierIcon)` - color: ${V3_Colour["icon"]}; - flex-shrink: 0; - height: 1em; - width: 1em; -`; - -export const ClearButton = styled(ClickableIcon)` - flex-shrink: 0; - align-self: stretch; - box-sizing: content-box; - padding: ${V3_Spacing["spacing-8"]}; - margin-left: -${V3_Spacing["spacing-8"]}; - color: ${V3_Colour["icon"]}; - cursor: pointer; - font-size: inherit; - - svg { - flex-shrink: 0; - height: 1em; - width: 1em; - } -`; diff --git a/src/shared/dropdown-list/dropdown-search.tsx b/src/shared/dropdown-list/dropdown-search.tsx index 3b7ce516c7..95d2785f6c 100644 --- a/src/shared/dropdown-list/dropdown-search.tsx +++ b/src/shared/dropdown-list/dropdown-search.tsx @@ -1,14 +1,12 @@ import { CrossIcon } from "@lifesg/react-icons/cross"; +import { MagnifierIcon } from "@lifesg/react-icons/magnifier"; +import clsx from "clsx"; import type React from "react"; import { forwardRef } from "react"; -import { - ClearButton, - Container, - SearchBox, - SearchIcon, - SearchInput, -} from "./dropdown-search.styles"; +import { ClickableIcon } from "../clickable-icon"; +import { BasicInput } from "../input-wrapper"; +import * as styles from "./dropdown-search.styles"; import type { DropdownVariantType } from "./types"; interface Props extends React.HTMLAttributes { @@ -22,26 +20,39 @@ const Component = ( ref: React.Ref ): JSX.Element => { return ( - - - - + + {value && ( - - + )} - + ); }; diff --git a/src/shared/dropdown-list/expandable-element.styles.ts b/src/shared/dropdown-list/expandable-element.styles.ts new file mode 100644 index 0000000000..653a8973bc --- /dev/null +++ b/src/shared/dropdown-list/expandable-element.styles.ts @@ -0,0 +1,55 @@ +import { css } from "@linaria/core"; + +import { Colour, Font, Motion, Spacing } from "../../theme"; + +// ============================================================================= +// STYLING +// ============================================================================= + +export const selector = css` + display: flex; + align-items: center; + gap: ${Spacing["spacing-8"]}; + width: 100%; + + ${Font["body-baseline-regular"]} + height: calc(3rem - 2px); + + &:disabled { + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${Colour["focus-ring"]}; + outline-offset: -2px; + } +`; + +export const selectorVariantSmall = css` + ${Font["body-md-regular"]} + height: calc(2.5rem - 2px); +`; + +export const selectorReadOnly = css` + padding: 0 ${Spacing["spacing-16"]}; +`; + +export const selectorEditable = css` + padding: ${Spacing["spacing-16"]}; +`; + +export const iconContainer = css` + display: flex; + align-items: center; + transition: transform ${Motion["duration-250"]} ${Motion["ease-default"]}; + + svg { + color: ${Colour["icon"]}; + height: 1em; + width: 1em; + } +`; + +export const iconContainerExpanded = css` + transform: rotate(180deg); +`; diff --git a/src/shared/dropdown-list/expandable-element.styles.tsx b/src/shared/dropdown-list/expandable-element.styles.tsx deleted file mode 100644 index 4b8fdd6ec5..0000000000 --- a/src/shared/dropdown-list/expandable-element.styles.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import styled, { css } from "styled-components"; - -import { V3_Colour, V3_Font, V3_Motion, V3_Spacing } from "../../v3_theme"; -import { BasicButton } from "../input-wrapper"; -import type { DropdownVariantType } from "./types"; - -// ============================================================================= -// STYLE INTERFACE -// ============================================================================= -interface StyleProps { - $expanded?: boolean; - $variant?: DropdownVariantType; - $readOnly?: boolean; -} - -// ============================================================================= -// STYLING -// ============================================================================= -export const Selector = styled(BasicButton)` - display: flex; - align-items: center; - gap: ${V3_Spacing["spacing-8"]}; - width: 100%; - padding: ${(props) => - props.$readOnly - ? `0 ${V3_Spacing["spacing-16"]}` - : V3_Spacing["spacing-16"]}; - - ${(props) => - props.$variant === "small" - ? css` - ${V3_Font["body-md-regular"]} - height: calc(2.5rem - 2px); // exclude top and bottom borders - ` - : css` - ${V3_Font["body-baseline-regular"]} - height: calc(3rem - 2px); - `} - - &:disabled { - cursor: not-allowed; - } - - &:focus-visible { - outline: 2px solid ${V3_Colour["focus-ring"]}; - outline-offset: -2px; - } -`; - -export const IconContainer = styled.div` - display: flex; - align-items: center; - transform: rotate(${(props) => (props.$expanded ? 180 : 0)}deg); - transition: transform ${V3_Motion["duration-250"]} - ${V3_Motion["ease-default"]}; - - svg { - color: ${V3_Colour["icon"]}; - height: 1em; - width: 1em; - } -`; diff --git a/src/shared/dropdown-list/expandable-element.tsx b/src/shared/dropdown-list/expandable-element.tsx index ecb4fd86bb..49a3d02c40 100644 --- a/src/shared/dropdown-list/expandable-element.tsx +++ b/src/shared/dropdown-list/expandable-element.tsx @@ -1,8 +1,10 @@ import { ChevronDownIcon } from "@lifesg/react-icons/chevron-down"; +import clsx from "clsx"; import type { AriaAttributes, Ref } from "react"; import { forwardRef } from "react"; -import { IconContainer, Selector } from "./expandable-element.styles"; +import { BasicButton } from "../input-wrapper"; +import * as styles from "./expandable-element.styles"; import type { DropdownVariantType } from "./types"; export interface ExpandableElementProps @@ -11,6 +13,7 @@ export interface ExpandableElementProps "aria-labelledby" | "aria-describedby" | "aria-invalid" > { children: React.ReactNode; + className?: string | undefined; disabled: boolean | undefined; expanded: boolean | undefined; listboxId: string; @@ -22,6 +25,7 @@ export interface ExpandableElementProps export const Component = ( { children, + className, disabled, expanded, listboxId, @@ -32,31 +36,37 @@ export const Component = ( }: ExpandableElementProps, ref: Ref ) => { - // ============================================================================= - // RENDER FUNCTION - // ============================================================================= return ( - {children} {!readOnly && ( - +
- +
)} -
+ ); }; diff --git a/src/shared/dropdown-list/nested-dropdown-list.styles.ts b/src/shared/dropdown-list/nested-dropdown-list.styles.ts new file mode 100644 index 0000000000..632c25e3ab --- /dev/null +++ b/src/shared/dropdown-list/nested-dropdown-list.styles.ts @@ -0,0 +1,95 @@ +import { css } from "@linaria/core"; + +import { Colour, Motion, Radius, Spacing } from "../../theme"; + +// ============================================================================= +// STYLING +// ============================================================================= + +export const tokens = { + level: "--fds-internal-nestedDropdownList-indent-level", +}; + +// ----------------------------------------------------------------------------- +// LIST ITEM STYLES +// ----------------------------------------------------------------------------- + +export const listItemContainer = css` + display: flex; +`; + +export const listItemContainerHidden = css` + display: none; +`; + +export const listItem = css` + flex: 1; + display: flex; + align-items: flex-start; + gap: ${Spacing["spacing-8"]}; + padding: ${Spacing["spacing-12"]} ${Spacing["spacing-8"]}; + cursor: pointer; + overflow: hidden; + border-radius: ${Radius["none"]}; + outline: none; +`; + +export const listItemToggleable = css` + cursor: default; +`; + +export const listItemActive = css` + background: ${Colour["bg-hover"]}; +`; + +export const indent = css` + ${tokens.level}: 0; + height: 1px; + width: calc((1lh + ${Spacing["spacing-8"]}) * var(${tokens.level})); +`; + +const expandButtonTransition = `transform ${Motion["duration-350"]} ${Motion["ease-standard"]}`; + +export const expandButton = css` + width: 1lh; + height: 1lh; + color: ${Colour["icon-primary"]}; + cursor: pointer; + + svg { + width: 1lh; + height: 1lh; + transition: ${expandButtonTransition}; + } +`; + +export const expandButtonExpanded = css` + svg { + transform: rotate(90deg); + } +`; + +export const unexpandableIndicator = css` + width: 1lh; + height: 1lh; + margin-right: ${Spacing["spacing-8"]}; +`; + +export const selectionIndicator = css` + flex-shrink: 0; + height: 1lh; + width: ${Spacing["spacing-16"]}; + display: flex; + justify-content: center; +`; + +export const selectionIndicatorNested = css` + width: 1lh; +`; + +export const checkboxMixedIndicator = css` + flex-shrink: 0; + height: 1lh; + width: 1lh; + color: ${Colour["icon-selected"]}; +`; diff --git a/src/shared/dropdown-list/nested-dropdown-list.styles.tsx b/src/shared/dropdown-list/nested-dropdown-list.styles.tsx deleted file mode 100644 index 6c1019d572..0000000000 --- a/src/shared/dropdown-list/nested-dropdown-list.styles.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { MinusSquareFillIcon } from "@lifesg/react-icons/minus-square-fill"; -import styled, { css } from "styled-components"; - -import { V3_Colour, V3_Motion, V3_Radius, V3_Spacing } from "../../v3_theme"; - -// ============================================================================= -// STYLE INTERFACE -// ============================================================================= -interface ListItemStyleProps { - $active?: boolean; - $visible?: boolean; - $toggleable?: boolean; -} - -interface IndentStyleProps { - $level?: number; -} - -interface IndicatorStyleProps { - $hasNestedSiblings?: boolean; -} - -interface ExpandButtonStyleProps { - $expanded?: boolean; -} - -// ============================================================================= -// STYLING -// ============================================================================= - -// ----------------------------------------------------------------------------- -// LIST ITEM STYLES -// ----------------------------------------------------------------------------- - -export const ListItemContainer = styled.li` - display: ${(props) => (props.$visible ? "flex" : "none")}; -`; - -export const ListItem = styled.div` - flex: 1; - display: flex; - align-items: flex-start; - gap: ${V3_Spacing["spacing-8"]}; - padding: ${V3_Spacing["spacing-12"]} ${V3_Spacing["spacing-8"]}; - cursor: ${(props) => (props.$toggleable ? "default" : "pointer")}; - overflow: hidden; // required for label to truncate properly - border-radius: ${V3_Radius["none"]}; - outline: none; - - ${(props) => - props.$active && - css` - background: ${V3_Colour["bg-hover"]}; - `} -`; - -export const Indent = styled.div` - height: 1px; - width: calc( - (1lh + ${V3_Spacing["spacing-8"]}) * ${(props) => props.$level} - ); -`; - -export const ExpandButton = styled.div` - width: 1lh; - height: 1lh; - color: ${V3_Colour["icon-primary"]}; - cursor: pointer; - - svg { - width: 1lh; - height: 1lh; - transition: transform ${V3_Motion["duration-350"]} - ${V3_Motion["ease-standard"]}; - - ${(props) => { - if (props.$expanded) { - return css` - transform: rotate(90deg); - `; - } - }} - } -`; - -export const UnexpandableIndicator = styled.div` - width: 1lh; - height: 1lh; - margin-right: ${V3_Spacing["spacing-8"]}; -`; - -export const SelectionIndicator = styled.div` - flex-shrink: 0; - height: 1lh; - width: ${(props) => - props.$hasNestedSiblings ? "1lh" : V3_Spacing["spacing-16"]}; - - display: flex; - justify-content: center; -`; - -export const CheckboxMixedIndicator = styled(MinusSquareFillIcon)` - flex-shrink: 0; - height: 1lh; - width: lh; - r: ${V3_Colour["icon-selected"]}; -`; diff --git a/src/shared/dropdown-list/nested-dropdown-list.tsx b/src/shared/dropdown-list/nested-dropdown-list.tsx index 9a5d684a41..de1ed535b2 100644 --- a/src/shared/dropdown-list/nested-dropdown-list.tsx +++ b/src/shared/dropdown-list/nested-dropdown-list.tsx @@ -1,39 +1,29 @@ import { CaretRightIcon } from "@lifesg/react-icons/caret-right"; +import { ExclamationCircleFillIcon } from "@lifesg/react-icons/exclamation-circle-fill"; +import { MinusSquareFillIcon } from "@lifesg/react-icons/minus-square-fill"; +import { SquareIcon } from "@lifesg/react-icons/square"; +import { SquareTickFillIcon } from "@lifesg/react-icons/square-tick-fill"; +import { TickIcon } from "@lifesg/react-icons/tick"; +import clsx from "clsx"; +import type React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Virtuoso } from "react-virtuoso"; +import { Markup } from "../../markup"; +import { useApplyStyle } from "../../theme"; import { mergeRefs, useEvent, useEventListener, useIsMounted, } from "../../util"; +import { ComponentLoadingSpinner } from "../component-loading-spinner"; import { useDropdownRender } from "../dropdown-wrapper"; +import { BasicButton } from "../input-wrapper"; import { DropdownLabel } from "./dropdown-label"; -import { - CheckboxSelectedIndicator, - CheckboxUnselectedIndicator, - Container, - LabelIcon, - List, - NoResultDescContainer, - ResultStateContainer, - SelectAllButton, - SelectAllContainer, - SelectedIndicator, - Spinner, - TryAgainButton, -} from "./dropdown-list.styles"; +import * as styles from "./dropdown-list.styles"; import { DropdownSearch } from "./dropdown-search"; -import { - CheckboxMixedIndicator, - ExpandButton, - Indent, - ListItem, - ListItemContainer, - SelectionIndicator, - UnexpandableIndicator, -} from "./nested-dropdown-list.styles"; +import * as nestedStyles from "./nested-dropdown-list.styles"; import { expandFirstSubtree, expandMatchedSubtrees, @@ -88,8 +78,12 @@ export const NestedDropdownList = ({ customLabels?.noResultsDescription || _noResultsDescription; const selectableCategory = multiSelect || _selectableCategory; - const { elementWidth, setFloatingRef, getFloatingProps, styles } = - useDropdownRender(); + const { + elementWidth, + setFloatingRef, + getFloatingProps, + styles: floatingStyles, + } = useDropdownRender(); const [searchValue, setSearchValue] = useState(""); const searchTerm = searchValue.toLowerCase().trim(); const [searchActive, setSearchActive] = useState(false); @@ -260,6 +254,12 @@ export const NestedDropdownList = ({ } }; + const containerWidthStyle: React.CSSProperties = width + ? { width } + : matchElementWidth && elementWidth + ? { width: elementWidth } + : {}; + // ========================================================================= // HELPER FUNCTIONS // ========================================================================= @@ -410,6 +410,15 @@ export const NestedDropdownList = ({ } }, [focusedIndex, virtuosoIndex, mounted]); + // ========================================================================= + // APPLY STYLE + // ========================================================================= + + useApplyStyle(nodeRef, { + ...floatingStyles, + ...containerWidthStyle, + }); + // ========================================================================= // RENDER FUNCTIONS // ========================================================================= @@ -438,13 +447,17 @@ export const NestedDropdownList = ({ itemsLoadState === "success" ) { return ( - - +
+ {selectedKeyPaths.size === 0 ? selectAllButtonLabel : clearAllButtonLabel} - - + +
); } }; @@ -458,14 +471,23 @@ export const NestedDropdownList = ({ ) { return ( <> - - +
+ {noResultsLabel} - +
{noResultsDescription && ( - + {noResultsDescription} - + )} ); @@ -475,10 +497,13 @@ export const NestedDropdownList = ({ const renderLoading = () => { if (onRetry && itemsLoadState === "loading") { return ( - - +
+ Loading... - +
); } }; @@ -486,13 +511,23 @@ export const NestedDropdownList = ({ const renderTryAgain = () => { if (onRetry && itemsLoadState === "fail") { return ( - - +
+ Failed to load.  - + Try again. - - + +
); } }; @@ -500,11 +535,26 @@ export const NestedDropdownList = ({ if (multiSelect) { switch (listItem.checked) { case "mixed": - return ; + return ( + + ); case true: - return ; + return ( + + ); default: - return ; + return ( + + ); } } @@ -514,13 +564,20 @@ export const NestedDropdownList = ({ } return ( - - {listItem.checked && } - + {listItem.checked && ( + + )} + ); }; @@ -543,15 +600,30 @@ export const NestedDropdownList = ({ const toggleable = hasSubItems && !selectableCategory; return ( - - {maxLevel > 0 && } + {maxLevel > 0 && ( +
{ + if (node) { + node.style.setProperty( + nestedStyles.tokens.level, + String(level) + ); + } + }} + /> + )} {maxLevel > 0 && !hasSubItems && multiSelect && ( - +
)} - ({ } role="treeitem" tabIndex={active ? 0 : -1} - $active={active} - $toggleable={toggleable} + className={clsx( + nestedStyles.listItem, + active && nestedStyles.listItemActive, + toggleable && nestedStyles.listItemToggleable + )} > {hasSubItems && ( // not an actual button, only required for visual display - { e.stopPropagation(); toggleCategory(itemIndex, !expanded, vIndex); }} - $expanded={expanded} + className={clsx( + nestedStyles.expandButton, + expanded && nestedStyles.expandButtonExpanded + )} > - +
)} {renderSelectionIcon(listItem)} ({ truncationType={itemTruncationType} maxLines={itemMaxLines} /> - - +
+ ); }; @@ -635,28 +713,30 @@ export const NestedDropdownList = ({ const renderList = () => { return ( - +
{renderSearchInput()} {renderSelectAll()} {renderNoResults()} {renderLoading()} {renderTryAgain()} {renderVirtualisedList()} - +
); }; return ( - {renderList()} - + ); }; diff --git a/tests/shared/dropdown-list/dropdown-list.spec.tsx b/tests/shared/dropdown-list/dropdown-list.spec.tsx new file mode 100644 index 0000000000..270cdb5217 --- /dev/null +++ b/tests/shared/dropdown-list/dropdown-list.spec.tsx @@ -0,0 +1,309 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DropdownList } from "src/shared/dropdown-list/dropdown-list"; +import { DropdownListState } from "src/shared/dropdown-list/dropdown-list-state"; + +const ITEMS = ["Option A", "Option B", "Option C", "Option D"]; + +const renderList = ( + props: Partial> = {} +) => + render( + + + + ); + +describe("DropdownList", () => { + beforeEach(() => { + jest.clearAllMocks(); + + global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + }); + + // ========================================================================= + // Single selection + // ========================================================================= + describe("single selection", () => { + it("should call onSelectItem with the item when clicked", async () => { + const user = userEvent.setup(); + const onSelectItem = jest.fn(); + renderList({ onSelectItem }); + + await user.click(screen.getByRole("option", { name: "Option A" })); + + expect(onSelectItem).toHaveBeenCalledWith("Option A", "Option A"); + }); + + it("should mark item as selected when it is in selectedItems", () => { + renderList({ selectedItems: ["Option B"] }); + + expect( + screen.getByRole("option", { name: "Option B" }) + ).toHaveAttribute("aria-selected", "true"); + expect( + screen.getByRole("option", { name: "Option A" }) + ).toHaveAttribute("aria-selected", "false"); + }); + + it("should not mark item as selected when selectedItems is empty", () => { + renderList({ selectedItems: [] }); + + screen.getAllByRole("option").forEach((option) => { + expect(option).toHaveAttribute("aria-selected", "false"); + }); + }); + }); + + // ========================================================================= + // Multi-selection + // ========================================================================= + describe("multi-selection", () => { + it("should call onSelectItem for each item clicked", async () => { + const user = userEvent.setup(); + const onSelectItem = jest.fn(); + renderList({ multiSelect: true, selectedItems: [], onSelectItem }); + + await user.click(screen.getByRole("option", { name: "Option A" })); + await user.click(screen.getByRole("option", { name: "Option C" })); + + expect(onSelectItem).toHaveBeenCalledTimes(2); + expect(onSelectItem).toHaveBeenNthCalledWith( + 1, + "Option A", + "Option A" + ); + expect(onSelectItem).toHaveBeenNthCalledWith( + 2, + "Option C", + "Option C" + ); + }); + + it("should call onSelectAll when select all button is clicked", async () => { + const user = userEvent.setup(); + const onSelectAll = jest.fn(); + renderList({ + multiSelect: true, + selectedItems: [], + onSelectAll, + }); + + await user.click( + screen.getByRole("button", { name: "Select all" }) + ); + + expect(onSelectAll).toHaveBeenCalledTimes(1); + }); + + it("should show clear all button when items are selected", () => { + renderList({ + multiSelect: true, + selectedItems: ["Option A"], + onSelectAll: jest.fn(), + }); + + expect( + screen.getByRole("button", { name: "Clear all" }) + ).toBeInTheDocument(); + }); + + it("should not allow selecting beyond maxSelectable", async () => { + const user = userEvent.setup(); + const onSelectItem = jest.fn(); + renderList({ + multiSelect: true, + maxSelectable: 2, + selectedItems: ["Option A", "Option B"], + onSelectItem, + }); + + await user.click(screen.getByRole("option", { name: "Option C" })); + + expect(onSelectItem).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================= + // Search + // ========================================================================= + describe("search", () => { + const SEARCH_ITEMS = [ + "Apple", + "Banana", + "Cherry", + "Date", + "Elderberry", + ]; + + it("should filter items matching the search term", async () => { + const user = userEvent.setup(); + renderList({ listItems: SEARCH_ITEMS, enableSearch: true }); + + await user.type( + screen.getByRole("textbox", { name: "Enter text to search" }), + "an" + ); + + expect(screen.getAllByRole("option")).toHaveLength(1); + expect( + screen.getByRole("option", { name: "Banana" }) + ).toBeInTheDocument(); + }); + + it("should show no-results element when nothing matches", async () => { + const user = userEvent.setup(); + renderList({ listItems: SEARCH_ITEMS, enableSearch: true }); + + await user.type( + screen.getByRole("textbox", { name: "Enter text to search" }), + "zzz" + ); + + expect(screen.getByTestId("list-no-results")).toBeInTheDocument(); + }); + + it("should restore all items after clearing the search", async () => { + const user = userEvent.setup(); + renderList({ listItems: SEARCH_ITEMS, enableSearch: true }); + + const input = screen.getByRole("textbox", { + name: "Enter text to search", + }); + await user.type(input, "Apple"); + expect(screen.getAllByRole("option")).toHaveLength(1); + + await user.clear(input); + expect(screen.getAllByRole("option")).toHaveLength( + SEARCH_ITEMS.length + ); + }); + }); + + // ========================================================================= + // Keyboard navigation + // ========================================================================= + describe("keyboard navigation", () => { + it("should move focus down on ArrowDown", async () => { + const user = userEvent.setup(); + renderList(); + + const options = screen.getAllByRole("option"); + options[0].focus(); + + await user.keyboard("{ArrowDown}"); + + expect(options[1]).toHaveFocus(); + }); + + it("should move focus up on ArrowUp", async () => { + const user = userEvent.setup(); + renderList(); + + const options = screen.getAllByRole("option"); + options[0].focus(); + + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{ArrowUp}"); + + expect(options[1]).toHaveFocus(); + }); + + it("should call onSelectItem on Enter", async () => { + const user = userEvent.setup(); + const onSelectItem = jest.fn(); + renderList({ selectedItems: [], onSelectItem }); + + const options = screen.getAllByRole("option"); + options[0].focus(); + + await user.keyboard("{Enter}"); + + expect(onSelectItem).toHaveBeenCalledWith("Option A", "Option A"); + }); + + it("should call onSelectItem on Space", async () => { + const user = userEvent.setup(); + const onSelectItem = jest.fn(); + renderList({ selectedItems: [], onSelectItem }); + + const options = screen.getAllByRole("option"); + options[0].focus(); + + await user.keyboard("{ArrowDown}"); + await user.keyboard(" "); + + expect(onSelectItem).toHaveBeenCalledWith("Option B", "Option B"); + }); + }); + + // ========================================================================= + // Load states + // ========================================================================= + describe("load states", () => { + it("should show loading spinner when itemsLoadState is loading", () => { + renderList({ itemsLoadState: "loading", onRetry: jest.fn() }); + + expect(screen.getByTestId("list-loading")).toBeInTheDocument(); + }); + + it("should show fail state when itemsLoadState is fail", () => { + renderList({ itemsLoadState: "fail", onRetry: jest.fn() }); + + expect(screen.getByTestId("list-fail")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Try again." }) + ).toBeInTheDocument(); + }); + + it("should call onRetry when try again is clicked", async () => { + const user = userEvent.setup(); + const onRetry = jest.fn(); + renderList({ itemsLoadState: "fail", onRetry }); + + await user.click( + screen.getByRole("button", { name: "Try again." }) + ); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================================================= + // Custom CTA + // ========================================================================= + describe("custom CTA", () => { + it("should render the custom call-to-action at the bottom", () => { + renderList({ + renderCustomCallToAction: () => ( + + ), + }); + + expect(screen.getByTestId("custom-cta")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Apply" }) + ).toBeInTheDocument(); + }); + + it("should pass the current display items to the custom CTA render function", () => { + const renderCustomCallToAction = jest.fn(() =>
CTA
); + renderList({ renderCustomCallToAction }); + + expect(renderCustomCallToAction).toHaveBeenCalledWith( + undefined, + ITEMS + ); + }); + }); +});