@elabs-ai/components-data 4.2.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/chunk-3OVN7MMO.js +2 -0
- package/dist/chunk-3OVN7MMO.js.map +1 -0
- package/dist/chunk-3UFWYVDS.js +2 -0
- package/dist/chunk-3UFWYVDS.js.map +1 -0
- package/dist/chunk-47ZBQZYS.js +47 -0
- package/dist/chunk-47ZBQZYS.js.map +1 -0
- package/dist/chunk-5K2IFOV2.js +2 -0
- package/dist/chunk-5K2IFOV2.js.map +1 -0
- package/dist/chunk-677ORWI6.js +17 -0
- package/dist/chunk-677ORWI6.js.map +1 -0
- package/dist/chunk-7ZYUY3UL.js +2 -0
- package/dist/chunk-7ZYUY3UL.js.map +1 -0
- package/dist/chunk-BEYHV3ZX.js +37 -0
- package/dist/chunk-BEYHV3ZX.js.map +1 -0
- package/dist/chunk-DBPJ7LVM.js +57 -0
- package/dist/chunk-DBPJ7LVM.js.map +1 -0
- package/dist/chunk-E3V3SYY7.js +87 -0
- package/dist/chunk-E3V3SYY7.js.map +1 -0
- package/dist/chunk-GBO56756.js +81 -0
- package/dist/chunk-GBO56756.js.map +1 -0
- package/dist/chunk-HG2YMSRM.js +95 -0
- package/dist/chunk-HG2YMSRM.js.map +1 -0
- package/dist/chunk-K2GHSRCP.js +1823 -0
- package/dist/chunk-K2GHSRCP.js.map +1 -0
- package/dist/chunk-KCB33PD3.js +43 -0
- package/dist/chunk-KCB33PD3.js.map +1 -0
- package/dist/chunk-MV2YVXEZ.js +67 -0
- package/dist/chunk-MV2YVXEZ.js.map +1 -0
- package/dist/chunk-NKVJUQYW.js +82 -0
- package/dist/chunk-NKVJUQYW.js.map +1 -0
- package/dist/chunk-OASM53LL.js +29 -0
- package/dist/chunk-OASM53LL.js.map +1 -0
- package/dist/chunk-RZ5MYZOA.js +2 -0
- package/dist/chunk-RZ5MYZOA.js.map +1 -0
- package/dist/chunk-SK6347WB.js +100 -0
- package/dist/chunk-SK6347WB.js.map +1 -0
- package/dist/chunk-TBB33PHQ.js +58 -0
- package/dist/chunk-TBB33PHQ.js.map +1 -0
- package/dist/chunk-VCLA45IC.js +150 -0
- package/dist/chunk-VCLA45IC.js.map +1 -0
- package/dist/chunk-XN6O6Z2N.js +58 -0
- package/dist/chunk-XN6O6Z2N.js.map +1 -0
- package/dist/chunk-ZFEW36RG.js +77 -0
- package/dist/chunk-ZFEW36RG.js.map +1 -0
- package/dist/chunk-ZQXJ34VJ.js +34 -0
- package/dist/chunk-ZQXJ34VJ.js.map +1 -0
- package/dist/chunk-ZUCGV2XD.js +42 -0
- package/dist/chunk-ZUCGV2XD.js.map +1 -0
- package/dist/column-picker/column-picker.js +8 -0
- package/dist/column-picker/column-picker.js.map +1 -0
- package/dist/column-picker/index.js +9 -0
- package/dist/column-picker/index.js.map +1 -0
- package/dist/data-table/card-layout.js +11 -0
- package/dist/data-table/card-layout.js.map +1 -0
- package/dist/data-table/cell-scales.js +26 -0
- package/dist/data-table/cell-scales.js.map +1 -0
- package/dist/data-table/cells/bar-cell.js +14 -0
- package/dist/data-table/cells/bar-cell.js.map +1 -0
- package/dist/data-table/cells/category-legend.js +9 -0
- package/dist/data-table/cells/category-legend.js.map +1 -0
- package/dist/data-table/cells/columns-cell.js +13 -0
- package/dist/data-table/cells/columns-cell.js.map +1 -0
- package/dist/data-table/cells/heatmap-cell.js +13 -0
- package/dist/data-table/cells/heatmap-cell.js.map +1 -0
- package/dist/data-table/cells/markdown-cell.js +13 -0
- package/dist/data-table/cells/markdown-cell.js.map +1 -0
- package/dist/data-table/cells/sparkline-cell.js +13 -0
- package/dist/data-table/cells/sparkline-cell.js.map +1 -0
- package/dist/data-table/column-meta.js +12 -0
- package/dist/data-table/column-meta.js.map +1 -0
- package/dist/data-table/data-table.js +23 -0
- package/dist/data-table/data-table.js.map +1 -0
- package/dist/data-table/index.js +23 -0
- package/dist/data-table/index.js.map +1 -0
- package/dist/data-table/ranks-column.js +13 -0
- package/dist/data-table/ranks-column.js.map +1 -0
- package/dist/data-table/sticky-rows.js +10 -0
- package/dist/data-table/sticky-rows.js.map +1 -0
- package/dist/data-table/use-table-breakpoint.js +13 -0
- package/dist/data-table/use-table-breakpoint.js.map +1 -0
- package/dist/facet-filter/facet-filter.js +8 -0
- package/dist/facet-filter/facet-filter.js.map +1 -0
- package/dist/facet-filter/index.js +9 -0
- package/dist/facet-filter/index.js.map +1 -0
- package/dist/filter-bar/filter-bar.js +8 -0
- package/dist/filter-bar/filter-bar.js.map +1 -0
- package/dist/filter-bar/filter-chip.js +9 -0
- package/dist/filter-bar/filter-chip.js.map +1 -0
- package/dist/filter-bar/index.js +13 -0
- package/dist/filter-bar/index.js.map +1 -0
- package/dist/index.d.ts +547 -8
- package/dist/index.js +102 -1512
- package/dist/index.js.map +1 -1
- package/dist/search-input/index.js +9 -0
- package/dist/search-input/index.js.map +1 -0
- package/dist/search-input/search-input.js +9 -0
- package/dist/search-input/search-input.js.map +1 -0
- package/dist/to-csv.js +10 -0
- package/dist/to-csv.js.map +1 -0
- package/package.json +7 -7
- package/src/data-table/card-layout.test.tsx +26 -0
- package/src/data-table/card-layout.tsx +74 -0
- package/src/data-table/cell-scales.test.ts +118 -0
- package/src/data-table/cell-scales.ts +246 -0
- package/src/data-table/cells/bar-cell.test.tsx +158 -0
- package/src/data-table/cells/bar-cell.tsx +135 -0
- package/src/data-table/cells/category-legend.test.tsx +37 -0
- package/src/data-table/cells/category-legend.tsx +58 -0
- package/src/data-table/cells/columns-cell.test.tsx +27 -0
- package/src/data-table/cells/columns-cell.tsx +79 -0
- package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
- package/src/data-table/cells/heatmap-cell.tsx +116 -0
- package/src/data-table/cells/markdown-cell.test.tsx +36 -0
- package/src/data-table/cells/markdown-cell.tsx +91 -0
- package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
- package/src/data-table/cells/sparkline-cell.tsx +113 -0
- package/src/data-table/column-meta.test.ts +42 -0
- package/src/data-table/column-meta.ts +282 -0
- package/src/data-table/data-table.stories.tsx +733 -1
- package/src/data-table/data-table.test.tsx +486 -0
- package/src/data-table/data-table.tsx +965 -165
- package/src/data-table/ranks-column.test.tsx +65 -0
- package/src/data-table/ranks-column.tsx +91 -0
- package/src/data-table/sticky-rows.test.ts +16 -0
- package/src/data-table/sticky-rows.ts +77 -0
- package/src/data-table/use-table-breakpoint.test.ts +14 -0
- package/src/data-table/use-table-breakpoint.ts +67 -0
- package/src/filter-bar/filter-chip.stories.tsx +1 -0
- package/src/index.ts +64 -0
- package/src/templates-data-app.stories.tsx +7 -2
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
forwardRef,
|
|
5
5
|
useCallback,
|
|
6
6
|
useEffect,
|
|
7
|
+
useId,
|
|
7
8
|
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
useState,
|
|
@@ -19,12 +20,13 @@ import {
|
|
|
19
20
|
type Column,
|
|
20
21
|
type ColumnDef,
|
|
21
22
|
type ColumnFiltersState,
|
|
23
|
+
type Cell,
|
|
22
24
|
type ColumnPinningState,
|
|
23
25
|
type ColumnSizingState,
|
|
26
|
+
type Header,
|
|
24
27
|
type OnChangeFn,
|
|
25
28
|
type PaginationState,
|
|
26
29
|
type Row,
|
|
27
|
-
type RowData,
|
|
28
30
|
type RowSelectionState,
|
|
29
31
|
type SortingState,
|
|
30
32
|
type Table as TanstackTable,
|
|
@@ -64,8 +66,35 @@ import {
|
|
|
64
66
|
} from "@dnd-kit/sortable";
|
|
65
67
|
import { CSS } from "@dnd-kit/utilities";
|
|
66
68
|
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from "lucide-react";
|
|
67
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
Button,
|
|
71
|
+
Checkbox,
|
|
72
|
+
Skeleton,
|
|
73
|
+
Spinner,
|
|
74
|
+
StatePanel,
|
|
75
|
+
useLocale,
|
|
76
|
+
type ColorScale,
|
|
77
|
+
} from "@elabs-ai/components-ui";
|
|
68
78
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
79
|
+
import {
|
|
80
|
+
columnSizeStyle,
|
|
81
|
+
formatCellValue,
|
|
82
|
+
resolveShowAt,
|
|
83
|
+
type DataTableColumnMeta,
|
|
84
|
+
} from "./column-meta";
|
|
85
|
+
import { computeColumnScales, extentOf, labelBoxCh, seriesEnds, seriesValues } from "./cell-scales";
|
|
86
|
+
import { BarCell } from "./cells/bar-cell";
|
|
87
|
+
import { ColumnsCell } from "./cells/columns-cell";
|
|
88
|
+
import { HeatmapCell, HeatmapLegend, heatmapCellStyle } from "./cells/heatmap-cell";
|
|
89
|
+
import { CategoryLegend } from "./cells/category-legend";
|
|
90
|
+
import { MarkdownCell } from "./cells/markdown-cell";
|
|
91
|
+
import { SparklineCell } from "./cells/sparkline-cell";
|
|
92
|
+
import { DataTableCard, DataTableCardList, type DataTableCardField } from "./card-layout";
|
|
93
|
+
import { DataTableRankCell, DataTableRankHeader, computeRowRanks } from "./ranks-column";
|
|
94
|
+
import { stickyRowPinning, withoutStickyRows, type DataTableStickyRows } from "./sticky-rows";
|
|
95
|
+
import { useTableBreakpoint } from "./use-table-breakpoint";
|
|
96
|
+
|
|
97
|
+
export type { DataTableColumnMeta } from "./column-meta";
|
|
69
98
|
|
|
70
99
|
// ─── Column meta seam (#69) ─────────────────────────────────────────────────────
|
|
71
100
|
// `columnDef.meta` is where TanStack lets a caller attach column-specific,
|
|
@@ -76,32 +105,9 @@ import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
|
76
105
|
// site. Exported (not just declared) so a consumer's own `ColumnDef` literal
|
|
77
106
|
// type-checks against a NAMED type, per component-api.md § Types.
|
|
78
107
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
* end-alignment on both the `<th>` and every `<td>` (including the loading
|
|
83
|
-
* skeleton) for free.
|
|
84
|
-
*/
|
|
85
|
-
export interface DataTableColumnMeta {
|
|
86
|
-
/** Numeric column: tabular figures + end alignment on header and cells. */
|
|
87
|
-
numeric?: boolean;
|
|
88
|
-
/**
|
|
89
|
-
* Explicit alignment override for when `numeric` isn't the right cue (or
|
|
90
|
-
* to align a non-numeric column). Independent of `numeric` — `numeric`
|
|
91
|
-
* alone still drives `tabular-nums` even when `align` overrides the
|
|
92
|
-
* alignment away from `"end"`.
|
|
93
|
-
*/
|
|
94
|
-
align?: "start" | "center" | "end";
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
declare module "@tanstack/react-table" {
|
|
98
|
-
// `TData`/`TValue` must stay in the signature to match the interface being
|
|
99
|
-
// augmented, even though `DataTableColumnMeta` (deliberately) doesn't use
|
|
100
|
-
// them; the empty extends-body is how TanStack's own module-augmentation
|
|
101
|
-
// pattern for `ColumnMeta` is documented.
|
|
102
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-object-type
|
|
103
|
-
interface ColumnMeta<TData extends RowData, TValue> extends DataTableColumnMeta {}
|
|
104
|
-
}
|
|
108
|
+
// `DataTableColumnMeta` and its TanStack `ColumnMeta` augmentation live in
|
|
109
|
+
// `./column-meta` (RM-123 grew the contract: visuals, format, colorBy, showAt,
|
|
110
|
+
// sizing, markdown); they are re-exported above under the same name.
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
113
|
* `<th>`/`<td>`/skeleton-`<td>` className for a column's `meta.numeric`/`meta.align`
|
|
@@ -421,6 +427,58 @@ export interface DataTableProps<TData, TValue> extends Omit<
|
|
|
421
427
|
*/
|
|
422
428
|
columnDividers?: boolean;
|
|
423
429
|
|
|
430
|
+
// ── Presentation (RM-123) ──────────────────────────────────────────────────
|
|
431
|
+
/**
|
|
432
|
+
* `"table"` (default): always a `<table>`. `"cards"`: always one card per
|
|
433
|
+
* row (a `<dl>` of header → cell). `"auto"`: cards while the table's own
|
|
434
|
+
* container is narrower than 450 px, a `<table>` above. Only the markup
|
|
435
|
+
* changes; sorting, filtering, paging and selection use the same table.
|
|
436
|
+
*/
|
|
437
|
+
layout?: "auto" | "table" | "cards";
|
|
438
|
+
/**
|
|
439
|
+
* Rows pinned to the top or bottom of every page. An "average" or "total"
|
|
440
|
+
* row stays put through sorting, paging and search. Receives each record
|
|
441
|
+
* and its `data` index.
|
|
442
|
+
*/
|
|
443
|
+
stickyRows?: DataTableStickyRows<TData>;
|
|
444
|
+
/**
|
|
445
|
+
* Prepend a 1…n rank column, in `data` order. Sticky rows are not ranked;
|
|
446
|
+
* the rank travels with its row — sorting never renumbers it.
|
|
447
|
+
*/
|
|
448
|
+
showRanks?: boolean;
|
|
449
|
+
/**
|
|
450
|
+
* What the rank column is CALLED — its accessible name, its `title`, the
|
|
451
|
+
* term in the cards layout, and the key printed above the table. Defaults to
|
|
452
|
+
* the locale seam (`data.table.rankHeader` / `data.table.rankKey`).
|
|
453
|
+
*
|
|
454
|
+
* The number is the row's position in `data`, not its position on screen, so
|
|
455
|
+
* beside a sorted column it reads 2, 1, 6, 4 on purpose. A column headed by
|
|
456
|
+
* a bare "#" gives a reader no way to know that; this names it.
|
|
457
|
+
*/
|
|
458
|
+
rankLabel?: string;
|
|
459
|
+
/** `"compact"` tightens row and header height. Default `"default"`. */
|
|
460
|
+
density?: "default" | "compact";
|
|
461
|
+
/**
|
|
462
|
+
* Span an ungrouped column's header over the empty group rows. With
|
|
463
|
+
* grouped headers, the placeholder cells above a column merge into one.
|
|
464
|
+
*/
|
|
465
|
+
mergeEmptyHeaders?: boolean;
|
|
466
|
+
/**
|
|
467
|
+
* `"exact"`: a row matches only when a cell equals the query. Default
|
|
468
|
+
* `"contains"` (TanStack's substring search); both ignore case.
|
|
469
|
+
*/
|
|
470
|
+
searchMode?: "contains" | "exact";
|
|
471
|
+
/**
|
|
472
|
+
* Hide the header row visually (a pixel heatmap). The headers stay for
|
|
473
|
+
* screen readers, and a focused sort button still shows itself.
|
|
474
|
+
*
|
|
475
|
+
* Sorting is then KEYBOARD-reachable, not clickable: the band has no height,
|
|
476
|
+
* so it offers no hit area — deliberately, since an invisible click target
|
|
477
|
+
* over the first data row would be worse than none. Tab to the column's sort
|
|
478
|
+
* button (it becomes visible on focus) and press Enter.
|
|
479
|
+
*/
|
|
480
|
+
hideHeader?: boolean;
|
|
481
|
+
|
|
424
482
|
// ── Row drag-reorder (#13) ───────────────────────────────────────────────
|
|
425
483
|
/**
|
|
426
484
|
* Opt-in row drag-reorder. Off by default — an existing table renders
|
|
@@ -439,6 +497,10 @@ export interface DataTableProps<TData, TValue> extends Omit<
|
|
|
439
497
|
* is silently disabled (a dev warning fires) when both are set. Combining
|
|
440
498
|
* it with active `sorting` also fires a dev warning (both still work, but
|
|
441
499
|
* a sort re-orders the very rows a drag just moved, which reads as broken).
|
|
500
|
+
*
|
|
501
|
+
* Table-only. The card layout (`layout="cards"`, or `"auto"` at the narrow
|
|
502
|
+
* tier) has no grip column and no row to drop onto, so reorder is a no-op
|
|
503
|
+
* there and `onRowReorder` never fires; a dev warning says so once per mount.
|
|
442
504
|
*/
|
|
443
505
|
enableRowReorder?: boolean;
|
|
444
506
|
/**
|
|
@@ -506,6 +568,48 @@ export interface DataTableProps<TData, TValue> extends Omit<
|
|
|
506
568
|
className?: string;
|
|
507
569
|
}
|
|
508
570
|
|
|
571
|
+
// ─── Presentation (RM-123, module-level) ────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
/** The category a `colorBy` key names on a record (a string or a finite number). */
|
|
574
|
+
function rowKeyValue(original: unknown, key: string): string | number | null {
|
|
575
|
+
const v = ((original ?? {}) as Record<string, unknown>)[key];
|
|
576
|
+
return typeof v === "string" || (typeof v === "number" && Number.isFinite(v)) ? v : null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* `colorBy` paint: a background wash (22 % of the category colour over the
|
|
581
|
+
* row's own ground), or text ink pulled 55 % toward `--foreground` so a
|
|
582
|
+
* categorical hue still clears text contrast in every theme.
|
|
583
|
+
*/
|
|
584
|
+
function colorByStyle(
|
|
585
|
+
target: "background" | "text",
|
|
586
|
+
color: string | null,
|
|
587
|
+
): React.CSSProperties | undefined {
|
|
588
|
+
if (!color) return undefined;
|
|
589
|
+
return target === "background"
|
|
590
|
+
? { backgroundColor: `color-mix(in oklab, ${color} 22%, transparent)` }
|
|
591
|
+
: { color: `color-mix(in oklab, ${color} 35%, var(--foreground))` };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The row's hidden activation button (#337): `sr-only` removes the box from the
|
|
596
|
+
* visual layout but not the browser's own focus ring — the ROW (or card)
|
|
597
|
+
* paints the deliberate compound indicator via a `has-[…]` selector, so the
|
|
598
|
+
* proxy's own native ring must be suppressed or it leaks as a stray dot.
|
|
599
|
+
*/
|
|
600
|
+
const ROW_ACTION_CLASS = "sr-only focus-visible:outline-none";
|
|
601
|
+
|
|
602
|
+
// ─── Exact search (RM-123) ─────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
/** `searchMode="exact"`: the cell equals the query, trimmed and case-insensitive. */
|
|
605
|
+
function exactSearchMatch(value: unknown, query: unknown): boolean {
|
|
606
|
+
const q = String(query ?? "")
|
|
607
|
+
.trim()
|
|
608
|
+
.toLowerCase();
|
|
609
|
+
if (q === "") return true;
|
|
610
|
+
return value !== null && value !== undefined && String(value).trim().toLowerCase() === q;
|
|
611
|
+
}
|
|
612
|
+
|
|
509
613
|
// ─── Row-click guards (module-level — shared by every renderRow call) ────────
|
|
510
614
|
|
|
511
615
|
/**
|
|
@@ -851,6 +955,16 @@ function DataTableInner<TData, TValue>(
|
|
|
851
955
|
zebra = true,
|
|
852
956
|
columnDividers = false,
|
|
853
957
|
|
|
958
|
+
// Presentation (RM-123)
|
|
959
|
+
layout = "table",
|
|
960
|
+
stickyRows,
|
|
961
|
+
showRanks = false,
|
|
962
|
+
rankLabel,
|
|
963
|
+
density = "default",
|
|
964
|
+
mergeEmptyHeaders = false,
|
|
965
|
+
searchMode = "contains",
|
|
966
|
+
hideHeader = false,
|
|
967
|
+
|
|
854
968
|
// Row drag-reorder (#13)
|
|
855
969
|
enableRowReorder = false,
|
|
856
970
|
onRowReorder,
|
|
@@ -1138,7 +1252,13 @@ function DataTableInner<TData, TValue>(
|
|
|
1138
1252
|
|
|
1139
1253
|
// ── Row models — omit client model for manual slices ─────────────────────
|
|
1140
1254
|
const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
|
|
1141
|
-
const filteredRowModel = manualFiltering
|
|
1255
|
+
const filteredRowModel = manualFiltering
|
|
1256
|
+
? {}
|
|
1257
|
+
: {
|
|
1258
|
+
getFilteredRowModel: stickyRows
|
|
1259
|
+
? withoutStickyRows(getFilteredRowModel<TData>())
|
|
1260
|
+
: getFilteredRowModel(),
|
|
1261
|
+
};
|
|
1142
1262
|
// Only attach the client pagination row model when we actually paginate locally.
|
|
1143
1263
|
// Under `manualPagination`, TanStack ignores a supplied `getPaginationRowModel`
|
|
1144
1264
|
// (it returns the pre-pagination rows — i.e. the page the app already fetched),
|
|
@@ -1147,6 +1267,15 @@ function DataTableInner<TData, TValue>(
|
|
|
1147
1267
|
const paginationRowModel =
|
|
1148
1268
|
enablePagination && !manualPagination ? { getPaginationRowModel: getPaginationRowModel() } : {};
|
|
1149
1269
|
|
|
1270
|
+
// ── Sticky rows (RM-123) ──────────────────────────────────────────────────
|
|
1271
|
+
// TanStack row pinning with `keepPinnedRows`: a sticky row renders on every
|
|
1272
|
+
// page and outside the sort, while keeping its id, selection and data index.
|
|
1273
|
+
const rowPinning = useMemo(
|
|
1274
|
+
() => stickyRowPinning(data, stickyRows, getRowId),
|
|
1275
|
+
[data, stickyRows, getRowId],
|
|
1276
|
+
);
|
|
1277
|
+
const stickyActive = (rowPinning.top?.length ?? 0) + (rowPinning.bottom?.length ?? 0) > 0;
|
|
1278
|
+
|
|
1150
1279
|
// ── Table instance ────────────────────────────────────────────────────────
|
|
1151
1280
|
const table = useReactTable({
|
|
1152
1281
|
data,
|
|
@@ -1160,7 +1289,16 @@ function DataTableInner<TData, TValue>(
|
|
|
1160
1289
|
columnPinning,
|
|
1161
1290
|
columnSizing,
|
|
1162
1291
|
rowSelection,
|
|
1292
|
+
...(stickyActive ? { rowPinning } : {}),
|
|
1163
1293
|
},
|
|
1294
|
+
...(stickyActive ? { enableRowPinning: true, keepPinnedRows: true } : {}),
|
|
1295
|
+
// RM-123 `searchMode="exact"`: a row matches when one cell EQUALS the query.
|
|
1296
|
+
...(searchMode === "exact"
|
|
1297
|
+
? {
|
|
1298
|
+
globalFilterFn: (row: Row<TData>, columnId: string, filterValue: unknown) =>
|
|
1299
|
+
exactSearchMatch(row.getValue(columnId), filterValue),
|
|
1300
|
+
}
|
|
1301
|
+
: {}),
|
|
1164
1302
|
|
|
1165
1303
|
// Sorting
|
|
1166
1304
|
onSortingChange: (updater) => {
|
|
@@ -1273,19 +1411,138 @@ function DataTableInner<TData, TValue>(
|
|
|
1273
1411
|
// TanStack `initialState` would be dead/misleading.
|
|
1274
1412
|
});
|
|
1275
1413
|
|
|
1276
|
-
|
|
1414
|
+
// Sticky rows (RM-123) render outside the centre rows, above and below them.
|
|
1415
|
+
const rows = stickyActive ? table.getCenterRows() : table.getRowModel().rows;
|
|
1416
|
+
const topRows = stickyActive ? table.getTopRows() : [];
|
|
1417
|
+
const bottomRows = stickyActive ? table.getBottomRows() : [];
|
|
1418
|
+
|
|
1419
|
+
// ── Presentation layer (RM-123) ──────────────────────────────────────────
|
|
1420
|
+
// Every piece below is gated on the column meta / prop that asks for it, so a
|
|
1421
|
+
// table that uses none of it renders exactly as before.
|
|
1422
|
+
const leafColumns = table.getAllLeafColumns();
|
|
1423
|
+
const coreRows = table.getCoreRowModel().rows;
|
|
1424
|
+
const needsScales = leafColumns.some(
|
|
1425
|
+
(c) => c.columnDef.meta?.visual !== undefined || c.columnDef.meta?.colorBy !== undefined,
|
|
1426
|
+
);
|
|
1427
|
+
// One scale per visual / colorBy column over ALL rows (never the page), so a
|
|
1428
|
+
// bar or a heatmap colour means the same thing on every page and sort.
|
|
1429
|
+
const columnScales = useMemo(
|
|
1430
|
+
() =>
|
|
1431
|
+
needsScales
|
|
1432
|
+
? computeColumnScales(
|
|
1433
|
+
leafColumns.map((c) => ({ id: c.id, meta: c.columnDef.meta })),
|
|
1434
|
+
coreRows,
|
|
1435
|
+
)
|
|
1436
|
+
: null,
|
|
1437
|
+
[needsScales, leafColumns, coreRows],
|
|
1438
|
+
);
|
|
1439
|
+
// One printed-label reservation per visual column, over ALL rows: a bar's
|
|
1440
|
+
// track and a sparkline's drawing get what the text leaves over, so a box
|
|
1441
|
+
// sized per row would give a row with a shorter number a LONGER bar (and move
|
|
1442
|
+
// a diverging column's zero rule from row to row). `ch` against the column's
|
|
1443
|
+
// longest label; `tabular-nums` makes every digit exactly 1ch.
|
|
1444
|
+
const labelBoxes = useMemo(() => {
|
|
1445
|
+
const boxes = new Map<string, { value?: number; ends?: readonly [number, number] }>();
|
|
1446
|
+
for (const column of leafColumns) {
|
|
1447
|
+
const meta = column.columnDef.meta;
|
|
1448
|
+
const visual = meta?.visual;
|
|
1449
|
+
const labelOf = (value: unknown) => formatCellValue(value, meta?.format, formatNumber);
|
|
1450
|
+
if (visual?.kind === "bar" && visual.style !== "slim") {
|
|
1451
|
+
boxes.set(column.id, {
|
|
1452
|
+
value: labelBoxCh(coreRows.map((row) => labelOf(row.getValue(column.id)))),
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
if (visual?.kind === "sparkline" && visual.labels === "ends") {
|
|
1456
|
+
const ends = coreRows
|
|
1457
|
+
.map((row) => seriesEnds(seriesValues(row.original, visual.keys)))
|
|
1458
|
+
.filter((pair): pair is readonly [number, number] => pair !== null);
|
|
1459
|
+
boxes.set(column.id, {
|
|
1460
|
+
ends: [
|
|
1461
|
+
labelBoxCh(ends.map(([first]) => labelOf(first))),
|
|
1462
|
+
labelBoxCh(ends.map(([, last]) => labelOf(last))),
|
|
1463
|
+
],
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
return boxes;
|
|
1468
|
+
}, [leafColumns, coreRows, formatNumber]);
|
|
1469
|
+
const rowRanks = useMemo(
|
|
1470
|
+
() =>
|
|
1471
|
+
showRanks
|
|
1472
|
+
? computeRowRanks(
|
|
1473
|
+
coreRows.map((r) => r.id),
|
|
1474
|
+
new Set([...(rowPinning.top ?? []), ...(rowPinning.bottom ?? [])]),
|
|
1475
|
+
)
|
|
1476
|
+
: null,
|
|
1477
|
+
[showRanks, coreRows, rowPinning],
|
|
1478
|
+
);
|
|
1479
|
+
const hasShowAt = leafColumns.some((c) => c.columnDef.meta?.showAt !== undefined);
|
|
1480
|
+
const { ref: breakpointRef, breakpoint } = useTableBreakpoint<HTMLDivElement>(
|
|
1481
|
+
layout === "auto" || hasShowAt,
|
|
1482
|
+
);
|
|
1483
|
+
const cardsActive = layout === "cards" || (layout === "auto" && breakpoint === "narrow");
|
|
1484
|
+
// Row reorder is table-only (RM-123): a card is a `<dl>` in a `<ul>`, with no
|
|
1485
|
+
// grip column and no row to drop onto, so dnd-kit is not mounted at all in
|
|
1486
|
+
// the card branch. That is a deliberate, documented no-op rather than a
|
|
1487
|
+
// half-working drag — but a silent one is a trap, so say it once per mount.
|
|
1488
|
+
const warnedCardReorderRef = useRef(false);
|
|
1489
|
+
useEffect(() => {
|
|
1490
|
+
if (
|
|
1491
|
+
process.env.NODE_ENV !== "production" &&
|
|
1492
|
+
cardsActive &&
|
|
1493
|
+
enableRowReorder &&
|
|
1494
|
+
!warnedCardReorderRef.current
|
|
1495
|
+
) {
|
|
1496
|
+
warnedCardReorderRef.current = true;
|
|
1497
|
+
console.warn(
|
|
1498
|
+
"[DataTable] `enableRowReorder` is ignored in the card layout — a card list has no " +
|
|
1499
|
+
"grip column and no drop target, so `onRowReorder` will never fire. Keep " +
|
|
1500
|
+
'`layout="table"` for reordering, or offer the move as a row action in cards.',
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
}, [cardsActive, enableRowReorder]);
|
|
1504
|
+
// Published only when a presentation prop is in play, so the default DOM is
|
|
1505
|
+
// unchanged: `data-layout` is what renders, `data-breakpoint` what was measured.
|
|
1506
|
+
const presentationAttrs =
|
|
1507
|
+
layout !== "table" || hasShowAt
|
|
1508
|
+
? {
|
|
1509
|
+
"data-layout": cardsActive ? "cards" : "table",
|
|
1510
|
+
"data-breakpoint": layout === "auto" || hasShowAt ? breakpoint : undefined,
|
|
1511
|
+
}
|
|
1512
|
+
: null;
|
|
1513
|
+
const isColumnShown = (column: Column<TData, unknown>) =>
|
|
1514
|
+
resolveShowAt(column.columnDef.meta?.showAt, breakpoint);
|
|
1515
|
+
const rootRef = useCallback(
|
|
1516
|
+
(node: HTMLDivElement | null) => {
|
|
1517
|
+
breakpointRef(node);
|
|
1518
|
+
if (typeof ref === "function") ref(node);
|
|
1519
|
+
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
1520
|
+
},
|
|
1521
|
+
[breakpointRef, ref],
|
|
1522
|
+
);
|
|
1523
|
+
const captionId = useId();
|
|
1524
|
+
const headerGroupsForCards = table.getHeaderGroups();
|
|
1525
|
+
// Leading columns DataTable adds beside the TanStack ones (grip, rank).
|
|
1526
|
+
const leadingColCount = (hasGripColumn ? 1 : 0) + (showRanks ? 1 : 0);
|
|
1527
|
+
|
|
1277
1528
|
// colSpan for spacer / empty / skeleton cells must match the number of cells a
|
|
1278
1529
|
// real data row renders (`row.getVisibleCells()`) — use VISIBLE leaf columns so a
|
|
1279
1530
|
// hidden column (a first-class slice here via columnVisibility + ColumnPicker)
|
|
1280
|
-
// doesn't make those rows over-span.
|
|
1281
|
-
const colCount = table.getVisibleLeafColumns().length;
|
|
1531
|
+
// doesn't make those rows over-span. `showAt` (RM-123) hides at render only.
|
|
1532
|
+
const colCount = table.getVisibleLeafColumns().filter(isColumnShown).length;
|
|
1282
1533
|
// Virtualized-table ARIA: only a window of rows is mounted, so assistive tech
|
|
1283
1534
|
// can't infer the true size from the DOM. aria-rowcount counts the header row(s)
|
|
1284
1535
|
// plus every data row; rendered data rows carry an absolute 1-based aria-rowindex
|
|
1285
1536
|
// (header rows occupy 1..headerRowCount). Falls back to rows.length for the
|
|
1286
1537
|
// client path; uses the server `rowCount` total when provided.
|
|
1287
1538
|
const headerRowCount = table.getHeaderGroups().length;
|
|
1288
|
-
|
|
1539
|
+
// `rows` is the CENTRE row model when `stickyRows` is on, so the pinned rows
|
|
1540
|
+
// above and below it are extra mounted rows. They join the count, and they
|
|
1541
|
+
// take the first / last indices, so `aria-rowindex` still rises with DOM
|
|
1542
|
+
// order — a screen reader hears "row 1 of 121", never an unplaced row.
|
|
1543
|
+
const centreRowCount = rowCount ?? rows.length;
|
|
1544
|
+
const ariaRowCount = centreRowCount + topRows.length + bottomRows.length + headerRowCount;
|
|
1545
|
+
const firstCentreRowIndex = headerRowCount + topRows.length + 1;
|
|
1289
1546
|
|
|
1290
1547
|
// ── Row drag-reorder (#13) ────────────────────────────────────────────────
|
|
1291
1548
|
// `rowActionName` (defined below, but hoisted as a function declaration) is
|
|
@@ -1707,7 +1964,8 @@ function DataTableInner<TData, TValue>(
|
|
|
1707
1964
|
if (el.firstElementChild) observer.observe(el.firstElementChild);
|
|
1708
1965
|
return () => observer.disconnect();
|
|
1709
1966
|
// Column/row-count changes can also change the table's intrinsic width.
|
|
1710
|
-
|
|
1967
|
+
// `cardsActive` (RM-123): switching back from cards mounts a new scroll box.
|
|
1968
|
+
}, [updateScrollAffordance, colCount, rows.length, cardsActive]);
|
|
1711
1969
|
|
|
1712
1970
|
// ─── Empty / loading state ───────────────────────────────────────────────
|
|
1713
1971
|
const showEmpty = !loading && rows.length === 0;
|
|
@@ -1718,6 +1976,197 @@ function DataTableInner<TData, TValue>(
|
|
|
1718
1976
|
|
|
1719
1977
|
// ─── Render helpers ───────────────────────────────────────────────────────
|
|
1720
1978
|
|
|
1979
|
+
// ─── Presentation helpers (RM-123) ─────────────────────────────────────────
|
|
1980
|
+
const headerHeightClass = density === "compact" ? "h-8" : "h-10";
|
|
1981
|
+
const cellPadYClass = density === "compact" ? "py-1" : "py-2";
|
|
1982
|
+
// TanStack's own default `cell` renderer: a column still using it gets its
|
|
1983
|
+
// `meta.format` applied; a column with its own `cell` renders that instead.
|
|
1984
|
+
const defaultCellRenderer = table._getDefaultColumnDef().cell;
|
|
1985
|
+
const rowColorColumns = leafColumns.filter((c) => c.columnDef.meta?.colorBy?.scope === "row");
|
|
1986
|
+
|
|
1987
|
+
function cellLabel(value: unknown, meta: DataTableColumnMeta | undefined): string {
|
|
1988
|
+
return formatCellValue(value, meta?.format, formatNumber);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/** A cell's content: its visual, its markdown, its formatted value, or its `cell` renderer. */
|
|
1992
|
+
function renderCellContent(cell: Cell<TData, unknown>): ReactNode {
|
|
1993
|
+
const meta = cell.column.columnDef.meta;
|
|
1994
|
+
const visual = meta?.visual;
|
|
1995
|
+
const value = cell.getValue();
|
|
1996
|
+
const scale = columnScales?.get(cell.column.id);
|
|
1997
|
+
if (visual?.kind === "bar") {
|
|
1998
|
+
return (
|
|
1999
|
+
<BarCell
|
|
2000
|
+
value={typeof value === "number" ? value : null}
|
|
2001
|
+
label={cellLabel(value, meta)}
|
|
2002
|
+
domain={scale?.barDomain ?? [0, 0]}
|
|
2003
|
+
variant={visual.style}
|
|
2004
|
+
track={visual.track}
|
|
2005
|
+
fillColor={
|
|
2006
|
+
visual.colorBy
|
|
2007
|
+
? scale?.barCategory?.colorOf(rowKeyValue(cell.row.original, visual.colorBy))
|
|
2008
|
+
: undefined
|
|
2009
|
+
}
|
|
2010
|
+
negativeColor={visual.negative !== false}
|
|
2011
|
+
labelWidth={labelBoxes.get(cell.column.id)?.value}
|
|
2012
|
+
/>
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
if (visual?.kind === "sparkline" || visual?.kind === "columns") {
|
|
2016
|
+
const values = seriesValues(cell.row.original, visual.keys);
|
|
2017
|
+
const label = values.map((v) => (v === null ? "–" : cellLabel(v, meta))).join(", ");
|
|
2018
|
+
const domain = visual.range === "column" ? (scale?.seriesExtent ?? null) : extentOf(values);
|
|
2019
|
+
if (visual.kind === "columns") {
|
|
2020
|
+
return (
|
|
2021
|
+
<ColumnsCell
|
|
2022
|
+
values={visual.keys.map((key, i) => ({ key, value: values[i] ?? null }))}
|
|
2023
|
+
domain={domain}
|
|
2024
|
+
label={label}
|
|
2025
|
+
height={visual.height}
|
|
2026
|
+
/>
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
const ends = seriesEnds(values);
|
|
2030
|
+
return (
|
|
2031
|
+
<SparklineCell
|
|
2032
|
+
values={values}
|
|
2033
|
+
domain={domain}
|
|
2034
|
+
label={label}
|
|
2035
|
+
fill={visual.fill}
|
|
2036
|
+
height={visual.height}
|
|
2037
|
+
ends={
|
|
2038
|
+
visual.labels === "ends" && ends
|
|
2039
|
+
? [cellLabel(ends[0], meta), cellLabel(ends[1], meta)]
|
|
2040
|
+
: undefined
|
|
2041
|
+
}
|
|
2042
|
+
endsWidth={labelBoxes.get(cell.column.id)?.ends}
|
|
2043
|
+
/>
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
if (visual?.kind === "heatmap") {
|
|
2047
|
+
return <HeatmapCell label={cellLabel(value, meta)} hideValue={visual.hideValue} />;
|
|
2048
|
+
}
|
|
2049
|
+
if (meta?.markdown && typeof value === "string") {
|
|
2050
|
+
const images = typeof meta.markdown === "object" && meta.markdown.images === true;
|
|
2051
|
+
return <MarkdownCell text={value} images={images} />;
|
|
2052
|
+
}
|
|
2053
|
+
if (meta?.format && cell.column.columnDef.cell === defaultCellRenderer) {
|
|
2054
|
+
return cellLabel(value, meta);
|
|
2055
|
+
}
|
|
2056
|
+
return flexRender(cell.column.columnDef.cell, cell.getContext());
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** Extra `<td>` classes / style from the column meta (sizing, heatmap fill, colorBy). */
|
|
2060
|
+
function cellPresentation(
|
|
2061
|
+
cell: Cell<TData, unknown>,
|
|
2062
|
+
includeSizing = true,
|
|
2063
|
+
): {
|
|
2064
|
+
className?: string;
|
|
2065
|
+
style?: React.CSSProperties;
|
|
2066
|
+
} {
|
|
2067
|
+
const meta = cell.column.columnDef.meta;
|
|
2068
|
+
if (!meta) return {};
|
|
2069
|
+
const scale = columnScales?.get(cell.column.id);
|
|
2070
|
+
let style = includeSizing ? columnSizeStyle(meta) : undefined;
|
|
2071
|
+
let className: string | undefined;
|
|
2072
|
+
if (meta.visual?.kind === "heatmap") {
|
|
2073
|
+
const value = cell.getValue();
|
|
2074
|
+
const color = scale?.heatmap?.colorOf(typeof value === "number" ? value : null) ?? null;
|
|
2075
|
+
style = { ...style, ...heatmapCellStyle(color) };
|
|
2076
|
+
// A value-less heatmap cell is pure colour: no padding (so the column can
|
|
2077
|
+
// shrink with the table) but a real height, or the band would vanish.
|
|
2078
|
+
className = meta.visual.hideValue ? "h-6 px-0 text-center" : "text-center";
|
|
2079
|
+
}
|
|
2080
|
+
if (meta.colorBy && (meta.colorBy.scope ?? "cell") === "cell") {
|
|
2081
|
+
const color =
|
|
2082
|
+
scale?.category?.colorOf(rowKeyValue(cell.row.original, meta.colorBy.key)) ?? null;
|
|
2083
|
+
const colorStyle = colorByStyle(meta.colorBy.target, color);
|
|
2084
|
+
if (colorStyle) style = { ...style, ...colorStyle };
|
|
2085
|
+
}
|
|
2086
|
+
return { className, style };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/** A row's `colorBy` (`scope: "row"`) style — the first such column that paints. */
|
|
2090
|
+
function rowColorStyle(row: Row<TData>): React.CSSProperties | undefined {
|
|
2091
|
+
for (const column of rowColorColumns) {
|
|
2092
|
+
const colorBy = column.columnDef.meta?.colorBy;
|
|
2093
|
+
if (!colorBy) continue;
|
|
2094
|
+
const color =
|
|
2095
|
+
columnScales?.get(column.id)?.category?.colorOf(rowKeyValue(row.original, colorBy.key)) ??
|
|
2096
|
+
null;
|
|
2097
|
+
const style = colorByStyle(colorBy.target, color);
|
|
2098
|
+
if (style) return style;
|
|
2099
|
+
}
|
|
2100
|
+
return undefined;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/** The printed rank for a row (`showRanks`); `undefined` for a sticky row. */
|
|
2104
|
+
function rankOf(row: Row<TData>): string | undefined {
|
|
2105
|
+
const rank = rowRanks?.get(row.id);
|
|
2106
|
+
return rank === undefined ? undefined : formatNumber(rank);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* A header's sort button — shared by the table header and the card layout's
|
|
2111
|
+
* sort bar (RM-123), so both name the column and its sort state identically.
|
|
2112
|
+
*/
|
|
2113
|
+
function renderSortButton(header: Header<TData, unknown>) {
|
|
2114
|
+
const sorted = header.column.getIsSorted();
|
|
2115
|
+
const headerLabel =
|
|
2116
|
+
typeof header.column.columnDef.header === "string"
|
|
2117
|
+
? header.column.columnDef.header
|
|
2118
|
+
: header.column.id;
|
|
2119
|
+
const sortStateLabel =
|
|
2120
|
+
sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
|
|
2121
|
+
const SortIcon = sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
|
|
2122
|
+
return (
|
|
2123
|
+
<button
|
|
2124
|
+
type="button"
|
|
2125
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
2126
|
+
aria-label={`Sort by ${headerLabel}, ${sortStateLabel}`}
|
|
2127
|
+
// `relative z-10` (round-2 fix, #82 follow-up — replaces
|
|
2128
|
+
// round-1's padding-based clearance, see the note on
|
|
2129
|
+
// `numericColumnClasses`): on a resizable column the
|
|
2130
|
+
// resize handle below is `absolute`, and CSS painting
|
|
2131
|
+
// order always puts a positioned descendant above
|
|
2132
|
+
// non-positioned in-flow content in the SAME stacking
|
|
2133
|
+
// context, regardless of DOM order — so without this,
|
|
2134
|
+
// the handle's 24px hit box would win every hit-test
|
|
2135
|
+
// where it overlaps this button's own trailing edge
|
|
2136
|
+
// (measured: a 12px overlap on an end-aligned
|
|
2137
|
+
// sortable+resizable column) no matter which element
|
|
2138
|
+
// renders first in markup. Giving the button its own
|
|
2139
|
+
// explicit positive z-index (not just `relative`, which
|
|
2140
|
+
// alone would still lose — see the code comment on
|
|
2141
|
+
// `numericColumnClasses` above) promotes it into a
|
|
2142
|
+
// later, higher-stacked paint step than the handle's
|
|
2143
|
+
// implicit `z-index: auto`, so the button wins the
|
|
2144
|
+
// overlap purely at the hit-test/paint layer — the
|
|
2145
|
+
// header's padding, and therefore its alignment with
|
|
2146
|
+
// the body `<td>`, never has to move. The handle's own
|
|
2147
|
+
// visible drag affordance (the `after:` seam, 0-8px
|
|
2148
|
+
// from the cell's trailing edge) sits entirely outside
|
|
2149
|
+
// this button's box (which ends at the same 12px inset
|
|
2150
|
+
// as the body), so dragging is unaffected.
|
|
2151
|
+
// RM-127 (a-5): `min-h-6` is the WCAG 2.2 target-size floor (2.5.8).
|
|
2152
|
+
// A sort button is 16–20 px of text, which only cleared the rule
|
|
2153
|
+
// through the "safe clickable space" around it — and two layouts have
|
|
2154
|
+
// no such space: `hideHeader` collapses the row to `h-0 p-0` (16 px
|
|
2155
|
+
// between neighbours) and the card sort bar packs the same buttons at
|
|
2156
|
+
// `gap-y-1` (4 px). The box is the target now, everywhere, rather than
|
|
2157
|
+
// the room that happens to be left beside it. No visual change in a
|
|
2158
|
+
// normal header, whose row is already taller than 24 px.
|
|
2159
|
+
className="relative z-10 inline-flex min-h-6 items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-ring"
|
|
2160
|
+
>
|
|
2161
|
+
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
2162
|
+
<SortIcon
|
|
2163
|
+
aria-hidden="true"
|
|
2164
|
+
className="size-3 shrink-0 transition-colors duration-fast ease-standard"
|
|
2165
|
+
/>
|
|
2166
|
+
</button>
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
1721
2170
|
/**
|
|
1722
2171
|
* thead — sticky in virtualized mode, normal otherwise.
|
|
1723
2172
|
* `withRowIndex` (virtualized only) sets the header row's `aria-rowindex` so the
|
|
@@ -1725,27 +2174,65 @@ function DataTableInner<TData, TValue>(
|
|
|
1725
2174
|
* absolute indices on the data rows.
|
|
1726
2175
|
*/
|
|
1727
2176
|
function renderThead(sticky: boolean, withRowIndex = false) {
|
|
2177
|
+
const headerGroups = table.getHeaderGroups();
|
|
2178
|
+
// Columns whose merged header already rendered in a higher row (RM-123).
|
|
2179
|
+
const mergedHeaderColumns = new Set<string>();
|
|
1728
2180
|
return (
|
|
1729
2181
|
<thead
|
|
1730
2182
|
className={cn(
|
|
1731
2183
|
// #173: header bottom is the only cue between header and first data row → border-strong
|
|
1732
|
-
|
|
2184
|
+
// RM-123 `hideHeader`: the header row collapses to zero height (its
|
|
2185
|
+
// labels stay for screen readers), so it draws no rule and no wash.
|
|
2186
|
+
// A hidden header must not reserve width either — its own padding
|
|
2187
|
+
// would otherwise floor every column (a 24 px minimum per cell).
|
|
2188
|
+
hideHeader ? "[&_th]:h-0 [&_th]:p-0" : "border-b border-border-strong",
|
|
1733
2189
|
// A sticky header scrolls OVER the body, so its fill must be opaque or data
|
|
1734
2190
|
// rows bleed through the labels; the non-sticky header keeps the /60 wash.
|
|
1735
2191
|
// z-20 (raised from z-10 for #333) puts the header row above the pinned
|
|
1736
2192
|
// body cells (z-10) and below the pinned header corner (z-30). No visual
|
|
1737
2193
|
// delta: nothing else in the table sits between those rungs.
|
|
1738
|
-
sticky ? "sticky top-0 z-20 bg-surface-muted" : "bg-surface-muted/60",
|
|
2194
|
+
!hideHeader && (sticky ? "sticky top-0 z-20 bg-surface-muted" : "bg-surface-muted/60"),
|
|
1739
2195
|
)}
|
|
1740
2196
|
>
|
|
1741
|
-
{
|
|
2197
|
+
{headerGroups.map((headerGroup, groupIndex) => (
|
|
1742
2198
|
<tr key={headerGroup.id} aria-rowindex={withRowIndex ? groupIndex + 1 : undefined}>
|
|
1743
2199
|
{hasGripColumn && (
|
|
1744
|
-
<th
|
|
2200
|
+
<th
|
|
2201
|
+
key="__reorder"
|
|
2202
|
+
scope="col"
|
|
2203
|
+
className="h-10 w-10 px-3 align-middle bg-table-header-background"
|
|
2204
|
+
>
|
|
1745
2205
|
<span className="sr-only">{t("data.table.reorderColumnHeader")}</span>
|
|
1746
2206
|
</th>
|
|
1747
2207
|
)}
|
|
2208
|
+
{showRanks && groupIndex === 0 && (
|
|
2209
|
+
<DataTableRankHeader
|
|
2210
|
+
key="__rank"
|
|
2211
|
+
label={rankLabel}
|
|
2212
|
+
rowSpan={headerGroups.length > 1 ? headerGroups.length : undefined}
|
|
2213
|
+
className={cn(
|
|
2214
|
+
headerHeightClass,
|
|
2215
|
+
"font-table-header bg-table-header-background text-table-header-foreground text-table-header",
|
|
2216
|
+
)}
|
|
2217
|
+
/>
|
|
2218
|
+
)}
|
|
1748
2219
|
{headerGroup.headers.map((header) => {
|
|
2220
|
+
// RM-123: `showAt` hides leaf columns at render time, so a group
|
|
2221
|
+
// header spans only its SHOWN leaves and vanishes with none.
|
|
2222
|
+
const shownLeaves = header
|
|
2223
|
+
.getLeafHeaders()
|
|
2224
|
+
.filter((h) => h.subHeaders.length === 0 && isColumnShown(h.column)).length;
|
|
2225
|
+
if (shownLeaves === 0) return null;
|
|
2226
|
+
// RM-123 `mergeEmptyHeaders`: a leaf column's topmost empty
|
|
2227
|
+
// placeholder renders the column's own header, spanning down to
|
|
2228
|
+
// the leaf row; the placeholders and the leaf below it are skipped.
|
|
2229
|
+
let mergedRowSpan: number | undefined;
|
|
2230
|
+
if (mergeEmptyHeaders && mergedHeaderColumns.has(header.column.id)) return null;
|
|
2231
|
+
if (mergeEmptyHeaders && header.isPlaceholder) {
|
|
2232
|
+
mergedHeaderColumns.add(header.column.id);
|
|
2233
|
+
mergedRowSpan = headerGroups.length - groupIndex;
|
|
2234
|
+
}
|
|
2235
|
+
const merged = mergedRowSpan !== undefined;
|
|
1749
2236
|
const geometry = pinnedCellGeometry(header.column);
|
|
1750
2237
|
const canSort = header.column.getCanSort();
|
|
1751
2238
|
const sorted = header.column.getIsSorted();
|
|
@@ -1755,10 +2242,6 @@ function DataTableInner<TData, TValue>(
|
|
|
1755
2242
|
typeof header.column.columnDef.header === "string"
|
|
1756
2243
|
? header.column.columnDef.header
|
|
1757
2244
|
: header.column.id;
|
|
1758
|
-
const sortStateLabel =
|
|
1759
|
-
sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
|
|
1760
|
-
const SortIcon =
|
|
1761
|
-
sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
|
|
1762
2245
|
// #12: every column gets the same explicit width triad a pinned
|
|
1763
2246
|
// column already has, gated behind `enableColumnResizing` so a
|
|
1764
2247
|
// table that doesn't opt in stays byte-identical to before.
|
|
@@ -1768,10 +2251,20 @@ function DataTableInner<TData, TValue>(
|
|
|
1768
2251
|
const canResize =
|
|
1769
2252
|
enableColumnResizing && !header.isPlaceholder && header.column.getCanResize();
|
|
1770
2253
|
const resizeMax = header.column.columnDef.maxSize;
|
|
2254
|
+
const content =
|
|
2255
|
+
header.isPlaceholder && !merged
|
|
2256
|
+
? null
|
|
2257
|
+
: canSort
|
|
2258
|
+
? renderSortButton(header)
|
|
2259
|
+
: flexRender(header.column.columnDef.header, header.getContext());
|
|
1771
2260
|
return (
|
|
1772
2261
|
<th
|
|
1773
2262
|
key={header.id}
|
|
1774
2263
|
scope="col"
|
|
2264
|
+
colSpan={shownLeaves > 1 ? shownLeaves : undefined}
|
|
2265
|
+
rowSpan={
|
|
2266
|
+
mergedRowSpan !== undefined && mergedRowSpan > 1 ? mergedRowSpan : undefined
|
|
2267
|
+
}
|
|
1775
2268
|
aria-sort={
|
|
1776
2269
|
canSort
|
|
1777
2270
|
? sorted === "asc"
|
|
@@ -1782,7 +2275,9 @@ function DataTableInner<TData, TValue>(
|
|
|
1782
2275
|
: undefined
|
|
1783
2276
|
}
|
|
1784
2277
|
data-pinned={geometry?.pinned ?? undefined}
|
|
1785
|
-
style={
|
|
2278
|
+
style={
|
|
2279
|
+
geometry?.style ?? resizeStyle ?? columnSizeStyle(header.column.columnDef.meta)
|
|
2280
|
+
}
|
|
1786
2281
|
className={cn(
|
|
1787
2282
|
// Same `px-3` the body `<td>` uses (below) — deliberately
|
|
1788
2283
|
// NOT split into `ps-3`/`pe-3` for a resize-handle
|
|
@@ -1791,7 +2286,14 @@ function DataTableInner<TData, TValue>(
|
|
|
1791
2286
|
// must stay byte-identical to the body's so an
|
|
1792
2287
|
// end-aligned numeric column's header lines up with its
|
|
1793
2288
|
// own values.
|
|
1794
|
-
|
|
2289
|
+
// Table-header seams (fidelity review #4): a theme dials
|
|
2290
|
+
// background/foreground/size/transform/tracking via the
|
|
2291
|
+
// `--table-header-*` contract; every default equals
|
|
2292
|
+
// today's byte-identical rendering (transparent bg,
|
|
2293
|
+
// `--muted-foreground` ink, 1em size = the table's own
|
|
2294
|
+
// body size, no transform, body tracking).
|
|
2295
|
+
headerHeightClass,
|
|
2296
|
+
"px-3 text-start align-middle font-table-header bg-table-header-background text-table-header-foreground text-table-header tracking-(--table-header-tracking) [text-transform:var(--table-header-transform)]",
|
|
1795
2297
|
// #69: a numeric column's `meta` overrides the default
|
|
1796
2298
|
// `text-start` — placed right after the base string so
|
|
1797
2299
|
// tailwind-merge lets it win over that default.
|
|
@@ -1821,6 +2323,17 @@ function DataTableInner<TData, TValue>(
|
|
|
1821
2323
|
(sticky
|
|
1822
2324
|
? "bg-surface-muted"
|
|
1823
2325
|
: "bg-card before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:bg-surface-muted/60 before:content-['']"),
|
|
2326
|
+
// Pinned corner keeps its OPAQUE ground above (unchanged
|
|
2327
|
+
// default `transparent` on `--table-header-background`
|
|
2328
|
+
// paints nothing here, byte-identical). A theme that fills
|
|
2329
|
+
// the header instead layers that fill on an `after:`
|
|
2330
|
+
// pseudo ABOVE the ground/wash (source order after
|
|
2331
|
+
// `before:` at the same `-z-10` rung, below the cell's own
|
|
2332
|
+
// text) so the corner stays opaque either way — it never
|
|
2333
|
+
// replaces the ground the way overwriting `background-color`
|
|
2334
|
+
// directly would.
|
|
2335
|
+
geometry &&
|
|
2336
|
+
"after:pointer-events-none after:absolute after:inset-0 after:-z-10 after:bg-table-header-background after:content-['']",
|
|
1824
2337
|
// Separate cn() argument on purpose: the seam is the sole
|
|
1825
2338
|
// structural cue between the frozen and scrolling blocks, so
|
|
1826
2339
|
// it must not read as a "boundary + fill in one class string"
|
|
@@ -1829,45 +2342,11 @@ function DataTableInner<TData, TValue>(
|
|
|
1829
2342
|
columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
|
|
1830
2343
|
)}
|
|
1831
2344
|
>
|
|
1832
|
-
{
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
onClick={header.column.getToggleSortingHandler()}
|
|
1836
|
-
aria-label={`Sort by ${headerLabel}, ${sortStateLabel}`}
|
|
1837
|
-
// `relative z-10` (round-2 fix, #82 follow-up — replaces
|
|
1838
|
-
// round-1's padding-based clearance, see the note on
|
|
1839
|
-
// `numericColumnClasses`): on a resizable column the
|
|
1840
|
-
// resize handle below is `absolute`, and CSS painting
|
|
1841
|
-
// order always puts a positioned descendant above
|
|
1842
|
-
// non-positioned in-flow content in the SAME stacking
|
|
1843
|
-
// context, regardless of DOM order — so without this,
|
|
1844
|
-
// the handle's 24px hit box would win every hit-test
|
|
1845
|
-
// where it overlaps this button's own trailing edge
|
|
1846
|
-
// (measured: a 12px overlap on an end-aligned
|
|
1847
|
-
// sortable+resizable column) no matter which element
|
|
1848
|
-
// renders first in markup. Giving the button its own
|
|
1849
|
-
// explicit positive z-index (not just `relative`, which
|
|
1850
|
-
// alone would still lose — see the code comment on
|
|
1851
|
-
// `numericColumnClasses` above) promotes it into a
|
|
1852
|
-
// later, higher-stacked paint step than the handle's
|
|
1853
|
-
// implicit `z-index: auto`, so the button wins the
|
|
1854
|
-
// overlap purely at the hit-test/paint layer — the
|
|
1855
|
-
// header's padding, and therefore its alignment with
|
|
1856
|
-
// the body `<td>`, never has to move. The handle's own
|
|
1857
|
-
// visible drag affordance (the `after:` seam, 0-8px
|
|
1858
|
-
// from the cell's trailing edge) sits entirely outside
|
|
1859
|
-
// this button's box (which ends at the same 12px inset
|
|
1860
|
-
// as the body), so dragging is unaffected.
|
|
1861
|
-
className="relative z-10 inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-ring"
|
|
1862
|
-
>
|
|
1863
|
-
{flexRender(header.column.columnDef.header, header.getContext())}
|
|
1864
|
-
<SortIcon
|
|
1865
|
-
aria-hidden="true"
|
|
1866
|
-
className="size-3 shrink-0 transition-colors duration-fast ease-standard"
|
|
1867
|
-
/>
|
|
1868
|
-
</button>
|
|
2345
|
+
{hideHeader && content !== null ? (
|
|
2346
|
+
// A focused sort button un-hides its label (skip-link idiom).
|
|
2347
|
+
<span className="sr-only focus-within:not-sr-only">{content}</span>
|
|
1869
2348
|
) : (
|
|
1870
|
-
|
|
2349
|
+
content
|
|
1871
2350
|
)}
|
|
1872
2351
|
{canResize && (
|
|
1873
2352
|
<div
|
|
@@ -2041,7 +2520,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2041
2520
|
"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']",
|
|
2042
2521
|
zebra && rowIndex % 2 === 1 && "before:bg-table-stripe",
|
|
2043
2522
|
"group-hover/row:before:bg-table-row-hover",
|
|
2044
|
-
"group-data-[state=selected]/row:before:bg-
|
|
2523
|
+
"group-data-[state=selected]/row:before:bg-selection",
|
|
2045
2524
|
);
|
|
2046
2525
|
}
|
|
2047
2526
|
|
|
@@ -2094,10 +2573,13 @@ function DataTableInner<TData, TValue>(
|
|
|
2094
2573
|
onRowClick?.(row, event);
|
|
2095
2574
|
}
|
|
2096
2575
|
|
|
2576
|
+
const sticky = stickyActive ? row.getIsPinned() || undefined : undefined;
|
|
2577
|
+
const rowStyle = rowColorStyle(row);
|
|
2097
2578
|
return (
|
|
2098
2579
|
<tr
|
|
2099
2580
|
key={row.id}
|
|
2100
2581
|
data-state={row.getIsSelected() ? "selected" : undefined}
|
|
2582
|
+
data-sticky={sticky}
|
|
2101
2583
|
onClick={clickable ? handleRowClick : undefined}
|
|
2102
2584
|
// Hover/selected are foreground-tint washes so they read more prominent than
|
|
2103
2585
|
// the zebra stripe in the SAME direction across light/dark themes (the old
|
|
@@ -2108,7 +2590,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2108
2590
|
// (only movement is neutralized); the gated duration-fast/ease-standard
|
|
2109
2591
|
// pair already collapses toward ~0ms via --motion-factor when the user
|
|
2110
2592
|
// or OS asks for reduced motion, matching the header sort button.
|
|
2111
|
-
"transition-colors duration-fast ease-standard hover:bg-table-row-hover data-[state=selected]:bg-
|
|
2593
|
+
"transition-colors duration-fast ease-standard hover:bg-table-row-hover data-[state=selected]:bg-selection",
|
|
2112
2594
|
// #13: the dragged row's live `transform` (set inline via `extras.style`,
|
|
2113
2595
|
// see `SortableDataRow`) is what actually MOVES it — this class only
|
|
2114
2596
|
// makes that movement glide instead of snapping, through the gated
|
|
@@ -2125,6 +2607,10 @@ function DataTableInner<TData, TValue>(
|
|
|
2125
2607
|
// emits no style of its own.
|
|
2126
2608
|
"group/row",
|
|
2127
2609
|
rowSeparationClass(rowIndex),
|
|
2610
|
+
// RM-123 sticky rows: a quiet header-tone wash + medium weight mark
|
|
2611
|
+
// the "average" / "total" rows that repeat on every page.
|
|
2612
|
+
sticky && "font-medium",
|
|
2613
|
+
sticky && "bg-surface-muted/60",
|
|
2128
2614
|
// `<tr>` isn't in the global auto-cursor-pointer role list (button/
|
|
2129
2615
|
// menuitem/tab/…), so a clickable row needs its own cursor. The focus
|
|
2130
2616
|
// ring is driven off the hidden button's `:focus-visible` (same
|
|
@@ -2136,6 +2622,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2136
2622
|
rowClassName?.(row),
|
|
2137
2623
|
)}
|
|
2138
2624
|
{...extras}
|
|
2625
|
+
style={rowStyle || extras?.style ? { ...rowStyle, ...extras?.style } : undefined}
|
|
2139
2626
|
>
|
|
2140
2627
|
{dragHandle?.activator && (
|
|
2141
2628
|
<td className="w-10 px-3 py-2 align-middle">
|
|
@@ -2155,50 +2642,58 @@ function DataTableInner<TData, TValue>(
|
|
|
2155
2642
|
</button>
|
|
2156
2643
|
</td>
|
|
2157
2644
|
)}
|
|
2158
|
-
{row
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2645
|
+
{showRanks && <DataTableRankCell rank={rankOf(row)} className={cellPadYClass} />}
|
|
2646
|
+
{row
|
|
2647
|
+
.getVisibleCells()
|
|
2648
|
+
.filter((cell) => isColumnShown(cell.column))
|
|
2649
|
+
.map((cell, cellIndex) => {
|
|
2650
|
+
const geometry = pinnedCellGeometry(cell.column);
|
|
2651
|
+
// #12: same width triad as the header cell — see `resizeWidthStyle`.
|
|
2652
|
+
const resizeStyle = enableColumnResizing
|
|
2653
|
+
? resizeWidthStyle(cell.column.getSize())
|
|
2654
|
+
: undefined;
|
|
2655
|
+
const presentation = cellPresentation(cell);
|
|
2656
|
+
const baseStyle = geometry?.style ?? resizeStyle;
|
|
2657
|
+
return (
|
|
2658
|
+
<td
|
|
2659
|
+
key={cell.id}
|
|
2660
|
+
data-pinned={geometry?.pinned ?? undefined}
|
|
2661
|
+
style={presentation.style ? { ...baseStyle, ...presentation.style } : baseStyle}
|
|
2662
|
+
className={cn(
|
|
2663
|
+
"px-3 align-middle",
|
|
2664
|
+
cellPadYClass,
|
|
2665
|
+
// #69: same numeric-column seam as the header — see
|
|
2666
|
+
// `numericColumnClasses`.
|
|
2667
|
+
numericColumnClasses(cell.column.columnDef.meta),
|
|
2668
|
+
// z-10: above the normal (unpositioned) cells it scrolls over,
|
|
2669
|
+
// below the sticky header row (z-20) and the pinned corner (z-30).
|
|
2670
|
+
geometry && "sticky z-10",
|
|
2671
|
+
geometry && pinnedCellFillClass(rowIndex),
|
|
2672
|
+
// Separate cn() argument — see pinnedCellGeometry's edgeClass.
|
|
2673
|
+
geometry?.edgeClass,
|
|
2674
|
+
columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
|
|
2675
|
+
presentation.className,
|
|
2676
|
+
)}
|
|
2677
|
+
>
|
|
2678
|
+
{clickable && cellIndex === 0 && (
|
|
2679
|
+
<button
|
|
2680
|
+
type="button"
|
|
2681
|
+
data-slot="data-table-row-action"
|
|
2682
|
+
// #311: `sr-only` removes the box from the visual layout but
|
|
2683
|
+
// not the browser's own focus ring — the ROW paints the
|
|
2684
|
+
// deliberate compound indicator (via the `has-[…]` selector
|
|
2685
|
+
// above), so the proxy's own native ring must be suppressed
|
|
2686
|
+
// or it leaks as a stray dot at the row's edge.
|
|
2687
|
+
className={ROW_ACTION_CLASS}
|
|
2688
|
+
onClick={(event) => onRowClick?.(row, event)}
|
|
2689
|
+
>
|
|
2690
|
+
{rowActionName(row)}
|
|
2691
|
+
</button>
|
|
2692
|
+
)}
|
|
2693
|
+
{renderCellContent(cell)}
|
|
2694
|
+
</td>
|
|
2695
|
+
);
|
|
2696
|
+
})}
|
|
2202
2697
|
</tr>
|
|
2203
2698
|
);
|
|
2204
2699
|
}
|
|
@@ -2213,7 +2708,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2213
2708
|
// header/body cells — a loading table whose skeleton didn't mirror the
|
|
2214
2709
|
// real alignment is exactly the column-shift-on-load bug
|
|
2215
2710
|
// loading-states.md § "CLS / space reservation" warns about.
|
|
2216
|
-
const visibleColumns = table.getVisibleLeafColumns();
|
|
2711
|
+
const visibleColumns = table.getVisibleLeafColumns().filter(isColumnShown);
|
|
2217
2712
|
return Array.from({ length: count }).map((_, i) => (
|
|
2218
2713
|
<tr key={`skeleton-${i}`} aria-hidden="true" className={rowSeparationClass(i)}>
|
|
2219
2714
|
{hasGripColumn && (
|
|
@@ -2221,11 +2716,17 @@ function DataTableInner<TData, TValue>(
|
|
|
2221
2716
|
<Skeleton className="size-4" />
|
|
2222
2717
|
</td>
|
|
2223
2718
|
)}
|
|
2719
|
+
{showRanks && (
|
|
2720
|
+
<td className={cn("w-10 px-3 align-middle", cellPadYClass)}>
|
|
2721
|
+
<Skeleton className="h-4 w-full" />
|
|
2722
|
+
</td>
|
|
2723
|
+
)}
|
|
2224
2724
|
{visibleColumns.map((column) => (
|
|
2225
2725
|
<td
|
|
2226
2726
|
key={column.id}
|
|
2227
2727
|
className={cn(
|
|
2228
|
-
"px-3
|
|
2728
|
+
"px-3 align-middle",
|
|
2729
|
+
cellPadYClass,
|
|
2229
2730
|
numericColumnClasses(column.columnDef.meta),
|
|
2230
2731
|
columnDividers && COLUMN_DIVIDER_CLASS,
|
|
2231
2732
|
)}
|
|
@@ -2245,7 +2746,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2245
2746
|
return (
|
|
2246
2747
|
<tr>
|
|
2247
2748
|
<td
|
|
2248
|
-
colSpan={colCount +
|
|
2749
|
+
colSpan={colCount + leadingColCount}
|
|
2249
2750
|
className="h-24 px-3 text-center text-muted-foreground"
|
|
2250
2751
|
>
|
|
2251
2752
|
{emptyMessage}
|
|
@@ -2263,7 +2764,13 @@ function DataTableInner<TData, TValue>(
|
|
|
2263
2764
|
return <tbody>{renderEmptyBody()}</tbody>;
|
|
2264
2765
|
}
|
|
2265
2766
|
if (!rowReorderActive) {
|
|
2266
|
-
return
|
|
2767
|
+
return (
|
|
2768
|
+
<tbody>
|
|
2769
|
+
{topRows.map((row, i) => renderRow(row, i))}
|
|
2770
|
+
{rows.map((row, i) => renderRow(row, i))}
|
|
2771
|
+
{bottomRows.map((row, i) => renderRow(row, i))}
|
|
2772
|
+
</tbody>
|
|
2773
|
+
);
|
|
2267
2774
|
}
|
|
2268
2775
|
|
|
2269
2776
|
// #13: `SortableContext` renders no DOM element of its own (a plain
|
|
@@ -2275,6 +2782,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2275
2782
|
strategy={verticalListSortingStrategy}
|
|
2276
2783
|
>
|
|
2277
2784
|
<tbody>
|
|
2785
|
+
{topRows.map((row, i) => renderRow(row, i))}
|
|
2278
2786
|
{rows.map((row, i) => (
|
|
2279
2787
|
<SortableDataRow
|
|
2280
2788
|
key={getReorderRowId(row)}
|
|
@@ -2318,6 +2826,7 @@ function DataTableInner<TData, TValue>(
|
|
|
2318
2826
|
}
|
|
2319
2827
|
</SortableDataRow>
|
|
2320
2828
|
))}
|
|
2829
|
+
{bottomRows.map((row, i) => renderRow(row, i))}
|
|
2321
2830
|
</tbody>
|
|
2322
2831
|
</SortableContext>
|
|
2323
2832
|
);
|
|
@@ -2338,10 +2847,15 @@ function DataTableInner<TData, TValue>(
|
|
|
2338
2847
|
renderEmptyBody()
|
|
2339
2848
|
) : (
|
|
2340
2849
|
<>
|
|
2850
|
+
{topRows.map((row, i) =>
|
|
2851
|
+
renderRow(row, i, {
|
|
2852
|
+
"aria-rowindex": headerRowCount + i + 1,
|
|
2853
|
+
} as React.HTMLAttributes<HTMLTableRowElement>),
|
|
2854
|
+
)}
|
|
2341
2855
|
{/* Top spacer — real <tr> so table layout is preserved */}
|
|
2342
2856
|
{paddingTop > 0 && (
|
|
2343
2857
|
<tr aria-hidden="true">
|
|
2344
|
-
<td style={{ height: paddingTop }} colSpan={colCount} />
|
|
2858
|
+
<td style={{ height: paddingTop }} colSpan={colCount + leadingColCount} />
|
|
2345
2859
|
</tr>
|
|
2346
2860
|
)}
|
|
2347
2861
|
{virtualItems.map((virtualRow) => {
|
|
@@ -2352,22 +2866,290 @@ function DataTableInner<TData, TValue>(
|
|
|
2352
2866
|
return renderRow(row, virtualRow.index, {
|
|
2353
2867
|
ref: virtualizer.measureElement as React.Ref<HTMLTableRowElement>,
|
|
2354
2868
|
"data-index": virtualRow.index,
|
|
2355
|
-
// Absolute 1-based row position; header row(s) occupy
|
|
2356
|
-
|
|
2869
|
+
// Absolute 1-based row position; header row(s) occupy
|
|
2870
|
+
// 1..headerRowCount and any top-pinned rows the slots after them.
|
|
2871
|
+
"aria-rowindex": firstCentreRowIndex + virtualRow.index,
|
|
2357
2872
|
} as React.HTMLAttributes<HTMLTableRowElement>);
|
|
2358
2873
|
})}
|
|
2359
2874
|
{/* Bottom spacer */}
|
|
2360
2875
|
{paddingBottom > 0 && (
|
|
2361
2876
|
<tr aria-hidden="true">
|
|
2362
|
-
<td style={{ height: paddingBottom }} colSpan={colCount} />
|
|
2877
|
+
<td style={{ height: paddingBottom }} colSpan={colCount + leadingColCount} />
|
|
2363
2878
|
</tr>
|
|
2364
2879
|
)}
|
|
2880
|
+
{bottomRows.map((row, i) =>
|
|
2881
|
+
renderRow(row, i, {
|
|
2882
|
+
"aria-rowindex": firstCentreRowIndex + centreRowCount + i,
|
|
2883
|
+
} as React.HTMLAttributes<HTMLTableRowElement>),
|
|
2884
|
+
)}
|
|
2365
2885
|
</>
|
|
2366
2886
|
)}
|
|
2367
2887
|
</tbody>
|
|
2368
2888
|
);
|
|
2369
2889
|
}
|
|
2370
2890
|
|
|
2891
|
+
// ─── Card layout (RM-123) ─────────────────────────────────────────────────
|
|
2892
|
+
// One `<dl>` card per row under `layout="cards"` / narrow `"auto"`. Same
|
|
2893
|
+
// table instance: the toolbar, the pager and the sort bar below drive it.
|
|
2894
|
+
|
|
2895
|
+
/** Leaf headers by column id — a card's terms. */
|
|
2896
|
+
function leafHeadersById(): Map<string, Header<TData, unknown>> {
|
|
2897
|
+
const bottom = headerGroupsForCards[headerGroupsForCards.length - 1];
|
|
2898
|
+
return new Map((bottom?.headers ?? []).map((h) => [h.column.id, h]));
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
function renderCardSortBar() {
|
|
2902
|
+
const sortable = [...leafHeadersById().values()].filter(
|
|
2903
|
+
(h) => !h.isPlaceholder && isColumnShown(h.column) && h.column.getCanSort(),
|
|
2904
|
+
);
|
|
2905
|
+
// The selection column's header (select-all) leads the bar, as it leads the thead.
|
|
2906
|
+
const selectHeader = leafHeadersById().get("select");
|
|
2907
|
+
if (sortable.length === 0 && !selectHeader) return null;
|
|
2908
|
+
return (
|
|
2909
|
+
<div
|
|
2910
|
+
data-slot="data-table-card-sort"
|
|
2911
|
+
className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-border-strong px-3 py-2 text-meta text-muted-foreground"
|
|
2912
|
+
>
|
|
2913
|
+
{selectHeader && !selectHeader.isPlaceholder ? (
|
|
2914
|
+
<span className="inline-flex">
|
|
2915
|
+
{flexRender(selectHeader.column.columnDef.header, selectHeader.getContext())}
|
|
2916
|
+
</span>
|
|
2917
|
+
) : null}
|
|
2918
|
+
{sortable.map((header) => (
|
|
2919
|
+
<span key={header.id} className="inline-flex">
|
|
2920
|
+
{renderSortButton(header)}
|
|
2921
|
+
</span>
|
|
2922
|
+
))}
|
|
2923
|
+
</div>
|
|
2924
|
+
);
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
function renderCard(
|
|
2928
|
+
row: Row<TData>,
|
|
2929
|
+
headers: Map<string, Header<TData, unknown>>,
|
|
2930
|
+
extras?: { ref?: React.Ref<HTMLLIElement>; "data-index"?: number },
|
|
2931
|
+
) {
|
|
2932
|
+
const cells = row.getVisibleCells().filter((cell) => isColumnShown(cell.column));
|
|
2933
|
+
const selectCell = cells.find((cell) => cell.column.id === "select");
|
|
2934
|
+
const clickable = Boolean(onRowClick);
|
|
2935
|
+
const sticky = stickyActive ? row.getIsPinned() || undefined : undefined;
|
|
2936
|
+
const fields: DataTableCardField[] = [];
|
|
2937
|
+
const rank = rankOf(row);
|
|
2938
|
+
if (showRanks && rank !== undefined) {
|
|
2939
|
+
// The cards layout has room for the column's real name, so it prints it:
|
|
2940
|
+
// a `<dl>` term reading "#" would carry the same ambiguity as the header.
|
|
2941
|
+
fields.push({
|
|
2942
|
+
id: "__rank",
|
|
2943
|
+
term: rankLabel ?? t("data.table.rankHeader"),
|
|
2944
|
+
value: rank,
|
|
2945
|
+
className: "tabular-nums",
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
for (const cell of cells) {
|
|
2949
|
+
if (cell === selectCell) continue;
|
|
2950
|
+
const def = cell.column.columnDef.header;
|
|
2951
|
+
const header = headers.get(cell.column.id);
|
|
2952
|
+
const presentation = cellPresentation(cell, false);
|
|
2953
|
+
fields.push({
|
|
2954
|
+
id: cell.column.id,
|
|
2955
|
+
term:
|
|
2956
|
+
typeof def === "string"
|
|
2957
|
+
? def
|
|
2958
|
+
: header
|
|
2959
|
+
? flexRender(def, header.getContext())
|
|
2960
|
+
: cell.column.id,
|
|
2961
|
+
value: renderCellContent(cell),
|
|
2962
|
+
className: cn(
|
|
2963
|
+
cell.column.columnDef.meta?.numeric && "tabular-nums",
|
|
2964
|
+
presentation.style?.backgroundColor !== undefined && "min-h-5 rounded-sm px-1",
|
|
2965
|
+
),
|
|
2966
|
+
style: presentation.style,
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
return (
|
|
2970
|
+
<DataTableCard
|
|
2971
|
+
key={row.id}
|
|
2972
|
+
ref={extras?.ref}
|
|
2973
|
+
data-index={extras?.["data-index"]}
|
|
2974
|
+
fields={fields}
|
|
2975
|
+
density={density}
|
|
2976
|
+
data-state={row.getIsSelected() ? "selected" : undefined}
|
|
2977
|
+
data-sticky={sticky}
|
|
2978
|
+
onClick={
|
|
2979
|
+
clickable
|
|
2980
|
+
? (event) => {
|
|
2981
|
+
if (isInteractiveEventTarget(event.target)) return;
|
|
2982
|
+
if (isActiveTextSelection()) return;
|
|
2983
|
+
onRowClick?.(row, event);
|
|
2984
|
+
}
|
|
2985
|
+
: undefined
|
|
2986
|
+
}
|
|
2987
|
+
style={rowColorStyle(row)}
|
|
2988
|
+
className={cn(
|
|
2989
|
+
"transition-colors duration-fast ease-standard data-[state=selected]:bg-selection",
|
|
2990
|
+
clickable &&
|
|
2991
|
+
"cursor-pointer hover:bg-table-row-hover has-[[data-slot=data-table-row-action]:focus-visible]:focus-ring-static-inset",
|
|
2992
|
+
sticky && "font-medium",
|
|
2993
|
+
sticky && "bg-surface-muted/60",
|
|
2994
|
+
)}
|
|
2995
|
+
lead={
|
|
2996
|
+
selectCell || clickable ? (
|
|
2997
|
+
<>
|
|
2998
|
+
{selectCell && (
|
|
2999
|
+
<div className="mb-1.5">
|
|
3000
|
+
{flexRender(selectCell.column.columnDef.cell, selectCell.getContext())}
|
|
3001
|
+
</div>
|
|
3002
|
+
)}
|
|
3003
|
+
{clickable && (
|
|
3004
|
+
<button
|
|
3005
|
+
type="button"
|
|
3006
|
+
data-slot="data-table-row-action"
|
|
3007
|
+
className={ROW_ACTION_CLASS}
|
|
3008
|
+
onClick={(event) => onRowClick?.(row, event)}
|
|
3009
|
+
>
|
|
3010
|
+
{rowActionName(row)}
|
|
3011
|
+
</button>
|
|
3012
|
+
)}
|
|
3013
|
+
</>
|
|
3014
|
+
) : undefined
|
|
3015
|
+
}
|
|
3016
|
+
/>
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
function renderCardList(virtualized: boolean) {
|
|
3021
|
+
if (showSkeletons) {
|
|
3022
|
+
const count = virtualized ? (loadingRows ?? Math.min(10, pageSize)) : skeletonRowCount;
|
|
3023
|
+
return (
|
|
3024
|
+
<DataTableCardList aria-hidden="true">
|
|
3025
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
3026
|
+
<li key={`skeleton-${i}`} className="space-y-2 px-3 py-3">
|
|
3027
|
+
<Skeleton className="h-4 w-2/3" />
|
|
3028
|
+
<Skeleton className="h-4 w-1/2" />
|
|
3029
|
+
</li>
|
|
3030
|
+
))}
|
|
3031
|
+
</DataTableCardList>
|
|
3032
|
+
);
|
|
3033
|
+
}
|
|
3034
|
+
if (showEmpty) {
|
|
3035
|
+
return <StatePanel kind="empty" title={emptyMessage} />;
|
|
3036
|
+
}
|
|
3037
|
+
const headers = leafHeadersById();
|
|
3038
|
+
return (
|
|
3039
|
+
<DataTableCardList aria-labelledby={caption != null ? captionId : undefined}>
|
|
3040
|
+
{topRows.map((row) => renderCard(row, headers))}
|
|
3041
|
+
{virtualized && paddingTop > 0 && <li aria-hidden="true" style={{ height: paddingTop }} />}
|
|
3042
|
+
{virtualized
|
|
3043
|
+
? virtualItems.map((virtualRow) => {
|
|
3044
|
+
const row = rows[virtualRow.index];
|
|
3045
|
+
if (!row) return null;
|
|
3046
|
+
return renderCard(row, headers, {
|
|
3047
|
+
ref: virtualizer.measureElement as React.Ref<HTMLLIElement>,
|
|
3048
|
+
"data-index": virtualRow.index,
|
|
3049
|
+
});
|
|
3050
|
+
})
|
|
3051
|
+
: rows.map((row) => renderCard(row, headers))}
|
|
3052
|
+
{virtualized && paddingBottom > 0 && (
|
|
3053
|
+
<li aria-hidden="true" style={{ height: paddingBottom }} />
|
|
3054
|
+
)}
|
|
3055
|
+
{bottomRows.map((row) => renderCard(row, headers))}
|
|
3056
|
+
</DataTableCardList>
|
|
3057
|
+
);
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
/** The cards' own accessible name: `caption`, as a hidden paragraph the list points at. */
|
|
3061
|
+
const cardsCaption =
|
|
3062
|
+
caption != null ? (
|
|
3063
|
+
<p id={captionId} className="sr-only">
|
|
3064
|
+
{caption}
|
|
3065
|
+
</p>
|
|
3066
|
+
) : null;
|
|
3067
|
+
|
|
3068
|
+
// ─── Heatmap legends (RM-123) ─────────────────────────────────────────────
|
|
3069
|
+
// One key per heatmap scale (columns sharing a spec share one), above the table.
|
|
3070
|
+
function renderLegends() {
|
|
3071
|
+
const seen = new Set<string>();
|
|
3072
|
+
const legends: ReactNode[] = [];
|
|
3073
|
+
for (const column of table.getVisibleLeafColumns()) {
|
|
3074
|
+
const visual = column.columnDef.meta?.visual;
|
|
3075
|
+
if (visual?.kind !== "heatmap" || !visual.legend || !isColumnShown(column)) continue;
|
|
3076
|
+
const scale = columnScales?.get(column.id);
|
|
3077
|
+
if (!scale?.heatmap || scale.heatmapGroup === undefined || seen.has(scale.heatmapGroup)) {
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
seen.add(scale.heatmapGroup);
|
|
3081
|
+
const header = column.columnDef.header;
|
|
3082
|
+
legends.push(
|
|
3083
|
+
<HeatmapLegend
|
|
3084
|
+
key={column.id}
|
|
3085
|
+
scale={scale.heatmap}
|
|
3086
|
+
title={
|
|
3087
|
+
typeof visual.legend === "string"
|
|
3088
|
+
? visual.legend
|
|
3089
|
+
: typeof header === "string"
|
|
3090
|
+
? header
|
|
3091
|
+
: undefined
|
|
3092
|
+
}
|
|
3093
|
+
formatValue={(v) => cellLabel(v, column.columnDef.meta)}
|
|
3094
|
+
/>,
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
// b-6: a `colorBy` column's category key. Same reason the heatmap column
|
|
3098
|
+
// gets one — a fill that is the only carrier of a category cannot be read
|
|
3099
|
+
// without a key (WCAG 1.4.1) — but for an UNORDERED scale, so it names
|
|
3100
|
+
// each category instead of printing class bounds. One key per source key,
|
|
3101
|
+
// however many columns colour by it; `legend: false` opts a column out.
|
|
3102
|
+
const seenCategoryKeys = new Set<string>();
|
|
3103
|
+
for (const column of table.getVisibleLeafColumns()) {
|
|
3104
|
+
if (!isColumnShown(column)) continue;
|
|
3105
|
+
const meta = column.columnDef.meta;
|
|
3106
|
+
const scale = columnScales?.get(column.id);
|
|
3107
|
+
const bar = meta?.visual?.kind === "bar" ? meta.visual : undefined;
|
|
3108
|
+
const keys: { key: string; scale: ColorScale; legend: string | boolean | undefined }[] = [];
|
|
3109
|
+
if (bar?.colorBy && scale?.barCategory) {
|
|
3110
|
+
keys.push({ key: bar.colorBy, scale: scale.barCategory, legend: bar.legend });
|
|
3111
|
+
}
|
|
3112
|
+
if (meta?.colorBy && scale?.category) {
|
|
3113
|
+
keys.push({ key: meta.colorBy.key, scale: scale.category, legend: meta.colorBy.legend });
|
|
3114
|
+
}
|
|
3115
|
+
for (const entry of keys) {
|
|
3116
|
+
if (entry.legend === false || seenCategoryKeys.has(entry.key)) continue;
|
|
3117
|
+
seenCategoryKeys.add(entry.key);
|
|
3118
|
+
legends.push(
|
|
3119
|
+
<CategoryLegend
|
|
3120
|
+
key={`category-${entry.key}`}
|
|
3121
|
+
scale={entry.scale}
|
|
3122
|
+
title={typeof entry.legend === "string" ? entry.legend : entry.key}
|
|
3123
|
+
/>,
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// b-5: the ranks column's key. A heatmap column gets a legend because its
|
|
3129
|
+
// colour is unreadable without one; the rank column has exactly the same
|
|
3130
|
+
// problem in digits — "2, 1, 6, 4" beside a descending column reads as a
|
|
3131
|
+
// broken ranking until something says the numbers are the DATA order. The
|
|
3132
|
+
// header carries it as an accessible name; this carries it for everyone
|
|
3133
|
+
// who can see the table. Rendered above both the table and the cards
|
|
3134
|
+
// layout, so it cannot be lost in a branch.
|
|
3135
|
+
const rankKey = showRanks ? (
|
|
3136
|
+
<p
|
|
3137
|
+
key="__rank-key"
|
|
3138
|
+
data-slot="data-table-rank-key"
|
|
3139
|
+
className="text-meta text-muted-foreground"
|
|
3140
|
+
>
|
|
3141
|
+
{rankLabel != null ? `# — ${rankLabel}` : t("data.table.rankKey")}
|
|
3142
|
+
</p>
|
|
3143
|
+
) : null;
|
|
3144
|
+
if (legends.length === 0 && rankKey === null) return null;
|
|
3145
|
+
return (
|
|
3146
|
+
<div data-slot="data-table-legends" className="flex flex-wrap gap-x-6 gap-y-2">
|
|
3147
|
+
{legends}
|
|
3148
|
+
{rankKey}
|
|
3149
|
+
</div>
|
|
3150
|
+
);
|
|
3151
|
+
}
|
|
3152
|
+
|
|
2371
3153
|
// ─── Pagination controls ──────────────────────────────────────────────────
|
|
2372
3154
|
function renderPagination() {
|
|
2373
3155
|
// Virtualization wins over pagination per spec — don't render controls
|
|
@@ -2423,8 +3205,9 @@ function DataTableInner<TData, TValue>(
|
|
|
2423
3205
|
// If both enablePagination and enableRowVirtualization are set,
|
|
2424
3206
|
// virtualization wins; pagination controls are silently suppressed.
|
|
2425
3207
|
return (
|
|
2426
|
-
<div ref={
|
|
3208
|
+
<div ref={rootRef} {...presentationAttrs} className={cn("space-y-3", className)} {...rest}>
|
|
2427
3209
|
{toolbar ? toolbar(table) : null}
|
|
3210
|
+
{renderLegends()}
|
|
2428
3211
|
{/* Outer border is redundant (surface change) → plain border per #173 spec.
|
|
2429
3212
|
tabIndex={0} makes the windowed scroll region keyboard-operable — the rows
|
|
2430
3213
|
themselves aren't focusable, so without it the off-screen rows are
|
|
@@ -2459,15 +3242,23 @@ function DataTableInner<TData, TValue>(
|
|
|
2459
3242
|
<span className="sr-only">{t("data.table.loading")}</span>
|
|
2460
3243
|
</div>
|
|
2461
3244
|
)}
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
3245
|
+
{cardsActive ? (
|
|
3246
|
+
<div data-slot="data-table-card-region" className="text-body">
|
|
3247
|
+
{cardsCaption}
|
|
3248
|
+
{renderCardSortBar()}
|
|
3249
|
+
{renderCardList(true)}
|
|
3250
|
+
</div>
|
|
3251
|
+
) : (
|
|
3252
|
+
<table
|
|
3253
|
+
aria-busy={loading || undefined}
|
|
3254
|
+
aria-rowcount={ariaRowCount}
|
|
3255
|
+
className="w-full caption-bottom text-body"
|
|
3256
|
+
>
|
|
3257
|
+
{captionElement}
|
|
3258
|
+
{renderThead(true, true)}
|
|
3259
|
+
{renderTbodyVirtualized()}
|
|
3260
|
+
</table>
|
|
3261
|
+
)}
|
|
2471
3262
|
</div>
|
|
2472
3263
|
</div>
|
|
2473
3264
|
);
|
|
@@ -2483,8 +3274,9 @@ function DataTableInner<TData, TValue>(
|
|
|
2483
3274
|
// region) so the edge-fade affordance can stay pinned to the visible edges
|
|
2484
3275
|
// instead of scrolling away with the table content.
|
|
2485
3276
|
const nonVirtualizedContent = (
|
|
2486
|
-
<div ref={
|
|
3277
|
+
<div ref={rootRef} {...presentationAttrs} className={cn("space-y-3", className)} {...rest}>
|
|
2487
3278
|
{toolbar ? toolbar(table) : null}
|
|
3279
|
+
{renderLegends()}
|
|
2488
3280
|
{/* Outer border is redundant (surface change) → plain border per #173 spec */}
|
|
2489
3281
|
<div
|
|
2490
3282
|
aria-busy={loading || undefined}
|
|
@@ -2517,22 +3309,30 @@ function DataTableInner<TData, TValue>(
|
|
|
2517
3309
|
never the `region` landmark: that would be redundant over the real
|
|
2518
3310
|
<table> and collide (axe `landmark-unique`) with every other
|
|
2519
3311
|
overflowing table on the page. */}
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
{
|
|
2532
|
-
{
|
|
2533
|
-
{
|
|
2534
|
-
|
|
2535
|
-
|
|
3312
|
+
{cardsActive ? (
|
|
3313
|
+
<div data-slot="data-table-card-region">
|
|
3314
|
+
{cardsCaption}
|
|
3315
|
+
{renderCardSortBar()}
|
|
3316
|
+
{renderCardList(false)}
|
|
3317
|
+
</div>
|
|
3318
|
+
) : (
|
|
3319
|
+
<div
|
|
3320
|
+
ref={plainScrollRef}
|
|
3321
|
+
data-slot="data-table-scroll-region"
|
|
3322
|
+
tabIndex={scrollOverflows ? 0 : undefined}
|
|
3323
|
+
role={scrollOverflows ? "group" : undefined}
|
|
3324
|
+
aria-label={scrollOverflows ? t("data.table.scrollRegion") : undefined}
|
|
3325
|
+
onScroll={updateScrollAffordance}
|
|
3326
|
+
className="overflow-auto rounded-lg focus-ring-inset"
|
|
3327
|
+
style={hasLeftPinned || hasRightPinned ? pinnedScrollPadding : undefined}
|
|
3328
|
+
>
|
|
3329
|
+
<table aria-busy={loading || undefined} className="w-full caption-bottom text-body">
|
|
3330
|
+
{captionElement}
|
|
3331
|
+
{renderThead(false)}
|
|
3332
|
+
{renderTbodyNormal()}
|
|
3333
|
+
</table>
|
|
3334
|
+
</div>
|
|
3335
|
+
)}
|
|
2536
3336
|
{/* Horizontal-scroll edge fade — a token-driven affordance that only
|
|
2537
3337
|
appears once the table actually overflows its container in that
|
|
2538
3338
|
direction, so a desktop/wide table renders neither (visual no-op).
|
|
@@ -2543,14 +3343,14 @@ function DataTableInner<TData, TValue>(
|
|
|
2543
3343
|
there by the pinned block's `border-border-strong` seam, which is
|
|
2544
3344
|
what a frozen column means ("content slides under this edge"). So
|
|
2545
3345
|
the fade stays the cue for a FREE edge only. */}
|
|
2546
|
-
{canScrollLeft && !hasLeftPinned && (
|
|
3346
|
+
{!cardsActive && canScrollLeft && !hasLeftPinned && (
|
|
2547
3347
|
<div
|
|
2548
3348
|
aria-hidden="true"
|
|
2549
3349
|
data-slot="data-table-scroll-fade-left"
|
|
2550
3350
|
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 rounded-lg bg-gradient-to-r from-card to-transparent"
|
|
2551
3351
|
/>
|
|
2552
3352
|
)}
|
|
2553
|
-
{canScrollRight && !hasRightPinned && (
|
|
3353
|
+
{!cardsActive && canScrollRight && !hasRightPinned && (
|
|
2554
3354
|
<div
|
|
2555
3355
|
aria-hidden="true"
|
|
2556
3356
|
data-slot="data-table-scroll-fade-right"
|