@elabs-ai/components-data 4.1.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.
Files changed (139) hide show
  1. package/README.md +1 -1
  2. package/dist/chunk-3OVN7MMO.js +2 -0
  3. package/dist/chunk-3OVN7MMO.js.map +1 -0
  4. package/dist/chunk-3UFWYVDS.js +2 -0
  5. package/dist/chunk-3UFWYVDS.js.map +1 -0
  6. package/dist/chunk-47ZBQZYS.js +47 -0
  7. package/dist/chunk-47ZBQZYS.js.map +1 -0
  8. package/dist/chunk-5K2IFOV2.js +2 -0
  9. package/dist/chunk-5K2IFOV2.js.map +1 -0
  10. package/dist/chunk-677ORWI6.js +17 -0
  11. package/dist/chunk-677ORWI6.js.map +1 -0
  12. package/dist/chunk-7ZYUY3UL.js +2 -0
  13. package/dist/chunk-7ZYUY3UL.js.map +1 -0
  14. package/dist/chunk-BEYHV3ZX.js +37 -0
  15. package/dist/chunk-BEYHV3ZX.js.map +1 -0
  16. package/dist/chunk-DBPJ7LVM.js +57 -0
  17. package/dist/chunk-DBPJ7LVM.js.map +1 -0
  18. package/dist/chunk-E3V3SYY7.js +87 -0
  19. package/dist/chunk-E3V3SYY7.js.map +1 -0
  20. package/dist/chunk-GBO56756.js +81 -0
  21. package/dist/chunk-GBO56756.js.map +1 -0
  22. package/dist/chunk-HG2YMSRM.js +95 -0
  23. package/dist/chunk-HG2YMSRM.js.map +1 -0
  24. package/dist/chunk-K2GHSRCP.js +1823 -0
  25. package/dist/chunk-K2GHSRCP.js.map +1 -0
  26. package/dist/chunk-KCB33PD3.js +43 -0
  27. package/dist/chunk-KCB33PD3.js.map +1 -0
  28. package/dist/chunk-MV2YVXEZ.js +67 -0
  29. package/dist/chunk-MV2YVXEZ.js.map +1 -0
  30. package/dist/chunk-NKVJUQYW.js +82 -0
  31. package/dist/chunk-NKVJUQYW.js.map +1 -0
  32. package/dist/chunk-OASM53LL.js +29 -0
  33. package/dist/chunk-OASM53LL.js.map +1 -0
  34. package/dist/chunk-RZ5MYZOA.js +2 -0
  35. package/dist/chunk-RZ5MYZOA.js.map +1 -0
  36. package/dist/chunk-SK6347WB.js +100 -0
  37. package/dist/chunk-SK6347WB.js.map +1 -0
  38. package/dist/chunk-TBB33PHQ.js +58 -0
  39. package/dist/chunk-TBB33PHQ.js.map +1 -0
  40. package/dist/chunk-VCLA45IC.js +150 -0
  41. package/dist/chunk-VCLA45IC.js.map +1 -0
  42. package/dist/chunk-XN6O6Z2N.js +58 -0
  43. package/dist/chunk-XN6O6Z2N.js.map +1 -0
  44. package/dist/chunk-ZFEW36RG.js +77 -0
  45. package/dist/chunk-ZFEW36RG.js.map +1 -0
  46. package/dist/chunk-ZQXJ34VJ.js +34 -0
  47. package/dist/chunk-ZQXJ34VJ.js.map +1 -0
  48. package/dist/chunk-ZUCGV2XD.js +42 -0
  49. package/dist/chunk-ZUCGV2XD.js.map +1 -0
  50. package/dist/column-picker/column-picker.js +8 -0
  51. package/dist/column-picker/column-picker.js.map +1 -0
  52. package/dist/column-picker/index.js +9 -0
  53. package/dist/column-picker/index.js.map +1 -0
  54. package/dist/data-table/card-layout.js +11 -0
  55. package/dist/data-table/card-layout.js.map +1 -0
  56. package/dist/data-table/cell-scales.js +26 -0
  57. package/dist/data-table/cell-scales.js.map +1 -0
  58. package/dist/data-table/cells/bar-cell.js +14 -0
  59. package/dist/data-table/cells/bar-cell.js.map +1 -0
  60. package/dist/data-table/cells/category-legend.js +9 -0
  61. package/dist/data-table/cells/category-legend.js.map +1 -0
  62. package/dist/data-table/cells/columns-cell.js +13 -0
  63. package/dist/data-table/cells/columns-cell.js.map +1 -0
  64. package/dist/data-table/cells/heatmap-cell.js +13 -0
  65. package/dist/data-table/cells/heatmap-cell.js.map +1 -0
  66. package/dist/data-table/cells/markdown-cell.js +13 -0
  67. package/dist/data-table/cells/markdown-cell.js.map +1 -0
  68. package/dist/data-table/cells/sparkline-cell.js +13 -0
  69. package/dist/data-table/cells/sparkline-cell.js.map +1 -0
  70. package/dist/data-table/column-meta.js +12 -0
  71. package/dist/data-table/column-meta.js.map +1 -0
  72. package/dist/data-table/data-table.js +23 -0
  73. package/dist/data-table/data-table.js.map +1 -0
  74. package/dist/data-table/index.js +23 -0
  75. package/dist/data-table/index.js.map +1 -0
  76. package/dist/data-table/ranks-column.js +13 -0
  77. package/dist/data-table/ranks-column.js.map +1 -0
  78. package/dist/data-table/sticky-rows.js +10 -0
  79. package/dist/data-table/sticky-rows.js.map +1 -0
  80. package/dist/data-table/use-table-breakpoint.js +13 -0
  81. package/dist/data-table/use-table-breakpoint.js.map +1 -0
  82. package/dist/facet-filter/facet-filter.js +8 -0
  83. package/dist/facet-filter/facet-filter.js.map +1 -0
  84. package/dist/facet-filter/index.js +9 -0
  85. package/dist/facet-filter/index.js.map +1 -0
  86. package/dist/filter-bar/filter-bar.js +8 -0
  87. package/dist/filter-bar/filter-bar.js.map +1 -0
  88. package/dist/filter-bar/filter-chip.js +9 -0
  89. package/dist/filter-bar/filter-chip.js.map +1 -0
  90. package/dist/filter-bar/index.js +13 -0
  91. package/dist/filter-bar/index.js.map +1 -0
  92. package/dist/index.d.ts +554 -10
  93. package/dist/index.js +102 -1494
  94. package/dist/index.js.map +1 -1
  95. package/dist/search-input/index.js +9 -0
  96. package/dist/search-input/index.js.map +1 -0
  97. package/dist/search-input/search-input.js +9 -0
  98. package/dist/search-input/search-input.js.map +1 -0
  99. package/dist/to-csv.js +10 -0
  100. package/dist/to-csv.js.map +1 -0
  101. package/package.json +7 -7
  102. package/src/__contract__/filter-chip.contract.test.tsx +49 -0
  103. package/src/column-picker/column-picker.tsx +3 -2
  104. package/src/data-table/card-layout.test.tsx +26 -0
  105. package/src/data-table/card-layout.tsx +74 -0
  106. package/src/data-table/cell-scales.test.ts +118 -0
  107. package/src/data-table/cell-scales.ts +246 -0
  108. package/src/data-table/cells/bar-cell.test.tsx +158 -0
  109. package/src/data-table/cells/bar-cell.tsx +135 -0
  110. package/src/data-table/cells/category-legend.test.tsx +37 -0
  111. package/src/data-table/cells/category-legend.tsx +58 -0
  112. package/src/data-table/cells/columns-cell.test.tsx +27 -0
  113. package/src/data-table/cells/columns-cell.tsx +79 -0
  114. package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
  115. package/src/data-table/cells/heatmap-cell.tsx +116 -0
  116. package/src/data-table/cells/markdown-cell.test.tsx +36 -0
  117. package/src/data-table/cells/markdown-cell.tsx +91 -0
  118. package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
  119. package/src/data-table/cells/sparkline-cell.tsx +113 -0
  120. package/src/data-table/column-meta.test.ts +42 -0
  121. package/src/data-table/column-meta.ts +282 -0
  122. package/src/data-table/data-table.stories.tsx +748 -1
  123. package/src/data-table/data-table.test.tsx +547 -11
  124. package/src/data-table/data-table.tsx +1014 -175
  125. package/src/data-table/ranks-column.test.tsx +65 -0
  126. package/src/data-table/ranks-column.tsx +91 -0
  127. package/src/data-table/sticky-rows.test.ts +16 -0
  128. package/src/data-table/sticky-rows.ts +77 -0
  129. package/src/data-table/use-table-breakpoint.test.ts +14 -0
  130. package/src/data-table/use-table-breakpoint.ts +67 -0
  131. package/src/facet-filter/facet-filter.stories.tsx +4 -1
  132. package/src/facet-filter/facet-filter.test.tsx +3 -3
  133. package/src/filter-bar/filter-chip.stories.tsx +1 -0
  134. package/src/index.ts +64 -0
  135. package/src/search-input/search-input.test.tsx +35 -0
  136. package/src/search-input/search-input.tsx +44 -19
  137. package/src/templates-data-app.stories.tsx +7 -2
  138. package/src/to-csv.test.ts +13 -0
  139. package/src/to-csv.ts +7 -30
@@ -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 { Button, Checkbox, Skeleton, Spinner, useLocale } from "@elabs-ai/components-ui";
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
- * `DataTable`'s `columnDef.meta` contract, read by the header/body/skeleton
81
- * cell renderers. Set `numeric: true` on a column to get `tabular-nums` +
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`
@@ -415,6 +421,64 @@ export interface DataTableProps<TData, TValue> extends Omit<
415
421
  */
416
422
  zebra?: boolean;
417
423
 
424
+ /**
425
+ * Draw a quiet `--rule` hairline between columns (header and body). Off by
426
+ * default. Pinned cells keep their own seam and never take a divider.
427
+ */
428
+ columnDividers?: boolean;
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
+
418
482
  // ── Row drag-reorder (#13) ───────────────────────────────────────────────
419
483
  /**
420
484
  * Opt-in row drag-reorder. Off by default — an existing table renders
@@ -433,6 +497,10 @@ export interface DataTableProps<TData, TValue> extends Omit<
433
497
  * is silently disabled (a dev warning fires) when both are set. Combining
434
498
  * it with active `sorting` also fires a dev warning (both still work, but
435
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.
436
504
  */
437
505
  enableRowReorder?: boolean;
438
506
  /**
@@ -500,6 +568,48 @@ export interface DataTableProps<TData, TValue> extends Omit<
500
568
  className?: string;
501
569
  }
502
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
+
503
613
  // ─── Row-click guards (module-level — shared by every renderRow call) ────────
504
614
 
505
615
  /**
@@ -538,6 +648,14 @@ function isActiveTextSelection(): boolean {
538
648
  const PINNED_SEAM_CLASS =
539
649
  "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border-strong after:content-['']";
540
650
 
651
+ /**
652
+ * Opt-in `columnDividers` hairline. `--rule`, not `--border-strong`: the column
653
+ * is already told apart by alignment and whitespace, so this line is a
654
+ * redundant boundary (ADR 0010). A real border is fine here, unlike the pinned
655
+ * seam above — pinned cells never take it.
656
+ */
657
+ const COLUMN_DIVIDER_CLASS = "border-e border-rule last:border-e-0";
658
+
541
659
  /**
542
660
  * Ids of leaf columns whose ORIGINAL `ColumnDef` declares no `size` (#333).
543
661
  *
@@ -835,6 +953,17 @@ function DataTableInner<TData, TValue>(
835
953
  maxBodyHeight = "32rem",
836
954
 
837
955
  zebra = true,
956
+ columnDividers = false,
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,
838
967
 
839
968
  // Row drag-reorder (#13)
840
969
  enableRowReorder = false,
@@ -1123,7 +1252,13 @@ function DataTableInner<TData, TValue>(
1123
1252
 
1124
1253
  // ── Row models — omit client model for manual slices ─────────────────────
1125
1254
  const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
1126
- const filteredRowModel = manualFiltering ? {} : { getFilteredRowModel: getFilteredRowModel() };
1255
+ const filteredRowModel = manualFiltering
1256
+ ? {}
1257
+ : {
1258
+ getFilteredRowModel: stickyRows
1259
+ ? withoutStickyRows(getFilteredRowModel<TData>())
1260
+ : getFilteredRowModel(),
1261
+ };
1127
1262
  // Only attach the client pagination row model when we actually paginate locally.
1128
1263
  // Under `manualPagination`, TanStack ignores a supplied `getPaginationRowModel`
1129
1264
  // (it returns the pre-pagination rows — i.e. the page the app already fetched),
@@ -1132,6 +1267,15 @@ function DataTableInner<TData, TValue>(
1132
1267
  const paginationRowModel =
1133
1268
  enablePagination && !manualPagination ? { getPaginationRowModel: getPaginationRowModel() } : {};
1134
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
+
1135
1279
  // ── Table instance ────────────────────────────────────────────────────────
1136
1280
  const table = useReactTable({
1137
1281
  data,
@@ -1145,7 +1289,16 @@ function DataTableInner<TData, TValue>(
1145
1289
  columnPinning,
1146
1290
  columnSizing,
1147
1291
  rowSelection,
1292
+ ...(stickyActive ? { rowPinning } : {}),
1148
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
+ : {}),
1149
1302
 
1150
1303
  // Sorting
1151
1304
  onSortingChange: (updater) => {
@@ -1258,19 +1411,138 @@ function DataTableInner<TData, TValue>(
1258
1411
  // TanStack `initialState` would be dead/misleading.
1259
1412
  });
1260
1413
 
1261
- const rows = table.getRowModel().rows;
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
+
1262
1528
  // colSpan for spacer / empty / skeleton cells must match the number of cells a
1263
1529
  // real data row renders (`row.getVisibleCells()`) — use VISIBLE leaf columns so a
1264
1530
  // hidden column (a first-class slice here via columnVisibility + ColumnPicker)
1265
- // doesn't make those rows over-span.
1266
- 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;
1267
1533
  // Virtualized-table ARIA: only a window of rows is mounted, so assistive tech
1268
1534
  // can't infer the true size from the DOM. aria-rowcount counts the header row(s)
1269
1535
  // plus every data row; rendered data rows carry an absolute 1-based aria-rowindex
1270
1536
  // (header rows occupy 1..headerRowCount). Falls back to rows.length for the
1271
1537
  // client path; uses the server `rowCount` total when provided.
1272
1538
  const headerRowCount = table.getHeaderGroups().length;
1273
- const ariaRowCount = (rowCount ?? rows.length) + headerRowCount;
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;
1274
1546
 
1275
1547
  // ── Row drag-reorder (#13) ────────────────────────────────────────────────
1276
1548
  // `rowActionName` (defined below, but hoisted as a function declaration) is
@@ -1692,7 +1964,8 @@ function DataTableInner<TData, TValue>(
1692
1964
  if (el.firstElementChild) observer.observe(el.firstElementChild);
1693
1965
  return () => observer.disconnect();
1694
1966
  // Column/row-count changes can also change the table's intrinsic width.
1695
- }, [updateScrollAffordance, colCount, rows.length]);
1967
+ // `cardsActive` (RM-123): switching back from cards mounts a new scroll box.
1968
+ }, [updateScrollAffordance, colCount, rows.length, cardsActive]);
1696
1969
 
1697
1970
  // ─── Empty / loading state ───────────────────────────────────────────────
1698
1971
  const showEmpty = !loading && rows.length === 0;
@@ -1703,6 +1976,197 @@ function DataTableInner<TData, TValue>(
1703
1976
 
1704
1977
  // ─── Render helpers ───────────────────────────────────────────────────────
1705
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
+
1706
2170
  /**
1707
2171
  * thead — sticky in virtualized mode, normal otherwise.
1708
2172
  * `withRowIndex` (virtualized only) sets the header row's `aria-rowindex` so the
@@ -1710,27 +2174,65 @@ function DataTableInner<TData, TValue>(
1710
2174
  * absolute indices on the data rows.
1711
2175
  */
1712
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>();
1713
2180
  return (
1714
2181
  <thead
1715
2182
  className={cn(
1716
2183
  // #173: header bottom is the only cue between header and first data row → border-strong
1717
- "border-b border-border-strong",
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",
1718
2189
  // A sticky header scrolls OVER the body, so its fill must be opaque or data
1719
2190
  // rows bleed through the labels; the non-sticky header keeps the /60 wash.
1720
2191
  // z-20 (raised from z-10 for #333) puts the header row above the pinned
1721
2192
  // body cells (z-10) and below the pinned header corner (z-30). No visual
1722
2193
  // delta: nothing else in the table sits between those rungs.
1723
- 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"),
1724
2195
  )}
1725
2196
  >
1726
- {table.getHeaderGroups().map((headerGroup, groupIndex) => (
2197
+ {headerGroups.map((headerGroup, groupIndex) => (
1727
2198
  <tr key={headerGroup.id} aria-rowindex={withRowIndex ? groupIndex + 1 : undefined}>
1728
2199
  {hasGripColumn && (
1729
- <th key="__reorder" scope="col" className="h-10 w-10 px-3 align-middle">
2200
+ <th
2201
+ key="__reorder"
2202
+ scope="col"
2203
+ className="h-10 w-10 px-3 align-middle bg-table-header-background"
2204
+ >
1730
2205
  <span className="sr-only">{t("data.table.reorderColumnHeader")}</span>
1731
2206
  </th>
1732
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
+ )}
1733
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;
1734
2236
  const geometry = pinnedCellGeometry(header.column);
1735
2237
  const canSort = header.column.getCanSort();
1736
2238
  const sorted = header.column.getIsSorted();
@@ -1740,10 +2242,6 @@ function DataTableInner<TData, TValue>(
1740
2242
  typeof header.column.columnDef.header === "string"
1741
2243
  ? header.column.columnDef.header
1742
2244
  : header.column.id;
1743
- const sortStateLabel =
1744
- sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
1745
- const SortIcon =
1746
- sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
1747
2245
  // #12: every column gets the same explicit width triad a pinned
1748
2246
  // column already has, gated behind `enableColumnResizing` so a
1749
2247
  // table that doesn't opt in stays byte-identical to before.
@@ -1753,10 +2251,20 @@ function DataTableInner<TData, TValue>(
1753
2251
  const canResize =
1754
2252
  enableColumnResizing && !header.isPlaceholder && header.column.getCanResize();
1755
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());
1756
2260
  return (
1757
2261
  <th
1758
2262
  key={header.id}
1759
2263
  scope="col"
2264
+ colSpan={shownLeaves > 1 ? shownLeaves : undefined}
2265
+ rowSpan={
2266
+ mergedRowSpan !== undefined && mergedRowSpan > 1 ? mergedRowSpan : undefined
2267
+ }
1760
2268
  aria-sort={
1761
2269
  canSort
1762
2270
  ? sorted === "asc"
@@ -1767,7 +2275,9 @@ function DataTableInner<TData, TValue>(
1767
2275
  : undefined
1768
2276
  }
1769
2277
  data-pinned={geometry?.pinned ?? undefined}
1770
- style={geometry?.style ?? resizeStyle}
2278
+ style={
2279
+ geometry?.style ?? resizeStyle ?? columnSizeStyle(header.column.columnDef.meta)
2280
+ }
1771
2281
  className={cn(
1772
2282
  // Same `px-3` the body `<td>` uses (below) — deliberately
1773
2283
  // NOT split into `ps-3`/`pe-3` for a resize-handle
@@ -1776,7 +2286,14 @@ function DataTableInner<TData, TValue>(
1776
2286
  // must stay byte-identical to the body's so an
1777
2287
  // end-aligned numeric column's header lines up with its
1778
2288
  // own values.
1779
- "h-10 px-3 text-start align-middle font-medium text-muted-foreground",
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)]",
1780
2297
  // #69: a numeric column's `meta` overrides the default
1781
2298
  // `text-start` — placed right after the base string so
1782
2299
  // tailwind-merge lets it win over that default.
@@ -1806,52 +2323,30 @@ function DataTableInner<TData, TValue>(
1806
2323
  (sticky
1807
2324
  ? "bg-surface-muted"
1808
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-['']",
1809
2337
  // Separate cn() argument on purpose: the seam is the sole
1810
2338
  // structural cue between the frozen and scrolling blocks, so
1811
2339
  // it must not read as a "boundary + fill in one class string"
1812
2340
  // redundancy (separation:check).
1813
2341
  geometry?.edgeClass,
2342
+ columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
1814
2343
  )}
1815
2344
  >
1816
- {header.isPlaceholder ? null : canSort ? (
1817
- <button
1818
- type="button"
1819
- onClick={header.column.getToggleSortingHandler()}
1820
- aria-label={`Sort by ${headerLabel}, ${sortStateLabel}`}
1821
- // `relative z-10` (round-2 fix, #82 follow-up — replaces
1822
- // round-1's padding-based clearance, see the note on
1823
- // `numericColumnClasses`): on a resizable column the
1824
- // resize handle below is `absolute`, and CSS painting
1825
- // order always puts a positioned descendant above
1826
- // non-positioned in-flow content in the SAME stacking
1827
- // context, regardless of DOM order — so without this,
1828
- // the handle's 24px hit box would win every hit-test
1829
- // where it overlaps this button's own trailing edge
1830
- // (measured: a 12px overlap on an end-aligned
1831
- // sortable+resizable column) no matter which element
1832
- // renders first in markup. Giving the button its own
1833
- // explicit positive z-index (not just `relative`, which
1834
- // alone would still lose — see the code comment on
1835
- // `numericColumnClasses` above) promotes it into a
1836
- // later, higher-stacked paint step than the handle's
1837
- // implicit `z-index: auto`, so the button wins the
1838
- // overlap purely at the hit-test/paint layer — the
1839
- // header's padding, and therefore its alignment with
1840
- // the body `<td>`, never has to move. The handle's own
1841
- // visible drag affordance (the `after:` seam, 0-8px
1842
- // from the cell's trailing edge) sits entirely outside
1843
- // this button's box (which ends at the same 12px inset
1844
- // as the body), so dragging is unaffected.
1845
- className="relative z-10 inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-ring"
1846
- >
1847
- {flexRender(header.column.columnDef.header, header.getContext())}
1848
- <SortIcon
1849
- aria-hidden="true"
1850
- className="size-3 shrink-0 transition-colors duration-fast ease-standard"
1851
- />
1852
- </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>
1853
2348
  ) : (
1854
- flexRender(header.column.columnDef.header, header.getContext())
2349
+ content
1855
2350
  )}
1856
2351
  {canResize && (
1857
2352
  <div
@@ -1967,17 +2462,25 @@ function DataTableInner<TData, TValue>(
1967
2462
  * under virtualization (a CSS `even:`/`odd:` variant would "swim" as the
1968
2463
  * windowed `<tr>`s recycle).
1969
2464
  *
1970
- * - zebra (default): a gentle `foreground/5` wash on alternate rows is the ONE
2465
+ * - zebra (default): a gentle `--table-stripe` wash on alternate rows is the ONE
1971
2466
  * separation gesture; rows carry NO divider (#173's strong divider was the cue
1972
2467
  * only because nothing else was — the stripe replaces it, so a border would now
1973
- * be redundant per the surface-separation rule).
2468
+ * be redundant per the surface-separation rule). A theme that turns the stripe
2469
+ * off (`--table-stripe: transparent`) sets `--table-row-rule-width` to put the
2470
+ * strong divider back as the sole cue; it is `0px` by default, so the stock
2471
+ * stripe carries no border and no extra pixel.
1974
2472
  * - lines (`zebra={false}`): the classic `border-border-strong` divider between
1975
2473
  * rows; `last:border-b-0` so the final divider doesn't double with the
1976
2474
  * container's own bottom border (which reads as a heavy edge / shadow).
1977
2475
  */
1978
2476
  function rowSeparationClass(rowIndex: number): string {
1979
2477
  if (!zebra) return "border-b border-border-strong last:border-b-0";
1980
- return rowIndex % 2 === 1 ? "bg-foreground/5" : "";
2478
+ return cn(
2479
+ "border-b-(length:--table-row-rule-width) border-border-strong last:border-b-0",
2480
+ // Separate cn() argument: the stripe and the (theme-gated) rule are
2481
+ // alternative cues, never both at once — see the jsdoc above.
2482
+ rowIndex % 2 === 1 && "bg-table-stripe",
2483
+ );
1981
2484
  }
1982
2485
 
1983
2486
  /**
@@ -2015,9 +2518,9 @@ function DataTableInner<TData, TValue>(
2015
2518
  return cn(
2016
2519
  "bg-card",
2017
2520
  "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']",
2018
- zebra && rowIndex % 2 === 1 && "before:bg-foreground/5",
2019
- "group-hover/row:before:bg-foreground/10",
2020
- "group-data-[state=selected]/row:before:bg-accent",
2521
+ zebra && rowIndex % 2 === 1 && "before:bg-table-stripe",
2522
+ "group-hover/row:before:bg-table-row-hover",
2523
+ "group-data-[state=selected]/row:before:bg-selection",
2021
2524
  );
2022
2525
  }
2023
2526
 
@@ -2070,10 +2573,13 @@ function DataTableInner<TData, TValue>(
2070
2573
  onRowClick?.(row, event);
2071
2574
  }
2072
2575
 
2576
+ const sticky = stickyActive ? row.getIsPinned() || undefined : undefined;
2577
+ const rowStyle = rowColorStyle(row);
2073
2578
  return (
2074
2579
  <tr
2075
2580
  key={row.id}
2076
2581
  data-state={row.getIsSelected() ? "selected" : undefined}
2582
+ data-sticky={sticky}
2077
2583
  onClick={clickable ? handleRowClick : undefined}
2078
2584
  // Hover/selected are foreground-tint washes so they read more prominent than
2079
2585
  // the zebra stripe in the SAME direction across light/dark themes (the old
@@ -2084,7 +2590,7 @@ function DataTableInner<TData, TValue>(
2084
2590
  // (only movement is neutralized); the gated duration-fast/ease-standard
2085
2591
  // pair already collapses toward ~0ms via --motion-factor when the user
2086
2592
  // or OS asks for reduced motion, matching the header sort button.
2087
- "transition-colors duration-fast ease-standard hover:bg-foreground/10 data-[state=selected]:bg-accent",
2593
+ "transition-colors duration-fast ease-standard hover:bg-table-row-hover data-[state=selected]:bg-selection",
2088
2594
  // #13: the dragged row's live `transform` (set inline via `extras.style`,
2089
2595
  // see `SortableDataRow`) is what actually MOVES it — this class only
2090
2596
  // makes that movement glide instead of snapping, through the gated
@@ -2101,6 +2607,10 @@ function DataTableInner<TData, TValue>(
2101
2607
  // emits no style of its own.
2102
2608
  "group/row",
2103
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",
2104
2614
  // `<tr>` isn't in the global auto-cursor-pointer role list (button/
2105
2615
  // menuitem/tab/…), so a clickable row needs its own cursor. The focus
2106
2616
  // ring is driven off the hidden button's `:focus-visible` (same
@@ -2112,6 +2622,7 @@ function DataTableInner<TData, TValue>(
2112
2622
  rowClassName?.(row),
2113
2623
  )}
2114
2624
  {...extras}
2625
+ style={rowStyle || extras?.style ? { ...rowStyle, ...extras?.style } : undefined}
2115
2626
  >
2116
2627
  {dragHandle?.activator && (
2117
2628
  <td className="w-10 px-3 py-2 align-middle">
@@ -2131,49 +2642,58 @@ function DataTableInner<TData, TValue>(
2131
2642
  </button>
2132
2643
  </td>
2133
2644
  )}
2134
- {row.getVisibleCells().map((cell, cellIndex) => {
2135
- const geometry = pinnedCellGeometry(cell.column);
2136
- // #12: same width triad as the header cell — see `resizeWidthStyle`.
2137
- const resizeStyle = enableColumnResizing
2138
- ? resizeWidthStyle(cell.column.getSize())
2139
- : undefined;
2140
- return (
2141
- <td
2142
- key={cell.id}
2143
- data-pinned={geometry?.pinned ?? undefined}
2144
- style={geometry?.style ?? resizeStyle}
2145
- className={cn(
2146
- "px-3 py-2 align-middle",
2147
- // #69: same numeric-column seam as the header — see
2148
- // `numericColumnClasses`.
2149
- numericColumnClasses(cell.column.columnDef.meta),
2150
- // z-10: above the normal (unpositioned) cells it scrolls over,
2151
- // below the sticky header row (z-20) and the pinned corner (z-30).
2152
- geometry && "sticky z-10",
2153
- geometry && pinnedCellFillClass(rowIndex),
2154
- // Separate cn() argument — see pinnedCellGeometry's edgeClass.
2155
- geometry?.edgeClass,
2156
- )}
2157
- >
2158
- {clickable && cellIndex === 0 && (
2159
- <button
2160
- type="button"
2161
- data-slot="data-table-row-action"
2162
- // #311: `sr-only` removes the box from the visual layout but
2163
- // not the browser's own focus ring — the ROW paints the
2164
- // deliberate compound indicator (via the `has-[…]` selector
2165
- // above), so the proxy's own native ring must be suppressed
2166
- // or it leaks as a stray dot at the row's edge.
2167
- className="sr-only focus-visible:outline-none"
2168
- onClick={(event) => onRowClick?.(row, event)}
2169
- >
2170
- {rowActionName(row)}
2171
- </button>
2172
- )}
2173
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
2174
- </td>
2175
- );
2176
- })}
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
+ })}
2177
2697
  </tr>
2178
2698
  );
2179
2699
  }
@@ -2188,7 +2708,7 @@ function DataTableInner<TData, TValue>(
2188
2708
  // header/body cells — a loading table whose skeleton didn't mirror the
2189
2709
  // real alignment is exactly the column-shift-on-load bug
2190
2710
  // loading-states.md § "CLS / space reservation" warns about.
2191
- const visibleColumns = table.getVisibleLeafColumns();
2711
+ const visibleColumns = table.getVisibleLeafColumns().filter(isColumnShown);
2192
2712
  return Array.from({ length: count }).map((_, i) => (
2193
2713
  <tr key={`skeleton-${i}`} aria-hidden="true" className={rowSeparationClass(i)}>
2194
2714
  {hasGripColumn && (
@@ -2196,10 +2716,20 @@ function DataTableInner<TData, TValue>(
2196
2716
  <Skeleton className="size-4" />
2197
2717
  </td>
2198
2718
  )}
2719
+ {showRanks && (
2720
+ <td className={cn("w-10 px-3 align-middle", cellPadYClass)}>
2721
+ <Skeleton className="h-4 w-full" />
2722
+ </td>
2723
+ )}
2199
2724
  {visibleColumns.map((column) => (
2200
2725
  <td
2201
2726
  key={column.id}
2202
- className={cn("px-3 py-2 align-middle", numericColumnClasses(column.columnDef.meta))}
2727
+ className={cn(
2728
+ "px-3 align-middle",
2729
+ cellPadYClass,
2730
+ numericColumnClasses(column.columnDef.meta),
2731
+ columnDividers && COLUMN_DIVIDER_CLASS,
2732
+ )}
2203
2733
  >
2204
2734
  <Skeleton className="h-4 w-full" />
2205
2735
  </td>
@@ -2216,7 +2746,7 @@ function DataTableInner<TData, TValue>(
2216
2746
  return (
2217
2747
  <tr>
2218
2748
  <td
2219
- colSpan={colCount + (hasGripColumn ? 1 : 0)}
2749
+ colSpan={colCount + leadingColCount}
2220
2750
  className="h-24 px-3 text-center text-muted-foreground"
2221
2751
  >
2222
2752
  {emptyMessage}
@@ -2234,7 +2764,13 @@ function DataTableInner<TData, TValue>(
2234
2764
  return <tbody>{renderEmptyBody()}</tbody>;
2235
2765
  }
2236
2766
  if (!rowReorderActive) {
2237
- return <tbody>{rows.map((row, i) => renderRow(row, i))}</tbody>;
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
+ );
2238
2774
  }
2239
2775
 
2240
2776
  // #13: `SortableContext` renders no DOM element of its own (a plain
@@ -2246,6 +2782,7 @@ function DataTableInner<TData, TValue>(
2246
2782
  strategy={verticalListSortingStrategy}
2247
2783
  >
2248
2784
  <tbody>
2785
+ {topRows.map((row, i) => renderRow(row, i))}
2249
2786
  {rows.map((row, i) => (
2250
2787
  <SortableDataRow
2251
2788
  key={getReorderRowId(row)}
@@ -2289,6 +2826,7 @@ function DataTableInner<TData, TValue>(
2289
2826
  }
2290
2827
  </SortableDataRow>
2291
2828
  ))}
2829
+ {bottomRows.map((row, i) => renderRow(row, i))}
2292
2830
  </tbody>
2293
2831
  </SortableContext>
2294
2832
  );
@@ -2309,10 +2847,15 @@ function DataTableInner<TData, TValue>(
2309
2847
  renderEmptyBody()
2310
2848
  ) : (
2311
2849
  <>
2850
+ {topRows.map((row, i) =>
2851
+ renderRow(row, i, {
2852
+ "aria-rowindex": headerRowCount + i + 1,
2853
+ } as React.HTMLAttributes<HTMLTableRowElement>),
2854
+ )}
2312
2855
  {/* Top spacer — real <tr> so table layout is preserved */}
2313
2856
  {paddingTop > 0 && (
2314
2857
  <tr aria-hidden="true">
2315
- <td style={{ height: paddingTop }} colSpan={colCount} />
2858
+ <td style={{ height: paddingTop }} colSpan={colCount + leadingColCount} />
2316
2859
  </tr>
2317
2860
  )}
2318
2861
  {virtualItems.map((virtualRow) => {
@@ -2323,22 +2866,290 @@ function DataTableInner<TData, TValue>(
2323
2866
  return renderRow(row, virtualRow.index, {
2324
2867
  ref: virtualizer.measureElement as React.Ref<HTMLTableRowElement>,
2325
2868
  "data-index": virtualRow.index,
2326
- // Absolute 1-based row position; header row(s) occupy 1..headerRowCount.
2327
- "aria-rowindex": headerRowCount + virtualRow.index + 1,
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,
2328
2872
  } as React.HTMLAttributes<HTMLTableRowElement>);
2329
2873
  })}
2330
2874
  {/* Bottom spacer */}
2331
2875
  {paddingBottom > 0 && (
2332
2876
  <tr aria-hidden="true">
2333
- <td style={{ height: paddingBottom }} colSpan={colCount} />
2877
+ <td style={{ height: paddingBottom }} colSpan={colCount + leadingColCount} />
2334
2878
  </tr>
2335
2879
  )}
2880
+ {bottomRows.map((row, i) =>
2881
+ renderRow(row, i, {
2882
+ "aria-rowindex": firstCentreRowIndex + centreRowCount + i,
2883
+ } as React.HTMLAttributes<HTMLTableRowElement>),
2884
+ )}
2336
2885
  </>
2337
2886
  )}
2338
2887
  </tbody>
2339
2888
  );
2340
2889
  }
2341
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
+
2342
3153
  // ─── Pagination controls ──────────────────────────────────────────────────
2343
3154
  function renderPagination() {
2344
3155
  // Virtualization wins over pagination per spec — don't render controls
@@ -2394,8 +3205,9 @@ function DataTableInner<TData, TValue>(
2394
3205
  // If both enablePagination and enableRowVirtualization are set,
2395
3206
  // virtualization wins; pagination controls are silently suppressed.
2396
3207
  return (
2397
- <div ref={ref} className={cn("space-y-3", className)} {...rest}>
3208
+ <div ref={rootRef} {...presentationAttrs} className={cn("space-y-3", className)} {...rest}>
2398
3209
  {toolbar ? toolbar(table) : null}
3210
+ {renderLegends()}
2399
3211
  {/* Outer border is redundant (surface change) → plain border per #173 spec.
2400
3212
  tabIndex={0} makes the windowed scroll region keyboard-operable — the rows
2401
3213
  themselves aren't focusable, so without it the off-screen rows are
@@ -2403,8 +3215,13 @@ function DataTableInner<TData, TValue>(
2403
3215
  <div
2404
3216
  ref={scrollRef}
2405
3217
  tabIndex={0}
2406
- // Names the focus stop (WCAG 4.1.2) without a landmark role a `role="region"`
2407
- // here would add a redundant landmark over the inner real <table>.
3218
+ // Names the focus stop (WCAG 4.1.2). A naming-capable role is required
3219
+ // for that name to compute at all `aria-label` on a plain `<div>`
3220
+ // (role `generic`) is not guaranteed to produce an accessible name.
3221
+ // `group`, not `region`: a landmark per table would be redundant over
3222
+ // the real <table> and collide under axe `landmark-unique` when two
3223
+ // tables share a page.
3224
+ role="group"
2408
3225
  aria-label={t("data.table.scrollRegion")}
2409
3226
  aria-busy={loading || undefined}
2410
3227
  className="relative overflow-auto rounded-lg border bg-card focus-ring"
@@ -2422,18 +3239,26 @@ function DataTableInner<TData, TValue>(
2422
3239
  className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
2423
3240
  >
2424
3241
  <Spinner aria-hidden="true" className="text-foreground" />
2425
- <span className="sr-only">Loading table data…</span>
3242
+ <span className="sr-only">{t("data.table.loading")}</span>
2426
3243
  </div>
2427
3244
  )}
2428
- <table
2429
- aria-busy={loading || undefined}
2430
- aria-rowcount={ariaRowCount}
2431
- className="w-full caption-bottom text-body"
2432
- >
2433
- {captionElement}
2434
- {renderThead(true, true)}
2435
- {renderTbodyVirtualized()}
2436
- </table>
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
+ )}
2437
3262
  </div>
2438
3263
  </div>
2439
3264
  );
@@ -2449,8 +3274,9 @@ function DataTableInner<TData, TValue>(
2449
3274
  // region) so the edge-fade affordance can stay pinned to the visible edges
2450
3275
  // instead of scrolling away with the table content.
2451
3276
  const nonVirtualizedContent = (
2452
- <div ref={ref} className={cn("space-y-3", className)} {...rest}>
3277
+ <div ref={rootRef} {...presentationAttrs} className={cn("space-y-3", className)} {...rest}>
2453
3278
  {toolbar ? toolbar(table) : null}
3279
+ {renderLegends()}
2454
3280
  {/* Outer border is redundant (surface change) → plain border per #173 spec */}
2455
3281
  <div
2456
3282
  aria-busy={loading || undefined}
@@ -2468,7 +3294,7 @@ function DataTableInner<TData, TValue>(
2468
3294
  className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
2469
3295
  >
2470
3296
  <Spinner aria-hidden="true" className="text-foreground" />
2471
- <span className="sr-only">Loading table data…</span>
3297
+ <span className="sr-only">{t("data.table.loading")}</span>
2472
3298
  </div>
2473
3299
  )}
2474
3300
  {/* The tab stop exists ONLY while the region measurably overflows: without
@@ -2476,24 +3302,37 @@ function DataTableInner<TData, TValue>(
2476
3302
  axe `scrollable-region-focusable`) — but adding it unconditionally would
2477
3303
  give every table that FITS a focus stop that does nothing and announces
2478
3304
  "scrollable" when it isn't. `aria-label` moves with it (WCAG 4.1.2:
2479
- a name for a stop that exists, none for one that doesn't). No
2480
- `role="region"` that would add a redundant landmark over the real
2481
- <table> inside it. */}
2482
- <div
2483
- ref={plainScrollRef}
2484
- data-slot="data-table-scroll-region"
2485
- tabIndex={scrollOverflows ? 0 : undefined}
2486
- aria-label={scrollOverflows ? t("data.table.scrollRegion") : undefined}
2487
- onScroll={updateScrollAffordance}
2488
- className="overflow-auto rounded-lg focus-ring-inset"
2489
- style={hasLeftPinned || hasRightPinned ? pinnedScrollPadding : undefined}
2490
- >
2491
- <table aria-busy={loading || undefined} className="w-full caption-bottom text-body">
2492
- {captionElement}
2493
- {renderThead(false)}
2494
- {renderTbodyNormal()}
2495
- </table>
2496
- </div>
3305
+ a name for a stop that exists, none for one that doesn't) — and
3306
+ `role="group"` moves with BOTH of them: `aria-label` on a plain
3307
+ `<div>` (role `generic`) is not guaranteed to compute into an
3308
+ accessible name, so the stop needs a naming-capable role. `group`,
3309
+ never the `region` landmark: that would be redundant over the real
3310
+ <table> and collide (axe `landmark-unique`) with every other
3311
+ overflowing table on the page. */}
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
+ )}
2497
3336
  {/* Horizontal-scroll edge fade — a token-driven affordance that only
2498
3337
  appears once the table actually overflows its container in that
2499
3338
  direction, so a desktop/wide table renders neither (visual no-op).
@@ -2504,14 +3343,14 @@ function DataTableInner<TData, TValue>(
2504
3343
  there by the pinned block's `border-border-strong` seam, which is
2505
3344
  what a frozen column means ("content slides under this edge"). So
2506
3345
  the fade stays the cue for a FREE edge only. */}
2507
- {canScrollLeft && !hasLeftPinned && (
3346
+ {!cardsActive && canScrollLeft && !hasLeftPinned && (
2508
3347
  <div
2509
3348
  aria-hidden="true"
2510
3349
  data-slot="data-table-scroll-fade-left"
2511
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"
2512
3351
  />
2513
3352
  )}
2514
- {canScrollRight && !hasRightPinned && (
3353
+ {!cardsActive && canScrollRight && !hasRightPinned && (
2515
3354
  <div
2516
3355
  aria-hidden="true"
2517
3356
  data-slot="data-table-scroll-fade-right"