1+ "use client" ;
2+
3+ import {
4+ flexRender ,
5+ getCoreRowModel ,
6+ getSortedRowModel ,
7+ useReactTable ,
8+ type ColumnDef ,
9+ type SortingState ,
10+ type RowSelectionState ,
11+ } from "@tanstack/react-table" ;
12+ import { useState } from "react" ;
13+
14+ // ---------------------------------------------------------------------------
15+ // Types
16+ // ---------------------------------------------------------------------------
17+ export interface DataTableProps < TData > {
18+ columns : ColumnDef < TData , unknown > [ ] ;
19+ data : TData [ ] ;
20+ isLoading ?: boolean ;
21+ /** Called whenever the set of selected rows changes. Receives an array of selected row objects. */
22+ onSelectionChange ?: ( selectedRows : TData [ ] ) => void ;
23+ /** Slot rendered when data is an empty array and isLoading is false. */
24+ emptyState ?: React . ReactNode ;
25+ }
26+
27+ // ---------------------------------------------------------------------------
28+ // Loading skeleton
29+ // ---------------------------------------------------------------------------
30+ function TableSkeleton ( { columnCount } : { columnCount : number } ) {
31+ return (
32+ < >
33+ { Array . from ( { length : 3 } ) . map ( ( _ , rowIdx ) => (
34+ < tr key = { rowIdx } className = "border-b border-gray-100" >
35+ { /* checkbox column */ }
36+ < td className = "px-4 py-3" >
37+ < div className = "h-4 w-4 rounded bg-gray-200 animate-pulse" />
38+ </ td >
39+ { Array . from ( { length : columnCount } ) . map ( ( _ , colIdx ) => (
40+ < td key = { colIdx } className = "px-4 py-3" >
41+ < div
42+ className = "h-4 rounded bg-gray-200 animate-pulse"
43+ style = { { width : `${ 55 + Math . random ( ) * 35 } %` } }
44+ />
45+ </ td >
46+ ) ) }
47+ </ tr >
48+ ) ) }
49+ </ >
50+ ) ;
51+ }
52+
53+ // ---------------------------------------------------------------------------
54+ // Sort icon
55+ // ---------------------------------------------------------------------------
56+ function SortIcon ( { direction } : { direction : "asc" | "desc" | false } ) {
57+ return (
58+ < span aria-hidden = "true" className = "ml-1.5 inline-flex flex-col gap-px" >
59+ < svg
60+ className = { `h-2 w-2 transition-colors ${ direction === "asc" ? "text-indigo-600" : "text-gray-300" } ` }
61+ viewBox = "0 0 8 5"
62+ fill = "currentColor"
63+ >
64+ < path d = "M4 0L8 5H0z" />
65+ </ svg >
66+ < svg
67+ className = { `h-2 w-2 transition-colors ${ direction === "desc" ? "text-indigo-600" : "text-gray-300" } ` }
68+ viewBox = "0 0 8 5"
69+ fill = "currentColor"
70+ >
71+ < path d = "M4 5L0 0h8z" />
72+ </ svg >
73+ </ span >
74+ ) ;
75+ }
76+
77+ // ---------------------------------------------------------------------------
78+ // DataTable
79+ // ---------------------------------------------------------------------------
80+ export function DataTable < TData > ( {
81+ columns,
82+ data,
83+ isLoading = false ,
84+ onSelectionChange,
85+ emptyState,
86+ } : DataTableProps < TData > ) {
87+ const [ sorting , setSorting ] = useState < SortingState > ( [ ] ) ;
88+ const [ rowSelection , setRowSelection ] = useState < RowSelectionState > ( { } ) ;
89+
90+ // Prepend a checkbox column
91+ const selectionColumn : ColumnDef < TData , unknown > = {
92+ id : "__select__" ,
93+ header : ( { table } ) => (
94+ < input
95+ type = "checkbox"
96+ aria-label = "Select all rows"
97+ checked = { table . getIsAllPageRowsSelected ( ) }
98+ ref = { ( el ) => {
99+ if ( el ) el . indeterminate = table . getIsSomePageRowsSelected ( ) ;
100+ } }
101+ onChange = { table . getToggleAllPageRowsSelectedHandler ( ) }
102+ className = "h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
103+ />
104+ ) ,
105+ cell : ( { row } ) => (
106+ < input
107+ type = "checkbox"
108+ aria-label = { `Select row ${ row . index + 1 } ` }
109+ checked = { row . getIsSelected ( ) }
110+ disabled = { ! row . getCanSelect ( ) }
111+ onChange = { row . getToggleSelectedHandler ( ) }
112+ onClick = { ( e ) => e . stopPropagation ( ) }
113+ className = "h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 cursor-pointer"
114+ />
115+ ) ,
116+ enableSorting : false ,
117+ size : 48 ,
118+ } ;
119+
120+ const table = useReactTable ( {
121+ data,
122+ columns : [ selectionColumn , ...columns ] ,
123+ state : { sorting, rowSelection } ,
124+ enableRowSelection : true ,
125+ onSortingChange : setSorting ,
126+ onRowSelectionChange : ( updater ) => {
127+ const next =
128+ typeof updater === "function" ? updater ( rowSelection ) : updater ;
129+ setRowSelection ( next ) ;
130+ if ( onSelectionChange ) {
131+ const selectedRows = Object . keys ( next )
132+ . filter ( ( key ) => next [ key ] )
133+ . map ( ( key ) => data [ parseInt ( key , 10 ) ] )
134+ . filter ( Boolean ) ;
135+ onSelectionChange ( selectedRows ) ;
136+ }
137+ } ,
138+ getCoreRowModel : getCoreRowModel ( ) ,
139+ getSortedRowModel : getSortedRowModel ( ) ,
140+ } ) ;
141+
142+ const isEmpty = ! isLoading && data . length === 0 ;
143+
144+ return (
145+ /* Responsive wrapper */
146+ < div className = "w-full overflow-x-auto rounded-xl border border-gray-200 shadow-sm" >
147+ < table className = "min-w-full divide-y divide-gray-100 text-sm" >
148+ { /* Head */ }
149+ < thead className = "bg-gray-50" >
150+ { table . getHeaderGroups ( ) . map ( ( headerGroup ) => (
151+ < tr key = { headerGroup . id } >
152+ { headerGroup . headers . map ( ( header ) => {
153+ const canSort = header . column . getCanSort ( ) ;
154+ const sorted = header . column . getIsSorted ( ) ;
155+ return (
156+ < th
157+ key = { header . id }
158+ scope = "col"
159+ style = { { width : header . column . columnDef . size } }
160+ className = { `px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500
161+ ${ canSort ? "cursor-pointer select-none hover:text-gray-800" : "" } ` }
162+ onClick = { canSort ? header . column . getToggleSortingHandler ( ) : undefined }
163+ aria-sort = {
164+ sorted === "asc"
165+ ? "ascending"
166+ : sorted === "desc"
167+ ? "descending"
168+ : canSort
169+ ? "none"
170+ : undefined
171+ }
172+ >
173+ < span className = "inline-flex items-center gap-0.5" >
174+ { header . isPlaceholder
175+ ? null
176+ : flexRender ( header . column . columnDef . header , header . getContext ( ) ) }
177+ { canSort && < SortIcon direction = { sorted } /> }
178+ </ span >
179+ </ th >
180+ ) ;
181+ } ) }
182+ </ tr >
183+ ) ) }
184+ </ thead >
185+
186+ { /* Body */ }
187+ < tbody className = "divide-y divide-gray-100 bg-white" >
188+ { isLoading ? (
189+ < TableSkeleton columnCount = { columns . length } />
190+ ) : isEmpty ? (
191+ < tr >
192+ < td
193+ colSpan = { columns . length + 1 }
194+ className = "px-4 py-16 text-center text-gray-400"
195+ >
196+ { emptyState ?? (
197+ < div className = "flex flex-col items-center gap-2" >
198+ < svg className = "h-10 w-10 text-gray-300" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" >
199+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 1.5 }
200+ d = "M3 10h18M3 14h18M10 6h4M10 18h4" />
201+ </ svg >
202+ < p className = "text-sm font-medium" > No results found</ p >
203+ </ div >
204+ ) }
205+ </ td >
206+ </ tr >
207+ ) : (
208+ table . getRowModel ( ) . rows . map ( ( row ) => (
209+ < tr
210+ key = { row . id }
211+ className = { `transition-colors hover:bg-gray-50
212+ ${ row . getIsSelected ( ) ? "bg-indigo-50" : "" } ` }
213+ >
214+ { row . getVisibleCells ( ) . map ( ( cell ) => (
215+ < td key = { cell . id } className = "px-4 py-3 text-gray-700" >
216+ { flexRender ( cell . column . columnDef . cell , cell . getContext ( ) ) }
217+ </ td >
218+ ) ) }
219+ </ tr >
220+ ) )
221+ ) }
222+ </ tbody >
223+ </ table >
224+ </ div >
225+ ) ;
226+ }
0 commit comments