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 (
+
` 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 (
+
+ );
+}
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 `