import React, { ReactElement, ReactNode, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; import { ColumnDef, Row, flexRender, getCoreRowModel, useReactTable, PaginationState, } from "@tanstack/react-table"; /** Props for {@link T2Table}. */ interface T2TableProps { /** Row data to render. */ sourceData?: TData[]; /** Total number of records across all pages, used for pagination display. */ totalPages?: number; /** Column definitions (TanStack Table {@link ColumnDef} array). */ columns?: ColumnDef[]; /** Callbacks for navigating between pages. */ paginationHandlers?: { nextPage?(pageIndex: number, pageSize: number): void; previousPage?(pageIndex: number, pageSize: number): void; }; /** Called with the TanStack row object when a row is clicked. */ rowClickHandler?(row: Row): void; /** Returns additional CSS classes for a given row (e.g. for highlight states). */ getRowClassName?(row: Row): string; /** Optional slot rendered in the toolbar area (e.g. a search input). */ children?: ReactNode; } /** * A paginated data table with a two-row sticky header. * * Header stickiness is detected via {@link IntersectionObserver} on a sentinel * element placed immediately before the table. The second header row's `top` * offset is measured at mount time so both rows stay flush regardless of font * size or padding changes. */ export const T2Table = ({ sourceData = [], columns = [], paginationHandlers: { nextPage, previousPage } = {}, totalPages = 0, rowClickHandler, getRowClassName, children, }: T2TableProps): ReactElement => { const sentinelRef = useRef(null); const firstHeaderRowRef = useRef(null); const [isSticky, setIsSticky] = useState(false); const [firstRowHeight, setFirstRowHeight] = useState(0); useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel) return; const observer = new IntersectionObserver( ([entry]) => setIsSticky(!entry.isIntersecting), { threshold: 0 }, ); observer.observe(sentinel); return () => observer.disconnect(); }, []); useLayoutEffect(() => { if (firstHeaderRowRef.current) setFirstRowHeight(firstHeaderRowRef.current.getBoundingClientRect().height); }, []); const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 1, pageSize: 15, }); const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); /** Advances to the next page and notifies the parent. */ const goToNextPage = () => { setPagination({ pageIndex: pageIndex + 1, pageSize }); nextPage?.(pageIndex, pageSize); }; /** Goes back one page and notifies the parent. */ const goToPreviousPage = () => { setPagination({ pageIndex: pageIndex - 1, pageSize }); previousPage?.(pageIndex, pageSize); }; const table = useReactTable({ data: sourceData, columns, manualPagination: true, getCoreRowModel: getCoreRowModel(), pageCount: sourceData.length ?? -1, state: { pagination }, onPaginationChange: setPagination, }); return (
{children}
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}

{totalPages} comics in all

{table.getHeaderGroups().map((headerGroup, groupIndex) => ( {headerGroup.headers.map((header) => ( ))} ))} {table.getRowModel().rows.map((row) => ( rowClickHandler?.(row)} className={`border-b border-gray-200 dark:border-slate-700 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : "hover:bg-slate-100/30 dark:hover:bg-slate-700/20"}`} > {row.getVisibleCells().map((cell) => ( ))} ))}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); }; export default T2Table;