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) => ( + + {children} +
+); +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( + + + + content + + +
+ ); + + 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"); + }); +});