Compare commits

..

9 Commits

7 changed files with 604 additions and 92 deletions

View File

@@ -1,7 +1,7 @@
"use client";
"use no memo";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { flexRender } from "@tanstack/react-table";
import useDataTable, {
toOnyxSortDirection,
@@ -24,6 +24,7 @@ import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibi
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
import type { ColumnDef } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import type {
DataTableProps,
DataTableFooterConfig,
@@ -34,8 +35,6 @@ import type {
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
const noopGetRowId = () => "";
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
// ---------------------------------------------------------------------------
@@ -121,15 +120,19 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
getRowId,
pageSize,
initialSorting,
initialColumnVisibility,
draggable,
footer,
size = "regular",
onSelectionChange,
onRowClick,
searchTerm,
height,
headerBackground,
serverSide,
} = props;
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
@@ -148,15 +151,30 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
pageSize: resolvedPageSize,
selectionState,
selectedCount,
selectedRowIds,
clearSelection,
toggleAllPageRowsSelected,
isAllPageRowsSelected,
isViewingSelected,
enterViewMode,
exitViewMode,
} = useDataTable({
data,
columns: tanstackColumns,
pageSize: effectivePageSize,
initialSorting,
initialColumnVisibility,
getRowId,
onSelectionChange,
searchTerm,
serverSide: serverSide
? {
totalItems: serverSide.totalItems,
onSortingChange: serverSide.onSortingChange,
onPaginationChange: serverSide.onPaginationChange,
onSearchTermChange: serverSide.onSearchTermChange,
}
: undefined,
});
// 3. Call useColumnWidths
@@ -165,15 +183,34 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
...widthConfig,
});
// 4. Call useDraggableRows (conditional)
// 4. Call useDraggableRows (conditional — disabled in server-side mode)
useEffect(() => {
if (process.env.NODE_ENV !== "production" && serverSide && draggable) {
console.warn(
"DataTable: `draggable` is ignored when `serverSide` is enabled. " +
"Drag-and-drop reordering is not supported with server-side pagination."
);
}
}, [!!serverSide, !!draggable]); // eslint-disable-line react-hooks/exhaustive-deps
const footerShowView =
footer?.mode === "selection" ? footer.showView : undefined;
useEffect(() => {
if (process.env.NODE_ENV !== "production" && serverSide && footerShowView) {
console.warn(
"DataTable: `showView` is ignored when `serverSide` is enabled. " +
"View mode requires client-side filtering."
);
}
}, [!!serverSide, !!footerShowView]); // eslint-disable-line react-hooks/exhaustive-deps
const effectiveDraggable = serverSide ? undefined : draggable;
const draggableReturn = useDraggableRows({
data,
getRowId: draggable?.getRowId ?? noopGetRowId,
enabled: !!draggable && table.getState().sorting.length === 0,
onReorder: draggable?.onReorder,
getRowId,
enabled: !!effectiveDraggable && table.getState().sorting.length === 0,
onReorder: effectiveDraggable?.onReorder,
});
const hasDraggable = !!draggable;
const hasDraggable = !!effectiveDraggable;
const rowVariant = hasDraggable ? "table" : "list";
const isSelectable =
@@ -183,11 +220,71 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
// Render
// ---------------------------------------------------------------------------
function renderContent() {
const isServerLoading = !!serverSide?.isLoading;
function renderFooter(footerConfig: DataTableFooterConfig) {
if (footerConfig.mode === "selection") {
return (
<Footer
mode="selection"
multiSelect={footerConfig.multiSelect !== false}
selectionState={selectionState}
selectedCount={selectedCount}
onClear={
footerConfig.onClear ??
(() => {
if (isViewingSelected) exitViewMode();
clearSelection();
})
}
onView={
footerConfig.showView
? isViewingSelected
? exitViewMode
: enterViewMode
: undefined
}
pageSize={resolvedPageSize}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
// Summary mode
const rangeStart =
totalItems === 0
? 0
: !isFinite(resolvedPageSize)
? 1
: (currentPage - 1) * resolvedPageSize + 1;
const rangeEnd = !isFinite(resolvedPageSize)
? totalItems
: Math.min(currentPage * resolvedPageSize, totalItems);
return (
<Footer
mode="summary"
rangeStart={rangeStart}
rangeEnd={rangeEnd}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
return (
<TableSizeProvider size={size}>
<div>
<div
className="overflow-x-auto"
className={cn(
"overflow-x-auto transition-opacity duration-150",
isServerLoading && "opacity-50 pointer-events-none"
)}
ref={containerRef}
style={{
...(height != null
@@ -315,19 +412,24 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
? (activeId) => {
const row = table
.getRowModel()
.rows.find(
(r) => draggable!.getRowId(r.original) === activeId
);
.rows.find((r) => getRowId(r.original) === activeId);
if (!row) return null;
return <DragOverlayRow row={row} variant={rowVariant} />;
return (
<DragOverlayRow
row={row}
variant={rowVariant}
columnWidths={columnWidths}
columnKindMap={columnKindMap}
qualifierColumn={qualifierColumn}
isSelectable={isSelectable}
/>
);
}
: undefined
}
>
{table.getRowModel().rows.map((row) => {
const rowId = hasDraggable
? draggable!.getRowId(row.original)
: undefined;
const rowId = hasDraggable ? getRowId(row.original) : undefined;
return (
<TableRow
@@ -336,6 +438,12 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
sortableId={rowId}
selected={row.getIsSelected()}
onClick={() => {
if (
hasDraggable &&
draggableReturn.wasDraggingRef.current
) {
return;
}
if (onRowClick) {
onRowClick(row.original);
} else if (isSelectable) {
@@ -377,7 +485,11 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
// Actions cell
if (cellColDef?.kind === "actions") {
return (
<ActionsContainer key={cell.id} type="cell">
<ActionsContainer
key={cell.id}
type="cell"
onClick={(e) => e.stopPropagation()}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
@@ -405,51 +517,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
{footer && renderFooter(footer)}
</div>
);
}
function renderFooter(footerConfig: DataTableFooterConfig) {
if (footerConfig.mode === "selection") {
return (
<Footer
mode="selection"
multiSelect={footerConfig.multiSelect !== false}
selectionState={selectionState}
selectedCount={selectedCount}
onClear={footerConfig.onClear ?? clearSelection}
onView={footerConfig.onView}
pageSize={resolvedPageSize}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
// Summary mode
const rangeStart =
totalItems === 0
? 0
: !isFinite(resolvedPageSize)
? 1
: (currentPage - 1) * resolvedPageSize + 1;
const rangeEnd = !isFinite(resolvedPageSize)
? totalItems
: Math.min(currentPage * resolvedPageSize, totalItems);
return (
<Footer
mode="summary"
rangeStart={rangeStart}
rangeEnd={rangeEnd}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
</TableSizeProvider>
);
}

View File

@@ -2,28 +2,87 @@ import { memo } from "react";
import { type Row, flexRender } from "@tanstack/react-table";
import TableRow from "@/refresh-components/table/TableRow";
import TableCell from "@/refresh-components/table/TableCell";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import type {
OnyxColumnDef,
OnyxQualifierColumn,
} from "@/refresh-components/table/types";
interface DragOverlayRowProps<TData> {
row: Row<TData>;
variant?: "table" | "list";
columnWidths?: Record<string, number>;
columnKindMap?: Map<string, OnyxColumnDef<TData>>;
qualifierColumn?: OnyxQualifierColumn<TData> | null;
isSelectable?: boolean;
}
function DragOverlayRowInner<TData>({
row,
variant,
columnWidths,
columnKindMap,
qualifierColumn,
isSelectable = false,
}: DragOverlayRowProps<TData>) {
const tableWidth = columnWidths
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
: undefined;
return (
<table
className="min-w-full border-collapse"
style={{ tableLayout: "fixed" }}
className="border-collapse"
style={{
tableLayout: "fixed",
...(tableWidth != null ? { width: tableWidth } : { minWidth: "100%" }),
}}
>
{columnWidths && (
<colgroup>
{row.getVisibleCells().map((cell) => (
<col
key={cell.column.id}
style={{ width: columnWidths[cell.column.id] }}
/>
))}
</colgroup>
)}
<tbody>
<TableRow variant={variant} selected={row.getIsSelected()}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} width={cell.column.getSize()}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
{row.getVisibleCells().map((cell) => {
const colDef = columnKindMap?.get(cell.column.id);
if (colDef?.kind === "qualifier" && qualifierColumn) {
return (
<QualifierContainer key={cell.id} type="cell">
<TableQualifier
content={qualifierColumn.content}
initials={qualifierColumn.getInitials?.(row.original)}
icon={qualifierColumn.getIcon?.(row.original)}
imageSrc={qualifierColumn.getImageSrc?.(row.original)}
selectable={isSelectable}
selected={isSelectable && row.getIsSelected()}
/>
</QualifierContainer>
);
}
if (colDef?.kind === "actions") {
return (
<ActionsContainer key={cell.id} type="cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</ActionsContainer>
);
}
return (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
</tbody>
</table>

View File

@@ -117,6 +117,7 @@ Fixed-width column rendered at the trailing edge. Houses column visibility and s
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
| `showSorting` | `boolean` | `true` | Show the sorting popover |
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
| `cell` | `(row: TData) => ReactNode` | - | Row-level cell renderer for action buttons |
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
@@ -126,6 +127,19 @@ tc.actions({
})
```
Row-level actions — the `cell` callback receives the row data and renders content in each body row. Clicks inside the cell automatically call `stopPropagation`, so they won't trigger row selection.
```tsx
tc.actions({
cell: (row) => (
<div className="flex gap-x-1">
<IconButton icon={SvgPencil} onClick={() => openEdit(row.id)} />
<IconButton icon={SvgTrash} onClick={() => confirmDelete(row.id)} />
</div>
),
})
```
## DataTable Props
`DataTableProps<TData>`:
@@ -143,6 +157,8 @@ tc.actions({
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
| `searchTerm` | `string` | - | Search term for client-side global text filtering (case-insensitive match across all accessor columns) |
| `serverSide` | `ServerSideConfig` | - | Enable server-side mode for manual pagination, sorting, and filtering ([see below](#server-side-mode)) |
## Footer Config
@@ -193,6 +209,110 @@ Enable drag-and-drop row reordering. DnD is automatically disabled when column s
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
## Server-Side Mode
Pass the `serverSide` prop to switch from client-side to server-side pagination, sorting, and filtering. In this mode `data` should contain **only the current page slice** — TanStack operates with `manualPagination`, `manualSorting`, and `manualFiltering` enabled. Drag-and-drop is automatically disabled.
### `ServerSideConfig`
| Prop | Type | Description |
|---|---|---|
| `totalItems` | `number` | Total row count from the server, used to compute page count |
| `isLoading` | `boolean` | Shows a loading overlay (opacity + pointer-events-none) while data is being fetched |
| `onSortingChange` | `(sorting: SortingState) => void` | Fired when the user clicks a column header |
| `onPaginationChange` | `(pageIndex: number, pageSize: number) => void` | Fired on page navigation and on automatic resets from sort/search changes |
| `onSearchTermChange` | `(searchTerm: string) => void` | Fired when the `searchTerm` prop changes |
### Callback contract
The callbacks fire in a predictable order:
- **Sort change** — `onSortingChange` fires first, then the page resets to 0 and `onPaginationChange(0, pageSize)` fires.
- **Page navigation** — only `onPaginationChange` fires.
- **Search change** — `onSearchTermChange` fires, and the page resets to 0. `onPaginationChange` only fires if the page was actually on a non-zero page. When already on page 0, `searchTerm` drives the re-fetch independently (e.g. via your SWR key) — no `onPaginationChange` is needed.
Your data-fetching layer should include `searchTerm` in its fetch dependencies (e.g. SWR key) so that search changes trigger re-fetches regardless of pagination state.
### Full example
```tsx
import { useState } from "react";
import useSWR from "swr";
import type { SortingState } from "@tanstack/react-table";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
interface User {
id: string;
name: string;
email: string;
}
const tc = createTableColumns<User>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 40, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 60, minWidth: 150 }),
tc.actions(),
];
function UsersTable() {
const [searchTerm, setSearchTerm] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data: response, isLoading } = useSWR(
["/api/users", sorting, pageIndex, pageSize, searchTerm],
([url, sorting, pageIndex, pageSize, searchTerm]) =>
fetch(
`${url}?` +
new URLSearchParams({
page: String(pageIndex),
size: String(pageSize),
search: searchTerm,
...(sorting[0] && {
sortBy: sorting[0].id,
sortDir: sorting[0].desc ? "desc" : "asc",
}),
})
).then((r) => r.json())
);
return (
<div className="space-y-4">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search users..."
/>
<DataTable
data={response?.items ?? []}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: response?.total ?? 0,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: () => {
// search state is already managed above via searchTerm prop;
// this callback is useful for analytics or debouncing
},
}}
/>
</div>
);
}
```
## Sizing
The `size` prop (`"regular"` or `"small"`) affects:
@@ -293,6 +413,31 @@ tc.qualifier({
</div>
```
### Server-side pagination
Minimal wiring for server-side mode — manage sorting/pagination state externally and pass the current page slice as `data`.
```tsx
<DataTable
data={currentPageRows}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: totalCount,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: (term) => setSearchTerm(term),
}}
/>
```
### Custom row click handler
```tsx

View File

@@ -83,13 +83,15 @@ interface DisplayColumnConfig<TData> {
// Actions column config
// ---------------------------------------------------------------------------
interface ActionsConfig {
interface ActionsConfig<TData = any> {
/** Show column visibility popover. @default true */
showColumnVisibility?: boolean;
/** Show sorting popover. @default true */
showSorting?: boolean;
/** Footer text for the sorting popover. */
sortingFooterText?: string;
/** Optional cell renderer for row-level action buttons. */
cell?: (row: TData) => ReactNode;
}
// ---------------------------------------------------------------------------
@@ -110,7 +112,7 @@ interface TableColumnsBuilder<TData> {
displayColumn(config: DisplayColumnConfig<TData>): OnyxDisplayColumn<TData>;
/** Create an actions column (visibility/sorting popovers). */
actions(config?: ActionsConfig): OnyxActionsColumn<TData>;
actions(config?: ActionsConfig<TData>): OnyxActionsColumn<TData>;
}
// ---------------------------------------------------------------------------
@@ -226,7 +228,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
};
},
actions(config?: ActionsConfig): OnyxActionsColumn<TData> {
actions(config?: ActionsConfig<TData>): OnyxActionsColumn<TData> {
const def: ColumnDef<TData, any> = {
id: "__actions",
enableHiding: false,
@@ -234,7 +236,9 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
enableResizing: false,
// Header rendering is handled by DataTable based on the actions config
header: () => null,
cell: () => null,
cell: config?.cell
? (info: CellContext<TData, any>) => config.cell!(info.row.original)
: () => null,
};
return {

View File

@@ -1,12 +1,13 @@
"use client";
"use no memo";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
getFilteredRowModel,
type Table,
type ColumnDef,
type RowData,
@@ -44,6 +45,15 @@ export function toOnyxSortDirection(
return "none";
}
// ---------------------------------------------------------------------------
// Global filter value (combines view-mode + text search)
// ---------------------------------------------------------------------------
interface GlobalFilterValue {
selectedIds: Set<string> | null;
searchTerm: string;
}
// ---------------------------------------------------------------------------
// Hook options & return types
// ---------------------------------------------------------------------------
@@ -58,12 +68,16 @@ type ManagedKeys =
| "onColumnSizingChange"
| "onColumnVisibilityChange"
| "onPaginationChange"
| "onGlobalFilterChange"
| "getCoreRowModel"
| "getSortedRowModel"
| "getPaginationRowModel"
| "getFilteredRowModel"
| "globalFilterFn"
| "columnResizeMode"
| "enableRowSelection"
| "enableColumnResizing";
| "enableColumnResizing"
| "getRowId";
/**
* Options accepted by {@link useDataTable}.
@@ -81,12 +95,26 @@ interface UseDataTableOptions<TData extends RowData> {
enableRowSelection?: boolean;
/** Whether columns can be resized. @default true */
enableColumnResizing?: boolean;
/** Stable row identity function. TanStack tracks selection by ID instead of array index. */
getRowId: TableOptions<TData>["getRowId"];
/** Resize strategy. @default "onChange" */
columnResizeMode?: ColumnResizeMode;
/** Initial sorting state. @default [] */
initialSorting?: SortingState;
/** Initial column visibility state. @default {} */
initialColumnVisibility?: VisibilityState;
/** Called whenever the set of selected row IDs changes. */
onSelectionChange?: (selectedIds: string[]) => void;
/** Search term for global text filtering. Rows are filtered to those containing
* the term in any accessor column value (case-insensitive). */
searchTerm?: string;
/** Server-side configuration. When provided, enables manual pagination/sorting/filtering. */
serverSide?: {
totalItems: number;
onSortingChange: (sorting: SortingState) => void;
onPaginationChange: (pageIndex: number, pageSize: number) => void;
onSearchTermChange: (searchTerm: string) => void;
};
/** Escape-hatch: extra options spread into `useReactTable`. Managed keys are excluded. */
tableOptions?: Partial<Omit<TableOptions<TData>, ManagedKeys>>;
}
@@ -119,10 +147,20 @@ interface UseDataTableReturn<TData extends RowData> {
selectedCount: number;
/** Whether every row on the current page is selected. */
isAllPageRowsSelected: boolean;
/** IDs of currently selected rows (derived from `getRowId`). */
selectedRowIds: string[];
/** Deselect all rows. */
clearSelection: () => void;
/** Select or deselect all rows on the current page. */
toggleAllPageRowsSelected: (selected: boolean) => void;
// View-mode (filter to selected rows)
/** Whether the table is currently filtered to show only selected rows. */
isViewingSelected: boolean;
/** Enter view mode — freeze the current selection as a filter. */
enterViewMode: () => void;
/** Exit view mode — remove the selection filter. */
exitViewMode: () => void;
}
// ---------------------------------------------------------------------------
@@ -153,9 +191,15 @@ export default function useDataTable<TData extends RowData>(
columnResizeMode = "onChange",
initialSorting = [],
initialColumnVisibility = {},
getRowId,
onSelectionChange,
searchTerm,
serverSide,
tableOptions,
} = options;
const isServerSide = !!serverSide;
// ---- internal state -----------------------------------------------------
const [sorting, setSorting] = useState<SortingState>(initialSorting);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
@@ -167,6 +211,11 @@ export default function useDataTable<TData extends RowData>(
pageIndex: 0,
pageSize: pageSizeOption,
});
/** Combined global filter: view-mode (selected IDs) + text search. */
const [globalFilter, setGlobalFilter] = useState<GlobalFilterValue>({
selectedIds: null,
searchTerm: "",
});
// ---- sync pageSize prop to internal state --------------------------------
useEffect(() => {
@@ -177,30 +226,130 @@ export default function useDataTable<TData extends RowData>(
}));
}, [pageSizeOption]);
// ---- sync external searchTerm prop into combined filter state ------------
// (client-side only — server-side uses separate callbacks instead)
const preSearchPageRef = useRef<number>(0);
useEffect(() => {
if (isServerSide) return;
const term = searchTerm ?? "";
const wasSearching = !!globalFilter.searchTerm;
if (!wasSearching && term) {
// Entering search — save current page, reset to 0
preSearchPageRef.current = pagination.pageIndex;
setPagination((p) => ({ ...p, pageIndex: 0 }));
} else if (wasSearching && !term) {
// Clearing search — restore saved page
setPagination((p) => ({ ...p, pageIndex: preSearchPageRef.current }));
}
setGlobalFilter((prev) => ({ ...prev, searchTerm: term }));
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally
// omits `globalFilter` and `pagination.pageIndex`: we only read snapshot
// values to detect the search enter/clear transition, not to react to
// every filter or page change.
}, [searchTerm, isServerSide]);
// ---- server-side: 3 separate callbacks -----------------------------------
// Single ref for the whole serverSide config — prevents effects from
// re-firing when the consumer passes an inline object each render.
const serverSideRef = useRef(serverSide);
serverSideRef.current = serverSide;
useEffect(() => {
if (!isServerSide) return;
serverSideRef.current!.onSortingChange(sorting);
}, [sorting, isServerSide]);
useEffect(() => {
if (!isServerSide) return;
serverSideRef.current!.onPaginationChange(
pagination.pageIndex,
pagination.pageSize
);
}, [pagination.pageIndex, pagination.pageSize, isServerSide]);
useEffect(() => {
if (!isServerSide) return;
setPagination((p) => ({ ...p, pageIndex: 0 }));
serverSideRef.current!.onSearchTermChange(searchTerm ?? "");
}, [searchTerm, isServerSide]);
// ---- TanStack table instance --------------------------------------------
const table = useReactTable({
const serverPageCount = isServerSide
? Math.ceil((serverSide!.totalItems || 0) / pagination.pageSize)
: undefined;
const tableOpts: TableOptions<TData> = {
data,
columns,
getRowId,
state: {
sorting,
rowSelection,
columnSizing,
columnVisibility,
pagination,
...(isServerSide ? {} : { globalFilter }),
},
onSortingChange: setSorting,
onSortingChange: isServerSide
? (updater) => {
setSorting(updater);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}
: setSorting,
onRowSelectionChange: setRowSelection,
onColumnSizingChange: setColumnSizing,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// We manage page resets explicitly (search enter/clear, view mode,
// pageSize change) so disable TanStack's auto-reset which would
// clobber our restored page index when the filter changes.
autoResetPageIndex: false,
columnResizeMode,
enableRowSelection,
enableColumnResizing,
...tableOptions,
});
};
if (isServerSide) {
tableOpts.manualPagination = true;
tableOpts.manualSorting = true;
tableOpts.manualFiltering = true;
tableOpts.pageCount = serverPageCount;
} else {
tableOpts.onGlobalFilterChange = setGlobalFilter;
tableOpts.getSortedRowModel = getSortedRowModel();
tableOpts.getPaginationRowModel = getPaginationRowModel();
tableOpts.getFilteredRowModel = getFilteredRowModel();
tableOpts.globalFilterFn = (
row,
_columnId,
filterValue: GlobalFilterValue
) => {
// View-mode filter (selected IDs)
if (
filterValue.selectedIds != null &&
!filterValue.selectedIds.has(row.id)
) {
return false;
}
// Text search filter
if (filterValue.searchTerm) {
const term = filterValue.searchTerm.toLowerCase();
return row.getAllCells().some((cell) => {
const value = cell.getValue();
if (value == null) return false;
return String(value).toLowerCase().includes(term);
});
}
return true;
};
}
const table = useReactTable(tableOpts);
// ---- derived values -----------------------------------------------------
const isAllPageRowsSelected = table.getIsAllPageRowsSelected();
@@ -212,12 +361,36 @@ export default function useDataTable<TData extends RowData>(
? "partial"
: "none";
const selectedCount = Object.keys(rowSelection).length;
const selectedRowIds = useMemo(
() => Object.keys(rowSelection),
[rowSelection]
);
const selectedCount = selectedRowIds.length;
const totalPages = Math.max(1, table.getPageCount());
const currentPage = pagination.pageIndex + 1;
const totalItems = data.length;
const hasActiveFilter =
!isServerSide &&
(globalFilter.selectedIds != null || !!globalFilter.searchTerm);
const totalItems = isServerSide
? serverSide!.totalItems
: hasActiveFilter
? table.getPrePaginationRowModel().rows.length
: data.length;
const isPaginated = isFinite(pagination.pageSize);
// ---- selection change callback ------------------------------------------
const isFirstRenderRef = useRef(true);
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
return;
}
onSelectionChangeRef.current?.(selectedRowIds);
}, [selectedRowIds]);
// ---- actions ------------------------------------------------------------
const setPage = (page: number) => {
const clamped = Math.max(1, Math.min(page, totalPages));
@@ -232,6 +405,26 @@ export default function useDataTable<TData extends RowData>(
table.toggleAllPageRowsSelected(selected);
};
// ---- view mode (filter to selected rows) --------------------------------
const isViewingSelected = globalFilter.selectedIds != null;
const enterViewMode = () => {
if (isServerSide) return;
if (selectedRowIds.length > 0) {
setGlobalFilter((prev) => ({
...prev,
selectedIds: new Set(selectedRowIds),
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}
};
const exitViewMode = () => {
if (isServerSide) return;
setGlobalFilter((prev) => ({ ...prev, selectedIds: null }));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
};
return {
table,
currentPage,
@@ -242,8 +435,12 @@ export default function useDataTable<TData extends RowData>(
isPaginated,
selectionState,
selectedCount,
selectedRowIds,
isAllPageRowsSelected,
clearSelection,
toggleAllPageRowsSelected,
isViewingSelected,
enterViewMode,
exitViewMode,
};
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useRef } from "react";
import {
useSensors,
useSensor,
@@ -49,6 +49,8 @@ interface DraggableRowsReturn {
isDragging: boolean;
/** Whether DnD is enabled. */
isEnabled: boolean;
/** Ref that is `true` briefly after a drag ends, used to suppress the trailing click. */
wasDraggingRef: React.RefObject<boolean>;
}
// ---------------------------------------------------------------------------
@@ -75,6 +77,7 @@ export default function useDraggableRows<TData>(
const { data, getRowId, enabled = true, onReorder } = options;
const [activeId, setActiveId] = useState<string | null>(null);
const wasDraggingRef = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -108,6 +111,11 @@ export default function useDraggableRows<TData>(
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveId(null);
// Suppress the trailing click event that the browser fires after pointerup.
wasDraggingRef.current = true;
requestAnimationFrame(() => {
wasDraggingRef.current = false;
});
if (event.activatorEvent instanceof PointerEvent) {
(document.activeElement as HTMLElement)?.blur();
}
@@ -152,5 +160,6 @@ export default function useDraggableRows<TData>(
activeId,
isDragging: activeId !== null,
isEnabled: enabled,
wasDraggingRef,
};
}

View File

@@ -99,13 +99,29 @@ export type OnyxColumnDef<TData> =
| OnyxDisplayColumn<TData>
| OnyxActionsColumn<TData>;
// ---------------------------------------------------------------------------
// Server-side pagination / sorting / search
// ---------------------------------------------------------------------------
/** Server-side configuration for DataTable. */
export interface ServerSideConfig {
/** Total row count from the server. Used to compute page count. */
totalItems: number;
/** Whether data is currently being fetched. Shows loading state. */
isLoading?: boolean;
/** Fired when sorting state changes. */
onSortingChange: (sorting: SortingState) => void;
/** Fired when pagination changes (including page resets from sort/search). */
onPaginationChange: (pageIndex: number, pageSize: number) => void;
/** Fired when searchTerm changes. */
onSearchTermChange: (searchTerm: string) => void;
}
// ---------------------------------------------------------------------------
// DataTable props
// ---------------------------------------------------------------------------
export interface DataTableDraggableConfig<TData> {
/** Extract a unique string ID from each row. */
getRowId: (row: TData) => string;
export interface DataTableDraggableConfig {
/** Called after a successful reorder with the new ID order and changed positions. */
onReorder: (
ids: string[],
@@ -117,8 +133,8 @@ export interface DataTableFooterSelection {
mode: "selection";
/** Whether the table supports selecting multiple rows. @default true */
multiSelect?: boolean;
/** Handler for the "View" button. */
onView?: () => void;
/** When true, shows a "View" button that filters the table to only selected rows. @default false */
showView?: boolean;
/** Handler for the "Clear" button. When omitted, the default clearSelection is used. */
onClear?: () => void;
}
@@ -136,6 +152,8 @@ export interface DataTableProps<TData> {
data: TData[];
/** Column definitions created via `createTableColumns()`. */
columns: OnyxColumnDef<TData>[];
/** Extract a unique string ID from each row. Used for stable row identity. */
getRowId: (row: TData) => string;
/** Rows per page. Set `Infinity` to disable pagination. @default 10 */
pageSize?: number;
/** Initial sorting state. */
@@ -143,13 +161,18 @@ export interface DataTableProps<TData> {
/** Initial column visibility state. */
initialColumnVisibility?: VisibilityState;
/** Enable drag-and-drop row reordering. */
draggable?: DataTableDraggableConfig<TData>;
draggable?: DataTableDraggableConfig;
/** Footer configuration. */
footer?: DataTableFooterConfig;
/** Table size variant. @default "regular" */
size?: TableSize;
/** Called whenever the set of selected row IDs changes. Receives IDs produced by `getRowId`. */
onSelectionChange?: (selectedIds: string[]) => void;
/** Called when a row is clicked (replaces the default selection toggle). */
onRowClick?: (row: TData) => void;
/** Search term for global text filtering. When provided, rows are filtered
* to those containing the term in any accessor column value (case-insensitive). */
searchTerm?: string;
/**
* Max height of the scrollable table area. When set, the table body scrolls
* vertically while the header stays pinned at the top.
@@ -159,4 +182,12 @@ export interface DataTableProps<TData> {
/** Background color for the sticky header row, preventing rows from showing
* through when scrolling. Accepts any CSS color value. */
headerBackground?: string;
/**
* Enable server-side mode. When provided:
* - TanStack uses manualPagination/manualSorting/manualFiltering
* - `data` should contain only the current page's rows
* - Dragging is automatically disabled
* - Fires separate callbacks for sorting, pagination, and search changes
*/
serverSide?: ServerSideConfig;
}