diff --git a/e2e/nextjs-app/src/app/components/table/basic.e2e.tsx b/e2e/nextjs-app/src/app/components/table/basic.e2e.tsx
new file mode 100644
index 0000000000..0ad7e8d59f
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/table/basic.e2e.tsx
@@ -0,0 +1,33 @@
+"use client";
+import { Table } from "@lifesg/react-design-system/table";
+
+export default function Story() {
+ return (
+
+
+
+ Name
+ Email
+ Status
+
+
+
+
+ Alice Tan
+ alice@example.com
+ Active
+
+
+ Bob Lim
+ bob@example.com
+ Inactive
+
+
+ Carol Wong
+ carol@example.com
+ Pending
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/table/horizontal-overflow.e2e.tsx b/e2e/nextjs-app/src/app/components/table/horizontal-overflow.e2e.tsx
new file mode 100644
index 0000000000..7d0d665b5e
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/table/horizontal-overflow.e2e.tsx
@@ -0,0 +1,37 @@
+"use client";
+import { Table } from "@lifesg/react-design-system/table";
+import styles from "./table.module.css";
+
+export default function Story() {
+ return (
+
+
+
+
+ First name
+ Last name
+ Email address
+ Department
+ Status
+
+
+
+
+ Alice
+ Tan
+ alice@example.com
+ Engineering
+ Active
+
+
+ Bob
+ Lim
+ bob@example.com
+ Design
+ Inactive
+
+
+
+
+ );
+}
diff --git a/e2e/nextjs-app/src/app/components/table/table.module.css b/e2e/nextjs-app/src/app/components/table/table.module.css
new file mode 100644
index 0000000000..2e0b1b193c
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/table/table.module.css
@@ -0,0 +1,7 @@
+.table-overflow {
+ width: 400px;
+}
+
+.table-vertical-overflow {
+ max-height: 200px;
+}
diff --git a/e2e/nextjs-app/src/app/components/table/vertical-overflow.e2e.tsx b/e2e/nextjs-app/src/app/components/table/vertical-overflow.e2e.tsx
new file mode 100644
index 0000000000..ed81f94830
--- /dev/null
+++ b/e2e/nextjs-app/src/app/components/table/vertical-overflow.e2e.tsx
@@ -0,0 +1,33 @@
+"use client";
+import { Table } from "@lifesg/react-design-system/table";
+import styles from "./table.module.css";
+
+export default function Story() {
+ return (
+
+
+
+
+ Name
+ Email
+ Status
+
+
+
+ {Array.from({ length: 10 }).map((_, i) => (
+
+ User {i + 1}
+ user{i + 1}@example.com
+
+ {i % 2 === 0 ? "Active" : "Inactive"}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/e2e/tests/components/table/__screenshots__/chromium/Table-Default-table--mount.png b/e2e/tests/components/table/__screenshots__/chromium/Table-Default-table--mount.png
new file mode 100644
index 0000000000..a2f8a860df
Binary files /dev/null and b/e2e/tests/components/table/__screenshots__/chromium/Table-Default-table--mount.png differ
diff --git a/e2e/tests/components/table/__screenshots__/chromium/Table-Default-table-dark-mode---mount.png b/e2e/tests/components/table/__screenshots__/chromium/Table-Default-table-dark-mode---mount.png
new file mode 100644
index 0000000000..2d433941ff
Binary files /dev/null and b/e2e/tests/components/table/__screenshots__/chromium/Table-Default-table-dark-mode---mount.png differ
diff --git a/e2e/tests/components/table/__screenshots__/chromium/Table-Horizontal-overflow-scrollable-table--mount.png b/e2e/tests/components/table/__screenshots__/chromium/Table-Horizontal-overflow-scrollable-table--mount.png
new file mode 100644
index 0000000000..dd8b3e010a
Binary files /dev/null and b/e2e/tests/components/table/__screenshots__/chromium/Table-Horizontal-overflow-scrollable-table--mount.png differ
diff --git a/e2e/tests/components/table/__screenshots__/chromium/Table-Table-can-be-scrolled-horizontally--scrolled.png b/e2e/tests/components/table/__screenshots__/chromium/Table-Table-can-be-scrolled-horizontally--scrolled.png
new file mode 100644
index 0000000000..5b0104cb38
Binary files /dev/null and b/e2e/tests/components/table/__screenshots__/chromium/Table-Table-can-be-scrolled-horizontally--scrolled.png differ
diff --git a/e2e/tests/components/table/__screenshots__/chromium/Table-Table-can-be-scrolled-vertically--scrolled.png b/e2e/tests/components/table/__screenshots__/chromium/Table-Table-can-be-scrolled-vertically--scrolled.png
new file mode 100644
index 0000000000..9be52a6321
Binary files /dev/null and b/e2e/tests/components/table/__screenshots__/chromium/Table-Table-can-be-scrolled-vertically--scrolled.png differ
diff --git a/e2e/tests/components/table/__screenshots__/chromium/Table-Vertical-overflow-scrollable-table--mount.png b/e2e/tests/components/table/__screenshots__/chromium/Table-Vertical-overflow-scrollable-table--mount.png
new file mode 100644
index 0000000000..fb1ca02703
Binary files /dev/null and b/e2e/tests/components/table/__screenshots__/chromium/Table-Vertical-overflow-scrollable-table--mount.png differ
diff --git a/e2e/tests/components/table/table.e2e.spec.ts b/e2e/tests/components/table/table.e2e.spec.ts
new file mode 100644
index 0000000000..6390d45812
--- /dev/null
+++ b/e2e/tests/components/table/table.e2e.spec.ts
@@ -0,0 +1,107 @@
+import { test as base, expect, Locator, Page } from "@playwright/test";
+import { AbstractStoryPage, compareScreenshot } from "../../utils";
+
+class StoryPage extends AbstractStoryPage {
+ protected readonly component = "table";
+
+ public readonly locators: {
+ table: Locator;
+ tableWrapper: Locator;
+ };
+
+ constructor(page: Page) {
+ super(page);
+
+ this.locators = {
+ table: page.getByTestId("table"),
+ tableWrapper: page.getByTestId("table-wrapper"),
+ };
+ }
+}
+
+const test = base.extend<{ story: StoryPage }>({
+ story: async ({ page }, use) => {
+ const story = new StoryPage(page);
+ await use(story);
+ },
+});
+
+test.describe("Table", () => {
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("basic");
+ });
+
+ test("Default table", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+
+ await expect(story.locators.table).toMatchAriaSnapshot(`
+ - table:
+ - rowgroup:
+ - row "Name Email Status":
+ - columnheader "Name"
+ - columnheader "Email"
+ - columnheader "Status"
+ - rowgroup:
+ - row "Alice Tan alice@example.com Active":
+ - cell "Alice Tan"
+ - cell "alice@example.com"
+ - cell "Active"
+ - row "Bob Lim bob@example.com Inactive":
+ - cell "Bob Lim"
+ - cell "bob@example.com"
+ - cell "Inactive"
+ - row "Carol Wong carol@example.com Pending":
+ - cell "Carol Wong"
+ - cell "carol@example.com"
+ - cell "Pending"
+ `);
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("basic", { mode: "dark" });
+ });
+
+ test("Default table (dark mode)", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("horizontal-overflow");
+ });
+
+ test("Horizontal overflow scrollable table", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+
+ test("Table can be scrolled horizontally", async ({ story }) => {
+ await story.locators.tableWrapper.hover();
+ await story.page.mouse.wheel(200, 0);
+ await compareScreenshot(story, "scrolled", {
+ locator: story.locators.tableWrapper,
+ });
+ });
+ });
+
+ test.describe(() => {
+ test.beforeEach(async ({ story }) => {
+ await story.init("vertical-overflow");
+ });
+
+ test("Vertical overflow scrollable table", async ({ story }) => {
+ await compareScreenshot(story, "mount");
+ });
+
+ test("Table can be scrolled vertically", async ({ story }) => {
+ await story.locators.tableWrapper.hover();
+ await story.page.mouse.wheel(0, 200);
+ await compareScreenshot(story, "scrolled", {
+ locator: story.locators.tableWrapper,
+ });
+ });
+ });
+});
diff --git a/src/table/table.styles.ts b/src/table/table.styles.ts
new file mode 100644
index 0000000000..fda9d42618
--- /dev/null
+++ b/src/table/table.styles.ts
@@ -0,0 +1,79 @@
+import { css } from "@linaria/core";
+
+import { Border, Colour, Radius, Spacing } from "../theme";
+
+// =============================================================================
+// STYLES CONSTANTS
+// =============================================================================
+const borderColor = Colour["border"];
+const fontColor = Colour["text"];
+
+// =============================================================================
+// STYLES
+// =============================================================================
+export const tableWrapper = css`
+ overflow: auto;
+ border: ${Border["width-010"]} ${Border["solid"]} ${borderColor};
+ border-radius: ${Radius["md"]};
+
+ /* Hide scrollbar */
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ * {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+ }
+`;
+
+export const tableComponent = css`
+ text-align: left;
+ border-collapse: separate;
+ border-spacing: 0;
+ width: 100%;
+`;
+
+export const tableBody = css`
+ :where(tr:last-child) {
+ td {
+ border-bottom: none;
+ }
+ }
+`;
+
+export const headerCell = css`
+ padding: ${Spacing["spacing-20"]} ${Spacing["spacing-16"]};
+ text-align: left;
+ cursor: default;
+ vertical-align: middle;
+ color: ${fontColor};
+ background-color: ${Colour["bg-stronger"]};
+ height: 6rem;
+ border-bottom: ${Border["width-010"]} ${Border["solid"]} ${borderColor};
+
+ &:where(&:first-child) {
+ padding-left: ${Spacing["spacing-24"]};
+ }
+ &:where(&:last-child) {
+ padding-right: ${Spacing["spacing-24"]};
+ }
+`;
+
+export const bodyRow = css`
+ background-color: ${Colour["bg"]};
+ border-top: ${Border["width-010"]} ${Border["solid"]} ${borderColor};
+`;
+
+export const bodyCell = css`
+ padding: ${Spacing["spacing-20"]} ${Spacing["spacing-16"]};
+ vertical-align: middle;
+ color: ${fontColor};
+ border-bottom: ${Border["width-010"]} ${Border["solid"]} ${borderColor};
+
+ &:where(&:first-child) {
+ padding-left: ${Spacing["spacing-24"]};
+ }
+ &:where(&:last-child) {
+ padding-right: ${Spacing["spacing-24"]};
+ }
+`;
diff --git a/src/table/table.styles.tsx b/src/table/table.styles.tsx
deleted file mode 100644
index cdcb12e5a0..0000000000
--- a/src/table/table.styles.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import styled from "styled-components";
-
-import { V3_Border, V3_Colour, V3_Radius, V3_Spacing } from "../v3_theme";
-
-// =============================================================================
-// STYLES CONSTANTS
-// =============================================================================
-const borderColor = V3_Colour["border"];
-const fontColor = V3_Colour["text"];
-
-// =============================================================================
-// STYLES
-// =============================================================================
-export const TableWrapper = styled.div`
- overflow: auto;
- border: ${V3_Border["width-010"]} ${V3_Border["solid"]} ${borderColor};
- border-radius: ${V3_Radius["md"]};
-
- // Hide scrollbar
- &::-webkit-scrollbar {
- display: none;
- }
- * {
- -ms-overflow-style: none; /* IE and Edge */
- scrollbar-width: none; /* Firefox */
- }
-`;
-
-TableWrapper.displayName = "Table.Container";
-
-export const TableComponent = styled.table`
- text-align: left;
- border-collapse: separate;
- border-spacing: 0;
- width: 100%;
-`;
-
-TableComponent.displayName = "Table.Table";
-
-export const TableBody = styled.tbody`
- :where(tr:last-child) {
- td {
- border-bottom: none;
- }
- }
-`;
-
-export const HeaderCell = styled.th`
- padding: ${V3_Spacing["spacing-20"]} ${V3_Spacing["spacing-16"]};
- text-align: left;
- cursor: default;
- vertical-align: middle;
- color: ${fontColor};
- background-color: ${V3_Colour["bg-stronger"]};
- height: 6rem;
- border-bottom: ${V3_Border["width-010"]} ${V3_Border["solid"]}
- ${borderColor};
-
- &:where(&:first-child) {
- padding-left: ${V3_Spacing["spacing-24"]};
- }
- &:where(&:last-child) {
- padding-right: ${V3_Spacing["spacing-24"]};
- }
-`;
-
-export const BodyRow = styled.tr`
- background-color: ${V3_Colour["bg"]};
- border-top: ${V3_Border["width-010"]} ${V3_Border["solid"]} ${borderColor};
-`;
-
-export const BodyCell = styled.td`
- padding: ${V3_Spacing["spacing-20"]} ${V3_Spacing["spacing-16"]};
- vertical-align: middle;
- color: ${fontColor};
- border-bottom: ${V3_Border["width-010"]} ${V3_Border["solid"]}
- ${borderColor};
-
- &:where(&:first-child) {
- padding-left: ${V3_Spacing["spacing-24"]};
- }
- &:where(&:last-child) {
- padding-right: ${V3_Spacing["spacing-24"]};
- }
-`;
diff --git a/src/table/table.tsx b/src/table/table.tsx
index ef2e2de941..7a09dfe990 100644
--- a/src/table/table.tsx
+++ b/src/table/table.tsx
@@ -1,11 +1,6 @@
-import {
- BodyCell,
- BodyRow,
- HeaderCell,
- TableBody,
- TableComponent,
- TableWrapper,
-} from "./table.styles";
+import clsx from "clsx";
+
+import * as styles from "./table.styles";
const Head = ({
children,
@@ -17,45 +12,83 @@ Head.displayName = "Table.Head";
const Body = ({
children,
+ className,
...props
}: React.HTMLAttributes) => (
- {children}
+
+ {children}
+
);
Body.displayName = "Table.Body";
const Row = ({
children,
+ className,
...props
}: React.HTMLAttributes) => (
- {children}
+
+ {children}
+
);
Row.displayName = "Table.Row";
const Cell = ({
children,
+ className,
...props
}: React.TdHTMLAttributes) => (
- {children}
+
+ {children}
+ |
);
Cell.displayName = "Table.Cell";
const Header = ({
children,
+ className,
...props
}: React.ThHTMLAttributes) => (
- {children}
+
+ {children}
+ |
);
Header.displayName = "Table.HeaderCell";
+const Container = ({
+ children,
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+ {children}
+
+);
+Container.displayName = "Table.Container";
+
+const TableEl = ({
+ children,
+ className,
+ ...props
+}: React.TableHTMLAttributes) => (
+
+);
+TableEl.displayName = "Table.Table";
+
export const Table = Object.assign(
({ children, ...props }: React.TableHTMLAttributes) => (
-
- {children}
-
+
+ {children}
+
),
{
- Container: TableWrapper,
- Table: TableComponent,
+ Container,
+ Table: TableEl,
Head,
Body,
Row,
diff --git a/tests/table/table.spec.tsx b/tests/table/table.spec.tsx
new file mode 100644
index 0000000000..10dd53e456
--- /dev/null
+++ b/tests/table/table.spec.tsx
@@ -0,0 +1,102 @@
+import { render, screen } from "@testing-library/react";
+import { Table } from "src/table";
+
+// =============================================================================
+// UNIT TESTS
+// =============================================================================
+
+describe("Table", () => {
+ it("should render the table structure", () => {
+ render(
+
+
+
+
+ Name
+
+
+
+
+
+
+ John Doe
+
+
+
+
+ );
+
+ expect(screen.getByTestId("table")).toBeInTheDocument();
+ expect(screen.getByTestId("table-head")).toBeInTheDocument();
+ expect(screen.getByTestId("header-row")).toBeInTheDocument();
+ expect(screen.getByTestId("header-cell")).toBeInTheDocument();
+ expect(screen.getByTestId("table-body")).toBeInTheDocument();
+ expect(screen.getByTestId("body-row")).toBeInTheDocument();
+ expect(screen.getByTestId("body-cell")).toBeInTheDocument();
+ });
+
+ it("should render cell and header content correctly", () => {
+ render(
+
+
+
+ Name
+ Status
+
+
+
+
+ John Doe
+ Active
+
+
+
+ );
+
+ expect(
+ screen.getByRole("columnheader", { name: "Name" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("columnheader", { name: "Status" })
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole("cell")).toHaveLength(2);
+ expect(screen.getAllByRole("cell")[0]).toHaveTextContent("John Doe");
+ expect(screen.getAllByRole("cell")[1]).toHaveTextContent("Active");
+ });
+
+ it("should render multiple rows correctly", () => {
+ render(
+
+
+
+ Alice
+
+
+ Bob
+
+
+
+ );
+
+ expect(screen.getAllByRole("row")).toHaveLength(2);
+ expect(screen.getByTestId("row-1")).toBeInTheDocument();
+ expect(screen.getByTestId("row-2")).toBeInTheDocument();
+ });
+
+ it("should apply custom classNames to subcomponents", () => {
+ render(
+
+ );
+
+ expect(screen.getByRole("table").className).toContain("custom-table");
+ expect(screen.getByRole("rowgroup").className).toContain("custom-body");
+ expect(screen.getByRole("row").className).toContain("custom-row");
+ expect(screen.getByRole("cell").className).toContain("custom-cell");
+ });
+});