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
+ );
+ });
+ });
+});