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

View File

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

View File

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

View File

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