@elabs-ai/components-data 4.2.0 → 5.1.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 (131) 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 +547 -8
  93. package/dist/index.js +102 -1512
  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/data-table/card-layout.test.tsx +26 -0
  103. package/src/data-table/card-layout.tsx +74 -0
  104. package/src/data-table/cell-scales.test.ts +118 -0
  105. package/src/data-table/cell-scales.ts +246 -0
  106. package/src/data-table/cells/bar-cell.test.tsx +158 -0
  107. package/src/data-table/cells/bar-cell.tsx +135 -0
  108. package/src/data-table/cells/category-legend.test.tsx +37 -0
  109. package/src/data-table/cells/category-legend.tsx +58 -0
  110. package/src/data-table/cells/columns-cell.test.tsx +27 -0
  111. package/src/data-table/cells/columns-cell.tsx +79 -0
  112. package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
  113. package/src/data-table/cells/heatmap-cell.tsx +116 -0
  114. package/src/data-table/cells/markdown-cell.test.tsx +36 -0
  115. package/src/data-table/cells/markdown-cell.tsx +91 -0
  116. package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
  117. package/src/data-table/cells/sparkline-cell.tsx +113 -0
  118. package/src/data-table/column-meta.test.ts +42 -0
  119. package/src/data-table/column-meta.ts +282 -0
  120. package/src/data-table/data-table.stories.tsx +733 -1
  121. package/src/data-table/data-table.test.tsx +486 -0
  122. package/src/data-table/data-table.tsx +965 -165
  123. package/src/data-table/ranks-column.test.tsx +65 -0
  124. package/src/data-table/ranks-column.tsx +91 -0
  125. package/src/data-table/sticky-rows.test.ts +16 -0
  126. package/src/data-table/sticky-rows.ts +77 -0
  127. package/src/data-table/use-table-breakpoint.test.ts +14 -0
  128. package/src/data-table/use-table-breakpoint.ts +67 -0
  129. package/src/filter-bar/filter-chip.stories.tsx +1 -0
  130. package/src/index.ts +64 -0
  131. 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 { 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`
@@ -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 ? {} : { getFilteredRowModel: getFilteredRowModel() };
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
- 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
+
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
- 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;
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
- }, [updateScrollAffordance, colCount, rows.length]);
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
- "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",
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
- {table.getHeaderGroups().map((headerGroup, groupIndex) => (
2197
+ {headerGroups.map((headerGroup, groupIndex) => (
1742
2198
  <tr key={headerGroup.id} aria-rowindex={withRowIndex ? groupIndex + 1 : undefined}>
1743
2199
  {hasGripColumn && (
1744
- <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
+ >
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={geometry?.style ?? resizeStyle}
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
- "h-10 px-3 text-start align-middle font-table-header 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)]",
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
- {header.isPlaceholder ? null : canSort ? (
1833
- <button
1834
- type="button"
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
- flexRender(header.column.columnDef.header, header.getContext())
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-accent",
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-accent",
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.getVisibleCells().map((cell, cellIndex) => {
2159
- const geometry = pinnedCellGeometry(cell.column);
2160
- // #12: same width triad as the header cell — see `resizeWidthStyle`.
2161
- const resizeStyle = enableColumnResizing
2162
- ? resizeWidthStyle(cell.column.getSize())
2163
- : undefined;
2164
- return (
2165
- <td
2166
- key={cell.id}
2167
- data-pinned={geometry?.pinned ?? undefined}
2168
- style={geometry?.style ?? resizeStyle}
2169
- className={cn(
2170
- "px-3 py-2 align-middle",
2171
- // #69: same numeric-column seam as the header — see
2172
- // `numericColumnClasses`.
2173
- numericColumnClasses(cell.column.columnDef.meta),
2174
- // z-10: above the normal (unpositioned) cells it scrolls over,
2175
- // below the sticky header row (z-20) and the pinned corner (z-30).
2176
- geometry && "sticky z-10",
2177
- geometry && pinnedCellFillClass(rowIndex),
2178
- // Separate cn() argument — see pinnedCellGeometry's edgeClass.
2179
- geometry?.edgeClass,
2180
- columnDividers && !geometry && COLUMN_DIVIDER_CLASS,
2181
- )}
2182
- >
2183
- {clickable && cellIndex === 0 && (
2184
- <button
2185
- type="button"
2186
- data-slot="data-table-row-action"
2187
- // #311: `sr-only` removes the box from the visual layout but
2188
- // not the browser's own focus ring — the ROW paints the
2189
- // deliberate compound indicator (via the `has-[…]` selector
2190
- // above), so the proxy's own native ring must be suppressed
2191
- // or it leaks as a stray dot at the row's edge.
2192
- className="sr-only focus-visible:outline-none"
2193
- onClick={(event) => onRowClick?.(row, event)}
2194
- >
2195
- {rowActionName(row)}
2196
- </button>
2197
- )}
2198
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
2199
- </td>
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 py-2 align-middle",
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 + (hasGripColumn ? 1 : 0)}
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 <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
+ );
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 1..headerRowCount.
2356
- "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,
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={ref} className={cn("space-y-3", className)} {...rest}>
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
- <table
2463
- aria-busy={loading || undefined}
2464
- aria-rowcount={ariaRowCount}
2465
- className="w-full caption-bottom text-body"
2466
- >
2467
- {captionElement}
2468
- {renderThead(true, true)}
2469
- {renderTbodyVirtualized()}
2470
- </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
+ )}
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={ref} className={cn("space-y-3", className)} {...rest}>
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
- <div
2521
- ref={plainScrollRef}
2522
- data-slot="data-table-scroll-region"
2523
- tabIndex={scrollOverflows ? 0 : undefined}
2524
- role={scrollOverflows ? "group" : undefined}
2525
- aria-label={scrollOverflows ? t("data.table.scrollRegion") : undefined}
2526
- onScroll={updateScrollAffordance}
2527
- className="overflow-auto rounded-lg focus-ring-inset"
2528
- style={hasLeftPinned || hasRightPinned ? pinnedScrollPadding : undefined}
2529
- >
2530
- <table aria-busy={loading || undefined} className="w-full caption-bottom text-body">
2531
- {captionElement}
2532
- {renderThead(false)}
2533
- {renderTbodyNormal()}
2534
- </table>
2535
- </div>
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"