@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 +57 -0
- package/src/components/data-table.tsx +1328 -0
- package/src/styles.css +10 -0
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.*";
|