@voila.dev/ui-datatable 1.1.9

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.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@voila.dev/ui-datatable",
3
+ "version": "1.1.9",
4
+ "type": "module",
5
+ "description": "Sorting, pinning, CSV export — the table you keep rebuilding, finished.",
6
+ "license": "MIT",
7
+ "homepage": "https://ui.voila.dev/ui-datatable/data-table",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/voila-voila-dev/ui.git",
11
+ "directory": "packages/ui-datatable"
12
+ },
13
+ "keywords": [
14
+ "react",
15
+ "data-table",
16
+ "table",
17
+ "tanstack-table",
18
+ "tailwindcss"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "!src/**/*.test.ts",
26
+ "!src/**/*.test.tsx",
27
+ "!src/**/*.stories.ts",
28
+ "!src/**/*.stories.tsx"
29
+ ],
30
+ "sideEffects": [
31
+ "**/*.css"
32
+ ],
33
+ "exports": {
34
+ "./styles.css": "./src/styles.css",
35
+ "./components/*": "./src/components/*.tsx"
36
+ },
37
+ "imports": {
38
+ "#/*": "./src/*"
39
+ },
40
+ "scripts": {
41
+ "check": "biome check",
42
+ "check-types": "tsc --noEmit",
43
+ "format": "biome format",
44
+ "lint": "biome lint",
45
+ "test": "vitest run"
46
+ },
47
+ "dependencies": {
48
+ "@phosphor-icons/react": "2.1.10",
49
+ "@tanstack/react-table": "^8.21.3",
50
+ "@voila.dev/ui": "0.0.0"
51
+ },
52
+ "peerDependencies": {
53
+ "react": "^19.0.0",
54
+ "react-dom": "^19.0.0",
55
+ "tailwindcss": "^4.0.0"
56
+ }
57
+ }
@@ -0,0 +1,1328 @@
1
+ import {
2
+ CaretDownIcon,
3
+ CaretRightIcon as CaretExpandIcon,
4
+ CaretLeftIcon,
5
+ CaretRightIcon,
6
+ CaretUpDownIcon,
7
+ CaretUpIcon,
8
+ ColumnsIcon,
9
+ DownloadSimpleIcon,
10
+ ListMagnifyingGlassIcon,
11
+ MagnifyingGlassIcon,
12
+ RowsIcon,
13
+ } from "@phosphor-icons/react";
14
+ import {
15
+ type Cell,
16
+ type Column,
17
+ type ColumnDef,
18
+ type ColumnPinningState,
19
+ type ColumnSizingState,
20
+ type ExpandedState,
21
+ flexRender,
22
+ getCoreRowModel,
23
+ getExpandedRowModel,
24
+ getFilteredRowModel,
25
+ getSortedRowModel,
26
+ type Header,
27
+ type HeaderGroup,
28
+ type OnChangeFn,
29
+ type Row,
30
+ type RowSelectionState,
31
+ type SortDirection,
32
+ type SortingState,
33
+ type Table as TanstackTable,
34
+ useReactTable,
35
+ type VisibilityState,
36
+ } from "@tanstack/react-table";
37
+ import { Button } from "@voila.dev/ui/components/button";
38
+ import { Checkbox } from "@voila.dev/ui/components/checkbox";
39
+ import {
40
+ DropdownMenu,
41
+ DropdownMenuCheckboxItem,
42
+ DropdownMenuContent,
43
+ DropdownMenuLabel,
44
+ DropdownMenuRadioGroup,
45
+ DropdownMenuRadioItem,
46
+ DropdownMenuSeparator,
47
+ DropdownMenuTrigger,
48
+ } from "@voila.dev/ui/components/dropdown-menu";
49
+ import {
50
+ Empty,
51
+ EmptyDescription,
52
+ EmptyHeader,
53
+ EmptyMedia,
54
+ EmptyTitle,
55
+ } from "@voila.dev/ui/components/empty";
56
+ import {
57
+ InputGroup,
58
+ InputGroupAddon,
59
+ InputGroupInput,
60
+ } from "@voila.dev/ui/components/input-group";
61
+ import { PaginationEllipsis } from "@voila.dev/ui/components/pagination";
62
+ import { Spinner } from "@voila.dev/ui/components/spinner";
63
+ import {
64
+ Table,
65
+ TableBody,
66
+ TableCell,
67
+ TableHead,
68
+ TableHeader,
69
+ TableRow,
70
+ } from "@voila.dev/ui/components/table";
71
+ import {
72
+ PAGINATION_ELLIPSIS,
73
+ usePagination,
74
+ } from "@voila.dev/ui/hooks/use-pagination";
75
+ import { cn } from "@voila.dev/ui/lib/utils";
76
+ import * as React from "react";
77
+
78
+ // Re-exported so consumers can type their `columns` without depending on
79
+ // @tanstack/react-table directly.
80
+ export type {
81
+ ColumnDef,
82
+ ColumnOrderState,
83
+ ColumnPinningState,
84
+ ColumnSizingState,
85
+ ExpandedState,
86
+ Row,
87
+ RowSelectionState,
88
+ SortingState,
89
+ VisibilityState,
90
+ } from "@tanstack/react-table";
91
+
92
+ /**
93
+ * Emplacement row above a `DataTable`: search at the start, filter controls
94
+ * next to it, end-aligned actions via `DataTableActions`. Wraps on narrow
95
+ * screens instead of overflowing.
96
+ */
97
+ function DataTableToolbar({
98
+ className,
99
+ ...props
100
+ }: React.ComponentProps<"div">) {
101
+ return (
102
+ <div
103
+ data-slot="data-table-toolbar"
104
+ className={cn("flex flex-wrap items-center gap-2", className)}
105
+ {...props}
106
+ />
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Search emplacement for the toolbar - a leading-icon search input. Controlled
112
+ * like a plain input (`value`/`onChange`); debouncing and where the filtering
113
+ * happens (client or server) stay with the consumer. `className` sizes the
114
+ * group; all other props go to the input itself.
115
+ */
116
+ function DataTableSearch({
117
+ className,
118
+ ...props
119
+ }: React.ComponentProps<typeof InputGroupInput>) {
120
+ return (
121
+ <InputGroup
122
+ data-slot="data-table-search"
123
+ className={cn("w-full sm:w-64", className)}
124
+ >
125
+ <InputGroupInput type="search" {...props} />
126
+ <InputGroupAddon>
127
+ <MagnifyingGlassIcon />
128
+ </InputGroupAddon>
129
+ </InputGroup>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Filters emplacement for the toolbar - groups selects, checkbox groups,
135
+ * toggle filters... so every table lays them out the same way.
136
+ */
137
+ function DataTableFilters({
138
+ className,
139
+ ...props
140
+ }: React.ComponentProps<"div">) {
141
+ return (
142
+ <div
143
+ role="group"
144
+ data-slot="data-table-filters"
145
+ className={cn("flex flex-wrap items-center gap-2", className)}
146
+ {...props}
147
+ />
148
+ );
149
+ }
150
+
151
+ /**
152
+ * End-aligned slot of the toolbar for bulk/primary actions (export, create,
153
+ * delete selection...).
154
+ */
155
+ function DataTableActions({
156
+ className,
157
+ ...props
158
+ }: React.ComponentProps<"div">) {
159
+ return (
160
+ <div
161
+ data-slot="data-table-actions"
162
+ className={cn("ms-auto flex items-center gap-2", className)}
163
+ {...props}
164
+ />
165
+ );
166
+ }
167
+
168
+ type DataTableSelectionColumnOptions<TData> = {
169
+ selectAllLabel?: string;
170
+ selectRowLabel?: (row: TData) => string;
171
+ };
172
+
173
+ /**
174
+ * Leading checkbox column wired to the table's row-selection state. Spread it
175
+ * first in `columns` and enable selection on the `DataTable`. Checkbox clicks
176
+ * never bubble into `onRowClick`.
177
+ */
178
+ function dataTableSelectionColumn<TData>({
179
+ selectAllLabel = "Select all rows",
180
+ selectRowLabel = () => "Select row",
181
+ }: DataTableSelectionColumnOptions<TData> = {}): ColumnDef<TData> {
182
+ return {
183
+ id: "select",
184
+ size: 36,
185
+ enableSorting: false,
186
+ header: ({ table }) => (
187
+ <Checkbox
188
+ aria-label={selectAllLabel}
189
+ checked={table.getIsAllPageRowsSelected()}
190
+ indeterminate={
191
+ table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
192
+ }
193
+ onCheckedChange={(checked) =>
194
+ table.toggleAllPageRowsSelected(checked === true)
195
+ }
196
+ />
197
+ ),
198
+ cell: ({ row }) => (
199
+ <Checkbox
200
+ aria-label={selectRowLabel(row.original)}
201
+ checked={row.getIsSelected()}
202
+ disabled={!row.getCanSelect()}
203
+ onCheckedChange={(checked) => row.toggleSelected(checked === true)}
204
+ onClick={(event) => event.stopPropagation()}
205
+ />
206
+ ),
207
+ };
208
+ }
209
+
210
+ export type DataTablePaginationProps = {
211
+ /** Zero-based page index. */
212
+ page: number;
213
+ pageSize: number;
214
+ /** Total number of rows across all pages (server-side count). */
215
+ total: number;
216
+ onPageChange: (page: number) => void;
217
+ /** Pages shown on each side of the current page (windowed with ellipses). */
218
+ siblingCount?: number;
219
+ previousText?: string;
220
+ nextText?: string;
221
+ /** Localizable "1-10 of 42" range line; receives 1-based row positions. */
222
+ rangeText?: (range: {
223
+ from: number;
224
+ to: number;
225
+ total: number;
226
+ }) => React.ReactNode;
227
+ /** Localizable aria-label for a page button; receives the 1-based number. */
228
+ pageLabel?: (pageNumber: number) => string;
229
+ };
230
+
231
+ /**
232
+ * Pagination footer: row range on the left, windowed page numbers (hidden on
233
+ * mobile) with previous/next on the right. State-driven (`onPageChange`)
234
+ * rather than link-driven - for URL-backed pagination use the `Pagination`
235
+ * components directly.
236
+ */
237
+ function DataTablePagination({
238
+ page,
239
+ pageSize,
240
+ total,
241
+ onPageChange,
242
+ siblingCount = 1,
243
+ previousText = "Previous",
244
+ nextText = "Next",
245
+ rangeText = ({ from, to, total: totalRows }) =>
246
+ `${from}-${to} of ${totalRows}`,
247
+ pageLabel = (pageNumber) => `Go to page ${pageNumber}`,
248
+ }: DataTablePaginationProps) {
249
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
250
+ const from = total === 0 ? 0 : page * pageSize + 1;
251
+ const to = Math.min(total, (page + 1) * pageSize);
252
+ const items = usePagination({ page, pageCount, siblingCount });
253
+
254
+ return (
255
+ <div
256
+ data-slot="data-table-pagination"
257
+ className="flex items-center justify-between gap-4 px-1 pt-3"
258
+ >
259
+ <span className="text-muted-foreground text-sm">
260
+ {rangeText({ from, to, total })}
261
+ </span>
262
+ {pageCount > 1 && (
263
+ <div className="flex items-center gap-1">
264
+ <Button
265
+ type="button"
266
+ variant="outline"
267
+ size="sm"
268
+ disabled={page === 0}
269
+ aria-label={previousText}
270
+ onClick={() => onPageChange(page - 1)}
271
+ >
272
+ <CaretLeftIcon data-icon="inline-start" />
273
+ <span className="hidden sm:inline">{previousText}</span>
274
+ </Button>
275
+ <div className="hidden items-center gap-0.5 sm:flex">
276
+ {items.map((item, index) =>
277
+ item === PAGINATION_ELLIPSIS ? (
278
+ // At most two ellipses; the position in the row identifies them.
279
+ <PaginationEllipsis
280
+ key={`ellipsis-${index}`}
281
+ className="size-7"
282
+ />
283
+ ) : (
284
+ <Button
285
+ key={item}
286
+ type="button"
287
+ variant={item === page ? "outline" : "ghost"}
288
+ size="icon-sm"
289
+ aria-label={pageLabel(item + 1)}
290
+ aria-current={item === page ? "page" : undefined}
291
+ onClick={() => onPageChange(item)}
292
+ >
293
+ {item + 1}
294
+ </Button>
295
+ ),
296
+ )}
297
+ </div>
298
+ <Button
299
+ type="button"
300
+ variant="outline"
301
+ size="sm"
302
+ disabled={page >= pageCount - 1}
303
+ aria-label={nextText}
304
+ onClick={() => onPageChange(page + 1)}
305
+ >
306
+ <span className="hidden sm:inline">{nextText}</span>
307
+ <CaretRightIcon data-icon="inline-end" />
308
+ </Button>
309
+ </div>
310
+ )}
311
+ </div>
312
+ );
313
+ }
314
+
315
+ type DataTableEmptyProps = {
316
+ title?: string;
317
+ description?: string;
318
+ };
319
+
320
+ function DataTableEmpty({
321
+ title = "No results",
322
+ description = "Try adjusting your search or filters.",
323
+ }: DataTableEmptyProps) {
324
+ return (
325
+ <Empty className="py-8">
326
+ <EmptyHeader>
327
+ <EmptyMedia variant="icon">
328
+ <ListMagnifyingGlassIcon />
329
+ </EmptyMedia>
330
+ <EmptyTitle>{title}</EmptyTitle>
331
+ <EmptyDescription>{description}</EmptyDescription>
332
+ </EmptyHeader>
333
+ </Empty>
334
+ );
335
+ }
336
+
337
+ export type DataTableProps<TData, TValue> = {
338
+ columns: ColumnDef<TData, TValue>[];
339
+ data: readonly TData[];
340
+ /** Sorting is managed internally; this seeds the initial column order. */
341
+ initialSorting?: SortingState;
342
+ /** When set, rows become clickable and invoke this with the row's data. */
343
+ onRowClick?: (row: TData) => void;
344
+ /** Refetch in progress: keeps the current rows visible under a spinner. */
345
+ loading?: boolean;
346
+ /** Custom empty state; defaults to a generic "No results" block. */
347
+ emptyState?: React.ReactNode;
348
+ /** Server-side pagination footer rendered below the table. */
349
+ pagination?: DataTablePaginationProps;
350
+ /**
351
+ * Emplacement rendered above the table - compose `DataTableToolbar` with
352
+ * `DataTableSearch`, `DataTableFilters` and `DataTableActions`.
353
+ */
354
+ toolbar?:
355
+ | React.ReactNode
356
+ | ((table: TanstackTable<TData>) => React.ReactNode);
357
+ /** Per-row or table-wide opt-in, forwarded to @tanstack/react-table. */
358
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
359
+ /** Controlled selection state; omit to let the table own it. */
360
+ rowSelection?: RowSelectionState;
361
+ onRowSelectionChange?: (state: RowSelectionState) => void;
362
+ /** Stable row ids for selection across pages; defaults to the row index. */
363
+ getRowId?: (row: TData, index: number) => string;
364
+ /**
365
+ * Keeps the header row visible while the body scrolls. Needs a bounded
366
+ * height on the scroll container - pass e.g. `max-h-96` through
367
+ * `containerClassName`.
368
+ */
369
+ stickyHeader?: boolean;
370
+ /** Forwarded to the underlying `Table` scroll container. */
371
+ containerClassName?: string;
372
+ /**
373
+ * Card content for one row on mobile. When provided, the table is replaced
374
+ * below the `md` breakpoint by a vertical list of cards (no horizontal
375
+ * scroll) built from the sorted row model; cards stay clickable through
376
+ * `onRowClick`. The toolbar and pagination footer remain visible.
377
+ */
378
+ renderMobileCard?: (row: TData) => React.ReactNode;
379
+ /**
380
+ * Lets the user drag column edges. Widths live in the table's own state, so
381
+ * `onColumnSizingChange` is only needed to persist them across mounts.
382
+ */
383
+ enableColumnResizing?: boolean;
384
+ columnSizing?: ColumnSizingState;
385
+ onColumnSizingChange?: (state: ColumnSizingState) => void;
386
+ /** Controlled column visibility; pair with `DataTableViewOptions`. */
387
+ columnVisibility?: VisibilityState;
388
+ onColumnVisibilityChange?: (state: VisibilityState) => void;
389
+ /**
390
+ * Columns frozen against an edge while the rest pans horizontally, by id:
391
+ * `{ left: ["name"], right: ["actions"] }`.
392
+ */
393
+ columnPinning?: ColumnPinningState;
394
+ onColumnPinningChange?: (state: ColumnPinningState) => void;
395
+ /** Row height. `compact` fits about a third more rows on a screen. */
396
+ density?: DataTableDensity;
397
+ /**
398
+ * Detail panel for an expanded row, rendered as a full-width row beneath it.
399
+ * Rows become expandable and grow a caret in the first cell.
400
+ */
401
+ renderExpandedRow?: (row: TData) => React.ReactNode;
402
+ /**
403
+ * Client-side search across every visible cell. Leave undefined for
404
+ * server-side search (drive `DataTableSearch` yourself instead).
405
+ */
406
+ globalFilter?: string;
407
+ className?: string;
408
+ };
409
+
410
+ /** Row height. `compact` fits about a third more rows on a screen. */
411
+ export type DataTableDensity = "comfortable" | "compact";
412
+
413
+ const DENSITY_CELL_CLASS: Record<DataTableDensity, string> = {
414
+ comfortable: "",
415
+ compact: "[&_td]:py-1 [&_th]:py-1 [&_td]:text-[0.8rem]",
416
+ };
417
+
418
+ /**
419
+ * A piece of state the caller may or may not own. Returns the live value and a
420
+ * TanStack updater that writes to whichever side is in charge, so every feature
421
+ * works uncontrolled by default and controlled when a prop is passed.
422
+ */
423
+ function useOptionalControlled<TState>(
424
+ controlled: TState | undefined,
425
+ onChange: ((state: TState) => void) | undefined,
426
+ initial: TState,
427
+ ): [TState, OnChangeFn<TState>] {
428
+ const [internal, setInternal] = React.useState<TState>(initial);
429
+ const value = controlled ?? internal;
430
+ const handleChange: OnChangeFn<TState> = (updater) => {
431
+ const next =
432
+ typeof updater === "function"
433
+ ? (updater as (old: TState) => TState)(value)
434
+ : updater;
435
+ if (controlled === undefined) {
436
+ setInternal(next);
437
+ }
438
+ onChange?.(next);
439
+ };
440
+ return [value, handleChange];
441
+ }
442
+
443
+ function useDataTable<TData, TValue>({
444
+ columns,
445
+ data,
446
+ initialSorting,
447
+ enableRowSelection,
448
+ rowSelection,
449
+ onRowSelectionChange,
450
+ getRowId,
451
+ enableColumnResizing,
452
+ columnSizing,
453
+ onColumnSizingChange,
454
+ columnVisibility,
455
+ onColumnVisibilityChange,
456
+ columnPinning,
457
+ onColumnPinningChange,
458
+ renderExpandedRow,
459
+ globalFilter,
460
+ }: Pick<
461
+ DataTableProps<TData, TValue>,
462
+ | "columns"
463
+ | "data"
464
+ | "initialSorting"
465
+ | "enableRowSelection"
466
+ | "rowSelection"
467
+ | "onRowSelectionChange"
468
+ | "getRowId"
469
+ | "enableColumnResizing"
470
+ | "columnSizing"
471
+ | "onColumnSizingChange"
472
+ | "columnVisibility"
473
+ | "onColumnVisibilityChange"
474
+ | "columnPinning"
475
+ | "onColumnPinningChange"
476
+ | "renderExpandedRow"
477
+ | "globalFilter"
478
+ >) {
479
+ const [sorting, setSorting] = React.useState<SortingState>(
480
+ initialSorting ?? [],
481
+ );
482
+ const [selection, handleSelectionChange] = useOptionalControlled(
483
+ rowSelection,
484
+ onRowSelectionChange,
485
+ {} as RowSelectionState,
486
+ );
487
+ const [sizing, handleSizingChange] = useOptionalControlled(
488
+ columnSizing,
489
+ onColumnSizingChange,
490
+ {} as ColumnSizingState,
491
+ );
492
+ const [visibility, handleVisibilityChange] = useOptionalControlled(
493
+ columnVisibility,
494
+ onColumnVisibilityChange,
495
+ {} as VisibilityState,
496
+ );
497
+ const [pinning, handlePinningChange] = useOptionalControlled(
498
+ columnPinning,
499
+ onColumnPinningChange,
500
+ { left: [], right: [] } as ColumnPinningState,
501
+ );
502
+ const [expanded, setExpanded] = React.useState<ExpandedState>({});
503
+
504
+ return useReactTable({
505
+ data: data as TData[],
506
+ columns,
507
+ getCoreRowModel: getCoreRowModel(),
508
+ getSortedRowModel: getSortedRowModel(),
509
+ getFilteredRowModel: getFilteredRowModel(),
510
+ getExpandedRowModel: getExpandedRowModel(),
511
+ onSortingChange: setSorting,
512
+ enableRowSelection,
513
+ onRowSelectionChange: handleSelectionChange,
514
+ enableColumnResizing,
515
+ columnResizeMode: "onChange",
516
+ onColumnSizingChange: handleSizingChange,
517
+ onColumnVisibilityChange: handleVisibilityChange,
518
+ onColumnPinningChange: handlePinningChange,
519
+ onExpandedChange: setExpanded,
520
+ // Every row can open a detail panel when one is supplied; sub-rows are
521
+ // not used, so the row model never nests.
522
+ getRowCanExpand: () => renderExpandedRow !== undefined,
523
+ getRowId,
524
+ state: {
525
+ sorting,
526
+ rowSelection: selection,
527
+ columnSizing: sizing,
528
+ columnVisibility: visibility,
529
+ columnPinning: pinning,
530
+ expanded,
531
+ globalFilter: globalFilter ?? "",
532
+ },
533
+ });
534
+ }
535
+
536
+ function DataTableLoadingOverlay({
537
+ loading,
538
+ className,
539
+ }: {
540
+ loading: boolean;
541
+ className?: string;
542
+ }) {
543
+ if (!loading) {
544
+ return null;
545
+ }
546
+ return (
547
+ <div
548
+ className={cn(
549
+ "absolute inset-0 z-20 flex items-center justify-center bg-background/50",
550
+ className,
551
+ )}
552
+ >
553
+ <Spinner className="size-5 text-muted-foreground" />
554
+ </div>
555
+ );
556
+ }
557
+
558
+ function DataTableMobileCard<TData>({
559
+ row,
560
+ onRowClick,
561
+ renderMobileCard,
562
+ }: {
563
+ row: Row<TData>;
564
+ onRowClick: ((row: TData) => void) | undefined;
565
+ renderMobileCard: (row: TData) => React.ReactNode;
566
+ }) {
567
+ if (onRowClick) {
568
+ return (
569
+ <button
570
+ type="button"
571
+ className="w-full rounded-md border bg-card p-3 text-left active:bg-muted/50"
572
+ onClick={() => onRowClick(row.original)}
573
+ >
574
+ {renderMobileCard(row.original)}
575
+ </button>
576
+ );
577
+ }
578
+ return (
579
+ <div className="rounded-md border bg-card p-3">
580
+ {renderMobileCard(row.original)}
581
+ </div>
582
+ );
583
+ }
584
+
585
+ function DataTableMobileCardList<TData>({
586
+ rows,
587
+ loading,
588
+ emptyState,
589
+ onRowClick,
590
+ renderMobileCard,
591
+ }: {
592
+ rows: Row<TData>[];
593
+ loading: boolean;
594
+ emptyState: React.ReactNode;
595
+ onRowClick: ((row: TData) => void) | undefined;
596
+ renderMobileCard: ((row: TData) => React.ReactNode) | undefined;
597
+ }) {
598
+ if (renderMobileCard === undefined) {
599
+ return null;
600
+ }
601
+ return (
602
+ <div data-slot="data-table-mobile-list" className="relative md:hidden">
603
+ <DataTableLoadingOverlay loading={loading} className="rounded-md" />
604
+ {rows.length ? (
605
+ <ul className="flex flex-col gap-2">
606
+ {rows.map((row) => (
607
+ <li key={row.id}>
608
+ <DataTableMobileCard
609
+ row={row}
610
+ onRowClick={onRowClick}
611
+ renderMobileCard={renderMobileCard}
612
+ />
613
+ </li>
614
+ ))}
615
+ </ul>
616
+ ) : (
617
+ <div className="rounded-md border">
618
+ {emptyState ?? <DataTableEmpty />}
619
+ </div>
620
+ )}
621
+ </div>
622
+ );
623
+ }
624
+
625
+ const ARIA_SORT_BY_DIRECTION: Record<
626
+ SortDirection,
627
+ "ascending" | "descending"
628
+ > = {
629
+ asc: "ascending",
630
+ desc: "descending",
631
+ };
632
+
633
+ function DataTableSortCaret({
634
+ canSort,
635
+ sorted,
636
+ }: {
637
+ canSort: boolean;
638
+ sorted: false | SortDirection;
639
+ }) {
640
+ if (sorted === "asc") {
641
+ return <CaretUpIcon className="size-3.5 shrink-0" />;
642
+ }
643
+ if (sorted === "desc") {
644
+ return <CaretDownIcon className="size-3.5 shrink-0" />;
645
+ }
646
+ if (canSort) {
647
+ return (
648
+ <CaretUpDownIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
649
+ );
650
+ }
651
+ return null;
652
+ }
653
+
654
+ /**
655
+ * Styles for a column frozen against an edge. The offset is the running width
656
+ * of the columns pinned before it, so several can stack.
657
+ */
658
+ function pinnedStyle<TData>(
659
+ column: Column<TData, unknown>,
660
+ ): React.CSSProperties | undefined {
661
+ const side = column.getIsPinned();
662
+ if (side === false) {
663
+ return undefined;
664
+ }
665
+ return side === "left"
666
+ ? { position: "sticky", left: column.getStart("left"), zIndex: 2 }
667
+ : { position: "sticky", right: column.getAfter("right"), zIndex: 2 };
668
+ }
669
+
670
+ function pinnedClass<TData>(column: Column<TData, unknown>): string | false {
671
+ const side = column.getIsPinned();
672
+ if (side === false) {
673
+ return false;
674
+ }
675
+ return cn(
676
+ // `bg-inherit` rather than a fixed colour: the row owns the background,
677
+ // so a pinned cell follows hover, selection and expansion instead of
678
+ // sitting there as an opaque patch that ignores them.
679
+ "bg-inherit",
680
+ // A border alone reads as just another column rule; the shadow is what
681
+ // says "this floats above the content sliding under it". Mixed from
682
+ // `--foreground` rather than black, so it darkens on a light theme and
683
+ // glows on a dark one instead of vanishing into it.
684
+ side === "left" &&
685
+ column.getIsLastColumn("left") &&
686
+ "border-r shadow-[6px_0_8px_-6px_color-mix(in_oklab,var(--foreground)_28%,transparent)]",
687
+ side === "right" &&
688
+ column.getIsFirstColumn("right") &&
689
+ "border-l shadow-[-6px_0_8px_-6px_color-mix(in_oklab,var(--foreground)_28%,transparent)]",
690
+ );
691
+ }
692
+
693
+ /**
694
+ * The drag target that resizes a column. Sits on the header's trailing edge and
695
+ * is also focusable, so the width is reachable without a pointer.
696
+ */
697
+ function DataTableResizeHandle<TData>({
698
+ header,
699
+ table,
700
+ }: {
701
+ header: Header<TData, unknown>;
702
+ table: TanstackTable<TData>;
703
+ }) {
704
+ const nudge = (delta: number) => {
705
+ const next = Math.max(40, header.getSize() + delta);
706
+ table.setColumnSizing((previous: ColumnSizingState) => ({
707
+ ...previous,
708
+ [header.column.id]: next,
709
+ }));
710
+ };
711
+ return (
712
+ <button
713
+ type="button"
714
+ data-slot="data-table-resize-handle"
715
+ aria-label={`Resize column`}
716
+ // The button is a wide, invisible hit area straddling the column
717
+ // boundary; the visible 2px rule is a child so the target can be
718
+ // comfortable without drawing a fat line.
719
+ className={cn(
720
+ "group/resize absolute inset-y-0 right-0 z-20 flex w-3 translate-x-1/2",
721
+ "cursor-col-resize touch-none select-none items-stretch justify-center",
722
+ "bg-transparent focus-visible:outline-none",
723
+ )}
724
+ onMouseDown={header.getResizeHandler()}
725
+ onTouchStart={header.getResizeHandler()}
726
+ // A resize handle inside a sortable header must not also sort.
727
+ onClick={(event) => event.stopPropagation()}
728
+ onKeyDown={(event) => {
729
+ if (event.key === "ArrowLeft") {
730
+ event.preventDefault();
731
+ nudge(-16);
732
+ }
733
+ if (event.key === "ArrowRight") {
734
+ event.preventDefault();
735
+ nudge(16);
736
+ }
737
+ }}
738
+ >
739
+ <span
740
+ aria-hidden="true"
741
+ className={cn(
742
+ "w-0.5 rounded-full transition-colors",
743
+ "group-hover/resize:bg-border group-focus-visible/resize:bg-ring",
744
+ header.column.getIsResizing() ? "bg-ring" : "bg-transparent",
745
+ )}
746
+ />
747
+ </button>
748
+ );
749
+ }
750
+
751
+ function DataTableHeadCell<TData>({
752
+ header,
753
+ resizable,
754
+ table,
755
+ }: {
756
+ header: Header<TData, unknown>;
757
+ resizable: boolean;
758
+ table: TanstackTable<TData>;
759
+ }) {
760
+ const size = header.getSize();
761
+ const isSized = header.column.columnDef.size !== undefined || resizable;
762
+ const canSort = header.column.getCanSort();
763
+ const sorted = header.column.getIsSorted();
764
+ return (
765
+ <TableHead
766
+ aria-sort={sorted === false ? undefined : ARIA_SORT_BY_DIRECTION[sorted]}
767
+ className={cn(
768
+ "relative",
769
+ canSort && "cursor-pointer select-none hover:bg-muted/50",
770
+ pinnedClass(header.column),
771
+ )}
772
+ style={{
773
+ ...(isSized ? { width: size } : {}),
774
+ ...pinnedStyle(header.column),
775
+ }}
776
+ onClick={header.column.getToggleSortingHandler()}
777
+ >
778
+ <div
779
+ className={cn("flex items-center gap-1", isSized && "overflow-hidden")}
780
+ >
781
+ <span className={isSized ? "truncate" : undefined}>
782
+ {header.isPlaceholder
783
+ ? null
784
+ : flexRender(header.column.columnDef.header, header.getContext())}
785
+ </span>
786
+ <DataTableSortCaret canSort={canSort} sorted={sorted} />
787
+ </div>
788
+ {resizable && header.column.getCanResize() && (
789
+ <DataTableResizeHandle header={header} table={table} />
790
+ )}
791
+ </TableHead>
792
+ );
793
+ }
794
+
795
+ function DataTableHeader<TData>({
796
+ headerGroups,
797
+ stickyHeader,
798
+ resizable,
799
+ table,
800
+ expandable,
801
+ pinned,
802
+ }: {
803
+ headerGroups: HeaderGroup<TData>[];
804
+ stickyHeader: boolean;
805
+ resizable: boolean;
806
+ table: TanstackTable<TData>;
807
+ expandable: boolean;
808
+ pinned: boolean;
809
+ }) {
810
+ return (
811
+ <TableHeader
812
+ className={cn(
813
+ // Sticky headers can't keep collapsed `tr` borders while
814
+ // scrolling, so an inset shadow redraws the bottom rule.
815
+ stickyHeader &&
816
+ "sticky top-0 z-10 bg-background shadow-[inset_0_-1px_0_0_var(--color-border)] [&_tr]:border-b-0",
817
+ )}
818
+ >
819
+ {headerGroups.map((headerGroup) => (
820
+ <TableRow
821
+ key={headerGroup.id}
822
+ className={cn(pinned && "bg-background")}
823
+ >
824
+ {/* The body renders a leading cell for the expander, so the header
825
+ has to as well or every column sits one place to the left. */}
826
+ {expandable && (
827
+ <TableHead className="w-10">
828
+ <span className="sr-only">Expand row</span>
829
+ </TableHead>
830
+ )}
831
+ {headerGroup.headers.map((header) => (
832
+ <DataTableHeadCell
833
+ key={header.id}
834
+ header={header}
835
+ resizable={resizable}
836
+ table={table}
837
+ />
838
+ ))}
839
+ </TableRow>
840
+ ))}
841
+ </TableHeader>
842
+ );
843
+ }
844
+
845
+ function DataTableBodyCell<TData>({
846
+ cell,
847
+ resizable,
848
+ }: {
849
+ cell: Cell<TData, unknown>;
850
+ resizable: boolean;
851
+ }) {
852
+ const isSized = cell.column.columnDef.size !== undefined || resizable;
853
+ return (
854
+ <TableCell
855
+ className={pinnedClass(cell.column) || undefined}
856
+ style={{
857
+ ...(isSized ? { width: cell.column.getSize() } : {}),
858
+ ...pinnedStyle(cell.column),
859
+ }}
860
+ >
861
+ <div className={isSized ? "truncate" : undefined}>
862
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
863
+ </div>
864
+ </TableCell>
865
+ );
866
+ }
867
+
868
+ function DataTableBodyRow<TData>({
869
+ row,
870
+ onRowClick,
871
+ resizable,
872
+ renderExpandedRow,
873
+ columnCount,
874
+ pinned,
875
+ }: {
876
+ row: Row<TData>;
877
+ onRowClick: ((row: TData) => void) | undefined;
878
+ resizable: boolean;
879
+ renderExpandedRow: ((row: TData) => React.ReactNode) | undefined;
880
+ columnCount: number;
881
+ pinned: boolean;
882
+ }) {
883
+ const handleClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
884
+ // Clicks on interactive cell content (selection checkbox, action
885
+ // buttons, links) are not row activations.
886
+ const target = event.target as HTMLElement;
887
+ if (target.closest("button, a, input, label")) {
888
+ return;
889
+ }
890
+ onRowClick?.(row.original);
891
+ };
892
+ const expandable = renderExpandedRow !== undefined;
893
+ const expanded = expandable && row.getIsExpanded();
894
+ return (
895
+ <>
896
+ <TableRow
897
+ data-selected={row.getIsSelected() || undefined}
898
+ data-expanded={expanded || undefined}
899
+ className={cn(
900
+ onRowClick && "cursor-pointer",
901
+ pinned && "bg-background",
902
+ )}
903
+ onClick={onRowClick ? handleClick : undefined}
904
+ >
905
+ {expandable && (
906
+ <TableCell className="w-10">
907
+ <Button
908
+ type="button"
909
+ variant="ghost"
910
+ size="icon-xs"
911
+ aria-expanded={expanded}
912
+ aria-label={expanded ? "Collapse row" : "Expand row"}
913
+ onClick={row.getToggleExpandedHandler()}
914
+ >
915
+ <CaretExpandIcon
916
+ className={cn("transition-transform", expanded && "rotate-90")}
917
+ />
918
+ </Button>
919
+ </TableCell>
920
+ )}
921
+ {row.getVisibleCells().map((cell) => (
922
+ <DataTableBodyCell key={cell.id} cell={cell} resizable={resizable} />
923
+ ))}
924
+ </TableRow>
925
+ {expanded && (
926
+ <TableRow data-slot="data-table-expanded-row">
927
+ <TableCell
928
+ colSpan={columnCount + (expandable ? 1 : 0)}
929
+ className="whitespace-normal bg-muted/30"
930
+ >
931
+ {renderExpandedRow(row.original)}
932
+ </TableCell>
933
+ </TableRow>
934
+ )}
935
+ </>
936
+ );
937
+ }
938
+
939
+ function DataTableDesktopTable<TData>({
940
+ headerGroups,
941
+ rows,
942
+ columnCount,
943
+ loading,
944
+ emptyState,
945
+ onRowClick,
946
+ stickyHeader,
947
+ containerClassName,
948
+ hasFixedSizes,
949
+ hiddenOnMobile,
950
+ resizable,
951
+ renderExpandedRow,
952
+ density,
953
+ table,
954
+ pinned,
955
+ }: {
956
+ headerGroups: HeaderGroup<TData>[];
957
+ rows: Row<TData>[];
958
+ columnCount: number;
959
+ loading: boolean;
960
+ emptyState: React.ReactNode;
961
+ onRowClick: ((row: TData) => void) | undefined;
962
+ stickyHeader: boolean;
963
+ containerClassName: string | undefined;
964
+ hasFixedSizes: boolean;
965
+ hiddenOnMobile: boolean;
966
+ resizable: boolean;
967
+ renderExpandedRow: ((row: TData) => React.ReactNode) | undefined;
968
+ density: DataTableDensity;
969
+ table: TanstackTable<TData>;
970
+ pinned: boolean;
971
+ }) {
972
+ return (
973
+ <div
974
+ className={cn(
975
+ "relative overflow-hidden rounded-md border",
976
+ hiddenOnMobile && "hidden md:block",
977
+ )}
978
+ >
979
+ <DataTableLoadingOverlay loading={loading} />
980
+ <Table
981
+ containerClassName={cn(
982
+ stickyHeader && "overflow-y-auto",
983
+ containerClassName,
984
+ )}
985
+ className={DENSITY_CELL_CLASS[density]}
986
+ style={
987
+ hasFixedSizes || resizable ? { tableLayout: "fixed" } : undefined
988
+ }
989
+ >
990
+ <DataTableHeader
991
+ headerGroups={headerGroups}
992
+ stickyHeader={stickyHeader}
993
+ resizable={resizable}
994
+ table={table}
995
+ expandable={renderExpandedRow !== undefined}
996
+ pinned={pinned}
997
+ />
998
+ <TableBody>
999
+ <DataTableRows
1000
+ rows={rows}
1001
+ columnCount={columnCount}
1002
+ emptyState={emptyState}
1003
+ onRowClick={onRowClick}
1004
+ resizable={resizable}
1005
+ renderExpandedRow={renderExpandedRow}
1006
+ pinned={pinned}
1007
+ />
1008
+ </TableBody>
1009
+ </Table>
1010
+ </div>
1011
+ );
1012
+ }
1013
+
1014
+ function DataTableRows<TData>({
1015
+ rows,
1016
+ columnCount,
1017
+ emptyState,
1018
+ onRowClick,
1019
+ resizable,
1020
+ renderExpandedRow,
1021
+ pinned,
1022
+ }: {
1023
+ rows: Row<TData>[];
1024
+ columnCount: number;
1025
+ emptyState: React.ReactNode;
1026
+ onRowClick: ((row: TData) => void) | undefined;
1027
+ resizable: boolean;
1028
+ renderExpandedRow: ((row: TData) => React.ReactNode) | undefined;
1029
+ pinned: boolean;
1030
+ }) {
1031
+ if (!rows.length) {
1032
+ return (
1033
+ <TableRow>
1034
+ <TableCell
1035
+ colSpan={columnCount + (renderExpandedRow === undefined ? 0 : 1)}
1036
+ className="whitespace-normal"
1037
+ >
1038
+ {emptyState ?? <DataTableEmpty />}
1039
+ </TableCell>
1040
+ </TableRow>
1041
+ );
1042
+ }
1043
+ return (
1044
+ <>
1045
+ {rows.map((row) => (
1046
+ <DataTableBodyRow
1047
+ key={row.id}
1048
+ row={row}
1049
+ onRowClick={onRowClick}
1050
+ resizable={resizable}
1051
+ renderExpandedRow={renderExpandedRow}
1052
+ columnCount={columnCount}
1053
+ pinned={pinned}
1054
+ />
1055
+ ))}
1056
+ </>
1057
+ );
1058
+ }
1059
+
1060
+ /**
1061
+ * Column visibility menu. Every hideable column gets a checkbox; the table owns
1062
+ * the state unless `columnVisibility` is passed to `DataTable`.
1063
+ */
1064
+ function DataTableViewOptions<TData>({
1065
+ table,
1066
+ label = "Columns",
1067
+ className,
1068
+ }: {
1069
+ table: TanstackTable<TData>;
1070
+ label?: string;
1071
+ className?: string;
1072
+ }) {
1073
+ const columns = table
1074
+ .getAllLeafColumns()
1075
+ .filter((column) => column.getCanHide());
1076
+ return (
1077
+ <DropdownMenu>
1078
+ <DropdownMenuTrigger
1079
+ render={
1080
+ <Button variant="outline" size="sm" className={className}>
1081
+ <ColumnsIcon />
1082
+ {label}
1083
+ </Button>
1084
+ }
1085
+ />
1086
+ <DropdownMenuContent align="end" className="w-48">
1087
+ <DropdownMenuLabel>{label}</DropdownMenuLabel>
1088
+ <DropdownMenuSeparator />
1089
+ {columns.map((column) => (
1090
+ <DropdownMenuCheckboxItem
1091
+ key={column.id}
1092
+ checked={column.getIsVisible()}
1093
+ onCheckedChange={(checked) => column.toggleVisibility(checked)}
1094
+ >
1095
+ {typeof column.columnDef.header === "string"
1096
+ ? column.columnDef.header
1097
+ : column.id}
1098
+ </DropdownMenuCheckboxItem>
1099
+ ))}
1100
+ </DropdownMenuContent>
1101
+ </DropdownMenu>
1102
+ );
1103
+ }
1104
+
1105
+ /** Row-height switch, for tables people scan rather than read. */
1106
+ function DataTableDensityToggle({
1107
+ density,
1108
+ onDensityChange,
1109
+ label = "Density",
1110
+ labels = { comfortable: "Comfortable", compact: "Compact" },
1111
+ className,
1112
+ }: {
1113
+ density: DataTableDensity;
1114
+ onDensityChange: (density: DataTableDensity) => void;
1115
+ label?: string;
1116
+ labels?: Record<DataTableDensity, string>;
1117
+ className?: string;
1118
+ }) {
1119
+ return (
1120
+ <DropdownMenu>
1121
+ <DropdownMenuTrigger
1122
+ render={
1123
+ <Button variant="outline" size="sm" className={className}>
1124
+ <RowsIcon />
1125
+ {label}
1126
+ </Button>
1127
+ }
1128
+ />
1129
+ <DropdownMenuContent align="end">
1130
+ <DropdownMenuRadioGroup
1131
+ value={density}
1132
+ onValueChange={(value) => onDensityChange(value as DataTableDensity)}
1133
+ >
1134
+ <DropdownMenuRadioItem value="comfortable">
1135
+ {labels.comfortable}
1136
+ </DropdownMenuRadioItem>
1137
+ <DropdownMenuRadioItem value="compact">
1138
+ {labels.compact}
1139
+ </DropdownMenuRadioItem>
1140
+ </DropdownMenuRadioGroup>
1141
+ </DropdownMenuContent>
1142
+ </DropdownMenu>
1143
+ );
1144
+ }
1145
+
1146
+ /** RFC 4180: quotes double up, and any field containing them is quoted. */
1147
+ function toCsvField(value: unknown): string {
1148
+ const text = value === null || value === undefined ? "" : String(value);
1149
+ return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
1150
+ }
1151
+
1152
+ /**
1153
+ * Serializes the rows the user is currently looking at — filtered and sorted,
1154
+ * visible columns only — to CSV. Exported so a consumer can send the same bytes
1155
+ * somewhere other than a download.
1156
+ */
1157
+ function dataTableToCsv<TData>(table: TanstackTable<TData>): string {
1158
+ const columns = table.getVisibleLeafColumns();
1159
+ const header = columns.map((column) =>
1160
+ toCsvField(
1161
+ typeof column.columnDef.header === "string"
1162
+ ? column.columnDef.header
1163
+ : column.id,
1164
+ ),
1165
+ );
1166
+ const rows = table
1167
+ .getSortedRowModel()
1168
+ .rows.map((row) =>
1169
+ columns.map((column) => toCsvField(row.getValue(column.id))),
1170
+ );
1171
+ return [header, ...rows].map((cells) => cells.join(",")).join("\r\n");
1172
+ }
1173
+
1174
+ /** Downloads the current view as CSV. */
1175
+ function DataTableExport<TData>({
1176
+ table,
1177
+ filename = "export.csv",
1178
+ label = "Export",
1179
+ className,
1180
+ }: {
1181
+ table: TanstackTable<TData>;
1182
+ filename?: string;
1183
+ label?: string;
1184
+ className?: string;
1185
+ }) {
1186
+ const download = () => {
1187
+ const blob = new Blob([dataTableToCsv(table)], {
1188
+ type: "text/csv;charset=utf-8",
1189
+ });
1190
+ const url = URL.createObjectURL(blob);
1191
+ const anchor = document.createElement("a");
1192
+ anchor.href = url;
1193
+ anchor.download = filename;
1194
+ anchor.click();
1195
+ URL.revokeObjectURL(url);
1196
+ };
1197
+ return (
1198
+ <Button
1199
+ variant="outline"
1200
+ size="sm"
1201
+ className={className}
1202
+ onClick={download}
1203
+ >
1204
+ <DownloadSimpleIcon />
1205
+ {label}
1206
+ </Button>
1207
+ );
1208
+ }
1209
+
1210
+ /**
1211
+ * The kit's table recipe: `Table` + sorting + row selection + sticky header +
1212
+ * windowed pagination, with toolbar emplacements for search and filters, and
1213
+ * the classic extras behind opt-in props — column resizing, visibility,
1214
+ * pinning, density, row expansion and CSV export.
1215
+ *
1216
+ * Column sizing: when any column sets a numeric `size` (or resizing is on) the
1217
+ * table switches to fixed layout and sized cells truncate instead of
1218
+ * stretching.
1219
+ *
1220
+ * `toolbar` is a render prop when it needs the table instance — the controls
1221
+ * that drive columns (`DataTableViewOptions`, `DataTableExport`) do.
1222
+ */
1223
+ function DataTable<TData, TValue>({
1224
+ columns,
1225
+ data,
1226
+ initialSorting,
1227
+ onRowClick,
1228
+ loading = false,
1229
+ emptyState,
1230
+ pagination,
1231
+ toolbar,
1232
+ enableRowSelection = false,
1233
+ rowSelection,
1234
+ onRowSelectionChange,
1235
+ getRowId,
1236
+ stickyHeader = false,
1237
+ containerClassName,
1238
+ renderMobileCard,
1239
+ enableColumnResizing = false,
1240
+ columnSizing,
1241
+ onColumnSizingChange,
1242
+ columnVisibility,
1243
+ onColumnVisibilityChange,
1244
+ columnPinning,
1245
+ onColumnPinningChange,
1246
+ density = "comfortable",
1247
+ renderExpandedRow,
1248
+ globalFilter,
1249
+ className,
1250
+ }: DataTableProps<TData, TValue>) {
1251
+ const table = useDataTable({
1252
+ columns,
1253
+ data,
1254
+ initialSorting,
1255
+ enableRowSelection,
1256
+ rowSelection,
1257
+ onRowSelectionChange,
1258
+ getRowId,
1259
+ enableColumnResizing,
1260
+ columnSizing,
1261
+ onColumnSizingChange,
1262
+ columnVisibility,
1263
+ onColumnVisibilityChange,
1264
+ columnPinning,
1265
+ onColumnPinningChange,
1266
+ renderExpandedRow,
1267
+ globalFilter,
1268
+ });
1269
+
1270
+ const hasFixedSizes = columns.some(
1271
+ (column) => typeof column.size === "number",
1272
+ );
1273
+ const rows = table.getRowModel().rows;
1274
+
1275
+ return (
1276
+ <div data-slot="data-table" className={className}>
1277
+ {toolbar !== undefined && (
1278
+ <div className="pb-3">
1279
+ {typeof toolbar === "function" ? toolbar(table) : toolbar}
1280
+ </div>
1281
+ )}
1282
+ <DataTableMobileCardList
1283
+ rows={rows}
1284
+ loading={loading}
1285
+ emptyState={emptyState}
1286
+ onRowClick={onRowClick}
1287
+ renderMobileCard={renderMobileCard}
1288
+ />
1289
+ <DataTableDesktopTable
1290
+ headerGroups={table.getHeaderGroups()}
1291
+ rows={rows}
1292
+ columnCount={table.getVisibleLeafColumns().length}
1293
+ loading={loading}
1294
+ emptyState={emptyState}
1295
+ onRowClick={onRowClick}
1296
+ stickyHeader={stickyHeader}
1297
+ containerClassName={containerClassName}
1298
+ hasFixedSizes={hasFixedSizes}
1299
+ hiddenOnMobile={renderMobileCard !== undefined}
1300
+ resizable={enableColumnResizing}
1301
+ renderExpandedRow={renderExpandedRow}
1302
+ density={density}
1303
+ table={table}
1304
+ pinned={
1305
+ (columnPinning?.left?.length ?? 0) +
1306
+ (columnPinning?.right?.length ?? 0) >
1307
+ 0
1308
+ }
1309
+ />
1310
+ {pagination !== undefined && <DataTablePagination {...pagination} />}
1311
+ </div>
1312
+ );
1313
+ }
1314
+
1315
+ export {
1316
+ DataTable,
1317
+ DataTableActions,
1318
+ DataTableDensityToggle,
1319
+ DataTableEmpty,
1320
+ DataTableExport,
1321
+ DataTableFilters,
1322
+ DataTablePagination,
1323
+ DataTableSearch,
1324
+ DataTableToolbar,
1325
+ DataTableViewOptions,
1326
+ dataTableSelectionColumn,
1327
+ dataTableToCsv,
1328
+ };
package/src/styles.css ADDED
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Tailwind must scan this package or none of its classes are generated — the
3
+ * components ship as .tsx source, not as compiled CSS.
4
+ *
5
+ * `@source` resolves relative to the file that declares it, so this works
6
+ * wherever the package ends up: node_modules in an app, a symlink in a
7
+ * workspace. Consumers import this file and add nothing of their own.
8
+ */
9
+ @source "./";
10
+ @source not "./**/*.test.*";