@optilogic/core 1.0.0-beta.1 → 1.0.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,735 @@
1
+ /**
2
+ * DataTable Component
3
+ *
4
+ * A data-driven table that sits between the simple Table primitives and the
5
+ * full DataGrid. Supports opt-in sorting, search, pagination, row selection,
6
+ * and pinned rows -- all composed from existing core primitives.
7
+ */
8
+
9
+ import * as React from "react";
10
+ import {
11
+ ArrowUp,
12
+ ArrowDown,
13
+ ArrowUpDown,
14
+ Search,
15
+ ChevronsLeft,
16
+ ChevronLeft,
17
+ ChevronRight,
18
+ ChevronsRight,
19
+ } from "lucide-react";
20
+
21
+ import { cn } from "../utils/cn";
22
+ import {
23
+ Table,
24
+ TableHeader,
25
+ TableBody,
26
+ TableHead,
27
+ TableRow,
28
+ TableCell,
29
+ TableCaption,
30
+ } from "./table";
31
+ import { Button } from "./button";
32
+ import { Input } from "./input";
33
+ import { Checkbox } from "./checkbox";
34
+ import {
35
+ Select,
36
+ SelectTrigger,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectValue,
40
+ } from "./select";
41
+ import { LoadingSpinner } from "./loading-spinner";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Types
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Column definition for the DataTable.
49
+ */
50
+ export interface DataTableColumn<T> {
51
+ /** Unique key identifying this column */
52
+ key: string;
53
+ /** Header label (string or ReactNode) */
54
+ header: string | React.ReactNode;
55
+ /**
56
+ * Custom cell renderer. When omitted the table renders
57
+ * `String(accessor(row))` or `String(row[key])`.
58
+ */
59
+ cell?: (row: T, rowIndex: number) => React.ReactNode;
60
+ /** Accessor function to pull the raw value from a row (used for sorting & default rendering) */
61
+ accessor?: (row: T) => string | number | boolean | null | undefined;
62
+ /** Whether this column is sortable (default false) */
63
+ sortable?: boolean;
64
+ /** Custom sort comparator. Receives two rows, return negative / 0 / positive. */
65
+ sortFn?: (a: T, b: T) => number;
66
+ /** Text alignment */
67
+ align?: "left" | "center" | "right";
68
+ /** Extra class name applied to every `<td>` in this column */
69
+ className?: string;
70
+ /** Extra class name applied to the `<th>` for this column */
71
+ headerClassName?: string;
72
+ /** Tailwind width class or inline width, e.g. "w-[100px]" */
73
+ width?: string;
74
+ }
75
+
76
+ /** Sort descriptor */
77
+ export interface DataTableSort {
78
+ /** Column key */
79
+ key: string;
80
+ /** Direction */
81
+ direction: "asc" | "desc";
82
+ }
83
+
84
+ /**
85
+ * Props for the DataTable component.
86
+ */
87
+ export interface DataTableProps<T> {
88
+ // ── Data ─────────────────────────────────────────────────────────────────
89
+ /** Array of row data */
90
+ data: T[];
91
+ /** Column definitions */
92
+ columns: DataTableColumn<T>[];
93
+ /** Return a stable unique key for each row */
94
+ getRowKey: (row: T, index: number) => string;
95
+
96
+ // ── Sorting ──────────────────────────────────────────────────────────────
97
+ /** Default sort (uncontrolled) */
98
+ defaultSort?: DataTableSort;
99
+ /** Controlled sort */
100
+ sort?: DataTableSort | null;
101
+ /** Called when sort changes (controlled) */
102
+ onSortChange?: (sort: DataTableSort | null) => void;
103
+
104
+ // ── Search ───────────────────────────────────────────────────────────────
105
+ /**
106
+ * Enable the search bar. Pass `true` for defaults or a config object.
107
+ * Search is always controlled by the consumer via `searchValue` /
108
+ * `onSearchChange`, or managed internally when omitted.
109
+ */
110
+ searchable?: boolean;
111
+ /** Placeholder text for the search input */
112
+ searchPlaceholder?: string;
113
+ /** Controlled search value */
114
+ searchValue?: string;
115
+ /** Called when the search value changes */
116
+ onSearchChange?: (value: string) => void;
117
+ /** Custom search predicate. When omitted, a case-insensitive substring
118
+ * match across all columns (using accessor / row[key]) is used. */
119
+ searchFn?: (row: T, query: string) => boolean;
120
+
121
+ // ── Pagination ───────────────────────────────────────────────────────────
122
+ /** Pass a number to enable pagination with that default page size */
123
+ pageSize?: number;
124
+ /** Options shown in the rows-per-page selector */
125
+ pageSizeOptions?: number[];
126
+ /** Controlled current page (0-indexed) */
127
+ currentPage?: number;
128
+ /** Called when page changes */
129
+ onPageChange?: (page: number) => void;
130
+ /** Called when page size changes */
131
+ onPageSizeChange?: (size: number) => void;
132
+
133
+ // ── Selection ────────────────────────────────────────────────────────────
134
+ /** Enable selection mode: "checkbox" adds a checkbox column, "highlight" selects on row click */
135
+ selectable?: "checkbox" | "highlight";
136
+ /** Currently selected row keys */
137
+ selectedRows?: string[];
138
+ /** Called when selection changes */
139
+ onSelectedRowsChange?: (keys: string[]) => void;
140
+
141
+ // ── Row interactions ─────────────────────────────────────────────────────
142
+ /** Called when a row is clicked (independent of selection) */
143
+ onRowClick?: (row: T, index: number) => void;
144
+ /** Dynamic row class name */
145
+ rowClassName?: (row: T, index: number) => string;
146
+
147
+ // ── Pinned rows ──────────────────────────────────────────────────────────
148
+ /** Row keys that should be pinned to the top */
149
+ pinnedRows?: string[];
150
+
151
+ // ── Slots ────────────────────────────────────────────────────────────────
152
+ /** Custom toolbar rendered between search bar and table */
153
+ toolbar?: React.ReactNode;
154
+ /** Custom empty state */
155
+ emptyState?: React.ReactNode;
156
+ /** Table caption */
157
+ caption?: string;
158
+
159
+ // ── Loading ──────────────────────────────────────────────────────────────
160
+ /** Show loading state */
161
+ loading?: boolean;
162
+
163
+ // ── Styling ──────────────────────────────────────────────────────────────
164
+ /** Class name for the outermost wrapper */
165
+ className?: string;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Helpers
170
+ // ---------------------------------------------------------------------------
171
+
172
+ function getCellRawValue<T>(row: T, col: DataTableColumn<T>): string | number | boolean | null | undefined {
173
+ if (col.accessor) return col.accessor(row);
174
+ return (row as Record<string, unknown>)[col.key] as string | number | boolean | null | undefined;
175
+ }
176
+
177
+ function defaultSearch<T>(row: T, query: string, columns: DataTableColumn<T>[]): boolean {
178
+ const lowerQuery = query.toLowerCase();
179
+ return columns.some((col) => {
180
+ const val = getCellRawValue(row, col);
181
+ if (val == null) return false;
182
+ return String(val).toLowerCase().includes(lowerQuery);
183
+ });
184
+ }
185
+
186
+ function defaultSortComparator<T>(
187
+ a: T,
188
+ b: T,
189
+ col: DataTableColumn<T>,
190
+ direction: "asc" | "desc",
191
+ ): number {
192
+ if (col.sortFn) {
193
+ const result = col.sortFn(a, b);
194
+ return direction === "asc" ? result : -result;
195
+ }
196
+ const aVal = getCellRawValue(a, col);
197
+ const bVal = getCellRawValue(b, col);
198
+ const aStr = aVal == null ? "" : String(aVal);
199
+ const bStr = bVal == null ? "" : String(bVal);
200
+
201
+ // Try numeric comparison first
202
+ const aNum = Number(aStr);
203
+ const bNum = Number(bStr);
204
+ if (!isNaN(aNum) && !isNaN(bNum) && aStr !== "" && bStr !== "") {
205
+ return direction === "asc" ? aNum - bNum : bNum - aNum;
206
+ }
207
+
208
+ const cmp = aStr.localeCompare(bStr);
209
+ return direction === "asc" ? cmp : -cmp;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // DataTable
214
+ // ---------------------------------------------------------------------------
215
+
216
+ export function DataTable<T>({
217
+ data,
218
+ columns,
219
+ getRowKey,
220
+
221
+ // Sorting
222
+ defaultSort,
223
+ sort: controlledSort,
224
+ onSortChange,
225
+
226
+ // Search
227
+ searchable = false,
228
+ searchPlaceholder = "Search...",
229
+ searchValue: controlledSearchValue,
230
+ onSearchChange,
231
+ searchFn,
232
+
233
+ // Pagination
234
+ pageSize: defaultPageSize,
235
+ pageSizeOptions = [10, 15, 25, 50, 100],
236
+ currentPage: controlledPage,
237
+ onPageChange,
238
+ onPageSizeChange,
239
+
240
+ // Selection
241
+ selectable,
242
+ selectedRows: controlledSelectedRows,
243
+ onSelectedRowsChange,
244
+
245
+ // Row interaction
246
+ onRowClick,
247
+ rowClassName,
248
+
249
+ // Pinned rows
250
+ pinnedRows = [],
251
+
252
+ // Slots
253
+ toolbar,
254
+ emptyState,
255
+ caption,
256
+
257
+ // Loading
258
+ loading = false,
259
+
260
+ // Styling
261
+ className,
262
+ }: DataTableProps<T>) {
263
+ // ── Internal state (uncontrolled defaults) ───────────────────────────────
264
+
265
+ const [internalSort, setInternalSort] = React.useState<DataTableSort | null>(
266
+ defaultSort ?? null,
267
+ );
268
+ const [internalSearchValue, setInternalSearchValue] = React.useState("");
269
+ const [internalPage, setInternalPage] = React.useState(0);
270
+ const [internalPageSize, setInternalPageSize] = React.useState(
271
+ defaultPageSize ?? 10,
272
+ );
273
+ const [internalSelectedRows, setInternalSelectedRows] = React.useState<string[]>([]);
274
+
275
+ // Resolve controlled vs uncontrolled
276
+ const sort = controlledSort !== undefined ? controlledSort : internalSort;
277
+ const searchQuery =
278
+ controlledSearchValue !== undefined ? controlledSearchValue : internalSearchValue;
279
+ const page = controlledPage !== undefined ? controlledPage : internalPage;
280
+ const pageSize = defaultPageSize != null ? (internalPageSize ?? defaultPageSize) : undefined;
281
+ const selectedRowKeys =
282
+ controlledSelectedRows !== undefined ? controlledSelectedRows : internalSelectedRows;
283
+
284
+ const handleSortChange = React.useCallback(
285
+ (next: DataTableSort | null) => {
286
+ if (onSortChange) onSortChange(next);
287
+ else setInternalSort(next);
288
+ },
289
+ [onSortChange],
290
+ );
291
+
292
+ const handleSearchChange = React.useCallback(
293
+ (value: string) => {
294
+ if (onSearchChange) onSearchChange(value);
295
+ else setInternalSearchValue(value);
296
+ // Reset to first page on search
297
+ if (onPageChange) onPageChange(0);
298
+ else setInternalPage(0);
299
+ },
300
+ [onSearchChange, onPageChange],
301
+ );
302
+
303
+ const handlePageChange = React.useCallback(
304
+ (p: number) => {
305
+ if (onPageChange) onPageChange(p);
306
+ else setInternalPage(p);
307
+ },
308
+ [onPageChange],
309
+ );
310
+
311
+ const handlePageSizeChange = React.useCallback(
312
+ (size: number) => {
313
+ if (onPageSizeChange) onPageSizeChange(size);
314
+ else setInternalPageSize(size);
315
+ // Reset to first page
316
+ if (onPageChange) onPageChange(0);
317
+ else setInternalPage(0);
318
+ },
319
+ [onPageSizeChange, onPageChange],
320
+ );
321
+
322
+ const handleSelectionChange = React.useCallback(
323
+ (keys: string[]) => {
324
+ if (onSelectedRowsChange) onSelectedRowsChange(keys);
325
+ else setInternalSelectedRows(keys);
326
+ },
327
+ [onSelectedRowsChange],
328
+ );
329
+
330
+ // ── Header sort click ────────────────────────────────────────────────────
331
+
332
+ const handleHeaderClick = React.useCallback(
333
+ (colKey: string) => {
334
+ if (sort?.key === colKey) {
335
+ if (sort.direction === "asc") {
336
+ handleSortChange({ key: colKey, direction: "desc" });
337
+ } else {
338
+ handleSortChange(null);
339
+ }
340
+ } else {
341
+ handleSortChange({ key: colKey, direction: "asc" });
342
+ }
343
+ },
344
+ [sort, handleSortChange],
345
+ );
346
+
347
+ // ── Data pipeline: search → sort → split pinned / unpinned → paginate ──
348
+
349
+ // 1. Search
350
+ const searchedData = React.useMemo(() => {
351
+ if (!searchable || !searchQuery) return data;
352
+ if (searchFn) return data.filter((row) => searchFn(row, searchQuery));
353
+ return data.filter((row) => defaultSearch(row, searchQuery, columns));
354
+ }, [data, searchable, searchQuery, searchFn, columns]);
355
+
356
+ // 2. Sort
357
+ const sortedData = React.useMemo(() => {
358
+ if (!sort) return searchedData;
359
+ const col = columns.find((c) => c.key === sort.key);
360
+ if (!col) return searchedData;
361
+ return [...searchedData].sort((a, b) =>
362
+ defaultSortComparator(a, b, col, sort.direction),
363
+ );
364
+ }, [searchedData, sort, columns]);
365
+
366
+ // 3. Split pinned vs unpinned
367
+ const pinnedSet = React.useMemo(() => new Set(pinnedRows), [pinnedRows]);
368
+
369
+ const { pinnedData, unpinnedData } = React.useMemo(() => {
370
+ if (pinnedSet.size === 0) return { pinnedData: [] as T[], unpinnedData: sortedData };
371
+ const pinned: T[] = [];
372
+ const unpinned: T[] = [];
373
+ // Collect pinned rows from the full dataset in pinnedRows order
374
+ const dataByKey = new Map<string, T>();
375
+ data.forEach((row, i) => {
376
+ dataByKey.set(getRowKey(row, i), row);
377
+ });
378
+ pinnedRows.forEach((key) => {
379
+ const row = dataByKey.get(key);
380
+ if (row) pinned.push(row);
381
+ });
382
+ // Unpinned: from sorted/searched data, excluding pinned keys
383
+ sortedData.forEach((row, i) => {
384
+ const key = getRowKey(row, i);
385
+ // Need original index from data for stable keys
386
+ if (!pinnedSet.has(key)) unpinned.push(row);
387
+ });
388
+ return { pinnedData: pinned, unpinnedData: unpinned };
389
+ }, [sortedData, pinnedSet, pinnedRows, data, getRowKey]);
390
+
391
+ // 4. Pagination (applied to unpinned data only)
392
+ const totalUnpinned = unpinnedData.length;
393
+ const totalPages = pageSize ? Math.max(1, Math.ceil(totalUnpinned / pageSize)) : 1;
394
+ const paginatedData = React.useMemo(() => {
395
+ if (!pageSize) return unpinnedData;
396
+ const start = page * pageSize;
397
+ return unpinnedData.slice(start, start + pageSize);
398
+ }, [unpinnedData, pageSize, page]);
399
+
400
+ // All visible rows (pinned + current page of unpinned)
401
+ const visibleRows = React.useMemo(
402
+ () => [...pinnedData, ...paginatedData],
403
+ [pinnedData, paginatedData],
404
+ );
405
+
406
+ // Total items count (for display in pagination)
407
+ const totalItems = searchedData.length;
408
+
409
+ // ── Selection helpers ────────────────────────────────────────────────────
410
+
411
+ const visibleKeys = React.useMemo(
412
+ () => visibleRows.map((row, i) => getRowKey(row, i)),
413
+ [visibleRows, getRowKey],
414
+ );
415
+
416
+ const allVisibleSelected =
417
+ visibleKeys.length > 0 && visibleKeys.every((k) => selectedRowKeys.includes(k));
418
+ const someVisibleSelected =
419
+ !allVisibleSelected && visibleKeys.some((k) => selectedRowKeys.includes(k));
420
+
421
+ const toggleRowSelection = React.useCallback(
422
+ (key: string) => {
423
+ const isSelected = selectedRowKeys.includes(key);
424
+ const next = isSelected
425
+ ? selectedRowKeys.filter((k) => k !== key)
426
+ : [...selectedRowKeys, key];
427
+ handleSelectionChange(next);
428
+ },
429
+ [selectedRowKeys, handleSelectionChange],
430
+ );
431
+
432
+ const toggleAllVisible = React.useCallback(() => {
433
+ if (allVisibleSelected) {
434
+ // Deselect all visible
435
+ const visibleSet = new Set(visibleKeys);
436
+ handleSelectionChange(selectedRowKeys.filter((k) => !visibleSet.has(k)));
437
+ } else {
438
+ // Select all visible
439
+ const merged = new Set([...selectedRowKeys, ...visibleKeys]);
440
+ handleSelectionChange(Array.from(merged));
441
+ }
442
+ }, [allVisibleSelected, visibleKeys, selectedRowKeys, handleSelectionChange]);
443
+
444
+ // ── Row click handler ────────────────────────────────────────────────────
445
+
446
+ const handleRowClick = React.useCallback(
447
+ (row: T, index: number, key: string) => {
448
+ if (selectable === "highlight") {
449
+ toggleRowSelection(key);
450
+ }
451
+ onRowClick?.(row, index);
452
+ },
453
+ [selectable, toggleRowSelection, onRowClick],
454
+ );
455
+
456
+ // ── Render helpers ───────────────────────────────────────────────────────
457
+
458
+ const renderCellContent = (row: T, col: DataTableColumn<T>, rowIndex: number) => {
459
+ if (col.cell) return col.cell(row, rowIndex);
460
+ const val = getCellRawValue(row, col);
461
+ if (val == null) return <span className="text-muted-foreground italic">--</span>;
462
+ return String(val);
463
+ };
464
+
465
+ const alignClass = (align?: "left" | "center" | "right") => {
466
+ if (align === "center") return "text-center";
467
+ if (align === "right") return "text-right";
468
+ return "text-left";
469
+ };
470
+
471
+ // ── Loading state ────────────────────────────────────────────────────────
472
+
473
+ if (loading) {
474
+ return (
475
+ <div className={cn("flex items-center justify-center py-16", className)}>
476
+ <LoadingSpinner label="Loading" showDots />
477
+ </div>
478
+ );
479
+ }
480
+
481
+ // ── Render ───────────────────────────────────────────────────────────────
482
+
483
+ const showSearch = searchable;
484
+ const showPagination = pageSize != null;
485
+ const showCheckboxColumn = selectable === "checkbox";
486
+
487
+ return (
488
+ <div className={cn("flex flex-col w-full", className)}>
489
+ {/* Toolbar area: search + custom toolbar */}
490
+ {(showSearch || toolbar) && (
491
+ <div className="flex items-center gap-3 pb-4">
492
+ {showSearch && (
493
+ <div className="relative flex-1 max-w-sm">
494
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
495
+ <Input
496
+ type="text"
497
+ placeholder={searchPlaceholder}
498
+ value={searchQuery}
499
+ onChange={(e) => handleSearchChange(e.target.value)}
500
+ className="pl-9"
501
+ />
502
+ </div>
503
+ )}
504
+ {toolbar && <div className="flex items-center gap-2">{toolbar}</div>}
505
+ </div>
506
+ )}
507
+
508
+ {/* Table */}
509
+ <Table>
510
+ {caption && <TableCaption>{caption}</TableCaption>}
511
+
512
+ <TableHeader>
513
+ <TableRow>
514
+ {showCheckboxColumn && (
515
+ <TableHead className="w-[40px]">
516
+ <Checkbox
517
+ checked={allVisibleSelected ? true : someVisibleSelected ? "indeterminate" : false}
518
+ onCheckedChange={() => toggleAllVisible()}
519
+ aria-label="Select all"
520
+ />
521
+ </TableHead>
522
+ )}
523
+ {columns.map((col) => {
524
+ const isSorted = sort?.key === col.key;
525
+ const sortDir = isSorted ? sort!.direction : null;
526
+
527
+ return (
528
+ <TableHead
529
+ key={col.key}
530
+ className={cn(
531
+ col.width,
532
+ col.headerClassName,
533
+ alignClass(col.align),
534
+ col.sortable && "cursor-pointer select-none",
535
+ )}
536
+ onClick={col.sortable ? () => handleHeaderClick(col.key) : undefined}
537
+ >
538
+ <div
539
+ className={cn(
540
+ "flex items-center gap-1",
541
+ col.align === "center" && "justify-center",
542
+ col.align === "right" && "justify-end",
543
+ )}
544
+ >
545
+ <span>{col.header}</span>
546
+ {col.sortable && (
547
+ <span className="inline-flex ml-1">
548
+ {sortDir === "asc" ? (
549
+ <ArrowUp className="h-3.5 w-3.5" />
550
+ ) : sortDir === "desc" ? (
551
+ <ArrowDown className="h-3.5 w-3.5" />
552
+ ) : (
553
+ <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
554
+ )}
555
+ </span>
556
+ )}
557
+ </div>
558
+ </TableHead>
559
+ );
560
+ })}
561
+ </TableRow>
562
+ </TableHeader>
563
+
564
+ {/* Pinned rows */}
565
+ {pinnedData.length > 0 && (
566
+ <TableBody className="bg-muted/30 border-b-2 border-border">
567
+ {pinnedData.map((row, i) => {
568
+ const key = getRowKey(row, i);
569
+ const isSelected = selectedRowKeys.includes(key);
570
+ return (
571
+ <TableRow
572
+ key={key}
573
+ data-state={isSelected ? "selected" : undefined}
574
+ className={cn(
575
+ (onRowClick || selectable === "highlight") && "cursor-pointer",
576
+ rowClassName?.(row, i),
577
+ )}
578
+ onClick={() => handleRowClick(row, i, key)}
579
+ >
580
+ {showCheckboxColumn && (
581
+ <TableCell className="w-[40px]">
582
+ <Checkbox
583
+ checked={isSelected}
584
+ onCheckedChange={() => toggleRowSelection(key)}
585
+ onClick={(e) => e.stopPropagation()}
586
+ aria-label="Select row"
587
+ />
588
+ </TableCell>
589
+ )}
590
+ {columns.map((col) => (
591
+ <TableCell
592
+ key={col.key}
593
+ className={cn(alignClass(col.align), col.className)}
594
+ >
595
+ {renderCellContent(row, col, i)}
596
+ </TableCell>
597
+ ))}
598
+ </TableRow>
599
+ );
600
+ })}
601
+ </TableBody>
602
+ )}
603
+
604
+ {/* Main body */}
605
+ <TableBody>
606
+ {paginatedData.length === 0 && pinnedData.length === 0 ? (
607
+ <TableRow>
608
+ <TableCell
609
+ colSpan={columns.length + (showCheckboxColumn ? 1 : 0)}
610
+ className="h-24 text-center"
611
+ >
612
+ {emptyState ?? (
613
+ <span className="text-muted-foreground">No results.</span>
614
+ )}
615
+ </TableCell>
616
+ </TableRow>
617
+ ) : (
618
+ paginatedData.map((row, i) => {
619
+ // Compute a stable key – need original data index for getRowKey
620
+ const globalIndex = pageSize ? page * pageSize + i : i;
621
+ const key = getRowKey(row, globalIndex);
622
+ const isSelected = selectedRowKeys.includes(key);
623
+ return (
624
+ <TableRow
625
+ key={key}
626
+ data-state={isSelected ? "selected" : undefined}
627
+ className={cn(
628
+ (onRowClick || selectable === "highlight") && "cursor-pointer",
629
+ rowClassName?.(row, globalIndex),
630
+ )}
631
+ onClick={() => handleRowClick(row, globalIndex, key)}
632
+ >
633
+ {showCheckboxColumn && (
634
+ <TableCell className="w-[40px]">
635
+ <Checkbox
636
+ checked={isSelected}
637
+ onCheckedChange={() => toggleRowSelection(key)}
638
+ onClick={(e) => e.stopPropagation()}
639
+ aria-label="Select row"
640
+ />
641
+ </TableCell>
642
+ )}
643
+ {columns.map((col) => (
644
+ <TableCell
645
+ key={col.key}
646
+ className={cn(alignClass(col.align), col.className)}
647
+ >
648
+ {renderCellContent(row, col, globalIndex)}
649
+ </TableCell>
650
+ ))}
651
+ </TableRow>
652
+ );
653
+ })
654
+ )}
655
+ </TableBody>
656
+ </Table>
657
+
658
+ {/* Pagination footer */}
659
+ {showPagination && (
660
+ <div className="flex items-center justify-between pt-4">
661
+ <div className="flex items-center gap-2">
662
+ <span className="text-sm text-muted-foreground">Rows per page</span>
663
+ <Select
664
+ value={String(pageSize)}
665
+ onValueChange={(val) => handlePageSizeChange(Number(val))}
666
+ >
667
+ <SelectTrigger className="h-8 w-[70px]">
668
+ <SelectValue placeholder={String(pageSize)} />
669
+ </SelectTrigger>
670
+ <SelectContent>
671
+ {pageSizeOptions.map((opt) => (
672
+ <SelectItem key={opt} value={String(opt)}>
673
+ {opt}
674
+ </SelectItem>
675
+ ))}
676
+ </SelectContent>
677
+ </Select>
678
+ </div>
679
+
680
+ <div className="flex items-center gap-4">
681
+ <span className="text-sm text-muted-foreground">
682
+ Page {page + 1} of {totalPages}
683
+ {` (${totalItems} total)`}
684
+ </span>
685
+
686
+ <div className="flex items-center gap-1">
687
+ <Button
688
+ variant="ghost"
689
+ size="icon"
690
+ className="h-8 w-8"
691
+ onClick={() => handlePageChange(0)}
692
+ disabled={page === 0}
693
+ aria-label="First page"
694
+ >
695
+ <ChevronsLeft className="h-4 w-4" />
696
+ </Button>
697
+ <Button
698
+ variant="ghost"
699
+ size="icon"
700
+ className="h-8 w-8"
701
+ onClick={() => handlePageChange(page - 1)}
702
+ disabled={page === 0}
703
+ aria-label="Previous page"
704
+ >
705
+ <ChevronLeft className="h-4 w-4" />
706
+ </Button>
707
+ <Button
708
+ variant="ghost"
709
+ size="icon"
710
+ className="h-8 w-8"
711
+ onClick={() => handlePageChange(page + 1)}
712
+ disabled={page >= totalPages - 1}
713
+ aria-label="Next page"
714
+ >
715
+ <ChevronRight className="h-4 w-4" />
716
+ </Button>
717
+ <Button
718
+ variant="ghost"
719
+ size="icon"
720
+ className="h-8 w-8"
721
+ onClick={() => handlePageChange(totalPages - 1)}
722
+ disabled={page >= totalPages - 1}
723
+ aria-label="Last page"
724
+ >
725
+ <ChevronsRight className="h-4 w-4" />
726
+ </Button>
727
+ </div>
728
+ </div>
729
+ </div>
730
+ )}
731
+ </div>
732
+ );
733
+ }
734
+
735
+ DataTable.displayName = "DataTable";