R: Improve data-table internals

This commit is contained in:
Ivan 2025-03-12 21:07:01 +03:00
parent 85faab2078
commit ea97a7a075
6 changed files with 198 additions and 135 deletions

View File

@ -1,20 +1,13 @@
'use client';
'use no memo';
import { useCallback, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import {
type ColumnSort,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type RowData,
type RowSelectionState,
type SortingState,
type TableOptions,
type Updater,
useReactTable,
type VisibilityState
} from '@tanstack/react-table';
@ -25,6 +18,7 @@ import { PaginationTools } from './pagination-tools';
import { TableBody } from './table-body';
import { TableFooter } from './table-footer';
import { TableHeader } from './table-header';
import { useDataTable } from './use-data-table';
export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState };
@ -125,62 +119,17 @@ export function DataTable<TData extends RowData>({
onRowDoubleClicked,
noDataComponent,
enableRowSelection,
rowSelection,
enableHiding,
columnVisibility,
enableSorting,
initialSorting,
enablePagination,
paginationPerPage = 10,
paginationOptions = [10, 20, 30, 40, 50],
onChangePaginationOption,
...restProps
}: DataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ? [initialSorting] : []);
const [lastSelected, setLastSelected] = useState<string | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: paginationPerPage
});
const table = useDataTable({ ...restProps });
console.log(table);
const handleChangePagination = useCallback(
(updater: Updater<PaginationState>) => {
setPagination(prev => {
const resolvedValue = typeof updater === 'function' ? updater(prev) : updater;
if (onChangePaginationOption && prev.pageSize !== resolvedValue.pageSize) {
onChangePaginationOption(resolvedValue.pageSize);
}
return resolvedValue;
});
},
[onChangePaginationOption]
);
const tableImpl = useReactTable({
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
state: {
pagination: pagination,
sorting: sorting,
rowSelection: rowSelection ?? {},
columnVisibility: columnVisibility ?? {}
},
enableHiding: enableHiding,
onPaginationChange: enablePagination ? handleChangePagination : undefined,
onSortingChange: enableSorting ? setSorting : undefined,
enableMultiRowSelection: enableRowSelection,
...restProps
});
const isEmpty = tableImpl.getRowModel().rows.length === 0;
const isPaginationEnabled = typeof table.getCanNextPage === 'function';
const isEmpty = table.getRowModel().rows.length === 0;
const fixedSize = useMemo(() => {
if (!rows) {
@ -194,47 +143,40 @@ export function DataTable<TData extends RowData>({
}, [rows, dense, noHeader, contentHeight]);
const columnSizeVars = useMemo(() => {
const headers = tableImpl.getFlatHeaders();
const headers = table.getFlatHeaders();
const colSizes: Record<string, number> = {};
for (const header of headers) {
colSizes[`--header-${header.id}-size`] = header.getSize();
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
}
return colSizes;
}, [tableImpl]);
}, [table]);
return (
<div tabIndex={-1} id={id} className={className} style={{ minHeight: fixedSize, maxHeight: fixedSize, ...style }}>
<table className='w-full' style={{ ...columnSizeVars }}>
{!noHeader ? (
<TableHeader
table={tableImpl}
enableRowSelection={enableRowSelection}
enableSorting={enableSorting}
headPosition={headPosition}
resetLastSelected={() => setLastSelected(null)}
/>
<TableHeader table={table} headPosition={headPosition} resetLastSelected={() => setLastSelected(null)} />
) : null}
<TableBody
table={tableImpl}
table={table}
dense={dense}
noHeader={noHeader}
conditionalRowStyles={conditionalRowStyles}
enableRowSelection={enableRowSelection}
lastSelected={lastSelected}
onChangeLastSelected={setLastSelected}
onRowClicked={onRowClicked}
onRowDoubleClicked={onRowDoubleClicked}
/>
{!noFooter ? <TableFooter table={tableImpl} /> : null}
{!noFooter ? <TableFooter table={table} /> : null}
</table>
{enablePagination && !isEmpty ? (
{isPaginationEnabled && !isEmpty ? (
<PaginationTools
id={id ? `${id}__pagination` : undefined}
table={tableImpl}
table={table}
paginationOptions={paginationOptions}
/>
) : null}

View File

@ -1,5 +1,5 @@
'use client';
'use no memo';
'use client';
import { useCallback } from 'react';
import { type Table } from '@tanstack/react-table';

View File

@ -1,20 +1,15 @@
'use no memo';
import { type Column } from '@tanstack/react-table';
import { IconSortAsc, IconSortDesc } from '../icons';
interface SortingIconProps<TData> {
column: Column<TData>;
interface SortingIconProps {
sortDirection?: 'asc' | 'desc' | false;
}
export function SortingIcon<TData>({ column }: SortingIconProps<TData>) {
return (
<>
{{
desc: <IconSortDesc size='1rem' />,
asc: <IconSortAsc size='1rem' />
}[column.getIsSorted() as string] ?? <IconSortDesc size='1rem' className='opacity-0 group-hover:opacity-25' />}
</>
);
export function SortingIcon({ sortDirection }: SortingIconProps) {
if (sortDirection === 'asc') {
return <IconSortAsc size='1rem' />;
}
if (sortDirection === 'desc') {
return <IconSortDesc size='1rem' />;
}
return <IconSortDesc size='1rem' className='opacity-0 group-hover:opacity-25' />;
}

View File

@ -1,5 +1,6 @@
'use no memo';
import { useCallback } from 'react';
import { type Cell, flexRender, type Row, type Table } from '@tanstack/react-table';
import clsx from 'clsx';
@ -10,7 +11,6 @@ interface TableBodyProps<TData> {
table: Table<TData>;
dense?: boolean;
noHeader?: boolean;
enableRowSelection?: boolean;
conditionalRowStyles?: IConditionalStyle<TData>[];
lastSelected: string | null;
@ -24,44 +24,49 @@ export function TableBody<TData>({
table,
dense,
noHeader,
enableRowSelection,
conditionalRowStyles,
lastSelected,
onChangeLastSelected,
onRowClicked,
onRowDoubleClicked
}: TableBodyProps<TData>) {
function handleRowClicked(target: Row<TData>, event: React.MouseEvent<Element>) {
onRowClicked?.(target.original, event);
if (enableRowSelection && target.getCanSelect()) {
if (event.shiftKey && !!lastSelected && lastSelected !== target.id) {
const { rows, rowsById } = table.getRowModel();
const lastIndex = rowsById[lastSelected].index;
const currentIndex = target.index;
const toggleRows = rows.slice(
lastIndex > currentIndex ? currentIndex : lastIndex + 1,
lastIndex > currentIndex ? lastIndex : currentIndex + 1
);
const newSelection: Record<string, boolean> = {};
toggleRows.forEach(row => {
newSelection[row.id] = !target.getIsSelected();
});
table.setRowSelection(prev => ({ ...prev, ...newSelection }));
onChangeLastSelected(null);
} else {
onChangeLastSelected(target.id);
target.toggleSelected(!target.getIsSelected());
const handleRowClicked = useCallback(
(target: Row<TData>, event: React.MouseEvent<Element>) => {
onRowClicked?.(target.original, event);
if (target.getCanSelect()) {
if (event.shiftKey && !!lastSelected && lastSelected !== target.id) {
const { rows, rowsById } = table.getRowModel();
const lastIndex = rowsById[lastSelected].index;
const currentIndex = target.index;
const toggleRows = rows.slice(
lastIndex > currentIndex ? currentIndex : lastIndex + 1,
lastIndex > currentIndex ? lastIndex : currentIndex + 1
);
const newSelection: Record<string, boolean> = {};
toggleRows.forEach(row => {
newSelection[row.id] = !target.getIsSelected();
});
table.setRowSelection(prev => ({ ...prev, ...newSelection }));
onChangeLastSelected(null);
} else {
onChangeLastSelected(target.id);
target.toggleSelected(!target.getIsSelected());
}
}
}
}
},
[table, lastSelected, onChangeLastSelected, onRowClicked]
);
function getRowStyles(row: Row<TData>) {
return {
...conditionalRowStyles!
.filter(item => item.when(row.original))
.reduce((prev, item) => ({ ...prev, ...item.style }), {})
};
}
const getRowStyles = useCallback(
(row: Row<TData>) => {
return {
...conditionalRowStyles!
.filter(item => item.when(row.original))
.reduce((prev, item) => ({ ...prev, ...item.style }), {})
};
},
[conditionalRowStyles]
);
return (
<tbody>
@ -72,11 +77,15 @@ export function TableBody<TData>({
'cc-scroll-row',
'clr-hover cc-animate-color',
!noHeader && 'scroll-mt-[calc(2px+2rem)]',
row.getIsSelected() ? 'clr-selected' : 'odd:bg-prim-200 even:bg-prim-100'
table.options.enableRowSelection && row.getIsSelected()
? 'clr-selected'
: 'odd:bg-prim-200 even:bg-prim-100'
)}
style={{ ...(conditionalRowStyles ? getRowStyles(row) : []) }}
onClick={event => handleRowClicked(row, event)}
onDoubleClick={event => onRowDoubleClicked?.(row.original, event)}
>
{enableRowSelection ? (
{table.options.enableRowSelection ? (
<td key={`select-${row.id}`} className='pl-2 border-y'>
<SelectRow row={row} onChangeLastSelected={onChangeLastSelected} />
</td>
@ -91,8 +100,6 @@ export function TableBody<TData>({
paddingTop: dense ? '0.25rem' : '0.5rem',
width: noHeader && index === 0 ? `calc(var(--col-${cell.column.id}-size) * 1px)` : 'auto'
}}
onClick={event => handleRowClicked(row, event)}
onDoubleClick={event => onRowDoubleClicked?.(row.original, event)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>

View File

@ -8,18 +8,10 @@ import { SortingIcon } from './sorting-icon';
interface TableHeaderProps<TData> {
table: Table<TData>;
headPosition?: string;
enableRowSelection?: boolean;
enableSorting?: boolean;
resetLastSelected: () => void;
}
export function TableHeader<TData>({
table,
headPosition,
enableRowSelection,
enableSorting,
resetLastSelected
}: TableHeaderProps<TData>) {
export function TableHeader<TData>({ table, headPosition, resetLastSelected }: TableHeaderProps<TData>) {
return (
<thead
className='bg-prim-100 cc-shadow-border'
@ -30,7 +22,7 @@ export function TableHeader<TData>({
>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => (
<tr key={headerGroup.id}>
{enableRowSelection ? (
{table.options.enableRowSelection ? (
<th className='pl-2' scope='col'>
<SelectAll table={table} resetLastSelected={resetLastSelected} />
</th>
@ -43,14 +35,16 @@ export function TableHeader<TData>({
className='cc-table-header group'
style={{
width: `calc(var(--header-${header?.id}-size) * 1px)`,
cursor: enableSorting && header.column.getCanSort() ? 'pointer' : 'auto'
cursor: table.options.enableSorting && header.column.getCanSort() ? 'pointer' : 'auto'
}}
onClick={enableSorting ? header.column.getToggleSortingHandler() : undefined}
onClick={table.options.enableSorting ? header.column.getToggleSortingHandler() : undefined}
>
{!header.isPlaceholder ? (
<span className='inline-flex gap-1'>
{flexRender(header.column.columnDef.header, header.getContext())}
{enableSorting && header.column.getCanSort() ? <SortingIcon column={header.column} /> : null}
{table.options.enableSorting && header.column.getCanSort() ? (
<SortingIcon sortDirection={header.column.getIsSorted()} />
) : null}
</span>
) : null}
</th>

View File

@ -0,0 +1,125 @@
'use client';
import { useCallback, useState } from 'react';
import {
type ColumnSort,
createColumnHelper,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type RowData,
type RowSelectionState,
type SortingState,
type TableOptions,
type Updater,
useReactTable,
type VisibilityState
} from '@tanstack/react-table';
export { type ColumnSort, createColumnHelper, type RowSelectionState, type VisibilityState };
/** Style to conditionally apply to rows. */
export interface IConditionalStyle<TData> {
/** Callback to determine if the style should be applied. */
when: (rowData: TData) => boolean;
/** Style to apply. */
style: React.CSSProperties;
}
export interface UseDataTableProps<TData extends RowData>
extends Pick<TableOptions<TData>, 'data' | 'columns' | 'onRowSelectionChange' | 'onColumnVisibilityChange'> {
/** Enable row selection. */
enableRowSelection?: boolean;
/** Current row selection. */
rowSelection?: RowSelectionState;
/** Enable hiding of columns. */
enableHiding?: boolean;
/** Current column visibility. */
columnVisibility?: VisibilityState;
/** Enable pagination. */
enablePagination?: boolean;
/** Number of rows per page. */
paginationPerPage?: number;
/** Callback to be called when the pagination option is changed. */
onChangePaginationOption?: (newValue: number) => void;
/** Enable sorting. */
enableSorting?: boolean;
/** Initial sorting. */
initialSorting?: ColumnSort;
}
/**
* Dta representation as a table.
*
* @param headPosition - Top position of sticky header (0 if no other sticky elements are present).
* No sticky header if omitted
*/
export function useDataTable<TData extends RowData>({
enableRowSelection,
rowSelection,
enableHiding,
columnVisibility,
enableSorting,
initialSorting,
enablePagination,
paginationPerPage = 10,
onChangePaginationOption,
...restProps
}: UseDataTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ? [initialSorting] : []);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: paginationPerPage
});
const handleChangePagination = useCallback(
(updater: Updater<PaginationState>) => {
setPagination(prev => {
const resolvedValue = typeof updater === 'function' ? updater(prev) : updater;
if (onChangePaginationOption && prev.pageSize !== resolvedValue.pageSize) {
onChangePaginationOption(resolvedValue.pageSize);
}
return resolvedValue;
});
},
[onChangePaginationOption]
);
const table = useReactTable({
state: {
pagination: pagination,
sorting: sorting,
rowSelection: rowSelection,
columnVisibility: columnVisibility
},
getCoreRowModel: getCoreRowModel(),
enableSorting: enableSorting,
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
onSortingChange: enableSorting ? setSorting : undefined,
getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
onPaginationChange: enablePagination ? handleChangePagination : undefined,
enableHiding: enableHiding,
enableMultiRowSelection: enableRowSelection,
enableRowSelection: enableRowSelection,
...restProps
});
return table;
}