import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { ReadOnlyGrid } from "./ReadOnlyGrid";
import { GridColumn } from "./GridColumn";
const sampleData = [
{ id: 1, name: "Alice", age: 30 },
{ id: 2, name: "Bob", age: 25 },
{ id: 3, name: "Charlie", age: 35 },
];
describe("ReadOnlyGrid - core rendering", () => {
it("renders a table with header and data rows", () => {
render(
);
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Age")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("Charlie")).toBeInTheDocument();
});
it("renders correct number of rows and columns", () => {
render(
);
const rows = screen.getAllByRole("row");
// 1 header row + 3 data rows
expect(rows).toHaveLength(4);
// Each data row has 2 cells
const cells = screen.getAllByRole("cell");
expect(cells).toHaveLength(6);
});
it("resolves string field name values", () => {
render(
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("Charlie")).toBeInTheDocument();
});
it("resolves function accessor values", () => {
render(
`${index}: ${row.name}`}
/>
);
expect(screen.getByText("0: Alice")).toBeInTheDocument();
expect(screen.getByText("1: Bob")).toBeInTheDocument();
expect(screen.getByText("2: Charlie")).toBeInTheDocument();
});
it("handles accessor that throws by rendering empty cell and logging warning", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
render(
{
throw new Error("boom");
}}
/>
);
// Should still render 3 data rows
const cells = screen.getAllByRole("cell");
expect(cells).toHaveLength(3);
cells.forEach((cell) => expect(cell).toHaveTextContent(""));
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("shows emptyGridMessage when data is empty", () => {
render(
);
expect(screen.getByText("No items available")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
it("shows emptyGridMessage when data is undefined", () => {
render(
);
expect(screen.getByText("No items available")).toBeInTheDocument();
});
it("shows custom emptyGridMessage", () => {
render(
);
expect(screen.getByText("Nothing here")).toBeInTheDocument();
});
it("returns null when showWhen is false", () => {
const { container } = render(
);
expect(container.innerHTML).toBe("");
});
it("hides columns with showWhen=false", () => {
render(
);
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
// Only 1 column visible, so 3 cells
expect(screen.getAllByRole("cell")).toHaveLength(3);
});
it("renders with label, instructions, and validations via FieldWrapper", () => {
render(
);
expect(screen.getByText("My Grid")).toBeInTheDocument();
expect(screen.getByText("Some instructions")).toBeInTheDocument();
expect(screen.getByText("Error 1")).toBeInTheDocument();
expect(screen.getByText("Error 2")).toBeInTheDocument();
});
it("sets aria-label on the table from accessibilityText", () => {
render(
);
expect(screen.getByRole("table")).toHaveAttribute(
"aria-label",
"Employee data grid"
);
});
it("ignores non-GridColumn children", () => {
render(
I should be ignored
);
// Only 2 column headers
const headers = screen.getAllByRole("columnheader");
expect(headers).toHaveLength(2);
});
});
import userEvent from "@testing-library/user-event";
// Generate an array of N items for paging tests
function generateData(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Item ${i + 1}`,
}));
}
/** Helper to find the paging range text container by matching its combined text content */
function expectRangeText(start: number, end: number, total: number) {
const text = `${start} – ${end} of ${total}`;
expect(screen.getByText((_content, element) => {
return element?.tagName === "SPAN" && element?.textContent === text;
})).toBeInTheDocument();
}
describe("ReadOnlyGrid - paging", () => {
it("shows paging controls when data exceeds pageSize", () => {
render(
);
expectRangeText(1, 5, 15);
expect(screen.getByRole("button", { name: "First page" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Previous page" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next page" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Last page" })).toBeInTheDocument();
});
it("hides paging controls when all data fits on one page", () => {
render(
);
expect(screen.queryByRole("button", { name: "Previous page" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Next page" })).not.toBeInTheDocument();
});
it("navigates to next page and back", async () => {
const user = userEvent.setup();
render(
);
// Page 1: items 1-5
expectRangeText(1, 5, 12);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.queryByText("Item 6")).not.toBeInTheDocument();
// Go to page 2
await user.click(screen.getByRole("button", { name: "Next page" }));
expectRangeText(6, 10, 12);
expect(screen.getByText("Item 6")).toBeInTheDocument();
expect(screen.queryByText("Item 1")).not.toBeInTheDocument();
// Go back to page 1
await user.click(screen.getByRole("button", { name: "Previous page" }));
expectRangeText(1, 5, 12);
expect(screen.getByText("Item 1")).toBeInTheDocument();
});
it("disables first/previous buttons on first page and last/next on last page", () => {
render(
);
expect(screen.getByRole("button", { name: "First page" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Last page" })).toBeEnabled();
});
it("disables next/last buttons on last page", async () => {
const user = userEvent.setup();
render(
);
// Navigate to last page (page 3)
await user.click(screen.getByRole("button", { name: "Last page" }));
expectRangeText(11, 13, 13);
expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Last page" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Previous page" })).toBeEnabled();
expect(screen.getByRole("button", { name: "First page" })).toBeEnabled();
});
it("first page button jumps to page 1, last page button jumps to final page", async () => {
const user = userEvent.setup();
render(
);
// Jump to last page
await user.click(screen.getByRole("button", { name: "Last page" }));
expectRangeText(21, 25, 25);
expect(screen.getByText("Item 25")).toBeInTheDocument();
// Jump back to first page
await user.click(screen.getByRole("button", { name: "First page" }));
expectRangeText(1, 5, 25);
expect(screen.getByText("Item 1")).toBeInTheDocument();
});
it("defaults pageSize to 10", () => {
render(
);
expectRangeText(1, 10, 15);
// Should render exactly 10 data rows
const rows = screen.getAllByRole("row");
expect(rows).toHaveLength(11); // 1 header + 10 data
});
it("shows correct page range text", async () => {
const user = userEvent.setup();
render(
);
expectRangeText(1, 10, 23);
await user.click(screen.getByRole("button", { name: "Next page" }));
expectRangeText(11, 20, 23);
await user.click(screen.getByRole("button", { name: "Next page" }));
expectRangeText(21, 23, 23);
});
it("handles invalid pageSize by defaulting to 10", () => {
render(
);
expectRangeText(1, 10, 15);
});
it("handles negative pageSize by defaulting to 10", () => {
render(
);
expectRangeText(1, 10, 15);
});
it("paging buttons have tooltips", () => {
render(
);
expect(screen.getByRole("button", { name: "First page" })).toHaveAttribute("title", "First page");
expect(screen.getByRole("button", { name: "Previous page" })).toHaveAttribute("title", "Previous page");
expect(screen.getByRole("button", { name: "Next page" })).toHaveAttribute("title", "Next page");
expect(screen.getByRole("button", { name: "Last page" })).toHaveAttribute("title", "Last page");
});
});
describe("ReadOnlyGrid - sorting", () => {
const sortableData = [
{ id: 1, name: "Charlie", age: 35 },
{ id: 2, name: "Alice", age: 30 },
{ id: 3, name: "Bob", age: 25 },
];
it("clicking a sortable column header sorts ascending", async () => {
const user = userEvent.setup();
render(
);
// Click "Name" header to sort ascending
await user.click(screen.getByRole("button", { name: "Name" }));
const cells = screen.getAllByRole("cell");
// 3 rows × 2 cols = 6 cells; first column cells at indices 0, 2, 4
expect(cells[0]).toHaveTextContent("Alice");
expect(cells[2]).toHaveTextContent("Bob");
expect(cells[4]).toHaveTextContent("Charlie");
});
it("clicking same header again toggles to descending", async () => {
const user = userEvent.setup();
render(
);
// Click "Name" twice: ascending then descending
await user.click(screen.getByRole("button", { name: "Name" }));
await user.click(screen.getByRole("button", { name: /Name/ }));
const cells = screen.getAllByRole("cell");
expect(cells[0]).toHaveTextContent("Charlie");
expect(cells[2]).toHaveTextContent("Bob");
expect(cells[4]).toHaveTextContent("Alice");
});
it("non-sortable column headers are not clickable", () => {
render(
);
// "Name" has no sortField, so no button
const headers = screen.getAllByRole("columnheader");
const nameHeader = headers[0];
expect(nameHeader.querySelector("button")).toBeNull();
// "Age" has sortField, so it has a button
const ageHeader = headers[1];
expect(ageHeader.querySelector("button")).not.toBeNull();
});
it("sort indicator shows on active sort column", async () => {
const user = userEvent.setup();
render(
);
// Click Name to sort ascending
await user.click(screen.getByRole("button", { name: "Name" }));
const nameHeader = screen.getAllByRole("columnheader")[0];
expect(nameHeader).toHaveAttribute("aria-sort", "ascending");
// MoveUp icon rendered as SVG
expect(nameHeader.querySelector("svg")).toBeInTheDocument();
// Click again to sort descending
await user.click(screen.getByRole("button", { name: /Name/ }));
expect(nameHeader).toHaveAttribute("aria-sort", "descending");
expect(nameHeader.querySelector("svg")).toBeInTheDocument();
});
it("initialSorts applies on first render", () => {
render(
);
const cells = screen.getAllByRole("cell");
// Sorted ascending by name: Alice, Bob, Charlie
expect(cells[0]).toHaveTextContent("Alice");
expect(cells[2]).toHaveTextContent("Bob");
expect(cells[4]).toHaveTextContent("Charlie");
});
it("sorting resets to page 1", async () => {
const user = userEvent.setup();
const data = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
name: `Item ${String.fromCharCode(65 + (11 - i))}`,
}));
render(
);
// Navigate to page 2
await user.click(screen.getByRole("button", { name: "Next page" }));
expectRangeText(6, 10, 12);
// Click sort — should reset to page 1
await user.click(screen.getByRole("button", { name: "Name" }));
expectRangeText(1, 5, 12);
});
});
describe("ReadOnlyGrid - selection", () => {
const selectableData = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" },
];
it("renders checkbox column when selectable=true", () => {
render(
);
// Header checkbox + 3 row checkboxes
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(4);
expect(screen.getByLabelText("Select all rows")).toBeInTheDocument();
expect(screen.getByLabelText("Select row 1")).toBeInTheDocument();
});
it("does not render checkbox column when selectable is false (default)", () => {
render(
);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
it("row checkbox toggles selection and calls selectionSaveInto", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
);
// Click first row checkbox to select
await user.click(screen.getByLabelText("Select row 1"));
expect(onSelect).toHaveBeenCalledWith([1]);
});
it("row checkbox deselects when already selected", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
);
// Click first row checkbox to deselect
await user.click(screen.getByLabelText("Select row 1"));
expect(onSelect).toHaveBeenCalledWith([2]);
});
it("header checkbox selects all page rows", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
);
await user.click(screen.getByLabelText("Select all rows"));
expect(onSelect).toHaveBeenCalledWith([1, 2, 3]);
});
it("header checkbox deselects all when all page rows are selected", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
);
await user.click(screen.getByLabelText("Select all rows"));
expect(onSelect).toHaveBeenCalledWith([]);
});
it("pre-selected rows render as checked", () => {
render(
);
const checkboxes = screen.getAllByRole("checkbox");
// Header checkbox (index 0), row 1 (index 1), row 2 (index 2), row 3 (index 3)
expect(checkboxes[1]).not.toBeChecked(); // Alice (id=1) not selected
expect(checkboxes[2]).toBeChecked(); // Bob (id=2) selected
expect(checkboxes[3]).toBeChecked(); // Charlie (id=3) selected
});
it("ROW_HIGHLIGHT style highlights selected rows instead of checkboxes", () => {
render(
);
// No checkboxes in ROW_HIGHLIGHT mode
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
// All rows should have cursor-pointer
const rows = screen.getAllByRole("row");
// rows[0] is header, rows[1..3] are data rows
expect(rows[1]).toHaveClass("cursor-pointer");
expect(rows[2]).toHaveClass("cursor-pointer");
expect(rows[3]).toHaveClass("cursor-pointer");
// Only Bob's row (id=2) should be highlighted with solid blue
expect(rows[1]).not.toHaveClass("bg-blue-500");
expect(rows[2]).toHaveClass("bg-blue-500");
expect(rows[3]).not.toHaveClass("bg-blue-500");
});
it("ROW_HIGHLIGHT clicking row toggles selection", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
);
// Click Bob's row
const rows = screen.getAllByRole("row");
await user.click(rows[2]); // rows[0]=header, rows[1]=Alice, rows[2]=Bob
expect(onSelect).toHaveBeenCalledWith([2]);
});
it("uses original index as identifier when row has no id field", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const dataWithoutId = [
{ name: "Alice" },
{ name: "Bob" },
{ name: "Charlie" },
];
render(
);
// Click second row checkbox (original index 1)
await user.click(screen.getByLabelText("Select row 2"));
expect(onSelect).toHaveBeenCalledWith([1]);
});
});
describe("ReadOnlyGrid - styling", () => {
const styleData = [
{ id: 1, name: "Alice", age: 30 },
{ id: 2, name: "Bob", age: 25 },
{ id: 3, name: "Charlie", age: 35 },
];
it('borderStyle "STANDARD" applies correct border classes', () => {
render(
);
const table = screen.getByRole("table");
expect(table).toHaveClass("border", "border-gray-300");
// Header row should have border-b
const headerRow = screen.getAllByRole("row")[0];
expect(headerRow).toHaveClass("border-b", "border-gray-300");
// Data rows should have border-b border-gray-300
const dataRows = screen.getAllByRole("row").slice(1);
dataRows.forEach((row) => {
expect(row).toHaveClass("border-b", "border-gray-300");
});
// Column dividers: first column header should have border-r (not the last)
const headers = screen.getAllByRole("columnheader");
expect(headers[0]).toHaveClass("border-r", "border-gray-300");
expect(headers[1]).not.toHaveClass("border-r");
// First data cell in each row should have border-r
const cells = screen.getAllByRole("cell");
expect(cells[0]).toHaveClass("border-r", "border-gray-300");
expect(cells[1]).not.toHaveClass("border-r");
});
it('borderStyle "LIGHT" (default) applies light border classes', () => {
render(
);
// LIGHT: no outer border on table
const table = screen.getByRole("table");
expect(table).not.toHaveClass("border");
// Header row still has bottom border
const headerRow = screen.getAllByRole("row")[0];
expect(headerRow).toHaveClass("border-b", "border-gray-200");
const dataRows = screen.getAllByRole("row").slice(1);
dataRows.forEach((row) => {
expect(row).toHaveClass("border-b", "border-gray-200");
});
// No column dividers in LIGHT mode
const headers = screen.getAllByRole("columnheader");
headers.forEach((header) => {
expect(header).not.toHaveClass("border-r");
});
});
it("shadeAlternateRows applies bg-gray-50 to even-indexed rows", () => {
render(
);
const dataRows = screen.getAllByRole("row").slice(1);
// Even-indexed (0, 2) get bg-gray-50; odd-indexed (1) does not
expect(dataRows[0]).toHaveClass("bg-gray-50");
expect(dataRows[1]).not.toHaveClass("bg-gray-50");
expect(dataRows[2]).toHaveClass("bg-gray-50");
});
it('spacing "DENSE" reduces padding', () => {
render(
);
const cells = screen.getAllByRole("cell");
cells.forEach((cell) => {
expect(cell).toHaveClass("px-2", "py-1");
expect(cell).not.toHaveClass("px-3", "py-2");
});
const headers = screen.getAllByRole("columnheader");
headers.forEach((header) => {
expect(header).toHaveClass("px-2", "py-1");
});
});
it('height "SHORT" constrains grid body with max-h and overflow', () => {
const { container } = render(
);
// The table should be wrapped in a scroll container
const scrollContainer = container.querySelector(".max-h-40");
expect(scrollContainer).toBeInTheDocument();
expect(scrollContainer).toHaveClass("overflow-y-auto");
// The thead should be sticky
const thead = container.querySelector("thead");
expect(thead).toHaveClass("sticky", "top-0", "bg-white", "z-10");
});
it('height "AUTO" (default) has no height constraint', () => {
const { container } = render(
);
// No scroll container
expect(container.querySelector(".overflow-y-auto")).not.toBeInTheDocument();
// thead should not be sticky
const thead = container.querySelector("thead");
expect(thead).not.toHaveClass("sticky");
});
it("column width applies correct class", () => {
render(
);
const headers = screen.getAllByRole("columnheader");
expect(headers[0]).toHaveClass("w-24");
expect(headers[1]).toHaveClass("w-64");
// Data cells should also have width classes
const cells = screen.getAllByRole("cell");
// Row 1: cells[0]=Name, cells[1]=Age; Row 2: cells[2]=Name, cells[3]=Age; etc.
expect(cells[0]).toHaveClass("w-24");
expect(cells[1]).toHaveClass("w-64");
});
it("column align applies correct text alignment", () => {
render(
);
const headers = screen.getAllByRole("columnheader");
expect(headers[0]).toHaveClass("text-left");
expect(headers[1]).toHaveClass("text-center");
expect(headers[2]).toHaveClass("text-right");
const cells = screen.getAllByRole("cell");
// 3 rows × 3 cols = 9 cells
// Row 1: cells 0,1,2
expect(cells[0]).toHaveClass("text-left");
expect(cells[1]).toHaveClass("text-center");
expect(cells[2]).toHaveClass("text-right");
});
it("column backgroundColor applies semantic color class", () => {
render(
);
const cells = screen.getAllByRole("cell");
// Row 1: cells[0]=Name(ACCENT), cells[1]=Age(SUCCESS)
expect(cells[0]).toHaveClass("bg-blue-50");
expect(cells[1]).toHaveClass("bg-green-50");
});
it("column backgroundColor applies hex color as inline style", () => {
render(
);
const cells = screen.getAllByRole("cell");
expect(cells[0]).toHaveStyle({ backgroundColor: "#ff5733" });
});
});