import React, { ReactElement, useMemo, useState, useRef, useEffect, useLayoutEffect } from "react"; import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, useReactTable, PaginationState, } from "@tanstack/react-table"; /** * Props for {@link T2Table}. */ interface T2TableProps { /** Row data to render. */ sourceData?: unknown[]; /** Total number of records across all pages, used for pagination display. */ totalPages?: number; /** Column definitions (TanStack Table {@link ColumnDef} array). */ columns?: unknown[]; /** Callbacks for navigating between pages. */ paginationHandlers?: { nextPage?(...args: unknown[]): unknown; previousPage?(...args: unknown[]): unknown; }; /** Called with the TanStack row object when a row is clicked. */ rowClickHandler?(...args: unknown[]): unknown; /** Returns additional CSS classes for a given row (e.g. for highlight states). */ getRowClassName?(row: any): string; /** Optional slot rendered in the toolbar area (e.g. a search input). */ children?: any; } /** * A paginated data table with a two-row sticky header. * * The header rounds its corners only while stuck to the top of the scroll * container, detected via {@link IntersectionObserver} on a sentinel element * placed immediately before the table. The second header row's `top` offset * is measured from the DOM at mount time so the two rows stay flush regardless * of font size or padding changes. */ export const T2Table = (tableOptions: T2TableProps): ReactElement => { const { sourceData, columns, paginationHandlers: { nextPage, previousPage }, totalPages, rowClickHandler, getRowClassName, } = tableOptions; 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 via {@link T2TableProps.paginationHandlers}. */ const goToNextPage = () => { setPagination({ pageIndex: pageIndex + 1, pageSize, }); nextPage(pageIndex, pageSize); }; /** Goes back one page and notifies the parent via {@link T2TableProps.paginationHandlers}. */ 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 (
{/* Search bar */} {tableOptions.children} {/* Pagination controls */}
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}

{totalPages} comics in all

{table.getHeaderGroups().map((headerGroup, groupIndex) => { return ( {headerGroup.headers.map((header) => ( ))} ); })} {table.getRowModel().rows.map((row, rowIndex) => ( 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;