diff --git a/packages/core/src/components/data-table/data-table-body.tsx b/packages/core/src/components/data-table/data-table-body.tsx new file mode 100644 index 0000000..8da8fba --- /dev/null +++ b/packages/core/src/components/data-table/data-table-body.tsx @@ -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 `` element, auto-generating one `` per row + * in the current page using the row data from context. + */ +export function DataTableBody(): JSX.Element { + const ctx = useInternalDataTableContext(); + + return ( + + + {(row, localIndex) => { + const pageStart = () => ctx.currentPage() * ctx.pageSize(); + const originalIndex = () => pageStart() + localIndex(); + return ( + + ); + }} + + + ); +} diff --git a/packages/core/src/components/data-table/data-table-cell.tsx b/packages/core/src/components/data-table/data-table-cell.tsx new file mode 100644 index 0000000..6691561 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-cell.tsx @@ -0,0 +1,13 @@ +import type { JSX } from "solid-js"; + +export interface DataTableCellProps { + children?: JSX.Element; +} + +/** + * Renders a single `` table cell. + * Serves as a thin wrapper so consumers can override styling via the element. + */ +export function DataTableCell(props: DataTableCellProps): JSX.Element { + return {props.children}; +} diff --git a/packages/core/src/components/data-table/data-table-context.ts b/packages/core/src/components/data-table/data-table-context.ts new file mode 100644 index 0000000..63e44a3 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-context.ts @@ -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[]>; + /** The current page of processed (sorted + filtered) rows. */ + pageData: Accessor; + /** Total number of pages. */ + pageCount: Accessor; + /** Current 0-based page index. */ + currentPage: Accessor; + /** Page size. */ + pageSize: Accessor; + /** Navigate to a page. */ + goToPage: (page: number) => void; + /** Current sort column id, or undefined. */ + sortColumnId: Accessor; + /** Current sort direction. */ + sortDirection: Accessor; + /** Toggle sort on a column. Cycles: asc → desc → none. */ + toggleSort: (columnId: string) => void; + /** Current filter string. */ + filterValue: Accessor; + /** Update the filter string. */ + setFilter: (value: string) => void; + /** Set of selected original-data row indices. */ + selectedIndices: Accessor>; + /** Toggle selection of a row by its index in the original data array. */ + toggleRowSelection: (index: number) => void; + /** Whether row selection is enabled. */ + enableRowSelection: Accessor; + /** Whether multi-row selection is enabled. */ + enableMultiRowSelection: Accessor; + /** Total number of rows after filtering. */ + totalRows: Accessor; +} + +const InternalDataTableContext = createContext(); + +/** + * 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 .\n" + + " Fix: Wrap DataTable.Header, DataTable.Body, DataTable.Pagination inside .\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; + /** Current page index (0-based). */ + currentPage: Accessor; + /** Total number of pages. */ + pageCount: Accessor; + /** Current sort column id. */ + sortColumnId: Accessor; + /** Current sort direction. */ + sortDirection: Accessor; + /** Current filter string. */ + filterValue: Accessor; + /** Selected row indices. */ + selectedIndices: Accessor>; +} + +const DataTableContext = createContext(); + +/** + * 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 ."); + } + return ctx; +} + +export const DataTableContextProvider = DataTableContext.Provider; diff --git a/packages/core/src/components/data-table/data-table-header-cell.tsx b/packages/core/src/components/data-table/data-table-header-cell.tsx new file mode 100644 index 0000000..4bc4b61 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-header-cell.tsx @@ -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 `` 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 ( + + {props.header} + + + + ); +} diff --git a/packages/core/src/components/data-table/data-table-header.tsx b/packages/core/src/components/data-table/data-table-header.tsx new file mode 100644 index 0000000..cc818f4 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-header.tsx @@ -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 `` element, auto-generating one `` per column + * using the column definitions from context. + */ +export function DataTableHeader(): JSX.Element { + const ctx = useInternalDataTableContext(); + + return ( + + + + {(col) => ( + + )} + + + + ); +} diff --git a/packages/core/src/components/data-table/data-table-pagination.tsx b/packages/core/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000..06e3521 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-pagination.tsx @@ -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 ( +
+ + + Page {ctx.currentPage() + 1} of {ctx.pageCount()} + + +
+ ); +} diff --git a/packages/core/src/components/data-table/data-table-root.tsx b/packages/core/src/components/data-table/data-table-root.tsx new file mode 100644 index 0000000..85f8144 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-root.tsx @@ -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(data: T[], filter: string, columns: DataTableColumn[]): 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(col: DataTableColumn, a: T, b: T): number { + const rowA = a as Record; + const rowB = b as Record; + 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(data: T[], col: DataTableColumn, 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(props: DataTableRootProps): 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({ + value: () => local.page, + defaultValue: () => local.defaultPage ?? 0, + onChange: local.onPageChange, + }); + + const [filterValue, setFilterInternal] = createControllableSignal({ + value: () => local.filterValue, + defaultValue: () => local.defaultFilterValue ?? "", + onChange: local.onFilterChange, + }); + + const [sortColumnId, setSortColumnId] = createSignal(undefined); + const [sortDirection, setSortDirection] = createSignal("none"); + const [selectedIndices, setSelectedIndices] = createSignal>(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[], + 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 ( + + + {local.children} + + + ); +} + +/** 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, +}); diff --git a/packages/core/src/components/data-table/data-table-row.tsx b/packages/core/src/components/data-table/data-table-row.tsx new file mode 100644 index 0000000..774de1c --- /dev/null +++ b/packages/core/src/components/data-table/data-table-row.tsx @@ -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 `` 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 `` 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 ( + + + {(col, colIndex) => ( + + {col.cell(props.row, colIndex())} + + )} + + + ); +} diff --git a/packages/core/src/components/data-table/data-table.props.ts b/packages/core/src/components/data-table/data-table.props.ts new file mode 100644 index 0000000..2e951d0 --- /dev/null +++ b/packages/core/src/components/data-table/data-table.props.ts @@ -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 extends z.infer { + /** 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 extends z.infer { + /** The data rows to display. */ + data: T[]; + /** Column definitions. */ + columns: DataTableColumn[]; + /** 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) => 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; diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts new file mode 100644 index 0000000..f44ee5b --- /dev/null +++ b/packages/core/src/components/data-table/index.ts @@ -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"; diff --git a/packages/core/tests/components/data-table/data-table.test.tsx b/packages/core/tests/components/data-table/data-table.test.tsx new file mode 100644 index 0000000..74bc03b --- /dev/null +++ b/packages/core/tests/components/data-table/data-table.test.tsx @@ -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 ( + + + + +
+ +
+ ); +} + +describe("DataTable — rendering", () => { + it("renders all rows by default", () => { + render(() => ); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.getByText("Bob")).toBeTruthy(); + expect(screen.getByText("Charlie")).toBeTruthy(); + }); + + it("renders column headers", () => { + render(() => ); + expect(screen.getByText("Name")).toBeTruthy(); + expect(screen.getByText("Age")).toBeTruthy(); + }); + + it("renders pagination controls", () => { + render(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + const cells = screen.getAllByRole("cell"); + expect(cells.length).toBe(4); + }); + + it("next page shows remaining rows", () => { + render(() => ); + fireEvent.click(screen.getByText("Next")); + expect(screen.getByText("Charlie")).toBeTruthy(); + }); + + it("previous button is disabled on first page", () => { + render(() => ); + const prev = screen.getByText("Previous") as HTMLButtonElement; + expect(prev.disabled).toBe(true); + }); + + it("next button is disabled on last page", () => { + render(() => ); + fireEvent.click(screen.getByText("Next")); + const next = screen.getByText("Next") as HTMLButtonElement; + expect(next.disabled).toBe(true); + }); + + it("shows correct page label", () => { + render(() => ); + expect(screen.getByText("Page 1 of 2")).toBeTruthy(); + }); +}); + +describe("DataTable — row selection", () => { + it("clicking a row marks it selected", () => { + render(() => ); + const rows = screen.getAllByRole("row"); + fireEvent.click(rows[1]); + expect(rows[1].getAttribute("aria-selected")).toBe("true"); + }); + + it("clicking selected row deselects it", () => { + render(() => ); + 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(() => ( + + + + +
+ +
+ )); + 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"); + }); +});