DataTable component
This commit is contained in:
parent
92435b2667
commit
ea18b5b62c
27
packages/core/src/components/data-table/data-table-body.tsx
Normal file
27
packages/core/src/components/data-table/data-table-body.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
packages/core/src/components/data-table/data-table-cell.tsx
Normal file
13
packages/core/src/components/data-table/data-table-cell.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
packages/core/src/components/data-table/data-table-root.tsx
Normal file
186
packages/core/src/components/data-table/data-table-root.tsx
Normal 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,
|
||||||
|
});
|
||||||
44
packages/core/src/components/data-table/data-table-row.tsx
Normal file
44
packages/core/src/components/data-table/data-table-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
packages/core/src/components/data-table/data-table.props.ts
Normal file
54
packages/core/src/components/data-table/data-table.props.ts
Normal 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;
|
||||||
4
packages/core/src/components/data-table/index.ts
Normal file
4
packages/core/src/components/data-table/index.ts
Normal 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";
|
||||||
174
packages/core/tests/components/data-table/data-table.test.tsx
Normal file
174
packages/core/tests/components/data-table/data-table.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user