DataTable component

This commit is contained in:
Mats Bosson 2026-03-29 21:17:56 +07:00
parent 92435b2667
commit ea18b5b62c
11 changed files with 704 additions and 0 deletions

View File

@ -0,0 +1,27 @@
// packages/core/src/components/data-table/data-table-body.tsx
import type { JSX } from "solid-js";
import { For } from "solid-js";
import { useInternalDataTableContext } from "./data-table-context";
import { DataTableRow } from "./data-table-row";
/**
* Renders the `<tbody>` element, auto-generating one `<tr>` per row
* in the current page using the row data from context.
*/
export function DataTableBody(): JSX.Element {
const ctx = useInternalDataTableContext();
return (
<tbody>
<For each={ctx.pageData()}>
{(row, localIndex) => {
const pageStart = () => ctx.currentPage() * ctx.pageSize();
const originalIndex = () => pageStart() + localIndex();
return (
<DataTableRow row={row} originalIndex={originalIndex()} />
);
}}
</For>
</tbody>
);
}

View File

@ -0,0 +1,13 @@
import type { JSX } from "solid-js";
export interface DataTableCellProps {
children?: JSX.Element;
}
/**
* Renders a single `<td>` table cell.
* Serves as a thin wrapper so consumers can override styling via the element.
*/
export function DataTableCell(props: DataTableCellProps): JSX.Element {
return <td>{props.children}</td>;
}

View File

@ -0,0 +1,94 @@
// packages/core/src/components/data-table/data-table-context.ts
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
import type { DataTableColumn, SortDirection } from "./data-table.props";
/** Internal context value shared by all DataTable sub-components. */
export interface InternalDataTableContextValue {
/** All column definitions (erased to unknown rows). */
columns: Accessor<DataTableColumn<unknown>[]>;
/** The current page of processed (sorted + filtered) rows. */
pageData: Accessor<unknown[]>;
/** Total number of pages. */
pageCount: Accessor<number>;
/** Current 0-based page index. */
currentPage: Accessor<number>;
/** Page size. */
pageSize: Accessor<number>;
/** Navigate to a page. */
goToPage: (page: number) => void;
/** Current sort column id, or undefined. */
sortColumnId: Accessor<string | undefined>;
/** Current sort direction. */
sortDirection: Accessor<SortDirection>;
/** Toggle sort on a column. Cycles: asc → desc → none. */
toggleSort: (columnId: string) => void;
/** Current filter string. */
filterValue: Accessor<string>;
/** Update the filter string. */
setFilter: (value: string) => void;
/** Set of selected original-data row indices. */
selectedIndices: Accessor<Set<number>>;
/** Toggle selection of a row by its index in the original data array. */
toggleRowSelection: (index: number) => void;
/** Whether row selection is enabled. */
enableRowSelection: Accessor<boolean>;
/** Whether multi-row selection is enabled. */
enableMultiRowSelection: Accessor<boolean>;
/** Total number of rows after filtering. */
totalRows: Accessor<number>;
}
const InternalDataTableContext = createContext<InternalDataTableContextValue>();
/**
* Returns the internal DataTable context.
* Throws a descriptive error when called outside a DataTable root.
*/
export function useInternalDataTableContext(): InternalDataTableContextValue {
const ctx = useContext(InternalDataTableContext);
if (!ctx) {
throw new Error(
"[PettyUI] DataTable parts must be used inside <DataTable>.\n" +
" Fix: Wrap DataTable.Header, DataTable.Body, DataTable.Pagination inside <DataTable>.\n" +
" Docs: https://pettyui.dev/components/data-table#composition",
);
}
return ctx;
}
export const InternalDataTableContextProvider = InternalDataTableContext.Provider;
/** Public context exposed via DataTable.useContext(). */
export interface DataTableContextValue {
/** Current page data rows. */
pageData: Accessor<unknown[]>;
/** Current page index (0-based). */
currentPage: Accessor<number>;
/** Total number of pages. */
pageCount: Accessor<number>;
/** Current sort column id. */
sortColumnId: Accessor<string | undefined>;
/** Current sort direction. */
sortDirection: Accessor<SortDirection>;
/** Current filter string. */
filterValue: Accessor<string>;
/** Selected row indices. */
selectedIndices: Accessor<Set<number>>;
}
const DataTableContext = createContext<DataTableContextValue>();
/**
* Returns the public DataTable context.
* Throws if called outside a DataTable root.
*/
export function useDataTableContext(): DataTableContextValue {
const ctx = useContext(DataTableContext);
if (!ctx) {
throw new Error("[PettyUI] DataTable.useContext() called outside of <DataTable>.");
}
return ctx;
}
export const DataTableContextProvider = DataTableContext.Provider;

View File

@ -0,0 +1,46 @@
// packages/core/src/components/data-table/data-table-header-cell.tsx
import type { JSX } from "solid-js";
import { Show } from "solid-js";
import { useInternalDataTableContext } from "./data-table-context";
export interface DataTableHeaderCellProps {
columnId: string;
header: string;
sortable?: boolean;
}
/** Maps the current sort state to an aria-sort attribute value. */
function toAriaSort(direction: string): JSX.AriaAttributes["aria-sort"] {
if (direction === "asc") return "ascending";
if (direction === "desc") return "descending";
return "none";
}
/**
* Renders a single `<th>` column header.
* When the column is sortable, clicking toggles sort asc desc none
* and the appropriate aria-sort attribute and visual indicator are applied.
*/
export function DataTableHeaderCell(props: DataTableHeaderCellProps): JSX.Element {
const ctx = useInternalDataTableContext();
const isActive = () => ctx.sortColumnId() === props.columnId;
const direction = () => (isActive() ? ctx.sortDirection() : "none");
function handleClick(): void {
if (props.sortable) ctx.toggleSort(props.columnId);
}
return (
<th
aria-sort={props.sortable ? toAriaSort(direction()) : undefined}
onClick={handleClick}
style={{ cursor: props.sortable ? "pointer" : undefined }}
data-sort-direction={isActive() ? direction() : undefined}
>
{props.header}
<Show when={props.sortable && isActive() && direction() === "asc"}> </Show>
<Show when={props.sortable && isActive() && direction() === "desc"}> </Show>
</th>
);
}

View File

@ -0,0 +1,25 @@
// packages/core/src/components/data-table/data-table-header.tsx
import type { JSX } from "solid-js";
import { For } from "solid-js";
import { useInternalDataTableContext } from "./data-table-context";
import { DataTableHeaderCell } from "./data-table-header-cell";
/**
* Renders the `<thead>` element, auto-generating one `<th>` per column
* using the column definitions from context.
*/
export function DataTableHeader(): JSX.Element {
const ctx = useInternalDataTableContext();
return (
<thead>
<tr>
<For each={ctx.columns()}>
{(col) => (
<DataTableHeaderCell columnId={col.id} header={col.header} sortable={col.sortable} />
)}
</For>
</tr>
</thead>
);
}

View File

@ -0,0 +1,37 @@
import type { JSX } from "solid-js";
import { useInternalDataTableContext } from "./data-table-context";
/**
* Renders page navigation controls: a "Previous" button, a "Page X of Y"
* label, and a "Next" button. Buttons are disabled at the page boundaries.
*/
export function DataTablePagination(): JSX.Element {
const ctx = useInternalDataTableContext();
const isFirst = () => ctx.currentPage() === 0;
const isLast = () => ctx.currentPage() >= ctx.pageCount() - 1;
return (
<div data-part="pagination">
<button
type="button"
onClick={() => ctx.goToPage(ctx.currentPage() - 1)}
disabled={isFirst()}
aria-label="Previous page"
>
Previous
</button>
<span>
Page {ctx.currentPage() + 1} of {ctx.pageCount()}
</span>
<button
type="button"
onClick={() => ctx.goToPage(ctx.currentPage() + 1)}
disabled={isLast()}
aria-label="Next page"
>
Next
</button>
</div>
);
}

View File

@ -0,0 +1,186 @@
// packages/core/src/components/data-table/data-table-root.tsx
import type { JSX } from "solid-js";
import { createMemo, createSignal, splitProps } from "solid-js";
import { createControllableSignal } from "../../primitives/create-controllable-signal";
import type { InternalDataTableContextValue } from "./data-table-context";
import { DataTableContextProvider, InternalDataTableContextProvider, useDataTableContext } from "./data-table-context";
import { DataTableBody } from "./data-table-body";
import { DataTableCell } from "./data-table-cell";
import { DataTableHeader } from "./data-table-header";
import { DataTableHeaderCell } from "./data-table-header-cell";
import { DataTablePagination } from "./data-table-pagination";
import { DataTableRow } from "./data-table-row";
import type { DataTableColumn, DataTableRootProps, SortDirection } from "./data-table.props";
/** Apply global filter across all rows using JSON serialisation for a broad match. */
function applyFilter<T>(data: T[], filter: string, columns: DataTableColumn<T>[]): T[] {
if (!filter) return data;
const lower = filter.toLowerCase();
return data.filter((row) => {
for (const col of columns) {
if (col.filterFn && col.filterFn(row, filter)) return true;
}
return JSON.stringify(row).toLowerCase().includes(lower);
});
}
/** Default sort comparator using localeCompare on stringified cell values. */
function defaultSortFn<T>(col: DataTableColumn<T>, a: T, b: T): number {
const rowA = a as Record<string, unknown>;
const rowB = b as Record<string, unknown>;
const aVal = String(JSON.stringify(rowA[col.id] ?? ""));
const bVal = String(JSON.stringify(rowB[col.id] ?? ""));
return aVal.localeCompare(bVal);
}
/** Sort a copy of data by a column in the given direction. */
function applySort<T>(data: T[], col: DataTableColumn<T>, direction: SortDirection): T[] {
if (direction === "none") return data;
return [...data].sort((a, b) => {
const result = col.sortFn ? col.sortFn(a, b) : defaultSortFn(col, a, b);
return direction === "asc" ? result : -result;
});
}
/** Cycle sort direction for a column: none/new-column → asc → desc → none. */
function nextSortDirection(current: SortDirection, isSameColumn: boolean): SortDirection {
if (!isSameColumn) return "asc";
if (current === "none") return "asc";
if (current === "asc") return "desc";
return "none";
}
/**
* Root component for DataTable. Owns all state: sort, filter, pagination,
* and row selection. Provides context to all sub-components.
*/
export function DataTableRoot<T = unknown>(props: DataTableRootProps<T>): JSX.Element {
const [local] = splitProps(props, [
"data",
"columns",
"pageSize",
"defaultPage",
"page",
"filterValue",
"defaultFilterValue",
"enableRowSelection",
"enableMultiRowSelection",
"onPageChange",
"onFilterChange",
"onSelectionChange",
"children",
]);
const resolvedPageSize = () => local.pageSize ?? 10;
const [currentPage, setCurrentPage] = createControllableSignal<number>({
value: () => local.page,
defaultValue: () => local.defaultPage ?? 0,
onChange: local.onPageChange,
});
const [filterValue, setFilterInternal] = createControllableSignal<string>({
value: () => local.filterValue,
defaultValue: () => local.defaultFilterValue ?? "",
onChange: local.onFilterChange,
});
const [sortColumnId, setSortColumnId] = createSignal<string | undefined>(undefined);
const [sortDirection, setSortDirection] = createSignal<SortDirection>("none");
const [selectedIndices, setSelectedIndices] = createSignal<Set<number>>(new Set());
const filteredData = createMemo(() => applyFilter(local.data, filterValue(), local.columns));
const sortedData = createMemo(() => {
const colId = sortColumnId();
const dir = sortDirection();
if (!colId || dir === "none") return filteredData();
const col = local.columns.find((c) => c.id === colId);
if (!col) return filteredData();
return applySort(filteredData(), col, dir);
});
const pageCount = createMemo(() =>
Math.max(1, Math.ceil(sortedData().length / resolvedPageSize())),
);
const safePage = () => Math.min(currentPage(), pageCount() - 1);
const pageData = createMemo(() => {
const start = safePage() * resolvedPageSize();
return sortedData().slice(start, start + resolvedPageSize());
});
/** Toggle sort on a column; cycles none → asc → desc → none. */
function toggleSort(columnId: string): void {
const isSame = sortColumnId() === columnId;
const next = nextSortDirection(sortDirection(), isSame);
setSortColumnId(next === "none" ? undefined : columnId);
setSortDirection(next);
setCurrentPage(0);
}
/** Update the global filter and reset to page 0. */
function setFilter(value: string): void {
setFilterInternal(value);
setCurrentPage(0);
}
/** Navigate to a page, clamped to valid range. */
function goToPage(page: number): void {
setCurrentPage(Math.max(0, Math.min(page, pageCount() - 1)));
}
/** Toggle selection of a row by its original-data index. */
function toggleRowSelection(index: number): void {
setSelectedIndices((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
if (!local.enableMultiRowSelection) next.clear();
next.add(index);
}
local.onSelectionChange?.(next);
return next;
});
}
const internalCtx: InternalDataTableContextValue = {
columns: () => local.columns as DataTableColumn<unknown>[],
pageData,
pageCount,
currentPage: safePage,
pageSize: resolvedPageSize,
goToPage,
sortColumnId,
sortDirection,
toggleSort,
filterValue,
setFilter,
selectedIndices,
toggleRowSelection,
enableRowSelection: () => local.enableRowSelection ?? false,
enableMultiRowSelection: () => local.enableMultiRowSelection ?? false,
totalRows: () => sortedData().length,
};
return (
<InternalDataTableContextProvider value={internalCtx}>
<DataTableContextProvider value={internalCtx}>
{local.children}
</DataTableContextProvider>
</InternalDataTableContextProvider>
);
}
/** Compound DataTable with all sub-components as static properties. */
export const DataTable = Object.assign(DataTableRoot, {
Header: DataTableHeader,
HeaderCell: DataTableHeaderCell,
Body: DataTableBody,
Row: DataTableRow,
Cell: DataTableCell,
Pagination: DataTablePagination,
useContext: useDataTableContext,
});

View File

@ -0,0 +1,44 @@
// packages/core/src/components/data-table/data-table-row.tsx
import type { JSX } from "solid-js";
import { For } from "solid-js";
import { useInternalDataTableContext } from "./data-table-context";
import { DataTableCell } from "./data-table-cell";
export interface DataTableRowProps {
row: unknown;
originalIndex: number;
}
/**
* Renders a single `<tr>` for one data row.
* When row selection is enabled, clicking the row toggles its selected state.
* Each column's cell renderer is called to produce the `<td>` content.
*/
export function DataTableRow(props: DataTableRowProps): JSX.Element {
const ctx = useInternalDataTableContext();
const isSelected = () => ctx.selectedIndices().has(props.originalIndex);
function handleClick(): void {
if (ctx.enableRowSelection()) {
ctx.toggleRowSelection(props.originalIndex);
}
}
return (
<tr
aria-selected={ctx.enableRowSelection() ? isSelected() : undefined}
data-selected={isSelected() ? "" : undefined}
onClick={handleClick}
style={{ cursor: ctx.enableRowSelection() ? "pointer" : undefined }}
>
<For each={ctx.columns()}>
{(col, colIndex) => (
<DataTableCell>
{col.cell(props.row, colIndex())}
</DataTableCell>
)}
</For>
</tr>
);
}

View File

@ -0,0 +1,54 @@
// packages/core/src/components/data-table/data-table.props.ts
import { z } from "zod/v4";
import type { JSX } from "solid-js";
import type { ComponentMeta } from "../../meta";
export const DataTableColumnSchema = z.object({
id: z.string().describe("Unique column identifier"),
header: z.string().describe("Column header label"),
sortable: z.boolean().optional().describe("Whether the column is sortable"),
width: z.number().optional().describe("Fixed column width in pixels"),
minWidth: z.number().optional().describe("Minimum column width in pixels"),
});
export type SortDirection = "asc" | "desc" | "none";
export interface DataTableColumn<T = unknown> extends z.infer<typeof DataTableColumnSchema> {
/** Renders the cell content for a given row. */
cell: (row: T, index: number) => JSX.Element;
/** Custom sort comparator. Falls back to localeCompare on JSON-stringified values. */
sortFn?: (a: T, b: T) => number;
/** Custom per-column filter. Falls back to global JSON.stringify includes. */
filterFn?: (row: T, filterValue: string) => boolean;
}
export const DataTableRootPropsSchema = z.object({
pageSize: z.number().optional().describe("Number of rows per page. Defaults to 10"),
defaultPage: z.number().optional().describe("Initial page index (0-based, uncontrolled)"),
page: z.number().optional().describe("Controlled page index (0-based)"),
filterValue: z.string().optional().describe("Controlled global filter string"),
defaultFilterValue: z.string().optional().describe("Initial filter value (uncontrolled)"),
enableRowSelection: z.boolean().optional().describe("Whether row selection is enabled"),
enableMultiRowSelection: z.boolean().optional().describe("Whether multiple rows can be selected"),
});
export interface DataTableRootProps<T = unknown> extends z.infer<typeof DataTableRootPropsSchema> {
/** The data rows to display. */
data: T[];
/** Column definitions. */
columns: DataTableColumn<T>[];
/** Called when the page changes. */
onPageChange?: (page: number) => void;
/** Called when the filter value changes. */
onFilterChange?: (value: string) => void;
/** Called when selection changes. */
onSelectionChange?: (selectedIndices: Set<number>) => void;
children: JSX.Element;
}
export const DataTableMeta: ComponentMeta = {
name: "DataTable",
description: "Feature-rich data table with sorting, filtering, pagination, and row selection",
parts: ["Root", "Header", "HeaderCell", "Body", "Row", "Cell", "Pagination"] as const,
requiredParts: ["Root", "Header", "Body", "Pagination"] as const,
} as const;

View File

@ -0,0 +1,4 @@
export { DataTable, DataTableRoot } from "./data-table-root";
export { useDataTableContext } from "./data-table-context";
export { DataTableRootPropsSchema, DataTableColumnSchema, DataTableMeta } from "./data-table.props";
export type { DataTableRootProps, DataTableColumn, SortDirection } from "./data-table.props";

View File

@ -0,0 +1,174 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { DataTable, DataTableColumnSchema, DataTableMeta, DataTableRootPropsSchema } from "../../../src/components/data-table/index";
const data = [
{ id: 1, name: "Alice", age: 30 },
{ id: 2, name: "Bob", age: 25 },
{ id: 3, name: "Charlie", age: 35 },
];
const columns = [
{ id: "name", header: "Name", cell: (row: typeof data[0]) => row.name, sortable: true },
{ id: "age", header: "Age", cell: (row: typeof data[0]) => String(row.age), sortable: true },
];
function TestTable(props: { pageSize?: number; enableRowSelection?: boolean }) {
return (
<DataTable data={data} columns={columns} pageSize={props.pageSize ?? 10} enableRowSelection={props.enableRowSelection}>
<table>
<DataTable.Header />
<DataTable.Body />
</table>
<DataTable.Pagination />
</DataTable>
);
}
describe("DataTable — rendering", () => {
it("renders all rows by default", () => {
render(() => <TestTable />);
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Bob")).toBeTruthy();
expect(screen.getByText("Charlie")).toBeTruthy();
});
it("renders column headers", () => {
render(() => <TestTable />);
expect(screen.getByText("Name")).toBeTruthy();
expect(screen.getByText("Age")).toBeTruthy();
});
it("renders pagination controls", () => {
render(() => <TestTable />);
expect(screen.getByText("Previous")).toBeTruthy();
expect(screen.getByText("Next")).toBeTruthy();
expect(screen.getByText(/Page 1 of/)).toBeTruthy();
});
});
describe("DataTable — sorting", () => {
it("sorts ascending on first header click", () => {
render(() => <TestTable />);
fireEvent.click(screen.getByText("Name"));
const cells = screen.getAllByRole("cell");
const names = cells.filter((_, i) => i % 2 === 0).map((c) => c.textContent);
expect(names[0]).toBe("Alice");
expect(names[1]).toBe("Bob");
expect(names[2]).toBe("Charlie");
});
it("sorts descending on second header click", () => {
render(() => <TestTable />);
const nameHeader = screen.getByRole("columnheader", { name: /Name/ });
fireEvent.click(nameHeader);
fireEvent.click(nameHeader);
const cells = screen.getAllByRole("cell");
const names = cells.filter((_, i) => i % 2 === 0).map((c) => c.textContent);
expect(names[0]).toBe("Charlie");
expect(names[1]).toBe("Bob");
expect(names[2]).toBe("Alice");
});
it("resets sort on third header click", () => {
render(() => <TestTable />);
const nameHeader = screen.getByRole("columnheader", { name: /Name/ });
fireEvent.click(nameHeader);
fireEvent.click(nameHeader);
fireEvent.click(nameHeader);
expect(nameHeader.getAttribute("aria-sort")).toBe("none");
});
it("header has aria-sort=ascending when sorted asc", () => {
render(() => <TestTable />);
const nameHeader = screen.getByRole("columnheader", { name: /Name/ });
fireEvent.click(nameHeader);
expect(nameHeader.getAttribute("aria-sort")).toBe("ascending");
});
});
describe("DataTable — pagination", () => {
it("shows only pageSize rows per page", () => {
render(() => <TestTable pageSize={2} />);
const cells = screen.getAllByRole("cell");
expect(cells.length).toBe(4);
});
it("next page shows remaining rows", () => {
render(() => <TestTable pageSize={2} />);
fireEvent.click(screen.getByText("Next"));
expect(screen.getByText("Charlie")).toBeTruthy();
});
it("previous button is disabled on first page", () => {
render(() => <TestTable pageSize={2} />);
const prev = screen.getByText("Previous") as HTMLButtonElement;
expect(prev.disabled).toBe(true);
});
it("next button is disabled on last page", () => {
render(() => <TestTable pageSize={2} />);
fireEvent.click(screen.getByText("Next"));
const next = screen.getByText("Next") as HTMLButtonElement;
expect(next.disabled).toBe(true);
});
it("shows correct page label", () => {
render(() => <TestTable pageSize={2} />);
expect(screen.getByText("Page 1 of 2")).toBeTruthy();
});
});
describe("DataTable — row selection", () => {
it("clicking a row marks it selected", () => {
render(() => <TestTable enableRowSelection />);
const rows = screen.getAllByRole("row");
fireEvent.click(rows[1]);
expect(rows[1].getAttribute("aria-selected")).toBe("true");
});
it("clicking selected row deselects it", () => {
render(() => <TestTable enableRowSelection />);
const rows = screen.getAllByRole("row");
fireEvent.click(rows[1]);
fireEvent.click(rows[1]);
expect(rows[1].getAttribute("aria-selected")).toBe("false");
});
it("onSelectionChange is called", () => {
const onChange = vi.fn();
render(() => (
<DataTable data={data} columns={columns} enableRowSelection onSelectionChange={onChange}>
<table>
<DataTable.Header />
<DataTable.Body />
</table>
<DataTable.Pagination />
</DataTable>
));
const rows = screen.getAllByRole("row");
fireEvent.click(rows[1]);
expect(onChange).toHaveBeenCalledWith(expect.any(Set));
});
});
describe("DataTable — schema and meta", () => {
it("DataTableColumnSchema validates a valid column", () => {
const result = DataTableColumnSchema.safeParse({ id: "name", header: "Name", sortable: true });
expect(result.success).toBe(true);
});
it("DataTableRootPropsSchema validates pageSize", () => {
const result = DataTableRootPropsSchema.safeParse({ pageSize: 20 });
expect(result.success).toBe(true);
});
it("DataTableMeta has correct name", () => {
expect(DataTableMeta.name).toBe("DataTable");
});
it("DataTableMeta lists required parts", () => {
expect(DataTableMeta.requiredParts).toContain("Root");
expect(DataTableMeta.requiredParts).toContain("Body");
});
});