@majapisoftwares/ui 6.0.0 → 6.1.2

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.
Files changed (73) hide show
  1. package/AGENTS.md +27 -27
  2. package/README.md +79 -79
  3. package/THIRD_PARTY_NOTICES.md +39 -39
  4. package/components.json +25 -25
  5. package/docs/MIGRATION-0.2.md +20 -20
  6. package/docs/MIGRATION-6.0.md +33 -33
  7. package/docs/NEXT-STARTER-VALIDATION.md +14 -14
  8. package/docs/STANDARDS.md +41 -41
  9. package/docs/VALIDATION.md +11 -11
  10. package/docs/VERSIONING.md +28 -28
  11. package/docs/validation/preference-titles.md +22 -0
  12. package/package.json +88 -84
  13. package/scripts/check-standards.mjs +41 -41
  14. package/src/components/ui/accordion.tsx +82 -82
  15. package/src/components/ui/alert-dialog.tsx +187 -187
  16. package/src/components/ui/alert.tsx +77 -77
  17. package/src/components/ui/avatar.tsx +108 -108
  18. package/src/components/ui/badge.tsx +55 -55
  19. package/src/components/ui/breadcrumb.tsx +125 -125
  20. package/src/components/ui/button-group.tsx +89 -89
  21. package/src/components/ui/button.tsx +59 -59
  22. package/src/components/ui/card.tsx +101 -101
  23. package/src/components/ui/checkbox.tsx +27 -27
  24. package/src/components/ui/collapsible.tsx +21 -21
  25. package/src/components/ui/context-menu.tsx +271 -271
  26. package/src/components/ui/dialog.tsx +161 -161
  27. package/src/components/ui/dropdown-menu.tsx +271 -271
  28. package/src/components/ui/empty.tsx +105 -105
  29. package/src/components/ui/field.tsx +238 -238
  30. package/src/components/ui/hover-card.tsx +50 -50
  31. package/src/components/ui/input-group.tsx +155 -155
  32. package/src/components/ui/input.tsx +21 -21
  33. package/src/components/ui/kbd.tsx +28 -28
  34. package/src/components/ui/label.tsx +19 -19
  35. package/src/components/ui/orb.css +1002 -1002
  36. package/src/components/ui/orb.tsx +752 -752
  37. package/src/components/ui/popover.tsx +89 -89
  38. package/src/components/ui/progress.tsx +82 -82
  39. package/src/components/ui/radio-group.tsx +37 -37
  40. package/src/components/ui/scroll-area.tsx +54 -54
  41. package/src/components/ui/select.tsx +204 -204
  42. package/src/components/ui/separator.tsx +24 -24
  43. package/src/components/ui/sheet.tsx +139 -139
  44. package/src/components/ui/sidebar.tsx +1039 -1035
  45. package/src/components/ui/skeleton.tsx +15 -15
  46. package/src/components/ui/slider.tsx +53 -53
  47. package/src/components/ui/sonner.tsx +44 -44
  48. package/src/components/ui/spinner.tsx +32 -32
  49. package/src/components/ui/switch.tsx +31 -31
  50. package/src/components/ui/table.tsx +121 -121
  51. package/src/components/ui/tabs.tsx +81 -81
  52. package/src/components/ui/textarea.tsx +19 -19
  53. package/src/components/ui/toggle-group.tsx +89 -89
  54. package/src/components/ui/toggle.tsx +44 -44
  55. package/src/components/ui/tooltip.tsx +65 -65
  56. package/src/hooks/use-mobile.ts +20 -20
  57. package/src/lib/i18n.tsx +109 -109
  58. package/src/lib/utils.ts +1 -1
  59. package/src/patterns/app-shell.tsx +177 -175
  60. package/src/patterns/data-table.tsx +2486 -2486
  61. package/src/patterns/details-sheet.tsx +54 -53
  62. package/src/patterns/filter-tabs.tsx +53 -53
  63. package/src/patterns/majapi-logo.tsx +32 -32
  64. package/src/patterns/page-header.tsx +62 -62
  65. package/src/patterns/preferences.tsx +15 -7
  66. package/src/patterns/status-badge.tsx +24 -24
  67. package/src/patterns/user-menu.tsx +218 -0
  68. package/src/patterns/workspace-switcher.tsx +176 -0
  69. package/src/styles.css +165 -165
  70. package/src/templates/admin-dashboard.tsx +44 -44
  71. package/src/templates/auth-layout.tsx +30 -30
  72. package/src/templates/login-form.tsx +129 -129
  73. package/src/templates/signup-form.tsx +177 -177
@@ -1,2486 +1,2486 @@
1
- "use client";
2
- "use no memo";
3
-
4
- // Ported from the Numa app's data table (majapisoftwares/numa); this is now the
5
- // canonical copy. Effects below intentionally sync restored localStorage
6
- // preferences and incoming props into table state.
7
- /* eslint-disable react-hooks/set-state-in-effect */
8
-
9
- import * as React from "react";
10
- import {
11
- closestCenter,
12
- DndContext,
13
- KeyboardSensor,
14
- MouseSensor,
15
- TouchSensor,
16
- useSensor,
17
- useSensors,
18
- type DragEndEvent,
19
- } from "@dnd-kit/core";
20
- import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
21
- import {
22
- arrayMove,
23
- horizontalListSortingStrategy,
24
- SortableContext,
25
- useSortable,
26
- } from "@dnd-kit/sortable";
27
- import { CSS } from "@dnd-kit/utilities";
28
- import {
29
- CircleAlertIcon,
30
- InboxIcon,
31
- LockIcon,
32
- RotateCwIcon,
33
- TriangleAlertIcon,
34
- ArrowDownWideNarrowIcon,
35
- ArrowUpDownIcon,
36
- ArrowUpNarrowWideIcon,
37
- CheckIcon,
38
- ChevronLeftIcon,
39
- ChevronRightIcon,
40
- ChevronsLeftIcon,
41
- ChevronsRightIcon,
42
- Columns3Icon,
43
- EyeOffIcon,
44
- FunnelIcon,
45
- FunnelXIcon,
46
- GripVerticalIcon,
47
- SearchIcon,
48
- XIcon,
49
- } from "lucide-react";
50
- import {
51
- createTable,
52
- flexRender,
53
- getCoreRowModel,
54
- getFilteredRowModel,
55
- getPaginationRowModel,
56
- getSortedRowModel,
57
- type Column,
58
- type ColumnDef,
59
- type ColumnFiltersState,
60
- type ColumnOrderState,
61
- type ColumnSizingState,
62
- type FilterFn,
63
- type Header,
64
- type OnChangeFn,
65
- type PaginationState,
66
- type RowData,
67
- type RowSelectionState,
68
- type SortingState,
69
- type Table as TanstackTable,
70
- type TableOptions,
71
- type TableOptionsResolved,
72
- type VisibilityState,
73
- } from "@tanstack/react-table";
74
-
75
- import { Badge } from "../components/ui/badge";
76
- import { Button } from "../components/ui/button";
77
- import { Checkbox } from "../components/ui/checkbox";
78
- import { FilterTabs } from "./filter-tabs";
79
- import {
80
- DropdownMenu,
81
- DropdownMenuCheckboxItem,
82
- DropdownMenuContent,
83
- DropdownMenuGroup,
84
- DropdownMenuItem,
85
- DropdownMenuLabel,
86
- DropdownMenuSeparator,
87
- DropdownMenuTrigger,
88
- } from "../components/ui/dropdown-menu";
89
- import { Input } from "../components/ui/input";
90
- import {
91
- InputGroup,
92
- InputGroupAddon,
93
- InputGroupInput,
94
- } from "../components/ui/input-group";
95
- import { Label } from "../components/ui/label";
96
- import {
97
- Popover,
98
- PopoverContent,
99
- PopoverTrigger,
100
- } from "../components/ui/popover";
101
- import {
102
- Select,
103
- SelectContent,
104
- SelectItem,
105
- SelectTrigger,
106
- SelectValue,
107
- } from "../components/ui/select";
108
- import { Separator } from "../components/ui/separator";
109
- import { Skeleton } from "../components/ui/skeleton";
110
- import {
111
- Table,
112
- TableBody,
113
- TableCell,
114
- TableHead,
115
- TableHeader,
116
- TableRow,
117
- } from "../components/ui/table";
118
- import {
119
- Tooltip,
120
- TooltipContent,
121
- TooltipProvider,
122
- TooltipTrigger,
123
- } from "../components/ui/tooltip";
124
- import { cn } from "cn";
125
- import { defineMessages, useMessages, useUiMessages } from "../lib/i18n";
126
- import {
127
- Empty,
128
- EmptyContent,
129
- EmptyDescription,
130
- EmptyHeader,
131
- EmptyMedia,
132
- EmptyTitle,
133
- } from "../components/ui/empty";
134
- import {
135
- Alert,
136
- AlertAction,
137
- AlertDescription,
138
- AlertTitle,
139
- } from "../components/ui/alert";
140
-
141
- export type DataTableColumnAlign = "left" | "center" | "right";
142
- type DataTableSortDirection = false | "asc" | "desc";
143
-
144
- export type DataTableFilterOption = {
145
- label: string;
146
- value: string;
147
- };
148
-
149
- export type DataTableRangeFilter = {
150
- min?: string;
151
- max?: string;
152
- };
153
-
154
- export type DataTableDateRangeFilter = {
155
- from?: string;
156
- to?: string;
157
- };
158
-
159
- // Multi-select filter value: an array keeps the matching rows; the object form
160
- // hides the selected values instead. Server-filtered tables only receive arrays.
161
- export type DataTableMultiFilterValue =
162
- string[] | { exclude: true; values: string[] };
163
-
164
- export type DataTableColumnFilter<TData extends RowData> =
165
- | {
166
- placeholder?: string;
167
- type: "text";
168
- }
169
- | {
170
- options: DataTableFilterOption[];
171
- type: "multi";
172
- }
173
- | {
174
- max?: number;
175
- maxPlaceholder?: string;
176
- min?: number;
177
- minPlaceholder?: string;
178
- type: "number-range";
179
- }
180
- | {
181
- type: "date-range";
182
- }
183
- | {
184
- render: (column: Column<TData, unknown>) => React.ReactNode;
185
- type: "custom";
186
- };
187
-
188
- export type DataTableColumnMeta<TData extends RowData> = {
189
- align?: DataTableColumnAlign;
190
- cellClassName?: string;
191
- filter?: DataTableColumnFilter<TData>;
192
- headerClassName?: string;
193
- label?: string;
194
- };
195
-
196
- declare module "@tanstack/react-table" {
197
- interface ColumnMeta<TData extends RowData, TValue> {
198
- align?: DataTableColumnAlign;
199
- cellClassName?: string;
200
- filter?: DataTableColumnFilter<TData>;
201
- headerClassName?: string;
202
- label?: string;
203
- valueType?: TValue;
204
- }
205
-
206
- interface TableMeta<TData extends RowData> {
207
- enableExcludeFilters?: boolean;
208
- rowType?: TData;
209
- }
210
- }
211
-
212
- /** Window event that clears a table's search and filters (detail: { storageKey? }). */
213
- export const DATA_TABLE_RESET_FILTERS_EVENT =
214
- "majapi-ui:data-table:reset-filters";
215
-
216
- /** Clears search and filters of every table, or only the one with `storageKey`. */
217
- export function resetDataTableFilters(storageKey?: string) {
218
- window.dispatchEvent(
219
- new CustomEvent(DATA_TABLE_RESET_FILTERS_EVENT, { detail: { storageKey } }),
220
- );
221
- }
222
-
223
- const dataTableMessages = defineMessages({
224
- en: {
225
- search: "Search...",
226
- resultCount: (count: number) =>
227
- `${count} ${count === 1 ? "result" : "results"}`,
228
- rowCount: (count: number) => `${count} ${count === 1 ? "row" : "rows"}`,
229
- // English needs no plural form here; pt-BR uses count.
230
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
231
- selected: (rows: string, count: number) => `${rows} selected`,
232
- clearSelection: "Clear selection",
233
- clearFilters: "Clear filters",
234
- open: "Open",
235
- resizeColumn: (label: string) => `Resize column ${label}`,
236
- filterActive: ", filter active",
237
- excludeFilterActive: ", exclusion filter active",
238
- sortCriterion: (n: number) => `Sort #${n}`,
239
- sortCriterionTitle: (n: number) => `Sort criterion #${n}`,
240
- clearAll: "Clear all",
241
- hide: "Hide",
242
- toggleSort: (label: string) => `Toggle sorting for ${label}`,
243
- reorderColumn: (label: string) => `Reorder column ${label}`,
244
- sort: "Sort",
245
- removeSort: "Remove sort",
246
- ascending: "Ascending",
247
- descending: "Descending",
248
- filter: "Filter",
249
- min: "Min.",
250
- max: "Max.",
251
- filterBy: (label: string) => `Filter by ${label}`,
252
- filterMode: "Filter mode",
253
- show: "Show",
254
- exclude: "Exclude",
255
- valuesToExclude: "Values to exclude",
256
- valuesToShow: "Values to show",
257
- customizeColumns: "Customize columns",
258
- columnsTooltip: "Show, hide and reset columns",
259
- columns: "Columns",
260
- resetColumns: "Reset columns",
261
- rowsPerPage: "Rows",
262
- page: (page: number, total: number) => `Page ${page} of ${total}`,
263
- ofSummary: (part: string, total: string) => `${part} of ${total}.`,
264
- firstPage: "First page",
265
- previousPage: "Previous page",
266
- nextPage: "Next page",
267
- lastPage: "Last page",
268
- },
269
- "pt-BR": {
270
- search: "Buscar...",
271
- resultCount: (count: number) =>
272
- `${count} resultado${count === 1 ? "" : "s"}`,
273
- rowCount: (count: number) => `${count} registro${count === 1 ? "" : "s"}`,
274
- selected: (rows: string, count: number) =>
275
- `${rows} selecionado${count === 1 ? "" : "s"}`,
276
- clearSelection: "Limpar seleção",
277
- clearFilters: "Limpar filtros",
278
- open: "Abrir",
279
- resizeColumn: (label: string) => `Redimensionar coluna ${label}`,
280
- filterActive: ", filtro ativo",
281
- excludeFilterActive: ", filtro de exclusão ativo",
282
- sortCriterion: (n: number) => `${n}º critério`,
283
- sortCriterionTitle: (n: number) => `${n}º critério de ordenação`,
284
- clearAll: "Limpar tudo",
285
- hide: "Ocultar",
286
- toggleSort: (label: string) => `Alternar ordenação da coluna ${label}`,
287
- reorderColumn: (label: string) => `Reordenar coluna ${label}`,
288
- sort: "Ordenar",
289
- removeSort: "Remover ordem",
290
- ascending: "Crescente",
291
- descending: "Decrescente",
292
- filter: "Filtrar",
293
- min: "Mín.",
294
- max: "Máx.",
295
- filterBy: (label: string) => `Filtrar por ${label}`,
296
- filterMode: "Modo do filtro",
297
- show: "Mostrar",
298
- exclude: "Excluir",
299
- valuesToExclude: "Valores a excluir",
300
- valuesToShow: "Valores a mostrar",
301
- customizeColumns: "Personalizar colunas",
302
- columnsTooltip: "Mostrar, ocultar e restaurar colunas",
303
- columns: "Colunas",
304
- resetColumns: "Restaurar colunas",
305
- rowsPerPage: "Linhas",
306
- page: (page: number, total: number) => `Página ${page} de ${total}`,
307
- ofSummary: (part: string, total: string) => `${part} de ${total}.`,
308
- firstPage: "Primeira página",
309
- previousPage: "Página anterior",
310
- nextPage: "Próxima página",
311
- lastPage: "Última página",
312
- },
313
- });
314
-
315
- type StoredDataTableSettings = {
316
- columnFilters?: ColumnFiltersState;
317
- columnOrder?: ColumnOrderState;
318
- columnSizing?: ColumnSizingState;
319
- columnVisibility?: VisibilityState;
320
- globalFilter?: string;
321
- pageSize?: number;
322
- sorting?: SortingState;
323
- };
324
-
325
- export type DataTableProps<TData extends RowData> = {
326
- activeRowId?: string | null;
327
- columns: ColumnDef<TData, unknown>[];
328
- data: TData[];
329
- /** Text when there are no rows; defaults to a translated message. */
330
- emptyMessage?: string;
331
- /** Rich empty state (e.g. with a create action), shown when nothing is filtered. */
332
- emptyState?: React.ReactNode;
333
- /**
334
- * Fills the parent's height and scrolls rows inside the table (sticky
335
- * header, toolbar and pagination stay visible). The parent must be a flex
336
- * column with a bounded height, e.g. `AppShell fillHeight`.
337
- */
338
- fillHeight?: boolean;
339
- /** Shows skeleton rows instead of data. */
340
- loading?: boolean;
341
- /** Load failure. With rows it shows as stale data; without, replaces the table. */
342
- error?: string;
343
- onRetry?: () => void;
344
- /** Non-blocking notice, e.g. partial coverage. */
345
- warning?: string;
346
- /** Data can't be shown at all, e.g. missing permission. */
347
- unavailable?: string;
348
- enablePersistence?: boolean;
349
- enableRowSelection?: boolean;
350
- // Offers "Excluir" in multi-select column filters. Defaults to off for
351
- // server-backed tables (onStateChange), whose APIs only read include arrays.
352
- enableExcludeFilters?: boolean;
353
- filterFns?: Record<string, FilterFn<TData>>;
354
- formatResultCount?: (count: number) => string;
355
- formatRowCount?: (count: number) => string;
356
- formatSelectedRowCount?: (count: number) => string;
357
- getRowId: (row: TData) => string;
358
- globalFilterFn?: FilterFn<TData>;
359
- initialColumnFilters?: ColumnFiltersState;
360
- /** Initial search text. */
361
- initialGlobalFilter?: string;
362
- /** Makes rows clickable (and Enter/Space activatable), e.g. to open details. */
363
- onRowClick?: (row: TData) => void;
364
- /** Accessible name for a clickable row; defaults to its id. */
365
- getRowLabel?: (row: TData) => string;
366
- initialColumnOrder?: ColumnOrderState;
367
- initialColumnVisibility?: VisibilityState;
368
- initialPageSize?: number;
369
- initialSorting?: SortingState;
370
- lockedEndColumnIds?: string[];
371
- lockedStartColumnIds?: string[];
372
- manualFiltering?: boolean;
373
- manualPagination?: boolean;
374
- manualSorting?: boolean;
375
- onStateChange?: (state: DataTableStateSnapshot) => void;
376
- pageCount?: number;
377
- pageSizeOptions?: number[];
378
- rowCount?: number;
379
- rowSelectionActions?: (table: TanstackTable<TData>) => React.ReactNode;
380
- searchPlaceholder?: string;
381
- /** Persists sorting, filters, columns and page size in localStorage under this key. */
382
- storageKey?: string;
383
- toolbarBelow?: (table: TanstackTable<TData>) => React.ReactNode;
384
- toolbarEnd?: (table: TanstackTable<TData>) => React.ReactNode;
385
- toolbarStart?: (table: TanstackTable<TData>) => React.ReactNode;
386
- };
387
-
388
- export type DataTableStateSnapshot = {
389
- columnFilters: ColumnFiltersState;
390
- globalFilter: string;
391
- pagination: PaginationState;
392
- sorting: SortingState;
393
- };
394
-
395
- export type DataTableFilterTab = {
396
- count?: number;
397
- label: string;
398
- value: string;
399
- };
400
-
401
- type DataTableObjectFilter = DataTableRangeFilter & DataTableDateRangeFilter;
402
-
403
- function isRecord(value: unknown): value is Record<string, unknown> {
404
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
405
- }
406
-
407
- function readStringProperty(value: unknown, key: string) {
408
- if (!isRecord(value)) {
409
- return undefined;
410
- }
411
-
412
- const property = value[key];
413
- return typeof property === "string" ? property : undefined;
414
- }
415
-
416
- function readStringArrayFilter(value: unknown) {
417
- if (!Array.isArray(value)) {
418
- return [];
419
- }
420
-
421
- return value.filter((item): item is string => typeof item === "string");
422
- }
423
-
424
- export function readMultiFilterValue(value: unknown): {
425
- exclude: boolean;
426
- values: string[];
427
- } {
428
- if (isRecord(value) && value.exclude === true) {
429
- return { exclude: true, values: readStringArrayFilter(value.values) };
430
- }
431
-
432
- return { exclude: false, values: readStringArrayFilter(value) };
433
- }
434
-
435
- function toMultiFilterValue(
436
- exclude: boolean,
437
- values: string[],
438
- ): DataTableMultiFilterValue | undefined {
439
- if (values.length === 0) {
440
- return undefined;
441
- }
442
-
443
- return exclude ? { exclude: true, values } : values;
444
- }
445
-
446
- function isString(value: unknown): value is string {
447
- return typeof value === "string";
448
- }
449
-
450
- function readRangeFilterValue(value: unknown): DataTableRangeFilter {
451
- return {
452
- max: readStringProperty(value, "max"),
453
- min: readStringProperty(value, "min"),
454
- };
455
- }
456
-
457
- function readDateRangeFilterValue(value: unknown): DataTableDateRangeFilter {
458
- return {
459
- from: readStringProperty(value, "from"),
460
- to: readStringProperty(value, "to"),
461
- };
462
- }
463
-
464
- function readObjectFilterValue(value: unknown): Partial<DataTableObjectFilter> {
465
- return {
466
- ...readRangeFilterValue(value),
467
- ...readDateRangeFilterValue(value),
468
- };
469
- }
470
-
471
- export function createArrayIncludesSomeFilter<
472
- TData extends RowData,
473
- >(): FilterFn<TData> {
474
- return (row, columnId, filterValue) => {
475
- const { exclude, values } = readMultiFilterValue(filterValue);
476
-
477
- if (values.length === 0) {
478
- return true;
479
- }
480
-
481
- return values.includes(row.getValue(columnId)) !== exclude;
482
- };
483
- }
484
-
485
- export function createNumberInRangeFilter<
486
- TData extends RowData,
487
- >(): FilterFn<TData> {
488
- return (row, columnId, filterValue) => {
489
- const range = readRangeFilterValue(filterValue);
490
- const min = range?.min ? Number(range.min) : null;
491
- const max = range?.max ? Number(range.max) : null;
492
- const value = Number(row.getValue(columnId));
493
-
494
- if (min !== null && Number.isFinite(min) && value < min) {
495
- return false;
496
- }
497
-
498
- if (max !== null && Number.isFinite(max) && value > max) {
499
- return false;
500
- }
501
-
502
- return true;
503
- };
504
- }
505
-
506
- export function createDateInRangeFilter<
507
- TData extends RowData,
508
- >(): FilterFn<TData> {
509
- return (row, columnId, filterValue) => {
510
- const range = readDateRangeFilterValue(filterValue);
511
- const value = new Date(row.getValue<string>(columnId)).getTime();
512
- const from = range?.from
513
- ? new Date(`${range.from}T00:00:00`).getTime()
514
- : null;
515
- const to = range?.to ? new Date(`${range.to}T23:59:59`).getTime() : null;
516
-
517
- if (from !== null && Number.isFinite(from) && value < from) {
518
- return false;
519
- }
520
-
521
- if (to !== null && Number.isFinite(to) && value > to) {
522
- return false;
523
- }
524
-
525
- return true;
526
- };
527
- }
528
-
529
- export function getSingleArrayFilterValue(
530
- filterValue: unknown,
531
- ): "all" | "custom" | string {
532
- const { exclude, values } = readMultiFilterValue(filterValue);
533
-
534
- if (exclude && values.length > 0) {
535
- return "custom";
536
- }
537
-
538
- if (values.length === 0) {
539
- return "all";
540
- }
541
-
542
- if (values.length === 1) {
543
- return values[0] ?? "all";
544
- }
545
-
546
- return "custom";
547
- }
548
-
549
- export function DataTableColumnFilterTabs<TData extends RowData>({
550
- className,
551
- columnId,
552
- options,
553
- table,
554
- }: {
555
- className?: string;
556
- columnId: string;
557
- options: DataTableFilterTab[];
558
- table: TanstackTable<TData>;
559
- }) {
560
- const column = table.getColumn(columnId);
561
- const value = getSingleArrayFilterValue(column?.getFilterValue());
562
-
563
- const handleValueChange = (nextValue: string) => {
564
- column?.setFilterValue(nextValue === "all" ? undefined : [nextValue]);
565
- };
566
-
567
- return (
568
- <FilterTabs
569
- className={className}
570
- onValueChange={handleValueChange}
571
- options={options}
572
- value={value}
573
- />
574
- );
575
- }
576
-
577
- export function createDataTableFilterTabOptions<TItem>(
578
- items: TItem[],
579
- filters: Array<Pick<DataTableFilterTab, "label" | "value">>,
580
- getItemValue: (item: TItem) => string,
581
- ) {
582
- return filters.map((filter) => ({
583
- ...filter,
584
- count:
585
- filter.value === "all"
586
- ? undefined
587
- : items.filter((item) => getItemValue(item) === filter.value).length,
588
- }));
589
- }
590
-
591
- function getColumnId<TData extends RowData>(
592
- column: ColumnDef<TData, unknown>,
593
- ): string | null {
594
- if ("id" in column && column.id) {
595
- return column.id;
596
- }
597
-
598
- if ("accessorKey" in column && typeof column.accessorKey === "string") {
599
- return column.accessorKey;
600
- }
601
-
602
- return null;
603
- }
604
-
605
- function normalizeColumnOrder(
606
- columnOrder: ColumnOrderState | undefined,
607
- defaultColumnOrder: ColumnOrderState,
608
- lockedStartColumnIds: string[],
609
- lockedEndColumnIds: string[],
610
- ) {
611
- const validColumns = new Set(defaultColumnOrder);
612
- const lockedStart = lockedStartColumnIds.filter((columnId) =>
613
- validColumns.has(columnId),
614
- );
615
- const lockedEnd = lockedEndColumnIds.filter((columnId) =>
616
- validColumns.has(columnId),
617
- );
618
- const lockedColumns = new Set([...lockedStart, ...lockedEnd]);
619
- const movableColumns = (columnOrder ?? []).filter(
620
- (columnId) => validColumns.has(columnId) && !lockedColumns.has(columnId),
621
- );
622
-
623
- for (const columnId of defaultColumnOrder) {
624
- if (!lockedColumns.has(columnId) && !movableColumns.includes(columnId)) {
625
- movableColumns.push(columnId);
626
- }
627
- }
628
-
629
- return [...lockedStart, ...movableColumns, ...lockedEnd];
630
- }
631
-
632
- function areColumnOrdersEqual(left: ColumnOrderState, right: ColumnOrderState) {
633
- return (
634
- left.length === right.length &&
635
- left.every((columnId, index) => columnId === right[index])
636
- );
637
- }
638
-
639
- function getColumnMeta<TData extends RowData>(column: Column<TData, unknown>) {
640
- return column.columnDef.meta;
641
- }
642
-
643
- function getColumnLabel<TData extends RowData>(column: Column<TData, unknown>) {
644
- const header = column.columnDef.header;
645
-
646
- return (
647
- getColumnMeta(column)?.label ??
648
- (typeof header === "string" ? header : column.id)
649
- );
650
- }
651
-
652
- function getColumnAlign<TData extends RowData>(column: Column<TData, unknown>) {
653
- return getColumnMeta(column)?.align ?? "left";
654
- }
655
-
656
- function getColumnAlignClass<TData extends RowData>(
657
- column: Column<TData, unknown>,
658
- ) {
659
- const align = getColumnAlign(column);
660
-
661
- return cn(
662
- align === "center" && "text-center",
663
- align === "right" && "text-right",
664
- );
665
- }
666
-
667
- function getColumnAriaSort(
668
- isSorted: DataTableSortDirection,
669
- ): "ascending" | "descending" | undefined {
670
- if (!isSorted) {
671
- return undefined;
672
- }
673
-
674
- return isSorted === "asc" ? "ascending" : "descending";
675
- }
676
-
677
- function getHeaderControlVisibilityClass(isVisible: boolean) {
678
- if (isVisible) {
679
- return "pointer-events-auto opacity-100";
680
- }
681
-
682
- return "pointer-events-none opacity-0 group-focus-within/header:pointer-events-auto group-focus-within/header:opacity-100 group-hover/header:pointer-events-auto group-hover/header:opacity-100";
683
- }
684
-
685
- function hasRangeFilter(value: unknown) {
686
- const range = readObjectFilterValue(value);
687
- return Boolean(range.min || range.max || range.from || range.to);
688
- }
689
-
690
- function isFilterValueActive(value: unknown) {
691
- if (Array.isArray(value)) {
692
- return value.length > 0;
693
- }
694
-
695
- if (isRecord(value) && value.exclude === true) {
696
- return readMultiFilterValue(value).values.length > 0;
697
- }
698
-
699
- if (typeof value === "object") {
700
- return hasRangeFilter(value);
701
- }
702
-
703
- return Boolean(value);
704
- }
705
-
706
- function setObjectFilterValue<TData extends RowData>(
707
- column: Column<TData, unknown>,
708
- key: keyof DataTableObjectFilter,
709
- value: string,
710
- ) {
711
- const next = {
712
- ...readObjectFilterValue(column.getFilterValue()),
713
- [key]: value,
714
- };
715
-
716
- if (!hasRangeFilter(next)) {
717
- column.setFilterValue(undefined);
718
- return;
719
- }
720
-
721
- column.setFilterValue(next);
722
- }
723
-
724
- function setColumnSorting<TData extends RowData>(
725
- table: TanstackTable<TData>,
726
- columnId: string,
727
- desc: boolean,
728
- ) {
729
- table.setSorting((current) => {
730
- const next = [
731
- ...current.filter((sort) => sort.id !== columnId),
732
- { id: columnId, desc },
733
- ];
734
-
735
- return next.slice(-5);
736
- });
737
- }
738
-
739
- function clearColumnSorting<TData extends RowData>(
740
- table: TanstackTable<TData>,
741
- columnId: string,
742
- ) {
743
- table.setSorting((current) => current.filter((sort) => sort.id !== columnId));
744
- }
745
-
746
- function toggleColumnSorting<TData extends RowData>(
747
- table: TanstackTable<TData>,
748
- columnId: string,
749
- ) {
750
- table.setSorting((current) => {
751
- const currentSort = current.find((sort) => sort.id === columnId);
752
- const next = current.filter((sort) => sort.id !== columnId);
753
-
754
- if (!currentSort) {
755
- return [...next, { id: columnId, desc: false }].slice(-5);
756
- }
757
-
758
- if (!currentSort.desc) {
759
- return [...next, { id: columnId, desc: true }].slice(-5);
760
- }
761
-
762
- return next;
763
- });
764
- }
765
-
766
- function isColumnOrderState(value: unknown): value is ColumnOrderState {
767
- return (
768
- Array.isArray(value) &&
769
- value.every((columnId) => typeof columnId === "string")
770
- );
771
- }
772
-
773
- function isColumnFiltersState(value: unknown): value is ColumnFiltersState {
774
- return (
775
- Array.isArray(value) &&
776
- value.every((filter) => isRecord(filter) && typeof filter.id === "string")
777
- );
778
- }
779
-
780
- function isVisibilityState(value: unknown): value is VisibilityState {
781
- return (
782
- isRecord(value) &&
783
- Object.values(value).every((visible) => typeof visible === "boolean")
784
- );
785
- }
786
-
787
- function isSortingState(value: unknown): value is SortingState {
788
- return (
789
- Array.isArray(value) &&
790
- value.every(
791
- (sort) =>
792
- isRecord(sort) &&
793
- typeof sort.id === "string" &&
794
- typeof sort.desc === "boolean",
795
- )
796
- );
797
- }
798
-
799
- function parseStoredDataTableSettings(
800
- value: string,
801
- ): StoredDataTableSettings | null {
802
- const parsed = JSON.parse(value);
803
-
804
- if (!isRecord(parsed)) {
805
- return null;
806
- }
807
-
808
- const settings: StoredDataTableSettings = {};
809
-
810
- if (isColumnFiltersState(parsed.columnFilters)) {
811
- settings.columnFilters = parsed.columnFilters;
812
- }
813
-
814
- if (isColumnOrderState(parsed.columnOrder)) {
815
- settings.columnOrder = parsed.columnOrder;
816
- }
817
-
818
- if (isVisibilityState(parsed.columnVisibility)) {
819
- settings.columnVisibility = parsed.columnVisibility;
820
- }
821
-
822
- if (typeof parsed.globalFilter === "string") {
823
- settings.globalFilter = parsed.globalFilter;
824
- }
825
-
826
- if (typeof parsed.pageSize === "number" && Number.isFinite(parsed.pageSize)) {
827
- settings.pageSize = parsed.pageSize;
828
- }
829
-
830
- if (isSortingState(parsed.sorting)) {
831
- settings.sorting = parsed.sorting;
832
- }
833
-
834
- return settings;
835
- }
836
-
837
- function getStoredDataTableSettings(storageKey: string) {
838
- if (typeof window === "undefined") {
839
- return null;
840
- }
841
-
842
- try {
843
- const value = window.localStorage.getItem(storageKey);
844
- return value ? parseStoredDataTableSettings(value) : null;
845
- } catch {
846
- return null;
847
- }
848
- }
849
-
850
- function pickKnownColumnEntries<TValue>(
851
- record: Record<string, TValue> | undefined,
852
- columnIds: string[],
853
- ) {
854
- if (!record) {
855
- return undefined;
856
- }
857
-
858
- return Object.fromEntries(
859
- Object.entries(record).filter(([columnId]) => columnIds.includes(columnId)),
860
- );
861
- }
862
-
863
- function retainKnownColumnSettings(
864
- settings: StoredDataTableSettings | null,
865
- columnIds: string[],
866
- ): StoredDataTableSettings | null {
867
- if (!settings) {
868
- return null;
869
- }
870
-
871
- const sorting = settings.sorting?.filter((sort) =>
872
- columnIds.includes(sort.id),
873
- );
874
-
875
- return {
876
- ...settings,
877
- columnFilters: settings.columnFilters?.filter((filter) =>
878
- columnIds.includes(filter.id),
879
- ),
880
- columnSizing: pickKnownColumnEntries(settings.columnSizing, columnIds),
881
- columnVisibility: pickKnownColumnEntries(
882
- settings.columnVisibility,
883
- columnIds,
884
- ),
885
- sorting: sorting?.length ? sorting : undefined,
886
- };
887
- }
888
-
889
- function getResetFiltersStorageKey(event: Event) {
890
- if (!(event instanceof CustomEvent)) {
891
- return null;
892
- }
893
-
894
- const detail = event.detail;
895
- if (!isRecord(detail)) {
896
- return null;
897
- }
898
-
899
- const storageKey = detail.storageKey;
900
- return typeof storageKey === "string" ? storageKey : null;
901
- }
902
-
903
- function isLockedColumn(
904
- columnId: string,
905
- lockedStartColumnIds: string[],
906
- lockedEndColumnIds: string[],
907
- ) {
908
- return (
909
- lockedStartColumnIds.includes(columnId) ||
910
- lockedEndColumnIds.includes(columnId)
911
- );
912
- }
913
-
914
- function getStorageSafeId(storageKey: string) {
915
- return storageKey.replace(/[^a-zA-Z0-9_-]/g, "-");
916
- }
917
-
918
- function getOptionalColumnOrderKey(columnOrder?: ColumnOrderState) {
919
- return columnOrder?.join("|") ?? "";
920
- }
921
-
922
- function getColumnFiltersKey(columnFilters: ColumnFiltersState) {
923
- return JSON.stringify(columnFilters);
924
- }
925
-
926
- function getVisibilityStateKey(visibility: VisibilityState) {
927
- const entries = Object.entries(visibility).sort(([left], [right]) =>
928
- left.localeCompare(right),
929
- );
930
-
931
- return JSON.stringify(entries);
932
- }
933
-
934
- function getSortingKey(sorting: SortingState) {
935
- return JSON.stringify(sorting);
936
- }
937
-
938
- function useStableValueByKey<TValue>(value: TValue, key: string) {
939
- const [stored, setStored] = React.useState({ key, value });
940
-
941
- React.useEffect(() => {
942
- setStored((current) => (current.key === key ? current : { key, value }));
943
- }, [key, value]);
944
-
945
- return stored.value;
946
- }
947
-
948
- function useDataTableInstance<TData extends RowData>(
949
- options: TableOptions<TData>,
950
- ) {
951
- const resolvedOptions: TableOptionsResolved<TData> = {
952
- onStateChange: () => undefined,
953
- renderFallbackValue: null,
954
- state: {},
955
- ...options,
956
- };
957
-
958
- const [table] = React.useState(() => createTable<TData>(resolvedOptions));
959
- const [state, setState] = React.useState(() => table.initialState);
960
-
961
- table.setOptions((previous) => ({
962
- ...previous,
963
- ...options,
964
- onStateChange: (updater) => {
965
- setState(updater);
966
- options.onStateChange?.(updater);
967
- },
968
- state: {
969
- ...state,
970
- ...options.state,
971
- },
972
- }));
973
-
974
- return table;
975
- }
976
-
977
- const emptyColumnFilters: ColumnFiltersState = [];
978
- const emptyColumnSizing: ColumnSizingState = {};
979
- const emptyColumnVisibility: VisibilityState = {};
980
- const emptySorting: SortingState = [];
981
- const emptyLockedColumnIds: string[] = [];
982
- const defaultPageSizeOptions = [10, 20, 30, 50, 100];
983
-
984
- function resolvePageSize(
985
- pageSize: number | undefined,
986
- fallbackPageSize: number,
987
- pageSizeOptions: number[],
988
- ) {
989
- if (typeof pageSize === "number" && pageSizeOptions.includes(pageSize)) {
990
- return pageSize;
991
- }
992
-
993
- if (pageSizeOptions.includes(fallbackPageSize)) {
994
- return fallbackPageSize;
995
- }
996
-
997
- return pageSizeOptions[0] ?? fallbackPageSize;
998
- }
999
-
1000
- export function DataTable<TData extends RowData>(props: DataTableProps<TData>) {
1001
- const {
1002
- activeRowId,
1003
- columns,
1004
- data,
1005
- emptyMessage,
1006
- emptyState,
1007
- fillHeight = false,
1008
- loading = false,
1009
- error,
1010
- onRetry,
1011
- warning,
1012
- unavailable,
1013
- enablePersistence: enablePersistenceProp,
1014
- enableRowSelection = false,
1015
- filterFns,
1016
- formatResultCount: formatResultCountProp,
1017
- formatRowCount: formatRowCountProp,
1018
- formatSelectedRowCount,
1019
- getRowId,
1020
- globalFilterFn,
1021
- initialColumnFilters = emptyColumnFilters,
1022
- initialGlobalFilter = "",
1023
- onRowClick,
1024
- getRowLabel,
1025
- initialColumnOrder,
1026
- initialColumnVisibility = emptyColumnVisibility,
1027
- initialPageSize = 10,
1028
- initialSorting = emptySorting,
1029
- lockedEndColumnIds = emptyLockedColumnIds,
1030
- lockedStartColumnIds = emptyLockedColumnIds,
1031
- manualFiltering = false,
1032
- enableExcludeFilters,
1033
- manualPagination = false,
1034
- manualSorting = false,
1035
- onStateChange,
1036
- pageCount,
1037
- pageSizeOptions = defaultPageSizeOptions,
1038
- rowCount,
1039
- rowSelectionActions,
1040
- searchPlaceholder,
1041
- storageKey: storageKeyProp,
1042
- toolbarBelow,
1043
- toolbarEnd,
1044
- toolbarStart,
1045
- } = props;
1046
- const t = useMessages(dataTableMessages);
1047
- const ui = useUiMessages();
1048
- // Persistence needs a key; without one the table keeps state in memory only.
1049
- const storageKey = storageKeyProp ?? "";
1050
- const enablePersistence =
1051
- Boolean(storageKeyProp) && enablePersistenceProp !== false;
1052
- const formatResultCount = formatResultCountProp ?? t.resultCount;
1053
- const formatRowCount = formatRowCountProp ?? t.rowCount;
1054
-
1055
- const defaultColumnOrder = React.useMemo(
1056
- () => columns.map(getColumnId).filter(isString),
1057
- [columns],
1058
- );
1059
- const defaultColumnOrderKey = defaultColumnOrder.join("|");
1060
- const pageSizeOptionsKey = pageSizeOptions.join("|");
1061
- const lockedStartColumnKey = lockedStartColumnIds.join("|");
1062
- const lockedEndColumnKey = lockedEndColumnIds.join("|");
1063
- const stablePageSizeOptions = useStableValueByKey(
1064
- pageSizeOptions,
1065
- pageSizeOptionsKey,
1066
- );
1067
- const stableDefaultColumnOrder = useStableValueByKey(
1068
- defaultColumnOrder,
1069
- defaultColumnOrderKey,
1070
- );
1071
- const stableInitialColumnFilters = useStableValueByKey(
1072
- initialColumnFilters,
1073
- getColumnFiltersKey(initialColumnFilters),
1074
- );
1075
- const stableInitialColumnOrder = useStableValueByKey(
1076
- initialColumnOrder,
1077
- getOptionalColumnOrderKey(initialColumnOrder),
1078
- );
1079
- const stableInitialColumnVisibility = useStableValueByKey(
1080
- initialColumnVisibility,
1081
- getVisibilityStateKey(initialColumnVisibility),
1082
- );
1083
- const stableInitialSorting = useStableValueByKey(
1084
- initialSorting,
1085
- getSortingKey(initialSorting),
1086
- );
1087
- const stableLockedStartColumnIds = useStableValueByKey(
1088
- lockedStartColumnIds,
1089
- lockedStartColumnKey,
1090
- );
1091
- const stableLockedEndColumnIds = useStableValueByKey(
1092
- lockedEndColumnIds,
1093
- lockedEndColumnKey,
1094
- );
1095
- const [preferencesReady, setPreferencesReady] =
1096
- React.useState(!enablePersistence);
1097
- const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
1098
- const [columnVisibility, setColumnVisibility] =
1099
- React.useState<VisibilityState>(stableInitialColumnVisibility);
1100
- const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(() =>
1101
- normalizeColumnOrder(
1102
- stableInitialColumnOrder ?? stableDefaultColumnOrder,
1103
- stableDefaultColumnOrder,
1104
- stableLockedStartColumnIds,
1105
- stableLockedEndColumnIds,
1106
- ),
1107
- );
1108
- const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
1109
- stableInitialColumnFilters,
1110
- );
1111
- const [columnSizing, setColumnSizing] =
1112
- React.useState<ColumnSizingState>(emptyColumnSizing);
1113
- const [sorting, setSorting] =
1114
- React.useState<SortingState>(stableInitialSorting);
1115
- const [globalFilter, setGlobalFilter] = React.useState(initialGlobalFilter);
1116
- const [pagination, setPagination] = React.useState<PaginationState>({
1117
- pageIndex: 0,
1118
- pageSize: resolvePageSize(
1119
- initialPageSize,
1120
- initialPageSize,
1121
- stablePageSizeOptions,
1122
- ),
1123
- });
1124
- const resetPaginationPage = React.useCallback(() => {
1125
- setPagination((current) =>
1126
- current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
1127
- );
1128
- }, []);
1129
- const handleColumnFiltersChange = React.useCallback<
1130
- OnChangeFn<ColumnFiltersState>
1131
- >(
1132
- (updater) => {
1133
- setColumnFilters(updater);
1134
- resetPaginationPage();
1135
- },
1136
- [resetPaginationPage],
1137
- );
1138
- const handleGlobalFilterChange = React.useCallback<OnChangeFn<string>>(
1139
- (updater) => {
1140
- setGlobalFilter(updater);
1141
- resetPaginationPage();
1142
- },
1143
- [resetPaginationPage],
1144
- );
1145
- const handleSortingChange = React.useCallback<OnChangeFn<SortingState>>(
1146
- (updater) => {
1147
- setSorting(updater);
1148
- resetPaginationPage();
1149
- },
1150
- [resetPaginationPage],
1151
- );
1152
-
1153
- const dndContextId = React.useId();
1154
- const sensors = useSensors(
1155
- useSensor(MouseSensor, {
1156
- activationConstraint: {
1157
- distance: 6,
1158
- },
1159
- }),
1160
- useSensor(TouchSensor, {
1161
- activationConstraint: {
1162
- delay: 150,
1163
- tolerance: 6,
1164
- },
1165
- }),
1166
- useSensor(KeyboardSensor),
1167
- );
1168
-
1169
- React.useEffect(() => {
1170
- if (!enablePersistence) {
1171
- return;
1172
- }
1173
-
1174
- const settings = retainKnownColumnSettings(
1175
- getStoredDataTableSettings(storageKey),
1176
- stableDefaultColumnOrder,
1177
- );
1178
-
1179
- setColumnVisibility(
1180
- settings?.columnVisibility ?? stableInitialColumnVisibility,
1181
- );
1182
- setColumnOrder(
1183
- normalizeColumnOrder(
1184
- settings?.columnOrder ??
1185
- stableInitialColumnOrder ??
1186
- stableDefaultColumnOrder,
1187
- stableDefaultColumnOrder,
1188
- stableLockedStartColumnIds,
1189
- stableLockedEndColumnIds,
1190
- ),
1191
- );
1192
- setColumnFilters(settings?.columnFilters ?? stableInitialColumnFilters);
1193
- setColumnSizing(settings?.columnSizing ?? emptyColumnSizing);
1194
- setSorting(settings?.sorting ?? stableInitialSorting);
1195
- setGlobalFilter(settings?.globalFilter ?? "");
1196
- setPagination({
1197
- pageIndex: 0,
1198
- pageSize: resolvePageSize(
1199
- settings?.pageSize,
1200
- initialPageSize,
1201
- stablePageSizeOptions,
1202
- ),
1203
- });
1204
- setPreferencesReady(true);
1205
- }, [
1206
- enablePersistence,
1207
- initialPageSize,
1208
- stableDefaultColumnOrder,
1209
- stableInitialColumnFilters,
1210
- stableInitialColumnOrder,
1211
- stableInitialColumnVisibility,
1212
- stableInitialSorting,
1213
- stableLockedEndColumnIds,
1214
- stableLockedStartColumnIds,
1215
- stablePageSizeOptions,
1216
- storageKey,
1217
- ]);
1218
-
1219
- React.useEffect(() => {
1220
- if (!preferencesReady) {
1221
- return;
1222
- }
1223
-
1224
- setColumnOrder((currentOrder) =>
1225
- normalizeColumnOrder(
1226
- currentOrder,
1227
- stableDefaultColumnOrder,
1228
- stableLockedStartColumnIds,
1229
- stableLockedEndColumnIds,
1230
- ),
1231
- );
1232
- }, [
1233
- preferencesReady,
1234
- stableDefaultColumnOrder,
1235
- stableLockedEndColumnIds,
1236
- stableLockedStartColumnIds,
1237
- ]);
1238
-
1239
- React.useEffect(() => {
1240
- if (!enablePersistence || !preferencesReady) {
1241
- return;
1242
- }
1243
-
1244
- window.localStorage.setItem(
1245
- storageKey,
1246
- JSON.stringify({
1247
- columnFilters,
1248
- columnOrder,
1249
- columnSizing,
1250
- columnVisibility,
1251
- globalFilter,
1252
- pageSize: pagination.pageSize,
1253
- sorting,
1254
- } satisfies StoredDataTableSettings),
1255
- );
1256
- }, [
1257
- columnFilters,
1258
- columnOrder,
1259
- columnSizing,
1260
- columnVisibility,
1261
- enablePersistence,
1262
- globalFilter,
1263
- pagination.pageSize,
1264
- preferencesReady,
1265
- sorting,
1266
- storageKey,
1267
- ]);
1268
-
1269
- React.useEffect(() => {
1270
- if (!preferencesReady) {
1271
- return;
1272
- }
1273
-
1274
- onStateChange?.({
1275
- columnFilters,
1276
- globalFilter,
1277
- pagination,
1278
- sorting,
1279
- });
1280
- }, [
1281
- columnFilters,
1282
- globalFilter,
1283
- onStateChange,
1284
- pagination,
1285
- preferencesReady,
1286
- sorting,
1287
- ]);
1288
-
1289
- const table = useDataTableInstance<TData>({
1290
- autoResetPageIndex: !manualPagination,
1291
- columnResizeMode: "onChange",
1292
- columns,
1293
- data,
1294
- enableColumnResizing: true,
1295
- enableMultiSort: true,
1296
- enableRowSelection,
1297
- filterFns,
1298
- getCoreRowModel: getCoreRowModel(),
1299
- getFilteredRowModel: getFilteredRowModel(),
1300
- getPaginationRowModel: getPaginationRowModel(),
1301
- getRowId,
1302
- getSortedRowModel: getSortedRowModel(),
1303
- // An explicit undefined would replace TanStack's default and disable search.
1304
- globalFilterFn: globalFilterFn ?? "includesString",
1305
- isMultiSortEvent: () => true,
1306
- manualFiltering,
1307
- manualPagination,
1308
- manualSorting,
1309
- maxMultiSortColCount: 5,
1310
- meta: {
1311
- enableExcludeFilters:
1312
- enableExcludeFilters ?? (!manualFiltering && !onStateChange),
1313
- },
1314
- onColumnFiltersChange: handleColumnFiltersChange,
1315
- onColumnOrderChange: setColumnOrder,
1316
- onColumnSizingChange: setColumnSizing,
1317
- onColumnVisibilityChange: setColumnVisibility,
1318
- onGlobalFilterChange: handleGlobalFilterChange,
1319
- onPaginationChange: setPagination,
1320
- onRowSelectionChange: setRowSelection,
1321
- onSortingChange: handleSortingChange,
1322
- pageCount,
1323
- rowCount,
1324
- state: {
1325
- columnFilters,
1326
- columnOrder,
1327
- columnSizing,
1328
- columnVisibility,
1329
- globalFilter,
1330
- pagination,
1331
- rowSelection,
1332
- sorting,
1333
- },
1334
- });
1335
-
1336
- const handleDragEnd = (event: DragEndEvent) => {
1337
- const { active, over } = event;
1338
-
1339
- if (!over || active.id === over.id) {
1340
- return;
1341
- }
1342
-
1343
- if (
1344
- isLockedColumn(
1345
- String(active.id),
1346
- stableLockedStartColumnIds,
1347
- stableLockedEndColumnIds,
1348
- )
1349
- ) {
1350
- return;
1351
- }
1352
-
1353
- setColumnOrder((currentOrder) => {
1354
- const oldIndex = currentOrder.indexOf(String(active.id));
1355
- const newIndex = currentOrder.indexOf(String(over.id));
1356
-
1357
- if (oldIndex === -1 || newIndex === -1) {
1358
- return currentOrder;
1359
- }
1360
-
1361
- return normalizeColumnOrder(
1362
- arrayMove(currentOrder, oldIndex, newIndex),
1363
- stableDefaultColumnOrder,
1364
- stableLockedStartColumnIds,
1365
- stableLockedEndColumnIds,
1366
- );
1367
- });
1368
- };
1369
-
1370
- const resetColumns = () => {
1371
- setColumnVisibility(stableInitialColumnVisibility);
1372
- setColumnOrder(
1373
- normalizeColumnOrder(
1374
- stableInitialColumnOrder ?? stableDefaultColumnOrder,
1375
- stableDefaultColumnOrder,
1376
- stableLockedStartColumnIds,
1377
- stableLockedEndColumnIds,
1378
- ),
1379
- );
1380
- setColumnSizing(emptyColumnSizing);
1381
- };
1382
-
1383
- const resetFilters = React.useCallback(() => {
1384
- setColumnFilters(stableInitialColumnFilters);
1385
- setGlobalFilter("");
1386
- setPagination((current) => ({ ...current, pageIndex: 0 }));
1387
- }, [stableInitialColumnFilters]);
1388
-
1389
- React.useEffect(() => {
1390
- const handleResetFilters = (event: Event) => {
1391
- const resetStorageKey = getResetFiltersStorageKey(event);
1392
-
1393
- if (resetStorageKey && resetStorageKey !== storageKey) {
1394
- return;
1395
- }
1396
-
1397
- resetFilters();
1398
- };
1399
-
1400
- window.addEventListener(DATA_TABLE_RESET_FILTERS_EVENT, handleResetFilters);
1401
-
1402
- return () => {
1403
- window.removeEventListener(
1404
- DATA_TABLE_RESET_FILTERS_EVENT,
1405
- handleResetFilters,
1406
- );
1407
- };
1408
- }, [resetFilters, storageKey]);
1409
-
1410
- if (!preferencesReady) {
1411
- return <DataTableSkeleton />;
1412
- }
1413
-
1414
- const retry = onRetry ? (
1415
- <Button variant="outline" size="sm" onClick={onRetry}>
1416
- <RotateCwIcon data-icon="inline-start" />
1417
- {ui.retry}
1418
- </Button>
1419
- ) : null;
1420
-
1421
- if (unavailable) {
1422
- return (
1423
- <Empty className="rounded-3xl border border-dashed">
1424
- <EmptyHeader>
1425
- <EmptyMedia variant="icon">
1426
- <LockIcon />
1427
- </EmptyMedia>
1428
- <EmptyTitle>{unavailable}</EmptyTitle>
1429
- </EmptyHeader>
1430
- </Empty>
1431
- );
1432
- }
1433
-
1434
- if (error && !data.length && !loading) {
1435
- return (
1436
- <Empty className="rounded-3xl border border-dashed">
1437
- <EmptyHeader>
1438
- <EmptyMedia variant="icon">
1439
- <CircleAlertIcon />
1440
- </EmptyMedia>
1441
- <EmptyTitle>{ui.loadFailed}</EmptyTitle>
1442
- <EmptyDescription>{error}</EmptyDescription>
1443
- </EmptyHeader>
1444
- {retry ? <EmptyContent>{retry}</EmptyContent> : null}
1445
- </Empty>
1446
- );
1447
- }
1448
-
1449
- const isFiltered = Boolean(globalFilter) || columnFilters.length > 0;
1450
-
1451
- const filteredRowCount = rowCount ?? table.getFilteredRowModel().rows.length;
1452
- const selectedRowCount = table.getFilteredSelectedRowModel().rows.length;
1453
- const selectedRowsLabel =
1454
- formatSelectedRowCount ??
1455
- ((count: number) => t.selected(formatRowCount(count), count));
1456
- const toolbarStartContent = toolbarStart?.(table);
1457
- const toolbarEndContent = toolbarEnd?.(table);
1458
- const toolbarBelowContent = toolbarBelow?.(table);
1459
- const rowSelectionActionsContent = rowSelectionActions?.(table);
1460
-
1461
- return (
1462
- <div
1463
- className={cn(
1464
- "flex w-full min-w-0 flex-col gap-4",
1465
- fillHeight && "min-h-0 flex-1",
1466
- )}
1467
- >
1468
- <div className="flex min-w-0 flex-wrap items-center gap-2">
1469
- {toolbarStartContent}
1470
- <div className="flex min-w-0 flex-1 basis-80 items-center gap-2">
1471
- <InputGroup className="flex-1">
1472
- <InputGroupInput
1473
- value={globalFilter}
1474
- onChange={(event) => handleGlobalFilterChange(event.target.value)}
1475
- placeholder={searchPlaceholder ?? t.search}
1476
- />
1477
- <InputGroupAddon className="pl-2">
1478
- <SearchIcon />
1479
- </InputGroupAddon>
1480
- <InputGroupAddon align="inline-end" className="pr-2">
1481
- {formatResultCount(filteredRowCount)}
1482
- </InputGroupAddon>
1483
- </InputGroup>
1484
- {toolbarEndContent}
1485
- <ColumnSettings
1486
- defaultColumnOrder={defaultColumnOrder}
1487
- lockedEndColumnIds={lockedEndColumnIds}
1488
- lockedStartColumnIds={lockedStartColumnIds}
1489
- onResetColumns={resetColumns}
1490
- table={table}
1491
- />
1492
- </div>
1493
- </div>
1494
-
1495
- {toolbarBelowContent}
1496
-
1497
- {error ? (
1498
- <Alert variant="destructive">
1499
- <CircleAlertIcon />
1500
- <AlertTitle>{ui.staleData}</AlertTitle>
1501
- <AlertDescription>{error}</AlertDescription>
1502
- {retry ? <AlertAction>{retry}</AlertAction> : null}
1503
- </Alert>
1504
- ) : null}
1505
- {warning ? (
1506
- <Alert>
1507
- <TriangleAlertIcon />
1508
- <AlertTitle>{warning}</AlertTitle>
1509
- </Alert>
1510
- ) : null}
1511
-
1512
- {enableRowSelection && selectedRowCount > 0 && (
1513
- <div className="bg-muted/40 flex flex-col gap-2 rounded-2xl border p-2 sm:flex-row sm:items-center sm:justify-between">
1514
- <p className="text-foreground px-1 text-sm font-medium">
1515
- {selectedRowsLabel(selectedRowCount)}
1516
- </p>
1517
- <div className="flex flex-wrap items-center gap-2">
1518
- {rowSelectionActionsContent}
1519
- <Button
1520
- type="button"
1521
- variant="outline"
1522
- size="sm"
1523
- onClick={() => table.resetRowSelection()}
1524
- >
1525
- <XIcon data-icon="inline-start" />
1526
- {t.clearSelection}
1527
- </Button>
1528
- </div>
1529
- </div>
1530
- )}
1531
-
1532
- <DndContext
1533
- // Stable id: dnd-kit's global counter differs between server and client.
1534
- id={dndContextId}
1535
- collisionDetection={closestCenter}
1536
- modifiers={[restrictToHorizontalAxis]}
1537
- onDragEnd={handleDragEnd}
1538
- sensors={sensors}
1539
- >
1540
- <div
1541
- className={cn(
1542
- "bg-card overflow-hidden rounded-2xl border",
1543
- fillHeight && "flex min-h-0 flex-1 flex-col",
1544
- )}
1545
- >
1546
- <Table
1547
- containerClassName={
1548
- // Bottom-only fade: a top fade would dissolve the sticky header.
1549
- fillHeight
1550
- ? "min-h-0 flex-1 overflow-auto scroll-fade-b"
1551
- : undefined
1552
- }
1553
- style={{ minWidth: "100%", width: table.getTotalSize() }}
1554
- >
1555
- {/* Solid background: rows scroll underneath the sticky header. */}
1556
- <TableHeader className="bg-muted sticky top-0 z-10">
1557
- {table.getHeaderGroups().map((headerGroup) => (
1558
- <TableRow key={headerGroup.id}>
1559
- <SortableContext
1560
- items={columnOrder}
1561
- strategy={horizontalListSortingStrategy}
1562
- >
1563
- {headerGroup.headers.map((header) => (
1564
- <DataTableHead
1565
- key={header.id}
1566
- header={header}
1567
- lockedEndColumnIds={lockedEndColumnIds}
1568
- lockedStartColumnIds={lockedStartColumnIds}
1569
- />
1570
- ))}
1571
- </SortableContext>
1572
- </TableRow>
1573
- ))}
1574
- </TableHeader>
1575
- <TableBody>
1576
- {loading ? (
1577
- Array.from({ length: 3 }, (_, index) => (
1578
- <TableRow key={index}>
1579
- {table.getVisibleLeafColumns().map((column) => (
1580
- <TableCell key={column.id}>
1581
- <Skeleton className="h-5 w-full" />
1582
- </TableCell>
1583
- ))}
1584
- </TableRow>
1585
- ))
1586
- ) : table.getRowModel().rows?.length ? (
1587
- table.getRowModel().rows.map((row) => (
1588
- <TableRow
1589
- key={row.id}
1590
- data-active={row.id === activeRowId || undefined}
1591
- aria-current={row.id === activeRowId ? "true" : undefined}
1592
- data-state={row.getIsSelected() && "selected"}
1593
- className={onRowClick ? "cursor-pointer" : undefined}
1594
- tabIndex={onRowClick ? 0 : undefined}
1595
- aria-label={
1596
- onRowClick
1597
- ? `${t.open}: ${getRowLabel?.(row.original) ?? row.id}`
1598
- : undefined
1599
- }
1600
- onClick={
1601
- onRowClick ? () => onRowClick(row.original) : undefined
1602
- }
1603
- onKeyDown={
1604
- onRowClick
1605
- ? (event) => {
1606
- if (event.key === "Enter" || event.key === " ") {
1607
- event.preventDefault();
1608
- onRowClick(row.original);
1609
- }
1610
- }
1611
- : undefined
1612
- }
1613
- >
1614
- {row.getVisibleCells().map((cell) => (
1615
- <TableCell
1616
- key={cell.id}
1617
- className={cn(
1618
- "whitespace-nowrap",
1619
- getColumnAlignClass(cell.column),
1620
- getColumnMeta(cell.column)?.cellClassName,
1621
- )}
1622
- style={{ width: cell.column.getSize() }}
1623
- >
1624
- {flexRender(
1625
- cell.column.columnDef.cell,
1626
- cell.getContext(),
1627
- )}
1628
- </TableCell>
1629
- ))}
1630
- </TableRow>
1631
- ))
1632
- ) : (
1633
- <TableRow>
1634
- <TableCell
1635
- colSpan={table.getVisibleLeafColumns().length}
1636
- className="text-muted-foreground h-24 text-center"
1637
- >
1638
- {isFiltered ? (
1639
- <Empty>
1640
- <EmptyHeader>
1641
- <EmptyTitle>
1642
- {emptyMessage ?? ui.noResults}
1643
- </EmptyTitle>
1644
- </EmptyHeader>
1645
- <EmptyContent>
1646
- <Button
1647
- variant="outline"
1648
- size="sm"
1649
- onClick={resetFilters}
1650
- >
1651
- {t.clearFilters}
1652
- </Button>
1653
- </EmptyContent>
1654
- </Empty>
1655
- ) : (
1656
- (emptyState ?? (
1657
- <Empty>
1658
- <EmptyHeader>
1659
- <EmptyMedia variant="icon">
1660
- <InboxIcon />
1661
- </EmptyMedia>
1662
- <EmptyTitle>{emptyMessage ?? ui.noData}</EmptyTitle>
1663
- </EmptyHeader>
1664
- </Empty>
1665
- ))
1666
- )}
1667
- </TableCell>
1668
- </TableRow>
1669
- )}
1670
- </TableBody>
1671
- </Table>
1672
- </div>
1673
- </DndContext>
1674
-
1675
- <DataTablePagination
1676
- formatRowCount={formatRowCount}
1677
- formatSelectedRowCount={formatSelectedRowCount}
1678
- pageSizeOptions={stablePageSizeOptions}
1679
- rowCount={rowCount}
1680
- storageKey={storageKey}
1681
- table={table}
1682
- />
1683
- </div>
1684
- );
1685
- }
1686
-
1687
- function DataTableHead<TData extends RowData>({
1688
- header,
1689
- lockedEndColumnIds,
1690
- lockedStartColumnIds,
1691
- }: {
1692
- header: Header<TData, unknown>;
1693
- lockedEndColumnIds: string[];
1694
- lockedStartColumnIds: string[];
1695
- }) {
1696
- const t = useMessages(dataTableMessages);
1697
- const column = header.column;
1698
- const table = header.getContext().table;
1699
- const align = getColumnAlign(column);
1700
- const isSorted = column.getIsSorted();
1701
- const sortIndex = table
1702
- .getState()
1703
- .sorting.findIndex((sort) => sort.id === column.id);
1704
- const isLocked = isLockedColumn(
1705
- column.id,
1706
- lockedStartColumnIds,
1707
- lockedEndColumnIds,
1708
- );
1709
- const canOpenColumnMenu = !header.isPlaceholder && !isLocked;
1710
- const {
1711
- attributes,
1712
- isDragging,
1713
- listeners,
1714
- setNodeRef,
1715
- transform,
1716
- transition,
1717
- } = useSortable({
1718
- id: column.id,
1719
- disabled: isLocked,
1720
- });
1721
- const [menuOpen, setMenuOpen] = React.useState(false);
1722
-
1723
- const columnControls =
1724
- column.getCanSort() || !isLocked ? (
1725
- <div
1726
- className={cn(
1727
- "flex shrink-0 items-center gap-1",
1728
- align === "right" ? "order-first mr-1" : "ml-1",
1729
- )}
1730
- >
1731
- {column.getCanSort() && (
1732
- <Button
1733
- type="button"
1734
- variant="ghost"
1735
- size="icon-xs"
1736
- className={cn(
1737
- "transition-opacity",
1738
- isSorted ? "text-foreground bg-accent" : "text-muted-foreground",
1739
- getHeaderControlVisibilityClass(Boolean(isSorted)),
1740
- )}
1741
- onClick={() => toggleColumnSorting(table, column.id)}
1742
- aria-label={t.toggleSort(getColumnLabel(column))}
1743
- >
1744
- <ColumnSortIcon isSorted={isSorted} />
1745
- </Button>
1746
- )}
1747
- {sortIndex >= 0 && table.getState().sorting.length > 1 && (
1748
- <Badge
1749
- variant="secondary"
1750
- className="px-1"
1751
- title={t.sortCriterionTitle(sortIndex + 1)}
1752
- >
1753
- {sortIndex + 1}
1754
- </Badge>
1755
- )}
1756
- {!isLocked && (
1757
- <Button
1758
- type="button"
1759
- variant="ghost"
1760
- size="icon-xs"
1761
- className={cn(
1762
- "text-muted-foreground cursor-grab transition-opacity active:cursor-grabbing",
1763
- getHeaderControlVisibilityClass(isDragging),
1764
- )}
1765
- aria-label={t.reorderColumn(getColumnLabel(column))}
1766
- {...attributes}
1767
- {...listeners}
1768
- >
1769
- <GripVerticalIcon />
1770
- </Button>
1771
- )}
1772
- </div>
1773
- ) : null;
1774
-
1775
- return (
1776
- <TableHead
1777
- ref={setNodeRef}
1778
- colSpan={header.colSpan}
1779
- aria-sort={getColumnAriaSort(isSorted)}
1780
- className={cn(
1781
- "relative whitespace-nowrap",
1782
- getColumnAlignClass(column),
1783
- getColumnMeta(column)?.headerClassName,
1784
- isDragging && "bg-muted relative z-20 opacity-80",
1785
- )}
1786
- style={{
1787
- transform: CSS.Translate.toString(transform),
1788
- transition,
1789
- width: header.getSize(),
1790
- }}
1791
- >
1792
- {header.isPlaceholder ? null : (
1793
- <div
1794
- className={cn(
1795
- "group/header flex w-full items-center",
1796
- align === "center" && "justify-center",
1797
- align === "right" && "justify-end",
1798
- )}
1799
- >
1800
- <div className="flex min-w-0 flex-1 items-center">
1801
- {canOpenColumnMenu ? (
1802
- <ColumnHeaderMenu
1803
- header={header}
1804
- open={menuOpen}
1805
- onOpenChange={setMenuOpen}
1806
- />
1807
- ) : (
1808
- <div className="flex h-8 items-center px-0">
1809
- {flexRender(column.columnDef.header, header.getContext())}
1810
- </div>
1811
- )}
1812
- </div>
1813
- {columnControls}
1814
- </div>
1815
- )}
1816
- {column.getCanResize() && !isLocked ? (
1817
- <div
1818
- role="separator"
1819
- aria-orientation="vertical"
1820
- aria-label={t.resizeColumn(getColumnLabel(column))}
1821
- className={cn(
1822
- "hover:bg-primary/50 absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize touch-none bg-transparent select-none",
1823
- column.getIsResizing() && "bg-primary",
1824
- )}
1825
- onDoubleClick={() => column.resetSize()}
1826
- onMouseDown={header.getResizeHandler()}
1827
- onTouchStart={header.getResizeHandler()}
1828
- />
1829
- ) : null}
1830
- </TableHead>
1831
- );
1832
- }
1833
-
1834
- function ColumnHeaderMenuTrigger<TData extends RowData>({
1835
- className,
1836
- header,
1837
- }: {
1838
- className?: string;
1839
- header: Header<TData, unknown>;
1840
- }) {
1841
- const t = useMessages(dataTableMessages);
1842
- const column = header.column;
1843
- const align = getColumnAlign(column);
1844
- const isFiltered = isFilterValueActive(column.getFilterValue());
1845
- const isExcluding =
1846
- isFiltered && readMultiFilterValue(column.getFilterValue()).exclude;
1847
- const ActiveFilterIcon = isExcluding ? FunnelXIcon : FunnelIcon;
1848
-
1849
- return (
1850
- <PopoverTrigger
1851
- render={
1852
- <Button
1853
- type="button"
1854
- variant="ghost"
1855
- size="sm"
1856
- className={cn(
1857
- "relative -mx-2.5 h-8 w-[calc(100%+1.25rem)] min-w-0 justify-start px-2.5",
1858
- align === "center" && "justify-center",
1859
- align === "right" && "justify-end",
1860
- className,
1861
- )}
1862
- />
1863
- }
1864
- >
1865
- <span className="truncate">{getColumnLabel(column)}</span>
1866
- {isFiltered && (
1867
- <>
1868
- <ActiveFilterIcon data-icon="inline-end" aria-hidden />
1869
- <span className="sr-only">
1870
- {isExcluding ? t.excludeFilterActive : t.filterActive}
1871
- </span>
1872
- </>
1873
- )}
1874
- </PopoverTrigger>
1875
- );
1876
- }
1877
-
1878
- function ColumnSortIcon({ isSorted }: { isSorted: DataTableSortDirection }) {
1879
- if (isSorted === "asc") {
1880
- return <ArrowUpNarrowWideIcon data-icon="inline-end" />;
1881
- }
1882
-
1883
- if (isSorted === "desc") {
1884
- return <ArrowDownWideNarrowIcon data-icon="inline-end" />;
1885
- }
1886
-
1887
- return <ArrowUpDownIcon data-icon="inline-end" />;
1888
- }
1889
-
1890
- function ColumnHeaderMenu<TData extends RowData>({
1891
- header,
1892
- onOpenChange,
1893
- open,
1894
- }: {
1895
- header: Header<TData, unknown>;
1896
- onOpenChange: (open: boolean) => void;
1897
- open: boolean;
1898
- }) {
1899
- const t = useMessages(dataTableMessages);
1900
- const column = header.column;
1901
- const table = header.getContext().table;
1902
- const isSorted = column.getIsSorted();
1903
- const isFiltered = isFilterValueActive(column.getFilterValue());
1904
- const sorting = table.getState().sorting;
1905
- const sortIndex = sorting.findIndex((sort) => sort.id === column.id);
1906
- const sortCount = sorting.length;
1907
- const hasColumnState = Boolean(isSorted) || isFiltered;
1908
-
1909
- const clearColumnState = () => {
1910
- column.setFilterValue(undefined);
1911
- clearColumnSorting(table, column.id);
1912
- };
1913
-
1914
- return (
1915
- <Popover open={open} onOpenChange={onOpenChange}>
1916
- <ColumnHeaderMenuTrigger header={header} />
1917
- <PopoverContent align="start" className="w-72 p-0">
1918
- <div className="flex flex-col gap-3 p-3">
1919
- <div className="flex min-w-0 items-start justify-between gap-2">
1920
- <div className="min-w-0">
1921
- <div className="truncate text-sm font-medium">
1922
- {getColumnLabel(column)}
1923
- </div>
1924
- </div>
1925
- {sortIndex >= 0 && sortCount > 1 && (
1926
- <Badge variant="outline" className="shrink-0">
1927
- {t.sortCriterion(sortIndex + 1)}
1928
- </Badge>
1929
- )}
1930
- </div>
1931
-
1932
- {column.getCanSort() && (
1933
- <ColumnSortControls
1934
- column={column}
1935
- isSorted={isSorted}
1936
- table={table}
1937
- />
1938
- )}
1939
-
1940
- <ColumnFilterControls
1941
- allowExclude={Boolean(table.options.meta?.enableExcludeFilters)}
1942
- column={column}
1943
- />
1944
-
1945
- {(hasColumnState || column.getCanHide()) && (
1946
- <>
1947
- <Separator />
1948
- <div className="flex gap-2">
1949
- {hasColumnState && (
1950
- <Button
1951
- variant="ghost"
1952
- size="sm"
1953
- className="flex-1"
1954
- onClick={clearColumnState}
1955
- >
1956
- <XIcon data-icon="inline-start" />
1957
- {t.clearAll}
1958
- </Button>
1959
- )}
1960
- {column.getCanHide() && (
1961
- <Button
1962
- variant="outline"
1963
- size="sm"
1964
- className="flex-1"
1965
- onClick={() => column.toggleVisibility(false)}
1966
- >
1967
- <EyeOffIcon data-icon="inline-start" />
1968
- {t.hide}
1969
- </Button>
1970
- )}
1971
- </div>
1972
- </>
1973
- )}
1974
- </div>
1975
- </PopoverContent>
1976
- </Popover>
1977
- );
1978
- }
1979
-
1980
- // Pressed option inside the column menu; the selected state uses the primary
1981
- // fill plus a check so it stays distinguishable in both themes.
1982
- function ColumnMenuOption({
1983
- buttonRef,
1984
- children,
1985
- onClick,
1986
- selected,
1987
- }: {
1988
- buttonRef?: React.Ref<HTMLButtonElement>;
1989
- children: React.ReactNode;
1990
- onClick: () => void;
1991
- selected: boolean;
1992
- }) {
1993
- return (
1994
- <Button
1995
- ref={buttonRef}
1996
- variant={selected ? "default" : "outline"}
1997
- size="sm"
1998
- className="justify-start"
1999
- aria-pressed={selected}
2000
- onClick={onClick}
2001
- >
2002
- {children}
2003
- {selected && <CheckIcon data-icon="inline-end" className="ml-auto" />}
2004
- </Button>
2005
- );
2006
- }
2007
-
2008
- function ColumnSortControls<TData extends RowData>({
2009
- column,
2010
- isSorted,
2011
- table,
2012
- }: {
2013
- column: Column<TData, unknown>;
2014
- isSorted: false | "asc" | "desc";
2015
- table: TanstackTable<TData>;
2016
- }) {
2017
- const t = useMessages(dataTableMessages);
2018
- const ascendingRef = React.useRef<HTMLButtonElement>(null);
2019
-
2020
- return (
2021
- <div className="flex flex-col gap-2">
2022
- <div className="flex min-h-6 items-center justify-between gap-2">
2023
- <Label className="text-muted-foreground text-xs">{t.sort}</Label>
2024
- {isSorted && (
2025
- <Button
2026
- variant="ghost"
2027
- size="xs"
2028
- onClick={() => {
2029
- clearColumnSorting(table, column.id);
2030
- ascendingRef.current?.focus();
2031
- }}
2032
- >
2033
- <XIcon data-icon="inline-start" />
2034
- {t.removeSort}
2035
- </Button>
2036
- )}
2037
- </div>
2038
- <div className="grid grid-cols-2 gap-2" role="group" aria-label={t.sort}>
2039
- <ColumnMenuOption
2040
- buttonRef={ascendingRef}
2041
- selected={isSorted === "asc"}
2042
- onClick={() => setColumnSorting(table, column.id, false)}
2043
- >
2044
- <ArrowUpNarrowWideIcon data-icon="inline-start" />
2045
- {t.ascending}
2046
- </ColumnMenuOption>
2047
- <ColumnMenuOption
2048
- selected={isSorted === "desc"}
2049
- onClick={() => setColumnSorting(table, column.id, true)}
2050
- >
2051
- <ArrowDownWideNarrowIcon data-icon="inline-start" />
2052
- {t.descending}
2053
- </ColumnMenuOption>
2054
- </div>
2055
- </div>
2056
- );
2057
- }
2058
-
2059
- function ColumnFilterControls<TData extends RowData>({
2060
- allowExclude,
2061
- column,
2062
- }: {
2063
- allowExclude: boolean;
2064
- column: Column<TData, unknown>;
2065
- }) {
2066
- const t = useMessages(dataTableMessages);
2067
- const filter = getColumnMeta(column)?.filter;
2068
-
2069
- if (!filter) {
2070
- return null;
2071
- }
2072
-
2073
- if (filter.type === "custom") {
2074
- return filter.render(column);
2075
- }
2076
-
2077
- if (filter.type === "multi") {
2078
- return (
2079
- <ColumnMultiFilter
2080
- allowExclude={allowExclude}
2081
- column={column}
2082
- options={filter.options}
2083
- />
2084
- );
2085
- }
2086
-
2087
- if (filter.type === "number-range") {
2088
- const range = readRangeFilterValue(column.getFilterValue());
2089
-
2090
- return (
2091
- <div className="flex flex-col gap-2">
2092
- <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2093
- <div className="grid grid-cols-2 gap-2">
2094
- <Input
2095
- type="number"
2096
- min={filter.min}
2097
- max={filter.max}
2098
- value={range.min ?? ""}
2099
- onChange={(event) =>
2100
- setObjectFilterValue(column, "min", event.target.value)
2101
- }
2102
- placeholder={filter.minPlaceholder ?? t.min}
2103
- />
2104
- <Input
2105
- type="number"
2106
- min={filter.min}
2107
- max={filter.max}
2108
- value={range.max ?? ""}
2109
- onChange={(event) =>
2110
- setObjectFilterValue(column, "max", event.target.value)
2111
- }
2112
- placeholder={filter.maxPlaceholder ?? t.max}
2113
- />
2114
- </div>
2115
- </div>
2116
- );
2117
- }
2118
-
2119
- if (filter.type === "date-range") {
2120
- const range = readDateRangeFilterValue(column.getFilterValue());
2121
-
2122
- return (
2123
- <div className="flex flex-col gap-2">
2124
- <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2125
- <div className="grid grid-cols-2 gap-2">
2126
- <Input
2127
- type="date"
2128
- value={range.from ?? ""}
2129
- onChange={(event) =>
2130
- setObjectFilterValue(column, "from", event.target.value)
2131
- }
2132
- />
2133
- <Input
2134
- type="date"
2135
- value={range.to ?? ""}
2136
- onChange={(event) =>
2137
- setObjectFilterValue(column, "to", event.target.value)
2138
- }
2139
- />
2140
- </div>
2141
- </div>
2142
- );
2143
- }
2144
-
2145
- return <ColumnTextFilter column={column} placeholder={filter.placeholder} />;
2146
- }
2147
-
2148
- function ColumnTextFilter<TData extends RowData>({
2149
- column,
2150
- placeholder,
2151
- }: {
2152
- column: Column<TData, unknown>;
2153
- placeholder?: string;
2154
- }) {
2155
- const t = useMessages(dataTableMessages);
2156
- const label = getColumnLabel(column).toLocaleLowerCase();
2157
-
2158
- return (
2159
- <div className="flex flex-col gap-2">
2160
- <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2161
- <Input
2162
- value={
2163
- readStringProperty({ value: column.getFilterValue() }, "value") ?? ""
2164
- }
2165
- onChange={(event) =>
2166
- column.setFilterValue(event.target.value || undefined)
2167
- }
2168
- placeholder={placeholder ?? t.filterBy(label)}
2169
- />
2170
- </div>
2171
- );
2172
- }
2173
-
2174
- function ColumnMultiFilter<TData extends RowData>({
2175
- allowExclude,
2176
- column,
2177
- options,
2178
- }: {
2179
- allowExclude: boolean;
2180
- column: Column<TData, unknown>;
2181
- options: DataTableFilterOption[];
2182
- }) {
2183
- const t = useMessages(dataTableMessages);
2184
- const stored = readMultiFilterValue(column.getFilterValue());
2185
- const [pendingExclude, setPendingExclude] = React.useState(stored.exclude);
2186
- const selected = stored.values;
2187
- const exclude =
2188
- allowExclude && (selected.length ? stored.exclude : pendingExclude);
2189
-
2190
- const toggleValue = (value: string, checked: boolean) => {
2191
- const next = checked
2192
- ? [...selected, value]
2193
- : selected.filter((item) => item !== value);
2194
-
2195
- column.setFilterValue(toMultiFilterValue(exclude, next));
2196
- };
2197
-
2198
- const setMode = (nextExclude: boolean) => {
2199
- setPendingExclude(nextExclude);
2200
- column.setFilterValue(toMultiFilterValue(nextExclude, selected));
2201
- };
2202
-
2203
- return (
2204
- <div className="flex flex-col gap-2">
2205
- <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2206
- {allowExclude && (
2207
- <div
2208
- className="grid grid-cols-2 gap-2"
2209
- role="group"
2210
- aria-label={t.filterMode}
2211
- >
2212
- <ColumnMenuOption selected={!exclude} onClick={() => setMode(false)}>
2213
- <FunnelIcon data-icon="inline-start" />
2214
- {t.show}
2215
- </ColumnMenuOption>
2216
- <ColumnMenuOption selected={exclude} onClick={() => setMode(true)}>
2217
- <FunnelXIcon data-icon="inline-start" />
2218
- {t.exclude}
2219
- </ColumnMenuOption>
2220
- </div>
2221
- )}
2222
- <div
2223
- className="flex max-h-56 scroll-fade flex-col gap-1 overflow-auto rounded-2xl border p-1"
2224
- role="group"
2225
- aria-label={exclude ? t.valuesToExclude : t.valuesToShow}
2226
- >
2227
- {options.map((option) => (
2228
- <label
2229
- key={option.value}
2230
- className="hover:bg-accent flex items-center gap-2 rounded-xl px-2 py-1.5 text-sm font-normal"
2231
- >
2232
- <Checkbox
2233
- checked={selected.includes(option.value)}
2234
- onCheckedChange={(checked) =>
2235
- toggleValue(option.value, Boolean(checked))
2236
- }
2237
- />
2238
- <span
2239
- className={cn(
2240
- "min-w-0 flex-1 truncate",
2241
- exclude &&
2242
- selected.includes(option.value) &&
2243
- "text-muted-foreground line-through",
2244
- )}
2245
- >
2246
- {option.label}
2247
- </span>
2248
- </label>
2249
- ))}
2250
- </div>
2251
- </div>
2252
- );
2253
- }
2254
-
2255
- function ColumnSettings<TData extends RowData>({
2256
- defaultColumnOrder,
2257
- lockedEndColumnIds,
2258
- lockedStartColumnIds,
2259
- onResetColumns,
2260
- table,
2261
- }: {
2262
- defaultColumnOrder: ColumnOrderState;
2263
- lockedEndColumnIds: string[];
2264
- lockedStartColumnIds: string[];
2265
- onResetColumns: () => void;
2266
- table: TanstackTable<TData>;
2267
- }) {
2268
- const t = useMessages(dataTableMessages);
2269
- const hiddenColumnCount = table
2270
- .getAllLeafColumns()
2271
- .filter((column) => column.getCanHide())
2272
- .filter((column) => !column.getIsVisible()).length;
2273
- const visibleColumnsCustomized = hiddenColumnCount > 0;
2274
- const columnOrderCustomized = !areColumnOrdersEqual(
2275
- normalizeColumnOrder(
2276
- table.getState().columnOrder,
2277
- defaultColumnOrder,
2278
- lockedStartColumnIds,
2279
- lockedEndColumnIds,
2280
- ),
2281
- normalizeColumnOrder(
2282
- defaultColumnOrder,
2283
- defaultColumnOrder,
2284
- lockedStartColumnIds,
2285
- lockedEndColumnIds,
2286
- ),
2287
- );
2288
- const columnsCustomized = visibleColumnsCustomized || columnOrderCustomized;
2289
-
2290
- return (
2291
- <TooltipProvider>
2292
- <Tooltip>
2293
- <DropdownMenu>
2294
- <TooltipTrigger
2295
- render={
2296
- <DropdownMenuTrigger
2297
- render={
2298
- <Button
2299
- variant="outline"
2300
- size="icon-sm"
2301
- className="relative"
2302
- aria-label={t.customizeColumns}
2303
- />
2304
- }
2305
- />
2306
- }
2307
- >
2308
- <Columns3Icon />
2309
- {hiddenColumnCount > 0 && (
2310
- <span className="bg-primary absolute -top-0.5 -right-0.5 size-2 rounded-full" />
2311
- )}
2312
- </TooltipTrigger>
2313
- <TooltipContent side="bottom" align="end">
2314
- {t.columnsTooltip}
2315
- </TooltipContent>
2316
- <DropdownMenuContent align="end" className="w-64">
2317
- <DropdownMenuGroup>
2318
- <DropdownMenuLabel>{t.columns}</DropdownMenuLabel>
2319
- {table
2320
- .getAllLeafColumns()
2321
- .filter((column) => column.getCanHide())
2322
- .map((column) => (
2323
- <DropdownMenuCheckboxItem
2324
- key={column.id}
2325
- checked={column.getIsVisible()}
2326
- onCheckedChange={(value) =>
2327
- column.toggleVisibility(!!value)
2328
- }
2329
- closeOnClick={false}
2330
- >
2331
- {getColumnLabel(column)}
2332
- </DropdownMenuCheckboxItem>
2333
- ))}
2334
- </DropdownMenuGroup>
2335
- {columnsCustomized && (
2336
- <>
2337
- <DropdownMenuSeparator />
2338
- <DropdownMenuItem onClick={onResetColumns}>
2339
- <XIcon />
2340
- {t.resetColumns}
2341
- </DropdownMenuItem>
2342
- </>
2343
- )}
2344
- </DropdownMenuContent>
2345
- </DropdownMenu>
2346
- </Tooltip>
2347
- </TooltipProvider>
2348
- );
2349
- }
2350
-
2351
- function DataTablePagination<TData extends RowData>({
2352
- formatRowCount,
2353
- formatSelectedRowCount,
2354
- pageSizeOptions,
2355
- rowCount,
2356
- storageKey,
2357
- table,
2358
- }: {
2359
- formatRowCount: (count: number) => string;
2360
- formatSelectedRowCount?: (count: number) => string;
2361
- pageSizeOptions: number[];
2362
- rowCount?: number;
2363
- storageKey: string;
2364
- table: TanstackTable<TData>;
2365
- }) {
2366
- const t = useMessages(dataTableMessages);
2367
- const selectedRows = table.getFilteredSelectedRowModel().rows.length;
2368
- const filteredRows = rowCount ?? table.getFilteredRowModel().rows.length;
2369
- const totalRows = rowCount ?? table.getCoreRowModel().rows.length;
2370
- const selectedRowsLabel =
2371
- formatSelectedRowCount ??
2372
- ((count: number) => t.selected(formatRowCount(count), count));
2373
- const fallbackId = React.useId();
2374
- const inputId = storageKey
2375
- ? `${getStorageSafeId(storageKey)}-rows-per-page`
2376
- : fallbackId;
2377
- let paginationSummary = t.ofSummary(
2378
- formatRowCount(filteredRows),
2379
- formatRowCount(totalRows),
2380
- );
2381
-
2382
- if (selectedRows > 0) {
2383
- paginationSummary = t.ofSummary(
2384
- selectedRowsLabel(selectedRows),
2385
- formatRowCount(filteredRows),
2386
- );
2387
- } else if (filteredRows === totalRows) {
2388
- paginationSummary = `${formatRowCount(totalRows)}.`;
2389
- }
2390
-
2391
- return (
2392
- <div className="flex flex-col gap-3 px-1 sm:flex-row sm:items-center sm:justify-between">
2393
- <div className="text-muted-foreground text-sm">{paginationSummary}</div>
2394
- <div className="flex flex-wrap items-center gap-3">
2395
- <div className="flex items-center gap-2">
2396
- <Label htmlFor={inputId} className="text-sm">
2397
- {t.rowsPerPage}
2398
- </Label>
2399
- <Select
2400
- value={`${table.getState().pagination.pageSize}`}
2401
- onValueChange={(value) => table.setPageSize(Number(value))}
2402
- >
2403
- <SelectTrigger id={inputId} size="sm" className="w-20">
2404
- <SelectValue />
2405
- </SelectTrigger>
2406
- <SelectContent side="top">
2407
- {pageSizeOptions.map((pageSize) => (
2408
- <SelectItem key={pageSize} value={`${pageSize}`}>
2409
- {pageSize}
2410
- </SelectItem>
2411
- ))}
2412
- </SelectContent>
2413
- </Select>
2414
- </div>
2415
- <div className="text-sm font-medium">
2416
- {t.page(
2417
- table.getState().pagination.pageIndex + 1,
2418
- Math.max(table.getPageCount(), 1),
2419
- )}
2420
- </div>
2421
- <div className="flex items-center gap-2">
2422
- <Button
2423
- variant="outline"
2424
- className="hidden sm:flex"
2425
- size="icon"
2426
- onClick={() => table.setPageIndex(0)}
2427
- disabled={!table.getCanPreviousPage()}
2428
- >
2429
- <span className="sr-only">{t.firstPage}</span>
2430
- <ChevronsLeftIcon />
2431
- </Button>
2432
- <Button
2433
- variant="outline"
2434
- size="icon"
2435
- onClick={() => table.previousPage()}
2436
- disabled={!table.getCanPreviousPage()}
2437
- >
2438
- <span className="sr-only">{t.previousPage}</span>
2439
- <ChevronLeftIcon />
2440
- </Button>
2441
- <Button
2442
- variant="outline"
2443
- size="icon"
2444
- onClick={() => table.nextPage()}
2445
- disabled={!table.getCanNextPage()}
2446
- >
2447
- <span className="sr-only">{t.nextPage}</span>
2448
- <ChevronRightIcon />
2449
- </Button>
2450
- <Button
2451
- variant="outline"
2452
- className="hidden sm:flex"
2453
- size="icon"
2454
- onClick={() => table.setPageIndex(table.getPageCount() - 1)}
2455
- disabled={!table.getCanNextPage()}
2456
- >
2457
- <span className="sr-only">{t.lastPage}</span>
2458
- <ChevronsRightIcon />
2459
- </Button>
2460
- </div>
2461
- </div>
2462
- </div>
2463
- );
2464
- }
2465
-
2466
- export function DataTableSkeleton() {
2467
- return (
2468
- <div className="flex w-full flex-col gap-4" aria-hidden="true">
2469
- <div className="flex items-center gap-2">
2470
- <Skeleton className="h-9 flex-1 rounded-3xl" />
2471
- <Skeleton className="size-8 rounded-full" />
2472
- </div>
2473
- <div className="bg-card overflow-hidden rounded-2xl border">
2474
- <Skeleton className="h-10 rounded-none" />
2475
- <div className="flex flex-col">
2476
- {Array.from({ length: 8 }).map((_, index) => (
2477
- <Skeleton
2478
- key={index}
2479
- className="bg-muted/40 h-14 rounded-none border-t"
2480
- />
2481
- ))}
2482
- </div>
2483
- </div>
2484
- </div>
2485
- );
2486
- }
1
+ "use client";
2
+ "use no memo";
3
+
4
+ // Ported from the Numa app's data table (majapisoftwares/numa); this is now the
5
+ // canonical copy. Effects below intentionally sync restored localStorage
6
+ // preferences and incoming props into table state.
7
+ /* eslint-disable react-hooks/set-state-in-effect */
8
+
9
+ import * as React from "react";
10
+ import {
11
+ closestCenter,
12
+ DndContext,
13
+ KeyboardSensor,
14
+ MouseSensor,
15
+ TouchSensor,
16
+ useSensor,
17
+ useSensors,
18
+ type DragEndEvent,
19
+ } from "@dnd-kit/core";
20
+ import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
21
+ import {
22
+ arrayMove,
23
+ horizontalListSortingStrategy,
24
+ SortableContext,
25
+ useSortable,
26
+ } from "@dnd-kit/sortable";
27
+ import { CSS } from "@dnd-kit/utilities";
28
+ import {
29
+ CircleAlertIcon,
30
+ InboxIcon,
31
+ LockIcon,
32
+ RotateCwIcon,
33
+ TriangleAlertIcon,
34
+ ArrowDownWideNarrowIcon,
35
+ ArrowUpDownIcon,
36
+ ArrowUpNarrowWideIcon,
37
+ CheckIcon,
38
+ ChevronLeftIcon,
39
+ ChevronRightIcon,
40
+ ChevronsLeftIcon,
41
+ ChevronsRightIcon,
42
+ Columns3Icon,
43
+ EyeOffIcon,
44
+ FunnelIcon,
45
+ FunnelXIcon,
46
+ GripVerticalIcon,
47
+ SearchIcon,
48
+ XIcon,
49
+ } from "lucide-react";
50
+ import {
51
+ createTable,
52
+ flexRender,
53
+ getCoreRowModel,
54
+ getFilteredRowModel,
55
+ getPaginationRowModel,
56
+ getSortedRowModel,
57
+ type Column,
58
+ type ColumnDef,
59
+ type ColumnFiltersState,
60
+ type ColumnOrderState,
61
+ type ColumnSizingState,
62
+ type FilterFn,
63
+ type Header,
64
+ type OnChangeFn,
65
+ type PaginationState,
66
+ type RowData,
67
+ type RowSelectionState,
68
+ type SortingState,
69
+ type Table as TanstackTable,
70
+ type TableOptions,
71
+ type TableOptionsResolved,
72
+ type VisibilityState,
73
+ } from "@tanstack/react-table";
74
+
75
+ import { Badge } from "../components/ui/badge";
76
+ import { Button } from "../components/ui/button";
77
+ import { Checkbox } from "../components/ui/checkbox";
78
+ import { FilterTabs } from "./filter-tabs";
79
+ import {
80
+ DropdownMenu,
81
+ DropdownMenuCheckboxItem,
82
+ DropdownMenuContent,
83
+ DropdownMenuGroup,
84
+ DropdownMenuItem,
85
+ DropdownMenuLabel,
86
+ DropdownMenuSeparator,
87
+ DropdownMenuTrigger,
88
+ } from "../components/ui/dropdown-menu";
89
+ import { Input } from "../components/ui/input";
90
+ import {
91
+ InputGroup,
92
+ InputGroupAddon,
93
+ InputGroupInput,
94
+ } from "../components/ui/input-group";
95
+ import { Label } from "../components/ui/label";
96
+ import {
97
+ Popover,
98
+ PopoverContent,
99
+ PopoverTrigger,
100
+ } from "../components/ui/popover";
101
+ import {
102
+ Select,
103
+ SelectContent,
104
+ SelectItem,
105
+ SelectTrigger,
106
+ SelectValue,
107
+ } from "../components/ui/select";
108
+ import { Separator } from "../components/ui/separator";
109
+ import { Skeleton } from "../components/ui/skeleton";
110
+ import {
111
+ Table,
112
+ TableBody,
113
+ TableCell,
114
+ TableHead,
115
+ TableHeader,
116
+ TableRow,
117
+ } from "../components/ui/table";
118
+ import {
119
+ Tooltip,
120
+ TooltipContent,
121
+ TooltipProvider,
122
+ TooltipTrigger,
123
+ } from "../components/ui/tooltip";
124
+ import { cn } from "cn";
125
+ import { defineMessages, useMessages, useUiMessages } from "../lib/i18n";
126
+ import {
127
+ Empty,
128
+ EmptyContent,
129
+ EmptyDescription,
130
+ EmptyHeader,
131
+ EmptyMedia,
132
+ EmptyTitle,
133
+ } from "../components/ui/empty";
134
+ import {
135
+ Alert,
136
+ AlertAction,
137
+ AlertDescription,
138
+ AlertTitle,
139
+ } from "../components/ui/alert";
140
+
141
+ export type DataTableColumnAlign = "left" | "center" | "right";
142
+ type DataTableSortDirection = false | "asc" | "desc";
143
+
144
+ export type DataTableFilterOption = {
145
+ label: string;
146
+ value: string;
147
+ };
148
+
149
+ export type DataTableRangeFilter = {
150
+ min?: string;
151
+ max?: string;
152
+ };
153
+
154
+ export type DataTableDateRangeFilter = {
155
+ from?: string;
156
+ to?: string;
157
+ };
158
+
159
+ // Multi-select filter value: an array keeps the matching rows; the object form
160
+ // hides the selected values instead. Server-filtered tables only receive arrays.
161
+ export type DataTableMultiFilterValue =
162
+ string[] | { exclude: true; values: string[] };
163
+
164
+ export type DataTableColumnFilter<TData extends RowData> =
165
+ | {
166
+ placeholder?: string;
167
+ type: "text";
168
+ }
169
+ | {
170
+ options: DataTableFilterOption[];
171
+ type: "multi";
172
+ }
173
+ | {
174
+ max?: number;
175
+ maxPlaceholder?: string;
176
+ min?: number;
177
+ minPlaceholder?: string;
178
+ type: "number-range";
179
+ }
180
+ | {
181
+ type: "date-range";
182
+ }
183
+ | {
184
+ render: (column: Column<TData, unknown>) => React.ReactNode;
185
+ type: "custom";
186
+ };
187
+
188
+ export type DataTableColumnMeta<TData extends RowData> = {
189
+ align?: DataTableColumnAlign;
190
+ cellClassName?: string;
191
+ filter?: DataTableColumnFilter<TData>;
192
+ headerClassName?: string;
193
+ label?: string;
194
+ };
195
+
196
+ declare module "@tanstack/react-table" {
197
+ interface ColumnMeta<TData extends RowData, TValue> {
198
+ align?: DataTableColumnAlign;
199
+ cellClassName?: string;
200
+ filter?: DataTableColumnFilter<TData>;
201
+ headerClassName?: string;
202
+ label?: string;
203
+ valueType?: TValue;
204
+ }
205
+
206
+ interface TableMeta<TData extends RowData> {
207
+ enableExcludeFilters?: boolean;
208
+ rowType?: TData;
209
+ }
210
+ }
211
+
212
+ /** Window event that clears a table's search and filters (detail: { storageKey? }). */
213
+ export const DATA_TABLE_RESET_FILTERS_EVENT =
214
+ "majapi-ui:data-table:reset-filters";
215
+
216
+ /** Clears search and filters of every table, or only the one with `storageKey`. */
217
+ export function resetDataTableFilters(storageKey?: string) {
218
+ window.dispatchEvent(
219
+ new CustomEvent(DATA_TABLE_RESET_FILTERS_EVENT, { detail: { storageKey } }),
220
+ );
221
+ }
222
+
223
+ const dataTableMessages = defineMessages({
224
+ en: {
225
+ search: "Search...",
226
+ resultCount: (count: number) =>
227
+ `${count} ${count === 1 ? "result" : "results"}`,
228
+ rowCount: (count: number) => `${count} ${count === 1 ? "row" : "rows"}`,
229
+ // English needs no plural form here; pt-BR uses count.
230
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
231
+ selected: (rows: string, count: number) => `${rows} selected`,
232
+ clearSelection: "Clear selection",
233
+ clearFilters: "Clear filters",
234
+ open: "Open",
235
+ resizeColumn: (label: string) => `Resize column ${label}`,
236
+ filterActive: ", filter active",
237
+ excludeFilterActive: ", exclusion filter active",
238
+ sortCriterion: (n: number) => `Sort #${n}`,
239
+ sortCriterionTitle: (n: number) => `Sort criterion #${n}`,
240
+ clearAll: "Clear all",
241
+ hide: "Hide",
242
+ toggleSort: (label: string) => `Toggle sorting for ${label}`,
243
+ reorderColumn: (label: string) => `Reorder column ${label}`,
244
+ sort: "Sort",
245
+ removeSort: "Remove sort",
246
+ ascending: "Ascending",
247
+ descending: "Descending",
248
+ filter: "Filter",
249
+ min: "Min.",
250
+ max: "Max.",
251
+ filterBy: (label: string) => `Filter by ${label}`,
252
+ filterMode: "Filter mode",
253
+ show: "Show",
254
+ exclude: "Exclude",
255
+ valuesToExclude: "Values to exclude",
256
+ valuesToShow: "Values to show",
257
+ customizeColumns: "Customize columns",
258
+ columnsTooltip: "Show, hide and reset columns",
259
+ columns: "Columns",
260
+ resetColumns: "Reset columns",
261
+ rowsPerPage: "Rows",
262
+ page: (page: number, total: number) => `Page ${page} of ${total}`,
263
+ ofSummary: (part: string, total: string) => `${part} of ${total}.`,
264
+ firstPage: "First page",
265
+ previousPage: "Previous page",
266
+ nextPage: "Next page",
267
+ lastPage: "Last page",
268
+ },
269
+ "pt-BR": {
270
+ search: "Buscar...",
271
+ resultCount: (count: number) =>
272
+ `${count} resultado${count === 1 ? "" : "s"}`,
273
+ rowCount: (count: number) => `${count} registro${count === 1 ? "" : "s"}`,
274
+ selected: (rows: string, count: number) =>
275
+ `${rows} selecionado${count === 1 ? "" : "s"}`,
276
+ clearSelection: "Limpar seleção",
277
+ clearFilters: "Limpar filtros",
278
+ open: "Abrir",
279
+ resizeColumn: (label: string) => `Redimensionar coluna ${label}`,
280
+ filterActive: ", filtro ativo",
281
+ excludeFilterActive: ", filtro de exclusão ativo",
282
+ sortCriterion: (n: number) => `${n}º critério`,
283
+ sortCriterionTitle: (n: number) => `${n}º critério de ordenação`,
284
+ clearAll: "Limpar tudo",
285
+ hide: "Ocultar",
286
+ toggleSort: (label: string) => `Alternar ordenação da coluna ${label}`,
287
+ reorderColumn: (label: string) => `Reordenar coluna ${label}`,
288
+ sort: "Ordenar",
289
+ removeSort: "Remover ordem",
290
+ ascending: "Crescente",
291
+ descending: "Decrescente",
292
+ filter: "Filtrar",
293
+ min: "Mín.",
294
+ max: "Máx.",
295
+ filterBy: (label: string) => `Filtrar por ${label}`,
296
+ filterMode: "Modo do filtro",
297
+ show: "Mostrar",
298
+ exclude: "Excluir",
299
+ valuesToExclude: "Valores a excluir",
300
+ valuesToShow: "Valores a mostrar",
301
+ customizeColumns: "Personalizar colunas",
302
+ columnsTooltip: "Mostrar, ocultar e restaurar colunas",
303
+ columns: "Colunas",
304
+ resetColumns: "Restaurar colunas",
305
+ rowsPerPage: "Linhas",
306
+ page: (page: number, total: number) => `Página ${page} de ${total}`,
307
+ ofSummary: (part: string, total: string) => `${part} de ${total}.`,
308
+ firstPage: "Primeira página",
309
+ previousPage: "Página anterior",
310
+ nextPage: "Próxima página",
311
+ lastPage: "Última página",
312
+ },
313
+ });
314
+
315
+ type StoredDataTableSettings = {
316
+ columnFilters?: ColumnFiltersState;
317
+ columnOrder?: ColumnOrderState;
318
+ columnSizing?: ColumnSizingState;
319
+ columnVisibility?: VisibilityState;
320
+ globalFilter?: string;
321
+ pageSize?: number;
322
+ sorting?: SortingState;
323
+ };
324
+
325
+ export type DataTableProps<TData extends RowData> = {
326
+ activeRowId?: string | null;
327
+ columns: ColumnDef<TData, unknown>[];
328
+ data: TData[];
329
+ /** Text when there are no rows; defaults to a translated message. */
330
+ emptyMessage?: string;
331
+ /** Rich empty state (e.g. with a create action), shown when nothing is filtered. */
332
+ emptyState?: React.ReactNode;
333
+ /**
334
+ * Fills the parent's height and scrolls rows inside the table (sticky
335
+ * header, toolbar and pagination stay visible). The parent must be a flex
336
+ * column with a bounded height, e.g. `AppShell fillHeight`.
337
+ */
338
+ fillHeight?: boolean;
339
+ /** Shows skeleton rows instead of data. */
340
+ loading?: boolean;
341
+ /** Load failure. With rows it shows as stale data; without, replaces the table. */
342
+ error?: string;
343
+ onRetry?: () => void;
344
+ /** Non-blocking notice, e.g. partial coverage. */
345
+ warning?: string;
346
+ /** Data can't be shown at all, e.g. missing permission. */
347
+ unavailable?: string;
348
+ enablePersistence?: boolean;
349
+ enableRowSelection?: boolean;
350
+ // Offers "Excluir" in multi-select column filters. Defaults to off for
351
+ // server-backed tables (onStateChange), whose APIs only read include arrays.
352
+ enableExcludeFilters?: boolean;
353
+ filterFns?: Record<string, FilterFn<TData>>;
354
+ formatResultCount?: (count: number) => string;
355
+ formatRowCount?: (count: number) => string;
356
+ formatSelectedRowCount?: (count: number) => string;
357
+ getRowId: (row: TData) => string;
358
+ globalFilterFn?: FilterFn<TData>;
359
+ initialColumnFilters?: ColumnFiltersState;
360
+ /** Initial search text. */
361
+ initialGlobalFilter?: string;
362
+ /** Makes rows clickable (and Enter/Space activatable), e.g. to open details. */
363
+ onRowClick?: (row: TData) => void;
364
+ /** Accessible name for a clickable row; defaults to its id. */
365
+ getRowLabel?: (row: TData) => string;
366
+ initialColumnOrder?: ColumnOrderState;
367
+ initialColumnVisibility?: VisibilityState;
368
+ initialPageSize?: number;
369
+ initialSorting?: SortingState;
370
+ lockedEndColumnIds?: string[];
371
+ lockedStartColumnIds?: string[];
372
+ manualFiltering?: boolean;
373
+ manualPagination?: boolean;
374
+ manualSorting?: boolean;
375
+ onStateChange?: (state: DataTableStateSnapshot) => void;
376
+ pageCount?: number;
377
+ pageSizeOptions?: number[];
378
+ rowCount?: number;
379
+ rowSelectionActions?: (table: TanstackTable<TData>) => React.ReactNode;
380
+ searchPlaceholder?: string;
381
+ /** Persists sorting, filters, columns and page size in localStorage under this key. */
382
+ storageKey?: string;
383
+ toolbarBelow?: (table: TanstackTable<TData>) => React.ReactNode;
384
+ toolbarEnd?: (table: TanstackTable<TData>) => React.ReactNode;
385
+ toolbarStart?: (table: TanstackTable<TData>) => React.ReactNode;
386
+ };
387
+
388
+ export type DataTableStateSnapshot = {
389
+ columnFilters: ColumnFiltersState;
390
+ globalFilter: string;
391
+ pagination: PaginationState;
392
+ sorting: SortingState;
393
+ };
394
+
395
+ export type DataTableFilterTab = {
396
+ count?: number;
397
+ label: string;
398
+ value: string;
399
+ };
400
+
401
+ type DataTableObjectFilter = DataTableRangeFilter & DataTableDateRangeFilter;
402
+
403
+ function isRecord(value: unknown): value is Record<string, unknown> {
404
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
405
+ }
406
+
407
+ function readStringProperty(value: unknown, key: string) {
408
+ if (!isRecord(value)) {
409
+ return undefined;
410
+ }
411
+
412
+ const property = value[key];
413
+ return typeof property === "string" ? property : undefined;
414
+ }
415
+
416
+ function readStringArrayFilter(value: unknown) {
417
+ if (!Array.isArray(value)) {
418
+ return [];
419
+ }
420
+
421
+ return value.filter((item): item is string => typeof item === "string");
422
+ }
423
+
424
+ export function readMultiFilterValue(value: unknown): {
425
+ exclude: boolean;
426
+ values: string[];
427
+ } {
428
+ if (isRecord(value) && value.exclude === true) {
429
+ return { exclude: true, values: readStringArrayFilter(value.values) };
430
+ }
431
+
432
+ return { exclude: false, values: readStringArrayFilter(value) };
433
+ }
434
+
435
+ function toMultiFilterValue(
436
+ exclude: boolean,
437
+ values: string[],
438
+ ): DataTableMultiFilterValue | undefined {
439
+ if (values.length === 0) {
440
+ return undefined;
441
+ }
442
+
443
+ return exclude ? { exclude: true, values } : values;
444
+ }
445
+
446
+ function isString(value: unknown): value is string {
447
+ return typeof value === "string";
448
+ }
449
+
450
+ function readRangeFilterValue(value: unknown): DataTableRangeFilter {
451
+ return {
452
+ max: readStringProperty(value, "max"),
453
+ min: readStringProperty(value, "min"),
454
+ };
455
+ }
456
+
457
+ function readDateRangeFilterValue(value: unknown): DataTableDateRangeFilter {
458
+ return {
459
+ from: readStringProperty(value, "from"),
460
+ to: readStringProperty(value, "to"),
461
+ };
462
+ }
463
+
464
+ function readObjectFilterValue(value: unknown): Partial<DataTableObjectFilter> {
465
+ return {
466
+ ...readRangeFilterValue(value),
467
+ ...readDateRangeFilterValue(value),
468
+ };
469
+ }
470
+
471
+ export function createArrayIncludesSomeFilter<
472
+ TData extends RowData,
473
+ >(): FilterFn<TData> {
474
+ return (row, columnId, filterValue) => {
475
+ const { exclude, values } = readMultiFilterValue(filterValue);
476
+
477
+ if (values.length === 0) {
478
+ return true;
479
+ }
480
+
481
+ return values.includes(row.getValue(columnId)) !== exclude;
482
+ };
483
+ }
484
+
485
+ export function createNumberInRangeFilter<
486
+ TData extends RowData,
487
+ >(): FilterFn<TData> {
488
+ return (row, columnId, filterValue) => {
489
+ const range = readRangeFilterValue(filterValue);
490
+ const min = range?.min ? Number(range.min) : null;
491
+ const max = range?.max ? Number(range.max) : null;
492
+ const value = Number(row.getValue(columnId));
493
+
494
+ if (min !== null && Number.isFinite(min) && value < min) {
495
+ return false;
496
+ }
497
+
498
+ if (max !== null && Number.isFinite(max) && value > max) {
499
+ return false;
500
+ }
501
+
502
+ return true;
503
+ };
504
+ }
505
+
506
+ export function createDateInRangeFilter<
507
+ TData extends RowData,
508
+ >(): FilterFn<TData> {
509
+ return (row, columnId, filterValue) => {
510
+ const range = readDateRangeFilterValue(filterValue);
511
+ const value = new Date(row.getValue<string>(columnId)).getTime();
512
+ const from = range?.from
513
+ ? new Date(`${range.from}T00:00:00`).getTime()
514
+ : null;
515
+ const to = range?.to ? new Date(`${range.to}T23:59:59`).getTime() : null;
516
+
517
+ if (from !== null && Number.isFinite(from) && value < from) {
518
+ return false;
519
+ }
520
+
521
+ if (to !== null && Number.isFinite(to) && value > to) {
522
+ return false;
523
+ }
524
+
525
+ return true;
526
+ };
527
+ }
528
+
529
+ export function getSingleArrayFilterValue(
530
+ filterValue: unknown,
531
+ ): "all" | "custom" | string {
532
+ const { exclude, values } = readMultiFilterValue(filterValue);
533
+
534
+ if (exclude && values.length > 0) {
535
+ return "custom";
536
+ }
537
+
538
+ if (values.length === 0) {
539
+ return "all";
540
+ }
541
+
542
+ if (values.length === 1) {
543
+ return values[0] ?? "all";
544
+ }
545
+
546
+ return "custom";
547
+ }
548
+
549
+ export function DataTableColumnFilterTabs<TData extends RowData>({
550
+ className,
551
+ columnId,
552
+ options,
553
+ table,
554
+ }: {
555
+ className?: string;
556
+ columnId: string;
557
+ options: DataTableFilterTab[];
558
+ table: TanstackTable<TData>;
559
+ }) {
560
+ const column = table.getColumn(columnId);
561
+ const value = getSingleArrayFilterValue(column?.getFilterValue());
562
+
563
+ const handleValueChange = (nextValue: string) => {
564
+ column?.setFilterValue(nextValue === "all" ? undefined : [nextValue]);
565
+ };
566
+
567
+ return (
568
+ <FilterTabs
569
+ className={className}
570
+ onValueChange={handleValueChange}
571
+ options={options}
572
+ value={value}
573
+ />
574
+ );
575
+ }
576
+
577
+ export function createDataTableFilterTabOptions<TItem>(
578
+ items: TItem[],
579
+ filters: Array<Pick<DataTableFilterTab, "label" | "value">>,
580
+ getItemValue: (item: TItem) => string,
581
+ ) {
582
+ return filters.map((filter) => ({
583
+ ...filter,
584
+ count:
585
+ filter.value === "all"
586
+ ? undefined
587
+ : items.filter((item) => getItemValue(item) === filter.value).length,
588
+ }));
589
+ }
590
+
591
+ function getColumnId<TData extends RowData>(
592
+ column: ColumnDef<TData, unknown>,
593
+ ): string | null {
594
+ if ("id" in column && column.id) {
595
+ return column.id;
596
+ }
597
+
598
+ if ("accessorKey" in column && typeof column.accessorKey === "string") {
599
+ return column.accessorKey;
600
+ }
601
+
602
+ return null;
603
+ }
604
+
605
+ function normalizeColumnOrder(
606
+ columnOrder: ColumnOrderState | undefined,
607
+ defaultColumnOrder: ColumnOrderState,
608
+ lockedStartColumnIds: string[],
609
+ lockedEndColumnIds: string[],
610
+ ) {
611
+ const validColumns = new Set(defaultColumnOrder);
612
+ const lockedStart = lockedStartColumnIds.filter((columnId) =>
613
+ validColumns.has(columnId),
614
+ );
615
+ const lockedEnd = lockedEndColumnIds.filter((columnId) =>
616
+ validColumns.has(columnId),
617
+ );
618
+ const lockedColumns = new Set([...lockedStart, ...lockedEnd]);
619
+ const movableColumns = (columnOrder ?? []).filter(
620
+ (columnId) => validColumns.has(columnId) && !lockedColumns.has(columnId),
621
+ );
622
+
623
+ for (const columnId of defaultColumnOrder) {
624
+ if (!lockedColumns.has(columnId) && !movableColumns.includes(columnId)) {
625
+ movableColumns.push(columnId);
626
+ }
627
+ }
628
+
629
+ return [...lockedStart, ...movableColumns, ...lockedEnd];
630
+ }
631
+
632
+ function areColumnOrdersEqual(left: ColumnOrderState, right: ColumnOrderState) {
633
+ return (
634
+ left.length === right.length &&
635
+ left.every((columnId, index) => columnId === right[index])
636
+ );
637
+ }
638
+
639
+ function getColumnMeta<TData extends RowData>(column: Column<TData, unknown>) {
640
+ return column.columnDef.meta;
641
+ }
642
+
643
+ function getColumnLabel<TData extends RowData>(column: Column<TData, unknown>) {
644
+ const header = column.columnDef.header;
645
+
646
+ return (
647
+ getColumnMeta(column)?.label ??
648
+ (typeof header === "string" ? header : column.id)
649
+ );
650
+ }
651
+
652
+ function getColumnAlign<TData extends RowData>(column: Column<TData, unknown>) {
653
+ return getColumnMeta(column)?.align ?? "left";
654
+ }
655
+
656
+ function getColumnAlignClass<TData extends RowData>(
657
+ column: Column<TData, unknown>,
658
+ ) {
659
+ const align = getColumnAlign(column);
660
+
661
+ return cn(
662
+ align === "center" && "text-center",
663
+ align === "right" && "text-right",
664
+ );
665
+ }
666
+
667
+ function getColumnAriaSort(
668
+ isSorted: DataTableSortDirection,
669
+ ): "ascending" | "descending" | undefined {
670
+ if (!isSorted) {
671
+ return undefined;
672
+ }
673
+
674
+ return isSorted === "asc" ? "ascending" : "descending";
675
+ }
676
+
677
+ function getHeaderControlVisibilityClass(isVisible: boolean) {
678
+ if (isVisible) {
679
+ return "pointer-events-auto opacity-100";
680
+ }
681
+
682
+ return "pointer-events-none opacity-0 group-focus-within/header:pointer-events-auto group-focus-within/header:opacity-100 group-hover/header:pointer-events-auto group-hover/header:opacity-100";
683
+ }
684
+
685
+ function hasRangeFilter(value: unknown) {
686
+ const range = readObjectFilterValue(value);
687
+ return Boolean(range.min || range.max || range.from || range.to);
688
+ }
689
+
690
+ function isFilterValueActive(value: unknown) {
691
+ if (Array.isArray(value)) {
692
+ return value.length > 0;
693
+ }
694
+
695
+ if (isRecord(value) && value.exclude === true) {
696
+ return readMultiFilterValue(value).values.length > 0;
697
+ }
698
+
699
+ if (typeof value === "object") {
700
+ return hasRangeFilter(value);
701
+ }
702
+
703
+ return Boolean(value);
704
+ }
705
+
706
+ function setObjectFilterValue<TData extends RowData>(
707
+ column: Column<TData, unknown>,
708
+ key: keyof DataTableObjectFilter,
709
+ value: string,
710
+ ) {
711
+ const next = {
712
+ ...readObjectFilterValue(column.getFilterValue()),
713
+ [key]: value,
714
+ };
715
+
716
+ if (!hasRangeFilter(next)) {
717
+ column.setFilterValue(undefined);
718
+ return;
719
+ }
720
+
721
+ column.setFilterValue(next);
722
+ }
723
+
724
+ function setColumnSorting<TData extends RowData>(
725
+ table: TanstackTable<TData>,
726
+ columnId: string,
727
+ desc: boolean,
728
+ ) {
729
+ table.setSorting((current) => {
730
+ const next = [
731
+ ...current.filter((sort) => sort.id !== columnId),
732
+ { id: columnId, desc },
733
+ ];
734
+
735
+ return next.slice(-5);
736
+ });
737
+ }
738
+
739
+ function clearColumnSorting<TData extends RowData>(
740
+ table: TanstackTable<TData>,
741
+ columnId: string,
742
+ ) {
743
+ table.setSorting((current) => current.filter((sort) => sort.id !== columnId));
744
+ }
745
+
746
+ function toggleColumnSorting<TData extends RowData>(
747
+ table: TanstackTable<TData>,
748
+ columnId: string,
749
+ ) {
750
+ table.setSorting((current) => {
751
+ const currentSort = current.find((sort) => sort.id === columnId);
752
+ const next = current.filter((sort) => sort.id !== columnId);
753
+
754
+ if (!currentSort) {
755
+ return [...next, { id: columnId, desc: false }].slice(-5);
756
+ }
757
+
758
+ if (!currentSort.desc) {
759
+ return [...next, { id: columnId, desc: true }].slice(-5);
760
+ }
761
+
762
+ return next;
763
+ });
764
+ }
765
+
766
+ function isColumnOrderState(value: unknown): value is ColumnOrderState {
767
+ return (
768
+ Array.isArray(value) &&
769
+ value.every((columnId) => typeof columnId === "string")
770
+ );
771
+ }
772
+
773
+ function isColumnFiltersState(value: unknown): value is ColumnFiltersState {
774
+ return (
775
+ Array.isArray(value) &&
776
+ value.every((filter) => isRecord(filter) && typeof filter.id === "string")
777
+ );
778
+ }
779
+
780
+ function isVisibilityState(value: unknown): value is VisibilityState {
781
+ return (
782
+ isRecord(value) &&
783
+ Object.values(value).every((visible) => typeof visible === "boolean")
784
+ );
785
+ }
786
+
787
+ function isSortingState(value: unknown): value is SortingState {
788
+ return (
789
+ Array.isArray(value) &&
790
+ value.every(
791
+ (sort) =>
792
+ isRecord(sort) &&
793
+ typeof sort.id === "string" &&
794
+ typeof sort.desc === "boolean",
795
+ )
796
+ );
797
+ }
798
+
799
+ function parseStoredDataTableSettings(
800
+ value: string,
801
+ ): StoredDataTableSettings | null {
802
+ const parsed = JSON.parse(value);
803
+
804
+ if (!isRecord(parsed)) {
805
+ return null;
806
+ }
807
+
808
+ const settings: StoredDataTableSettings = {};
809
+
810
+ if (isColumnFiltersState(parsed.columnFilters)) {
811
+ settings.columnFilters = parsed.columnFilters;
812
+ }
813
+
814
+ if (isColumnOrderState(parsed.columnOrder)) {
815
+ settings.columnOrder = parsed.columnOrder;
816
+ }
817
+
818
+ if (isVisibilityState(parsed.columnVisibility)) {
819
+ settings.columnVisibility = parsed.columnVisibility;
820
+ }
821
+
822
+ if (typeof parsed.globalFilter === "string") {
823
+ settings.globalFilter = parsed.globalFilter;
824
+ }
825
+
826
+ if (typeof parsed.pageSize === "number" && Number.isFinite(parsed.pageSize)) {
827
+ settings.pageSize = parsed.pageSize;
828
+ }
829
+
830
+ if (isSortingState(parsed.sorting)) {
831
+ settings.sorting = parsed.sorting;
832
+ }
833
+
834
+ return settings;
835
+ }
836
+
837
+ function getStoredDataTableSettings(storageKey: string) {
838
+ if (typeof window === "undefined") {
839
+ return null;
840
+ }
841
+
842
+ try {
843
+ const value = window.localStorage.getItem(storageKey);
844
+ return value ? parseStoredDataTableSettings(value) : null;
845
+ } catch {
846
+ return null;
847
+ }
848
+ }
849
+
850
+ function pickKnownColumnEntries<TValue>(
851
+ record: Record<string, TValue> | undefined,
852
+ columnIds: string[],
853
+ ) {
854
+ if (!record) {
855
+ return undefined;
856
+ }
857
+
858
+ return Object.fromEntries(
859
+ Object.entries(record).filter(([columnId]) => columnIds.includes(columnId)),
860
+ );
861
+ }
862
+
863
+ function retainKnownColumnSettings(
864
+ settings: StoredDataTableSettings | null,
865
+ columnIds: string[],
866
+ ): StoredDataTableSettings | null {
867
+ if (!settings) {
868
+ return null;
869
+ }
870
+
871
+ const sorting = settings.sorting?.filter((sort) =>
872
+ columnIds.includes(sort.id),
873
+ );
874
+
875
+ return {
876
+ ...settings,
877
+ columnFilters: settings.columnFilters?.filter((filter) =>
878
+ columnIds.includes(filter.id),
879
+ ),
880
+ columnSizing: pickKnownColumnEntries(settings.columnSizing, columnIds),
881
+ columnVisibility: pickKnownColumnEntries(
882
+ settings.columnVisibility,
883
+ columnIds,
884
+ ),
885
+ sorting: sorting?.length ? sorting : undefined,
886
+ };
887
+ }
888
+
889
+ function getResetFiltersStorageKey(event: Event) {
890
+ if (!(event instanceof CustomEvent)) {
891
+ return null;
892
+ }
893
+
894
+ const detail = event.detail;
895
+ if (!isRecord(detail)) {
896
+ return null;
897
+ }
898
+
899
+ const storageKey = detail.storageKey;
900
+ return typeof storageKey === "string" ? storageKey : null;
901
+ }
902
+
903
+ function isLockedColumn(
904
+ columnId: string,
905
+ lockedStartColumnIds: string[],
906
+ lockedEndColumnIds: string[],
907
+ ) {
908
+ return (
909
+ lockedStartColumnIds.includes(columnId) ||
910
+ lockedEndColumnIds.includes(columnId)
911
+ );
912
+ }
913
+
914
+ function getStorageSafeId(storageKey: string) {
915
+ return storageKey.replace(/[^a-zA-Z0-9_-]/g, "-");
916
+ }
917
+
918
+ function getOptionalColumnOrderKey(columnOrder?: ColumnOrderState) {
919
+ return columnOrder?.join("|") ?? "";
920
+ }
921
+
922
+ function getColumnFiltersKey(columnFilters: ColumnFiltersState) {
923
+ return JSON.stringify(columnFilters);
924
+ }
925
+
926
+ function getVisibilityStateKey(visibility: VisibilityState) {
927
+ const entries = Object.entries(visibility).sort(([left], [right]) =>
928
+ left.localeCompare(right),
929
+ );
930
+
931
+ return JSON.stringify(entries);
932
+ }
933
+
934
+ function getSortingKey(sorting: SortingState) {
935
+ return JSON.stringify(sorting);
936
+ }
937
+
938
+ function useStableValueByKey<TValue>(value: TValue, key: string) {
939
+ const [stored, setStored] = React.useState({ key, value });
940
+
941
+ React.useEffect(() => {
942
+ setStored((current) => (current.key === key ? current : { key, value }));
943
+ }, [key, value]);
944
+
945
+ return stored.value;
946
+ }
947
+
948
+ function useDataTableInstance<TData extends RowData>(
949
+ options: TableOptions<TData>,
950
+ ) {
951
+ const resolvedOptions: TableOptionsResolved<TData> = {
952
+ onStateChange: () => undefined,
953
+ renderFallbackValue: null,
954
+ state: {},
955
+ ...options,
956
+ };
957
+
958
+ const [table] = React.useState(() => createTable<TData>(resolvedOptions));
959
+ const [state, setState] = React.useState(() => table.initialState);
960
+
961
+ table.setOptions((previous) => ({
962
+ ...previous,
963
+ ...options,
964
+ onStateChange: (updater) => {
965
+ setState(updater);
966
+ options.onStateChange?.(updater);
967
+ },
968
+ state: {
969
+ ...state,
970
+ ...options.state,
971
+ },
972
+ }));
973
+
974
+ return table;
975
+ }
976
+
977
+ const emptyColumnFilters: ColumnFiltersState = [];
978
+ const emptyColumnSizing: ColumnSizingState = {};
979
+ const emptyColumnVisibility: VisibilityState = {};
980
+ const emptySorting: SortingState = [];
981
+ const emptyLockedColumnIds: string[] = [];
982
+ const defaultPageSizeOptions = [10, 20, 30, 50, 100];
983
+
984
+ function resolvePageSize(
985
+ pageSize: number | undefined,
986
+ fallbackPageSize: number,
987
+ pageSizeOptions: number[],
988
+ ) {
989
+ if (typeof pageSize === "number" && pageSizeOptions.includes(pageSize)) {
990
+ return pageSize;
991
+ }
992
+
993
+ if (pageSizeOptions.includes(fallbackPageSize)) {
994
+ return fallbackPageSize;
995
+ }
996
+
997
+ return pageSizeOptions[0] ?? fallbackPageSize;
998
+ }
999
+
1000
+ export function DataTable<TData extends RowData>(props: DataTableProps<TData>) {
1001
+ const {
1002
+ activeRowId,
1003
+ columns,
1004
+ data,
1005
+ emptyMessage,
1006
+ emptyState,
1007
+ fillHeight = false,
1008
+ loading = false,
1009
+ error,
1010
+ onRetry,
1011
+ warning,
1012
+ unavailable,
1013
+ enablePersistence: enablePersistenceProp,
1014
+ enableRowSelection = false,
1015
+ filterFns,
1016
+ formatResultCount: formatResultCountProp,
1017
+ formatRowCount: formatRowCountProp,
1018
+ formatSelectedRowCount,
1019
+ getRowId,
1020
+ globalFilterFn,
1021
+ initialColumnFilters = emptyColumnFilters,
1022
+ initialGlobalFilter = "",
1023
+ onRowClick,
1024
+ getRowLabel,
1025
+ initialColumnOrder,
1026
+ initialColumnVisibility = emptyColumnVisibility,
1027
+ initialPageSize = 10,
1028
+ initialSorting = emptySorting,
1029
+ lockedEndColumnIds = emptyLockedColumnIds,
1030
+ lockedStartColumnIds = emptyLockedColumnIds,
1031
+ manualFiltering = false,
1032
+ enableExcludeFilters,
1033
+ manualPagination = false,
1034
+ manualSorting = false,
1035
+ onStateChange,
1036
+ pageCount,
1037
+ pageSizeOptions = defaultPageSizeOptions,
1038
+ rowCount,
1039
+ rowSelectionActions,
1040
+ searchPlaceholder,
1041
+ storageKey: storageKeyProp,
1042
+ toolbarBelow,
1043
+ toolbarEnd,
1044
+ toolbarStart,
1045
+ } = props;
1046
+ const t = useMessages(dataTableMessages);
1047
+ const ui = useUiMessages();
1048
+ // Persistence needs a key; without one the table keeps state in memory only.
1049
+ const storageKey = storageKeyProp ?? "";
1050
+ const enablePersistence =
1051
+ Boolean(storageKeyProp) && enablePersistenceProp !== false;
1052
+ const formatResultCount = formatResultCountProp ?? t.resultCount;
1053
+ const formatRowCount = formatRowCountProp ?? t.rowCount;
1054
+
1055
+ const defaultColumnOrder = React.useMemo(
1056
+ () => columns.map(getColumnId).filter(isString),
1057
+ [columns],
1058
+ );
1059
+ const defaultColumnOrderKey = defaultColumnOrder.join("|");
1060
+ const pageSizeOptionsKey = pageSizeOptions.join("|");
1061
+ const lockedStartColumnKey = lockedStartColumnIds.join("|");
1062
+ const lockedEndColumnKey = lockedEndColumnIds.join("|");
1063
+ const stablePageSizeOptions = useStableValueByKey(
1064
+ pageSizeOptions,
1065
+ pageSizeOptionsKey,
1066
+ );
1067
+ const stableDefaultColumnOrder = useStableValueByKey(
1068
+ defaultColumnOrder,
1069
+ defaultColumnOrderKey,
1070
+ );
1071
+ const stableInitialColumnFilters = useStableValueByKey(
1072
+ initialColumnFilters,
1073
+ getColumnFiltersKey(initialColumnFilters),
1074
+ );
1075
+ const stableInitialColumnOrder = useStableValueByKey(
1076
+ initialColumnOrder,
1077
+ getOptionalColumnOrderKey(initialColumnOrder),
1078
+ );
1079
+ const stableInitialColumnVisibility = useStableValueByKey(
1080
+ initialColumnVisibility,
1081
+ getVisibilityStateKey(initialColumnVisibility),
1082
+ );
1083
+ const stableInitialSorting = useStableValueByKey(
1084
+ initialSorting,
1085
+ getSortingKey(initialSorting),
1086
+ );
1087
+ const stableLockedStartColumnIds = useStableValueByKey(
1088
+ lockedStartColumnIds,
1089
+ lockedStartColumnKey,
1090
+ );
1091
+ const stableLockedEndColumnIds = useStableValueByKey(
1092
+ lockedEndColumnIds,
1093
+ lockedEndColumnKey,
1094
+ );
1095
+ const [preferencesReady, setPreferencesReady] =
1096
+ React.useState(!enablePersistence);
1097
+ const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
1098
+ const [columnVisibility, setColumnVisibility] =
1099
+ React.useState<VisibilityState>(stableInitialColumnVisibility);
1100
+ const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(() =>
1101
+ normalizeColumnOrder(
1102
+ stableInitialColumnOrder ?? stableDefaultColumnOrder,
1103
+ stableDefaultColumnOrder,
1104
+ stableLockedStartColumnIds,
1105
+ stableLockedEndColumnIds,
1106
+ ),
1107
+ );
1108
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
1109
+ stableInitialColumnFilters,
1110
+ );
1111
+ const [columnSizing, setColumnSizing] =
1112
+ React.useState<ColumnSizingState>(emptyColumnSizing);
1113
+ const [sorting, setSorting] =
1114
+ React.useState<SortingState>(stableInitialSorting);
1115
+ const [globalFilter, setGlobalFilter] = React.useState(initialGlobalFilter);
1116
+ const [pagination, setPagination] = React.useState<PaginationState>({
1117
+ pageIndex: 0,
1118
+ pageSize: resolvePageSize(
1119
+ initialPageSize,
1120
+ initialPageSize,
1121
+ stablePageSizeOptions,
1122
+ ),
1123
+ });
1124
+ const resetPaginationPage = React.useCallback(() => {
1125
+ setPagination((current) =>
1126
+ current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
1127
+ );
1128
+ }, []);
1129
+ const handleColumnFiltersChange = React.useCallback<
1130
+ OnChangeFn<ColumnFiltersState>
1131
+ >(
1132
+ (updater) => {
1133
+ setColumnFilters(updater);
1134
+ resetPaginationPage();
1135
+ },
1136
+ [resetPaginationPage],
1137
+ );
1138
+ const handleGlobalFilterChange = React.useCallback<OnChangeFn<string>>(
1139
+ (updater) => {
1140
+ setGlobalFilter(updater);
1141
+ resetPaginationPage();
1142
+ },
1143
+ [resetPaginationPage],
1144
+ );
1145
+ const handleSortingChange = React.useCallback<OnChangeFn<SortingState>>(
1146
+ (updater) => {
1147
+ setSorting(updater);
1148
+ resetPaginationPage();
1149
+ },
1150
+ [resetPaginationPage],
1151
+ );
1152
+
1153
+ const dndContextId = React.useId();
1154
+ const sensors = useSensors(
1155
+ useSensor(MouseSensor, {
1156
+ activationConstraint: {
1157
+ distance: 6,
1158
+ },
1159
+ }),
1160
+ useSensor(TouchSensor, {
1161
+ activationConstraint: {
1162
+ delay: 150,
1163
+ tolerance: 6,
1164
+ },
1165
+ }),
1166
+ useSensor(KeyboardSensor),
1167
+ );
1168
+
1169
+ React.useEffect(() => {
1170
+ if (!enablePersistence) {
1171
+ return;
1172
+ }
1173
+
1174
+ const settings = retainKnownColumnSettings(
1175
+ getStoredDataTableSettings(storageKey),
1176
+ stableDefaultColumnOrder,
1177
+ );
1178
+
1179
+ setColumnVisibility(
1180
+ settings?.columnVisibility ?? stableInitialColumnVisibility,
1181
+ );
1182
+ setColumnOrder(
1183
+ normalizeColumnOrder(
1184
+ settings?.columnOrder ??
1185
+ stableInitialColumnOrder ??
1186
+ stableDefaultColumnOrder,
1187
+ stableDefaultColumnOrder,
1188
+ stableLockedStartColumnIds,
1189
+ stableLockedEndColumnIds,
1190
+ ),
1191
+ );
1192
+ setColumnFilters(settings?.columnFilters ?? stableInitialColumnFilters);
1193
+ setColumnSizing(settings?.columnSizing ?? emptyColumnSizing);
1194
+ setSorting(settings?.sorting ?? stableInitialSorting);
1195
+ setGlobalFilter(settings?.globalFilter ?? "");
1196
+ setPagination({
1197
+ pageIndex: 0,
1198
+ pageSize: resolvePageSize(
1199
+ settings?.pageSize,
1200
+ initialPageSize,
1201
+ stablePageSizeOptions,
1202
+ ),
1203
+ });
1204
+ setPreferencesReady(true);
1205
+ }, [
1206
+ enablePersistence,
1207
+ initialPageSize,
1208
+ stableDefaultColumnOrder,
1209
+ stableInitialColumnFilters,
1210
+ stableInitialColumnOrder,
1211
+ stableInitialColumnVisibility,
1212
+ stableInitialSorting,
1213
+ stableLockedEndColumnIds,
1214
+ stableLockedStartColumnIds,
1215
+ stablePageSizeOptions,
1216
+ storageKey,
1217
+ ]);
1218
+
1219
+ React.useEffect(() => {
1220
+ if (!preferencesReady) {
1221
+ return;
1222
+ }
1223
+
1224
+ setColumnOrder((currentOrder) =>
1225
+ normalizeColumnOrder(
1226
+ currentOrder,
1227
+ stableDefaultColumnOrder,
1228
+ stableLockedStartColumnIds,
1229
+ stableLockedEndColumnIds,
1230
+ ),
1231
+ );
1232
+ }, [
1233
+ preferencesReady,
1234
+ stableDefaultColumnOrder,
1235
+ stableLockedEndColumnIds,
1236
+ stableLockedStartColumnIds,
1237
+ ]);
1238
+
1239
+ React.useEffect(() => {
1240
+ if (!enablePersistence || !preferencesReady) {
1241
+ return;
1242
+ }
1243
+
1244
+ window.localStorage.setItem(
1245
+ storageKey,
1246
+ JSON.stringify({
1247
+ columnFilters,
1248
+ columnOrder,
1249
+ columnSizing,
1250
+ columnVisibility,
1251
+ globalFilter,
1252
+ pageSize: pagination.pageSize,
1253
+ sorting,
1254
+ } satisfies StoredDataTableSettings),
1255
+ );
1256
+ }, [
1257
+ columnFilters,
1258
+ columnOrder,
1259
+ columnSizing,
1260
+ columnVisibility,
1261
+ enablePersistence,
1262
+ globalFilter,
1263
+ pagination.pageSize,
1264
+ preferencesReady,
1265
+ sorting,
1266
+ storageKey,
1267
+ ]);
1268
+
1269
+ React.useEffect(() => {
1270
+ if (!preferencesReady) {
1271
+ return;
1272
+ }
1273
+
1274
+ onStateChange?.({
1275
+ columnFilters,
1276
+ globalFilter,
1277
+ pagination,
1278
+ sorting,
1279
+ });
1280
+ }, [
1281
+ columnFilters,
1282
+ globalFilter,
1283
+ onStateChange,
1284
+ pagination,
1285
+ preferencesReady,
1286
+ sorting,
1287
+ ]);
1288
+
1289
+ const table = useDataTableInstance<TData>({
1290
+ autoResetPageIndex: !manualPagination,
1291
+ columnResizeMode: "onChange",
1292
+ columns,
1293
+ data,
1294
+ enableColumnResizing: true,
1295
+ enableMultiSort: true,
1296
+ enableRowSelection,
1297
+ filterFns,
1298
+ getCoreRowModel: getCoreRowModel(),
1299
+ getFilteredRowModel: getFilteredRowModel(),
1300
+ getPaginationRowModel: getPaginationRowModel(),
1301
+ getRowId,
1302
+ getSortedRowModel: getSortedRowModel(),
1303
+ // An explicit undefined would replace TanStack's default and disable search.
1304
+ globalFilterFn: globalFilterFn ?? "includesString",
1305
+ isMultiSortEvent: () => true,
1306
+ manualFiltering,
1307
+ manualPagination,
1308
+ manualSorting,
1309
+ maxMultiSortColCount: 5,
1310
+ meta: {
1311
+ enableExcludeFilters:
1312
+ enableExcludeFilters ?? (!manualFiltering && !onStateChange),
1313
+ },
1314
+ onColumnFiltersChange: handleColumnFiltersChange,
1315
+ onColumnOrderChange: setColumnOrder,
1316
+ onColumnSizingChange: setColumnSizing,
1317
+ onColumnVisibilityChange: setColumnVisibility,
1318
+ onGlobalFilterChange: handleGlobalFilterChange,
1319
+ onPaginationChange: setPagination,
1320
+ onRowSelectionChange: setRowSelection,
1321
+ onSortingChange: handleSortingChange,
1322
+ pageCount,
1323
+ rowCount,
1324
+ state: {
1325
+ columnFilters,
1326
+ columnOrder,
1327
+ columnSizing,
1328
+ columnVisibility,
1329
+ globalFilter,
1330
+ pagination,
1331
+ rowSelection,
1332
+ sorting,
1333
+ },
1334
+ });
1335
+
1336
+ const handleDragEnd = (event: DragEndEvent) => {
1337
+ const { active, over } = event;
1338
+
1339
+ if (!over || active.id === over.id) {
1340
+ return;
1341
+ }
1342
+
1343
+ if (
1344
+ isLockedColumn(
1345
+ String(active.id),
1346
+ stableLockedStartColumnIds,
1347
+ stableLockedEndColumnIds,
1348
+ )
1349
+ ) {
1350
+ return;
1351
+ }
1352
+
1353
+ setColumnOrder((currentOrder) => {
1354
+ const oldIndex = currentOrder.indexOf(String(active.id));
1355
+ const newIndex = currentOrder.indexOf(String(over.id));
1356
+
1357
+ if (oldIndex === -1 || newIndex === -1) {
1358
+ return currentOrder;
1359
+ }
1360
+
1361
+ return normalizeColumnOrder(
1362
+ arrayMove(currentOrder, oldIndex, newIndex),
1363
+ stableDefaultColumnOrder,
1364
+ stableLockedStartColumnIds,
1365
+ stableLockedEndColumnIds,
1366
+ );
1367
+ });
1368
+ };
1369
+
1370
+ const resetColumns = () => {
1371
+ setColumnVisibility(stableInitialColumnVisibility);
1372
+ setColumnOrder(
1373
+ normalizeColumnOrder(
1374
+ stableInitialColumnOrder ?? stableDefaultColumnOrder,
1375
+ stableDefaultColumnOrder,
1376
+ stableLockedStartColumnIds,
1377
+ stableLockedEndColumnIds,
1378
+ ),
1379
+ );
1380
+ setColumnSizing(emptyColumnSizing);
1381
+ };
1382
+
1383
+ const resetFilters = React.useCallback(() => {
1384
+ setColumnFilters(stableInitialColumnFilters);
1385
+ setGlobalFilter("");
1386
+ setPagination((current) => ({ ...current, pageIndex: 0 }));
1387
+ }, [stableInitialColumnFilters]);
1388
+
1389
+ React.useEffect(() => {
1390
+ const handleResetFilters = (event: Event) => {
1391
+ const resetStorageKey = getResetFiltersStorageKey(event);
1392
+
1393
+ if (resetStorageKey && resetStorageKey !== storageKey) {
1394
+ return;
1395
+ }
1396
+
1397
+ resetFilters();
1398
+ };
1399
+
1400
+ window.addEventListener(DATA_TABLE_RESET_FILTERS_EVENT, handleResetFilters);
1401
+
1402
+ return () => {
1403
+ window.removeEventListener(
1404
+ DATA_TABLE_RESET_FILTERS_EVENT,
1405
+ handleResetFilters,
1406
+ );
1407
+ };
1408
+ }, [resetFilters, storageKey]);
1409
+
1410
+ if (!preferencesReady) {
1411
+ return <DataTableSkeleton />;
1412
+ }
1413
+
1414
+ const retry = onRetry ? (
1415
+ <Button variant="outline" size="sm" onClick={onRetry}>
1416
+ <RotateCwIcon data-icon="inline-start" />
1417
+ {ui.retry}
1418
+ </Button>
1419
+ ) : null;
1420
+
1421
+ if (unavailable) {
1422
+ return (
1423
+ <Empty className="rounded-3xl border border-dashed">
1424
+ <EmptyHeader>
1425
+ <EmptyMedia variant="icon">
1426
+ <LockIcon />
1427
+ </EmptyMedia>
1428
+ <EmptyTitle>{unavailable}</EmptyTitle>
1429
+ </EmptyHeader>
1430
+ </Empty>
1431
+ );
1432
+ }
1433
+
1434
+ if (error && !data.length && !loading) {
1435
+ return (
1436
+ <Empty className="rounded-3xl border border-dashed">
1437
+ <EmptyHeader>
1438
+ <EmptyMedia variant="icon">
1439
+ <CircleAlertIcon />
1440
+ </EmptyMedia>
1441
+ <EmptyTitle>{ui.loadFailed}</EmptyTitle>
1442
+ <EmptyDescription>{error}</EmptyDescription>
1443
+ </EmptyHeader>
1444
+ {retry ? <EmptyContent>{retry}</EmptyContent> : null}
1445
+ </Empty>
1446
+ );
1447
+ }
1448
+
1449
+ const isFiltered = Boolean(globalFilter) || columnFilters.length > 0;
1450
+
1451
+ const filteredRowCount = rowCount ?? table.getFilteredRowModel().rows.length;
1452
+ const selectedRowCount = table.getFilteredSelectedRowModel().rows.length;
1453
+ const selectedRowsLabel =
1454
+ formatSelectedRowCount ??
1455
+ ((count: number) => t.selected(formatRowCount(count), count));
1456
+ const toolbarStartContent = toolbarStart?.(table);
1457
+ const toolbarEndContent = toolbarEnd?.(table);
1458
+ const toolbarBelowContent = toolbarBelow?.(table);
1459
+ const rowSelectionActionsContent = rowSelectionActions?.(table);
1460
+
1461
+ return (
1462
+ <div
1463
+ className={cn(
1464
+ "flex w-full min-w-0 flex-col gap-4",
1465
+ fillHeight && "min-h-0 flex-1",
1466
+ )}
1467
+ >
1468
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
1469
+ {toolbarStartContent}
1470
+ <div className="flex min-w-0 flex-1 basis-80 items-center gap-2">
1471
+ <InputGroup className="flex-1">
1472
+ <InputGroupInput
1473
+ value={globalFilter}
1474
+ onChange={(event) => handleGlobalFilterChange(event.target.value)}
1475
+ placeholder={searchPlaceholder ?? t.search}
1476
+ />
1477
+ <InputGroupAddon className="pl-2">
1478
+ <SearchIcon />
1479
+ </InputGroupAddon>
1480
+ <InputGroupAddon align="inline-end" className="pr-2">
1481
+ {formatResultCount(filteredRowCount)}
1482
+ </InputGroupAddon>
1483
+ </InputGroup>
1484
+ {toolbarEndContent}
1485
+ <ColumnSettings
1486
+ defaultColumnOrder={defaultColumnOrder}
1487
+ lockedEndColumnIds={lockedEndColumnIds}
1488
+ lockedStartColumnIds={lockedStartColumnIds}
1489
+ onResetColumns={resetColumns}
1490
+ table={table}
1491
+ />
1492
+ </div>
1493
+ </div>
1494
+
1495
+ {toolbarBelowContent}
1496
+
1497
+ {error ? (
1498
+ <Alert variant="destructive">
1499
+ <CircleAlertIcon />
1500
+ <AlertTitle>{ui.staleData}</AlertTitle>
1501
+ <AlertDescription>{error}</AlertDescription>
1502
+ {retry ? <AlertAction>{retry}</AlertAction> : null}
1503
+ </Alert>
1504
+ ) : null}
1505
+ {warning ? (
1506
+ <Alert>
1507
+ <TriangleAlertIcon />
1508
+ <AlertTitle>{warning}</AlertTitle>
1509
+ </Alert>
1510
+ ) : null}
1511
+
1512
+ {enableRowSelection && selectedRowCount > 0 && (
1513
+ <div className="bg-muted/40 flex flex-col gap-2 rounded-2xl border p-2 sm:flex-row sm:items-center sm:justify-between">
1514
+ <p className="text-foreground px-1 text-sm font-medium">
1515
+ {selectedRowsLabel(selectedRowCount)}
1516
+ </p>
1517
+ <div className="flex flex-wrap items-center gap-2">
1518
+ {rowSelectionActionsContent}
1519
+ <Button
1520
+ type="button"
1521
+ variant="outline"
1522
+ size="sm"
1523
+ onClick={() => table.resetRowSelection()}
1524
+ >
1525
+ <XIcon data-icon="inline-start" />
1526
+ {t.clearSelection}
1527
+ </Button>
1528
+ </div>
1529
+ </div>
1530
+ )}
1531
+
1532
+ <DndContext
1533
+ // Stable id: dnd-kit's global counter differs between server and client.
1534
+ id={dndContextId}
1535
+ collisionDetection={closestCenter}
1536
+ modifiers={[restrictToHorizontalAxis]}
1537
+ onDragEnd={handleDragEnd}
1538
+ sensors={sensors}
1539
+ >
1540
+ <div
1541
+ className={cn(
1542
+ "bg-card overflow-hidden rounded-2xl border",
1543
+ fillHeight && "flex min-h-0 flex-1 flex-col",
1544
+ )}
1545
+ >
1546
+ <Table
1547
+ containerClassName={
1548
+ // Bottom-only fade: a top fade would dissolve the sticky header.
1549
+ fillHeight
1550
+ ? "min-h-0 flex-1 overflow-auto scroll-fade-b"
1551
+ : undefined
1552
+ }
1553
+ style={{ minWidth: "100%", width: table.getTotalSize() }}
1554
+ >
1555
+ {/* Solid background: rows scroll underneath the sticky header. */}
1556
+ <TableHeader className="bg-muted sticky top-0 z-10">
1557
+ {table.getHeaderGroups().map((headerGroup) => (
1558
+ <TableRow key={headerGroup.id}>
1559
+ <SortableContext
1560
+ items={columnOrder}
1561
+ strategy={horizontalListSortingStrategy}
1562
+ >
1563
+ {headerGroup.headers.map((header) => (
1564
+ <DataTableHead
1565
+ key={header.id}
1566
+ header={header}
1567
+ lockedEndColumnIds={lockedEndColumnIds}
1568
+ lockedStartColumnIds={lockedStartColumnIds}
1569
+ />
1570
+ ))}
1571
+ </SortableContext>
1572
+ </TableRow>
1573
+ ))}
1574
+ </TableHeader>
1575
+ <TableBody>
1576
+ {loading ? (
1577
+ Array.from({ length: 3 }, (_, index) => (
1578
+ <TableRow key={index}>
1579
+ {table.getVisibleLeafColumns().map((column) => (
1580
+ <TableCell key={column.id}>
1581
+ <Skeleton className="h-5 w-full" />
1582
+ </TableCell>
1583
+ ))}
1584
+ </TableRow>
1585
+ ))
1586
+ ) : table.getRowModel().rows?.length ? (
1587
+ table.getRowModel().rows.map((row) => (
1588
+ <TableRow
1589
+ key={row.id}
1590
+ data-active={row.id === activeRowId || undefined}
1591
+ aria-current={row.id === activeRowId ? "true" : undefined}
1592
+ data-state={row.getIsSelected() && "selected"}
1593
+ className={onRowClick ? "cursor-pointer" : undefined}
1594
+ tabIndex={onRowClick ? 0 : undefined}
1595
+ aria-label={
1596
+ onRowClick
1597
+ ? `${t.open}: ${getRowLabel?.(row.original) ?? row.id}`
1598
+ : undefined
1599
+ }
1600
+ onClick={
1601
+ onRowClick ? () => onRowClick(row.original) : undefined
1602
+ }
1603
+ onKeyDown={
1604
+ onRowClick
1605
+ ? (event) => {
1606
+ if (event.key === "Enter" || event.key === " ") {
1607
+ event.preventDefault();
1608
+ onRowClick(row.original);
1609
+ }
1610
+ }
1611
+ : undefined
1612
+ }
1613
+ >
1614
+ {row.getVisibleCells().map((cell) => (
1615
+ <TableCell
1616
+ key={cell.id}
1617
+ className={cn(
1618
+ "whitespace-nowrap",
1619
+ getColumnAlignClass(cell.column),
1620
+ getColumnMeta(cell.column)?.cellClassName,
1621
+ )}
1622
+ style={{ width: cell.column.getSize() }}
1623
+ >
1624
+ {flexRender(
1625
+ cell.column.columnDef.cell,
1626
+ cell.getContext(),
1627
+ )}
1628
+ </TableCell>
1629
+ ))}
1630
+ </TableRow>
1631
+ ))
1632
+ ) : (
1633
+ <TableRow>
1634
+ <TableCell
1635
+ colSpan={table.getVisibleLeafColumns().length}
1636
+ className="text-muted-foreground h-24 text-center"
1637
+ >
1638
+ {isFiltered ? (
1639
+ <Empty>
1640
+ <EmptyHeader>
1641
+ <EmptyTitle>
1642
+ {emptyMessage ?? ui.noResults}
1643
+ </EmptyTitle>
1644
+ </EmptyHeader>
1645
+ <EmptyContent>
1646
+ <Button
1647
+ variant="outline"
1648
+ size="sm"
1649
+ onClick={resetFilters}
1650
+ >
1651
+ {t.clearFilters}
1652
+ </Button>
1653
+ </EmptyContent>
1654
+ </Empty>
1655
+ ) : (
1656
+ (emptyState ?? (
1657
+ <Empty>
1658
+ <EmptyHeader>
1659
+ <EmptyMedia variant="icon">
1660
+ <InboxIcon />
1661
+ </EmptyMedia>
1662
+ <EmptyTitle>{emptyMessage ?? ui.noData}</EmptyTitle>
1663
+ </EmptyHeader>
1664
+ </Empty>
1665
+ ))
1666
+ )}
1667
+ </TableCell>
1668
+ </TableRow>
1669
+ )}
1670
+ </TableBody>
1671
+ </Table>
1672
+ </div>
1673
+ </DndContext>
1674
+
1675
+ <DataTablePagination
1676
+ formatRowCount={formatRowCount}
1677
+ formatSelectedRowCount={formatSelectedRowCount}
1678
+ pageSizeOptions={stablePageSizeOptions}
1679
+ rowCount={rowCount}
1680
+ storageKey={storageKey}
1681
+ table={table}
1682
+ />
1683
+ </div>
1684
+ );
1685
+ }
1686
+
1687
+ function DataTableHead<TData extends RowData>({
1688
+ header,
1689
+ lockedEndColumnIds,
1690
+ lockedStartColumnIds,
1691
+ }: {
1692
+ header: Header<TData, unknown>;
1693
+ lockedEndColumnIds: string[];
1694
+ lockedStartColumnIds: string[];
1695
+ }) {
1696
+ const t = useMessages(dataTableMessages);
1697
+ const column = header.column;
1698
+ const table = header.getContext().table;
1699
+ const align = getColumnAlign(column);
1700
+ const isSorted = column.getIsSorted();
1701
+ const sortIndex = table
1702
+ .getState()
1703
+ .sorting.findIndex((sort) => sort.id === column.id);
1704
+ const isLocked = isLockedColumn(
1705
+ column.id,
1706
+ lockedStartColumnIds,
1707
+ lockedEndColumnIds,
1708
+ );
1709
+ const canOpenColumnMenu = !header.isPlaceholder && !isLocked;
1710
+ const {
1711
+ attributes,
1712
+ isDragging,
1713
+ listeners,
1714
+ setNodeRef,
1715
+ transform,
1716
+ transition,
1717
+ } = useSortable({
1718
+ id: column.id,
1719
+ disabled: isLocked,
1720
+ });
1721
+ const [menuOpen, setMenuOpen] = React.useState(false);
1722
+
1723
+ const columnControls =
1724
+ column.getCanSort() || !isLocked ? (
1725
+ <div
1726
+ className={cn(
1727
+ "flex shrink-0 items-center gap-1",
1728
+ align === "right" ? "order-first mr-1" : "ml-1",
1729
+ )}
1730
+ >
1731
+ {column.getCanSort() && (
1732
+ <Button
1733
+ type="button"
1734
+ variant="ghost"
1735
+ size="icon-xs"
1736
+ className={cn(
1737
+ "transition-opacity",
1738
+ isSorted ? "text-foreground bg-accent" : "text-muted-foreground",
1739
+ getHeaderControlVisibilityClass(Boolean(isSorted)),
1740
+ )}
1741
+ onClick={() => toggleColumnSorting(table, column.id)}
1742
+ aria-label={t.toggleSort(getColumnLabel(column))}
1743
+ >
1744
+ <ColumnSortIcon isSorted={isSorted} />
1745
+ </Button>
1746
+ )}
1747
+ {sortIndex >= 0 && table.getState().sorting.length > 1 && (
1748
+ <Badge
1749
+ variant="secondary"
1750
+ className="px-1"
1751
+ title={t.sortCriterionTitle(sortIndex + 1)}
1752
+ >
1753
+ {sortIndex + 1}
1754
+ </Badge>
1755
+ )}
1756
+ {!isLocked && (
1757
+ <Button
1758
+ type="button"
1759
+ variant="ghost"
1760
+ size="icon-xs"
1761
+ className={cn(
1762
+ "text-muted-foreground cursor-grab transition-opacity active:cursor-grabbing",
1763
+ getHeaderControlVisibilityClass(isDragging),
1764
+ )}
1765
+ aria-label={t.reorderColumn(getColumnLabel(column))}
1766
+ {...attributes}
1767
+ {...listeners}
1768
+ >
1769
+ <GripVerticalIcon />
1770
+ </Button>
1771
+ )}
1772
+ </div>
1773
+ ) : null;
1774
+
1775
+ return (
1776
+ <TableHead
1777
+ ref={setNodeRef}
1778
+ colSpan={header.colSpan}
1779
+ aria-sort={getColumnAriaSort(isSorted)}
1780
+ className={cn(
1781
+ "relative whitespace-nowrap",
1782
+ getColumnAlignClass(column),
1783
+ getColumnMeta(column)?.headerClassName,
1784
+ isDragging && "bg-muted relative z-20 opacity-80",
1785
+ )}
1786
+ style={{
1787
+ transform: CSS.Translate.toString(transform),
1788
+ transition,
1789
+ width: header.getSize(),
1790
+ }}
1791
+ >
1792
+ {header.isPlaceholder ? null : (
1793
+ <div
1794
+ className={cn(
1795
+ "group/header flex w-full items-center",
1796
+ align === "center" && "justify-center",
1797
+ align === "right" && "justify-end",
1798
+ )}
1799
+ >
1800
+ <div className="flex min-w-0 flex-1 items-center">
1801
+ {canOpenColumnMenu ? (
1802
+ <ColumnHeaderMenu
1803
+ header={header}
1804
+ open={menuOpen}
1805
+ onOpenChange={setMenuOpen}
1806
+ />
1807
+ ) : (
1808
+ <div className="flex h-8 items-center px-0">
1809
+ {flexRender(column.columnDef.header, header.getContext())}
1810
+ </div>
1811
+ )}
1812
+ </div>
1813
+ {columnControls}
1814
+ </div>
1815
+ )}
1816
+ {column.getCanResize() && !isLocked ? (
1817
+ <div
1818
+ role="separator"
1819
+ aria-orientation="vertical"
1820
+ aria-label={t.resizeColumn(getColumnLabel(column))}
1821
+ className={cn(
1822
+ "hover:bg-primary/50 absolute inset-y-0 right-0 z-10 w-1 cursor-col-resize touch-none bg-transparent select-none",
1823
+ column.getIsResizing() && "bg-primary",
1824
+ )}
1825
+ onDoubleClick={() => column.resetSize()}
1826
+ onMouseDown={header.getResizeHandler()}
1827
+ onTouchStart={header.getResizeHandler()}
1828
+ />
1829
+ ) : null}
1830
+ </TableHead>
1831
+ );
1832
+ }
1833
+
1834
+ function ColumnHeaderMenuTrigger<TData extends RowData>({
1835
+ className,
1836
+ header,
1837
+ }: {
1838
+ className?: string;
1839
+ header: Header<TData, unknown>;
1840
+ }) {
1841
+ const t = useMessages(dataTableMessages);
1842
+ const column = header.column;
1843
+ const align = getColumnAlign(column);
1844
+ const isFiltered = isFilterValueActive(column.getFilterValue());
1845
+ const isExcluding =
1846
+ isFiltered && readMultiFilterValue(column.getFilterValue()).exclude;
1847
+ const ActiveFilterIcon = isExcluding ? FunnelXIcon : FunnelIcon;
1848
+
1849
+ return (
1850
+ <PopoverTrigger
1851
+ render={
1852
+ <Button
1853
+ type="button"
1854
+ variant="ghost"
1855
+ size="sm"
1856
+ className={cn(
1857
+ "relative -mx-2.5 h-8 w-[calc(100%+1.25rem)] min-w-0 justify-start px-2.5",
1858
+ align === "center" && "justify-center",
1859
+ align === "right" && "justify-end",
1860
+ className,
1861
+ )}
1862
+ />
1863
+ }
1864
+ >
1865
+ <span className="truncate">{getColumnLabel(column)}</span>
1866
+ {isFiltered && (
1867
+ <>
1868
+ <ActiveFilterIcon data-icon="inline-end" aria-hidden />
1869
+ <span className="sr-only">
1870
+ {isExcluding ? t.excludeFilterActive : t.filterActive}
1871
+ </span>
1872
+ </>
1873
+ )}
1874
+ </PopoverTrigger>
1875
+ );
1876
+ }
1877
+
1878
+ function ColumnSortIcon({ isSorted }: { isSorted: DataTableSortDirection }) {
1879
+ if (isSorted === "asc") {
1880
+ return <ArrowUpNarrowWideIcon data-icon="inline-end" />;
1881
+ }
1882
+
1883
+ if (isSorted === "desc") {
1884
+ return <ArrowDownWideNarrowIcon data-icon="inline-end" />;
1885
+ }
1886
+
1887
+ return <ArrowUpDownIcon data-icon="inline-end" />;
1888
+ }
1889
+
1890
+ function ColumnHeaderMenu<TData extends RowData>({
1891
+ header,
1892
+ onOpenChange,
1893
+ open,
1894
+ }: {
1895
+ header: Header<TData, unknown>;
1896
+ onOpenChange: (open: boolean) => void;
1897
+ open: boolean;
1898
+ }) {
1899
+ const t = useMessages(dataTableMessages);
1900
+ const column = header.column;
1901
+ const table = header.getContext().table;
1902
+ const isSorted = column.getIsSorted();
1903
+ const isFiltered = isFilterValueActive(column.getFilterValue());
1904
+ const sorting = table.getState().sorting;
1905
+ const sortIndex = sorting.findIndex((sort) => sort.id === column.id);
1906
+ const sortCount = sorting.length;
1907
+ const hasColumnState = Boolean(isSorted) || isFiltered;
1908
+
1909
+ const clearColumnState = () => {
1910
+ column.setFilterValue(undefined);
1911
+ clearColumnSorting(table, column.id);
1912
+ };
1913
+
1914
+ return (
1915
+ <Popover open={open} onOpenChange={onOpenChange}>
1916
+ <ColumnHeaderMenuTrigger header={header} />
1917
+ <PopoverContent align="start" className="w-72 p-0">
1918
+ <div className="flex flex-col gap-3 p-3">
1919
+ <div className="flex min-w-0 items-start justify-between gap-2">
1920
+ <div className="min-w-0">
1921
+ <div className="truncate text-sm font-medium">
1922
+ {getColumnLabel(column)}
1923
+ </div>
1924
+ </div>
1925
+ {sortIndex >= 0 && sortCount > 1 && (
1926
+ <Badge variant="outline" className="shrink-0">
1927
+ {t.sortCriterion(sortIndex + 1)}
1928
+ </Badge>
1929
+ )}
1930
+ </div>
1931
+
1932
+ {column.getCanSort() && (
1933
+ <ColumnSortControls
1934
+ column={column}
1935
+ isSorted={isSorted}
1936
+ table={table}
1937
+ />
1938
+ )}
1939
+
1940
+ <ColumnFilterControls
1941
+ allowExclude={Boolean(table.options.meta?.enableExcludeFilters)}
1942
+ column={column}
1943
+ />
1944
+
1945
+ {(hasColumnState || column.getCanHide()) && (
1946
+ <>
1947
+ <Separator />
1948
+ <div className="flex gap-2">
1949
+ {hasColumnState && (
1950
+ <Button
1951
+ variant="ghost"
1952
+ size="sm"
1953
+ className="flex-1"
1954
+ onClick={clearColumnState}
1955
+ >
1956
+ <XIcon data-icon="inline-start" />
1957
+ {t.clearAll}
1958
+ </Button>
1959
+ )}
1960
+ {column.getCanHide() && (
1961
+ <Button
1962
+ variant="outline"
1963
+ size="sm"
1964
+ className="flex-1"
1965
+ onClick={() => column.toggleVisibility(false)}
1966
+ >
1967
+ <EyeOffIcon data-icon="inline-start" />
1968
+ {t.hide}
1969
+ </Button>
1970
+ )}
1971
+ </div>
1972
+ </>
1973
+ )}
1974
+ </div>
1975
+ </PopoverContent>
1976
+ </Popover>
1977
+ );
1978
+ }
1979
+
1980
+ // Pressed option inside the column menu; the selected state uses the primary
1981
+ // fill plus a check so it stays distinguishable in both themes.
1982
+ function ColumnMenuOption({
1983
+ buttonRef,
1984
+ children,
1985
+ onClick,
1986
+ selected,
1987
+ }: {
1988
+ buttonRef?: React.Ref<HTMLButtonElement>;
1989
+ children: React.ReactNode;
1990
+ onClick: () => void;
1991
+ selected: boolean;
1992
+ }) {
1993
+ return (
1994
+ <Button
1995
+ ref={buttonRef}
1996
+ variant={selected ? "default" : "outline"}
1997
+ size="sm"
1998
+ className="justify-start"
1999
+ aria-pressed={selected}
2000
+ onClick={onClick}
2001
+ >
2002
+ {children}
2003
+ {selected && <CheckIcon data-icon="inline-end" className="ml-auto" />}
2004
+ </Button>
2005
+ );
2006
+ }
2007
+
2008
+ function ColumnSortControls<TData extends RowData>({
2009
+ column,
2010
+ isSorted,
2011
+ table,
2012
+ }: {
2013
+ column: Column<TData, unknown>;
2014
+ isSorted: false | "asc" | "desc";
2015
+ table: TanstackTable<TData>;
2016
+ }) {
2017
+ const t = useMessages(dataTableMessages);
2018
+ const ascendingRef = React.useRef<HTMLButtonElement>(null);
2019
+
2020
+ return (
2021
+ <div className="flex flex-col gap-2">
2022
+ <div className="flex min-h-6 items-center justify-between gap-2">
2023
+ <Label className="text-muted-foreground text-xs">{t.sort}</Label>
2024
+ {isSorted && (
2025
+ <Button
2026
+ variant="ghost"
2027
+ size="xs"
2028
+ onClick={() => {
2029
+ clearColumnSorting(table, column.id);
2030
+ ascendingRef.current?.focus();
2031
+ }}
2032
+ >
2033
+ <XIcon data-icon="inline-start" />
2034
+ {t.removeSort}
2035
+ </Button>
2036
+ )}
2037
+ </div>
2038
+ <div className="grid grid-cols-2 gap-2" role="group" aria-label={t.sort}>
2039
+ <ColumnMenuOption
2040
+ buttonRef={ascendingRef}
2041
+ selected={isSorted === "asc"}
2042
+ onClick={() => setColumnSorting(table, column.id, false)}
2043
+ >
2044
+ <ArrowUpNarrowWideIcon data-icon="inline-start" />
2045
+ {t.ascending}
2046
+ </ColumnMenuOption>
2047
+ <ColumnMenuOption
2048
+ selected={isSorted === "desc"}
2049
+ onClick={() => setColumnSorting(table, column.id, true)}
2050
+ >
2051
+ <ArrowDownWideNarrowIcon data-icon="inline-start" />
2052
+ {t.descending}
2053
+ </ColumnMenuOption>
2054
+ </div>
2055
+ </div>
2056
+ );
2057
+ }
2058
+
2059
+ function ColumnFilterControls<TData extends RowData>({
2060
+ allowExclude,
2061
+ column,
2062
+ }: {
2063
+ allowExclude: boolean;
2064
+ column: Column<TData, unknown>;
2065
+ }) {
2066
+ const t = useMessages(dataTableMessages);
2067
+ const filter = getColumnMeta(column)?.filter;
2068
+
2069
+ if (!filter) {
2070
+ return null;
2071
+ }
2072
+
2073
+ if (filter.type === "custom") {
2074
+ return filter.render(column);
2075
+ }
2076
+
2077
+ if (filter.type === "multi") {
2078
+ return (
2079
+ <ColumnMultiFilter
2080
+ allowExclude={allowExclude}
2081
+ column={column}
2082
+ options={filter.options}
2083
+ />
2084
+ );
2085
+ }
2086
+
2087
+ if (filter.type === "number-range") {
2088
+ const range = readRangeFilterValue(column.getFilterValue());
2089
+
2090
+ return (
2091
+ <div className="flex flex-col gap-2">
2092
+ <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2093
+ <div className="grid grid-cols-2 gap-2">
2094
+ <Input
2095
+ type="number"
2096
+ min={filter.min}
2097
+ max={filter.max}
2098
+ value={range.min ?? ""}
2099
+ onChange={(event) =>
2100
+ setObjectFilterValue(column, "min", event.target.value)
2101
+ }
2102
+ placeholder={filter.minPlaceholder ?? t.min}
2103
+ />
2104
+ <Input
2105
+ type="number"
2106
+ min={filter.min}
2107
+ max={filter.max}
2108
+ value={range.max ?? ""}
2109
+ onChange={(event) =>
2110
+ setObjectFilterValue(column, "max", event.target.value)
2111
+ }
2112
+ placeholder={filter.maxPlaceholder ?? t.max}
2113
+ />
2114
+ </div>
2115
+ </div>
2116
+ );
2117
+ }
2118
+
2119
+ if (filter.type === "date-range") {
2120
+ const range = readDateRangeFilterValue(column.getFilterValue());
2121
+
2122
+ return (
2123
+ <div className="flex flex-col gap-2">
2124
+ <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2125
+ <div className="grid grid-cols-2 gap-2">
2126
+ <Input
2127
+ type="date"
2128
+ value={range.from ?? ""}
2129
+ onChange={(event) =>
2130
+ setObjectFilterValue(column, "from", event.target.value)
2131
+ }
2132
+ />
2133
+ <Input
2134
+ type="date"
2135
+ value={range.to ?? ""}
2136
+ onChange={(event) =>
2137
+ setObjectFilterValue(column, "to", event.target.value)
2138
+ }
2139
+ />
2140
+ </div>
2141
+ </div>
2142
+ );
2143
+ }
2144
+
2145
+ return <ColumnTextFilter column={column} placeholder={filter.placeholder} />;
2146
+ }
2147
+
2148
+ function ColumnTextFilter<TData extends RowData>({
2149
+ column,
2150
+ placeholder,
2151
+ }: {
2152
+ column: Column<TData, unknown>;
2153
+ placeholder?: string;
2154
+ }) {
2155
+ const t = useMessages(dataTableMessages);
2156
+ const label = getColumnLabel(column).toLocaleLowerCase();
2157
+
2158
+ return (
2159
+ <div className="flex flex-col gap-2">
2160
+ <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2161
+ <Input
2162
+ value={
2163
+ readStringProperty({ value: column.getFilterValue() }, "value") ?? ""
2164
+ }
2165
+ onChange={(event) =>
2166
+ column.setFilterValue(event.target.value || undefined)
2167
+ }
2168
+ placeholder={placeholder ?? t.filterBy(label)}
2169
+ />
2170
+ </div>
2171
+ );
2172
+ }
2173
+
2174
+ function ColumnMultiFilter<TData extends RowData>({
2175
+ allowExclude,
2176
+ column,
2177
+ options,
2178
+ }: {
2179
+ allowExclude: boolean;
2180
+ column: Column<TData, unknown>;
2181
+ options: DataTableFilterOption[];
2182
+ }) {
2183
+ const t = useMessages(dataTableMessages);
2184
+ const stored = readMultiFilterValue(column.getFilterValue());
2185
+ const [pendingExclude, setPendingExclude] = React.useState(stored.exclude);
2186
+ const selected = stored.values;
2187
+ const exclude =
2188
+ allowExclude && (selected.length ? stored.exclude : pendingExclude);
2189
+
2190
+ const toggleValue = (value: string, checked: boolean) => {
2191
+ const next = checked
2192
+ ? [...selected, value]
2193
+ : selected.filter((item) => item !== value);
2194
+
2195
+ column.setFilterValue(toMultiFilterValue(exclude, next));
2196
+ };
2197
+
2198
+ const setMode = (nextExclude: boolean) => {
2199
+ setPendingExclude(nextExclude);
2200
+ column.setFilterValue(toMultiFilterValue(nextExclude, selected));
2201
+ };
2202
+
2203
+ return (
2204
+ <div className="flex flex-col gap-2">
2205
+ <Label className="text-muted-foreground text-xs">{t.filter}</Label>
2206
+ {allowExclude && (
2207
+ <div
2208
+ className="grid grid-cols-2 gap-2"
2209
+ role="group"
2210
+ aria-label={t.filterMode}
2211
+ >
2212
+ <ColumnMenuOption selected={!exclude} onClick={() => setMode(false)}>
2213
+ <FunnelIcon data-icon="inline-start" />
2214
+ {t.show}
2215
+ </ColumnMenuOption>
2216
+ <ColumnMenuOption selected={exclude} onClick={() => setMode(true)}>
2217
+ <FunnelXIcon data-icon="inline-start" />
2218
+ {t.exclude}
2219
+ </ColumnMenuOption>
2220
+ </div>
2221
+ )}
2222
+ <div
2223
+ className="flex max-h-56 scroll-fade flex-col gap-1 overflow-auto rounded-2xl border p-1"
2224
+ role="group"
2225
+ aria-label={exclude ? t.valuesToExclude : t.valuesToShow}
2226
+ >
2227
+ {options.map((option) => (
2228
+ <label
2229
+ key={option.value}
2230
+ className="hover:bg-accent flex items-center gap-2 rounded-xl px-2 py-1.5 text-sm font-normal"
2231
+ >
2232
+ <Checkbox
2233
+ checked={selected.includes(option.value)}
2234
+ onCheckedChange={(checked) =>
2235
+ toggleValue(option.value, Boolean(checked))
2236
+ }
2237
+ />
2238
+ <span
2239
+ className={cn(
2240
+ "min-w-0 flex-1 truncate",
2241
+ exclude &&
2242
+ selected.includes(option.value) &&
2243
+ "text-muted-foreground line-through",
2244
+ )}
2245
+ >
2246
+ {option.label}
2247
+ </span>
2248
+ </label>
2249
+ ))}
2250
+ </div>
2251
+ </div>
2252
+ );
2253
+ }
2254
+
2255
+ function ColumnSettings<TData extends RowData>({
2256
+ defaultColumnOrder,
2257
+ lockedEndColumnIds,
2258
+ lockedStartColumnIds,
2259
+ onResetColumns,
2260
+ table,
2261
+ }: {
2262
+ defaultColumnOrder: ColumnOrderState;
2263
+ lockedEndColumnIds: string[];
2264
+ lockedStartColumnIds: string[];
2265
+ onResetColumns: () => void;
2266
+ table: TanstackTable<TData>;
2267
+ }) {
2268
+ const t = useMessages(dataTableMessages);
2269
+ const hiddenColumnCount = table
2270
+ .getAllLeafColumns()
2271
+ .filter((column) => column.getCanHide())
2272
+ .filter((column) => !column.getIsVisible()).length;
2273
+ const visibleColumnsCustomized = hiddenColumnCount > 0;
2274
+ const columnOrderCustomized = !areColumnOrdersEqual(
2275
+ normalizeColumnOrder(
2276
+ table.getState().columnOrder,
2277
+ defaultColumnOrder,
2278
+ lockedStartColumnIds,
2279
+ lockedEndColumnIds,
2280
+ ),
2281
+ normalizeColumnOrder(
2282
+ defaultColumnOrder,
2283
+ defaultColumnOrder,
2284
+ lockedStartColumnIds,
2285
+ lockedEndColumnIds,
2286
+ ),
2287
+ );
2288
+ const columnsCustomized = visibleColumnsCustomized || columnOrderCustomized;
2289
+
2290
+ return (
2291
+ <TooltipProvider>
2292
+ <Tooltip>
2293
+ <DropdownMenu>
2294
+ <TooltipTrigger
2295
+ render={
2296
+ <DropdownMenuTrigger
2297
+ render={
2298
+ <Button
2299
+ variant="outline"
2300
+ size="icon-sm"
2301
+ className="relative"
2302
+ aria-label={t.customizeColumns}
2303
+ />
2304
+ }
2305
+ />
2306
+ }
2307
+ >
2308
+ <Columns3Icon />
2309
+ {hiddenColumnCount > 0 && (
2310
+ <span className="bg-primary absolute -top-0.5 -right-0.5 size-2 rounded-full" />
2311
+ )}
2312
+ </TooltipTrigger>
2313
+ <TooltipContent side="bottom" align="end">
2314
+ {t.columnsTooltip}
2315
+ </TooltipContent>
2316
+ <DropdownMenuContent align="end" className="w-64">
2317
+ <DropdownMenuGroup>
2318
+ <DropdownMenuLabel>{t.columns}</DropdownMenuLabel>
2319
+ {table
2320
+ .getAllLeafColumns()
2321
+ .filter((column) => column.getCanHide())
2322
+ .map((column) => (
2323
+ <DropdownMenuCheckboxItem
2324
+ key={column.id}
2325
+ checked={column.getIsVisible()}
2326
+ onCheckedChange={(value) =>
2327
+ column.toggleVisibility(!!value)
2328
+ }
2329
+ closeOnClick={false}
2330
+ >
2331
+ {getColumnLabel(column)}
2332
+ </DropdownMenuCheckboxItem>
2333
+ ))}
2334
+ </DropdownMenuGroup>
2335
+ {columnsCustomized && (
2336
+ <>
2337
+ <DropdownMenuSeparator />
2338
+ <DropdownMenuItem onClick={onResetColumns}>
2339
+ <XIcon />
2340
+ {t.resetColumns}
2341
+ </DropdownMenuItem>
2342
+ </>
2343
+ )}
2344
+ </DropdownMenuContent>
2345
+ </DropdownMenu>
2346
+ </Tooltip>
2347
+ </TooltipProvider>
2348
+ );
2349
+ }
2350
+
2351
+ function DataTablePagination<TData extends RowData>({
2352
+ formatRowCount,
2353
+ formatSelectedRowCount,
2354
+ pageSizeOptions,
2355
+ rowCount,
2356
+ storageKey,
2357
+ table,
2358
+ }: {
2359
+ formatRowCount: (count: number) => string;
2360
+ formatSelectedRowCount?: (count: number) => string;
2361
+ pageSizeOptions: number[];
2362
+ rowCount?: number;
2363
+ storageKey: string;
2364
+ table: TanstackTable<TData>;
2365
+ }) {
2366
+ const t = useMessages(dataTableMessages);
2367
+ const selectedRows = table.getFilteredSelectedRowModel().rows.length;
2368
+ const filteredRows = rowCount ?? table.getFilteredRowModel().rows.length;
2369
+ const totalRows = rowCount ?? table.getCoreRowModel().rows.length;
2370
+ const selectedRowsLabel =
2371
+ formatSelectedRowCount ??
2372
+ ((count: number) => t.selected(formatRowCount(count), count));
2373
+ const fallbackId = React.useId();
2374
+ const inputId = storageKey
2375
+ ? `${getStorageSafeId(storageKey)}-rows-per-page`
2376
+ : fallbackId;
2377
+ let paginationSummary = t.ofSummary(
2378
+ formatRowCount(filteredRows),
2379
+ formatRowCount(totalRows),
2380
+ );
2381
+
2382
+ if (selectedRows > 0) {
2383
+ paginationSummary = t.ofSummary(
2384
+ selectedRowsLabel(selectedRows),
2385
+ formatRowCount(filteredRows),
2386
+ );
2387
+ } else if (filteredRows === totalRows) {
2388
+ paginationSummary = `${formatRowCount(totalRows)}.`;
2389
+ }
2390
+
2391
+ return (
2392
+ <div className="flex flex-col gap-3 px-1 sm:flex-row sm:items-center sm:justify-between">
2393
+ <div className="text-muted-foreground text-sm">{paginationSummary}</div>
2394
+ <div className="flex flex-wrap items-center gap-3">
2395
+ <div className="flex items-center gap-2">
2396
+ <Label htmlFor={inputId} className="text-sm">
2397
+ {t.rowsPerPage}
2398
+ </Label>
2399
+ <Select
2400
+ value={`${table.getState().pagination.pageSize}`}
2401
+ onValueChange={(value) => table.setPageSize(Number(value))}
2402
+ >
2403
+ <SelectTrigger id={inputId} size="sm" className="w-20">
2404
+ <SelectValue />
2405
+ </SelectTrigger>
2406
+ <SelectContent side="top">
2407
+ {pageSizeOptions.map((pageSize) => (
2408
+ <SelectItem key={pageSize} value={`${pageSize}`}>
2409
+ {pageSize}
2410
+ </SelectItem>
2411
+ ))}
2412
+ </SelectContent>
2413
+ </Select>
2414
+ </div>
2415
+ <div className="text-sm font-medium">
2416
+ {t.page(
2417
+ table.getState().pagination.pageIndex + 1,
2418
+ Math.max(table.getPageCount(), 1),
2419
+ )}
2420
+ </div>
2421
+ <div className="flex items-center gap-2">
2422
+ <Button
2423
+ variant="outline"
2424
+ className="hidden sm:flex"
2425
+ size="icon"
2426
+ onClick={() => table.setPageIndex(0)}
2427
+ disabled={!table.getCanPreviousPage()}
2428
+ >
2429
+ <span className="sr-only">{t.firstPage}</span>
2430
+ <ChevronsLeftIcon />
2431
+ </Button>
2432
+ <Button
2433
+ variant="outline"
2434
+ size="icon"
2435
+ onClick={() => table.previousPage()}
2436
+ disabled={!table.getCanPreviousPage()}
2437
+ >
2438
+ <span className="sr-only">{t.previousPage}</span>
2439
+ <ChevronLeftIcon />
2440
+ </Button>
2441
+ <Button
2442
+ variant="outline"
2443
+ size="icon"
2444
+ onClick={() => table.nextPage()}
2445
+ disabled={!table.getCanNextPage()}
2446
+ >
2447
+ <span className="sr-only">{t.nextPage}</span>
2448
+ <ChevronRightIcon />
2449
+ </Button>
2450
+ <Button
2451
+ variant="outline"
2452
+ className="hidden sm:flex"
2453
+ size="icon"
2454
+ onClick={() => table.setPageIndex(table.getPageCount() - 1)}
2455
+ disabled={!table.getCanNextPage()}
2456
+ >
2457
+ <span className="sr-only">{t.lastPage}</span>
2458
+ <ChevronsRightIcon />
2459
+ </Button>
2460
+ </div>
2461
+ </div>
2462
+ </div>
2463
+ );
2464
+ }
2465
+
2466
+ export function DataTableSkeleton() {
2467
+ return (
2468
+ <div className="flex w-full flex-col gap-4" aria-hidden="true">
2469
+ <div className="flex items-center gap-2">
2470
+ <Skeleton className="h-9 flex-1 rounded-3xl" />
2471
+ <Skeleton className="size-8 rounded-full" />
2472
+ </div>
2473
+ <div className="bg-card overflow-hidden rounded-2xl border">
2474
+ <Skeleton className="h-10 rounded-none" />
2475
+ <div className="flex flex-col">
2476
+ {Array.from({ length: 8 }).map((_, index) => (
2477
+ <Skeleton
2478
+ key={index}
2479
+ className="bg-muted/40 h-14 rounded-none border-t"
2480
+ />
2481
+ ))}
2482
+ </div>
2483
+ </div>
2484
+ </div>
2485
+ );
2486
+ }