import * as React from "react"; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronUp } from "lucide-react"; import { cn } from "@/lib/cn"; import { EmptyState } from "./empty-state"; import { LoadingState } from "./loading-state"; import { Inbox } from "lucide-react"; export interface Column { /** Unique column identifier. */ key: string; /** Header label. */ header: string; /** Custom render per row. */ render?: (row: T, index: number) => React.ReactNode; /** Resolve string value for fallback default render. */ accessor?: (row: T) => React.ReactNode; /** ClassName for cell (mobile card). */ cellClassName?: string; /** Minimum cell width (desktop table only). */ width?: string; /** Hide on mobile card view. */ mobileHide?: boolean; /** Data type used for default sort. */ sortAccessor?: (row: T) => string | number | Date; /** Whether this column is sortable (click header to toggle). Default: false. */ sortable?: boolean; /** Header className. */ headerClassName?: string; } export type SortDirection = "asc" | "desc"; export interface DataTableProps extends React.HTMLAttributes { columns: Column[]; data: T[]; /** Extract key from row. */ keyExtractor: (row: T) => string; /** Mobile card content: function returning ReactNode from row. If omitted, falls back to default layout. */ renderMobileCard?: (row: T, index: number) => React.ReactNode; /** Show loading state. Default: false. */ loading?: boolean; /** Display when data is empty. */ emptyState?: React.ReactNode; /** Click handler per row. */ onRowClick?: (row: T) => void; /** Default sort when mount. */ defaultSort?: { key: string; direction: SortDirection }; /** Controlled sort state. */ sort?: { key: string; direction: SortDirection } | null; /** Sort change callback. */ onSortChange?: (next: { key: string; direction: SortDirection } | null) => void; /** Pagination: number of rows per page (default 0 = off). */ pageSize?: number; /** Pagination: controlled page state. */ page?: number; /** Pagination: callback when page changes. */ onPageChange?: (page: number) => void; /** Pagination: label text for the row range. */ pageLabel?: (current: number, total: number) => string; } /** * DataTable - mobile-first table rendered as one card per row on small * screens, and as a traditional table at >= 768 px. * * v1.3.0: add optional sort (click header) + pagination (pageSize). * - Sort: toggle asc/desc via header click; controlled or uncontrolled. * - Pagination: client-side slice via pageSize; default pageSize = 0 (off). * - Header click area is at least 44px (sd-tap + h-11). * - Reduced-motion: animations disabled. */ function DataTable({ columns, data, keyExtractor, renderMobileCard, loading, emptyState, onRowClick, defaultSort, sort: sortProp, onSortChange, pageSize = 0, page: pageProp, onPageChange, pageLabel = (c, t) => `${c} - ${t} of ${t}`, className, ...props }: DataTableProps) { // ===== Sort (controlled or uncontrolled) ===== const [internalSort, setInternalSort] = React.useState< { key: string; direction: SortDirection } | null >(defaultSort ?? null); const sort = sortProp !== undefined ? sortProp : internalSort; const setSort = (next: { key: string; direction: SortDirection } | null) => { if (sortProp === undefined) setInternalSort(next); onSortChange?.(next); }; const toggleSort = (key: string) => { if (!sort || sort.key !== key) { setSort({ key, direction: "asc" }); } else if (sort.direction === "asc") { setSort({ key, direction: "desc" }); } else { setSort(null); } }; const sortedData = React.useMemo(() => { if (!sort) return data; const col = columns.find((c) => c.key === sort.key); if (!col) return data; const fn = col.sortAccessor; if (!fn) return data; const dir = sort.direction === "asc" ? 1 : -1; return [...data].sort((a, b) => { const va = fn(a); const vb = fn(b); if (va < vb) return -1 * dir; if (va > vb) return 1 * dir; return 0; }); }, [data, sort, columns]); // ===== Pagination ===== const totalItems = sortedData.length; const totalPages = pageSize > 0 ? Math.max(1, Math.ceil(totalItems / pageSize)) : 1; const [internalPage, setInternalPage] = React.useState(1); const currentPage = pageProp !== undefined ? pageProp : internalPage; const safePage = Math.max(1, Math.min(currentPage, totalPages)); const setPage = (n: number) => { const clamped = Math.max(1, Math.min(n, totalPages)); if (pageProp === undefined) setInternalPage(clamped); onPageChange?.(clamped); }; // Reset to page 1 when data/sort changes and the current page is out of bounds. React.useEffect(() => { if (safePage !== currentPage) { if (pageProp === undefined) setInternalPage(safePage); onPageChange?.(safePage); } }, [safePage, currentPage, pageProp, onPageChange]); const paginatedData = pageSize > 0 ? sortedData.slice((safePage - 1) * pageSize, safePage * pageSize) : sortedData; // ===== Render ===== if (loading) { return (
); } if (data.length === 0) { return (
{emptyState ?? ( } title="No data" description="No items match the current filter." /> )}
); } const defaultRenderCell = ( row: T, col: Column, index: number ): React.ReactNode => { if (col.render) return col.render(row, index); if (col.accessor) return col.accessor(row); return String((row as Record)[col.key] ?? ""); }; const defaultMobileCard = (row: T, index: number) => { const visibleCols = columns.filter((c) => !c.mobileHide); return (
{visibleCols.map((col) => (
{col.header} {defaultRenderCell(row, col, index)}
))}
); }; const renderSortIcon = (col: Column) => { if (!col.sortable) return null; const active = sort?.key === col.key; const dir = active ? sort!.direction : null; const Icon = dir === "desc" ? ChevronDown : ChevronUp; return ( ); }; return (
{/* Mobile: one card per row */}
    {paginatedData.map((row, idx) => (
  • ))}
{/* Desktop: traditional table */}
{columns.map((col) => { const sortable = !!col.sortable; const isActive = sort?.key === col.key; return ( ); })} {paginatedData.map((row, idx) => ( onRowClick?.(row)} className={cn( "transition-colors hover:bg-muted", onRowClick && "cursor-pointer" )} > {columns.map((col) => ( ))} ))}
{sortable ? ( ) : ( col.header )}
{defaultRenderCell(row, col, idx)}
{/* Pagination footer */} {pageSize > 0 && totalPages > 1 && (
{pageLabel( Math.min((safePage - 1) * pageSize + 1, totalItems), Math.min(safePage * pageSize, totalItems) )}
{safePage} / {totalPages}
)}
); } export { DataTable };