@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.
- package/README.md +1 -1
- package/dist/chunk-3OVN7MMO.js +2 -0
- package/dist/chunk-3OVN7MMO.js.map +1 -0
- package/dist/chunk-3UFWYVDS.js +2 -0
- package/dist/chunk-3UFWYVDS.js.map +1 -0
- package/dist/chunk-47ZBQZYS.js +47 -0
- package/dist/chunk-47ZBQZYS.js.map +1 -0
- package/dist/chunk-5K2IFOV2.js +2 -0
- package/dist/chunk-5K2IFOV2.js.map +1 -0
- package/dist/chunk-677ORWI6.js +17 -0
- package/dist/chunk-677ORWI6.js.map +1 -0
- package/dist/chunk-7ZYUY3UL.js +2 -0
- package/dist/chunk-7ZYUY3UL.js.map +1 -0
- package/dist/chunk-BEYHV3ZX.js +37 -0
- package/dist/chunk-BEYHV3ZX.js.map +1 -0
- package/dist/chunk-DBPJ7LVM.js +57 -0
- package/dist/chunk-DBPJ7LVM.js.map +1 -0
- package/dist/chunk-E3V3SYY7.js +87 -0
- package/dist/chunk-E3V3SYY7.js.map +1 -0
- package/dist/chunk-GBO56756.js +81 -0
- package/dist/chunk-GBO56756.js.map +1 -0
- package/dist/chunk-HG2YMSRM.js +95 -0
- package/dist/chunk-HG2YMSRM.js.map +1 -0
- package/dist/chunk-K2GHSRCP.js +1823 -0
- package/dist/chunk-K2GHSRCP.js.map +1 -0
- package/dist/chunk-KCB33PD3.js +43 -0
- package/dist/chunk-KCB33PD3.js.map +1 -0
- package/dist/chunk-MV2YVXEZ.js +67 -0
- package/dist/chunk-MV2YVXEZ.js.map +1 -0
- package/dist/chunk-NKVJUQYW.js +82 -0
- package/dist/chunk-NKVJUQYW.js.map +1 -0
- package/dist/chunk-OASM53LL.js +29 -0
- package/dist/chunk-OASM53LL.js.map +1 -0
- package/dist/chunk-RZ5MYZOA.js +2 -0
- package/dist/chunk-RZ5MYZOA.js.map +1 -0
- package/dist/chunk-SK6347WB.js +100 -0
- package/dist/chunk-SK6347WB.js.map +1 -0
- package/dist/chunk-TBB33PHQ.js +58 -0
- package/dist/chunk-TBB33PHQ.js.map +1 -0
- package/dist/chunk-VCLA45IC.js +150 -0
- package/dist/chunk-VCLA45IC.js.map +1 -0
- package/dist/chunk-XN6O6Z2N.js +58 -0
- package/dist/chunk-XN6O6Z2N.js.map +1 -0
- package/dist/chunk-ZFEW36RG.js +77 -0
- package/dist/chunk-ZFEW36RG.js.map +1 -0
- package/dist/chunk-ZQXJ34VJ.js +34 -0
- package/dist/chunk-ZQXJ34VJ.js.map +1 -0
- package/dist/chunk-ZUCGV2XD.js +42 -0
- package/dist/chunk-ZUCGV2XD.js.map +1 -0
- package/dist/column-picker/column-picker.js +8 -0
- package/dist/column-picker/column-picker.js.map +1 -0
- package/dist/column-picker/index.js +9 -0
- package/dist/column-picker/index.js.map +1 -0
- package/dist/data-table/card-layout.js +11 -0
- package/dist/data-table/card-layout.js.map +1 -0
- package/dist/data-table/cell-scales.js +26 -0
- package/dist/data-table/cell-scales.js.map +1 -0
- package/dist/data-table/cells/bar-cell.js +14 -0
- package/dist/data-table/cells/bar-cell.js.map +1 -0
- package/dist/data-table/cells/category-legend.js +9 -0
- package/dist/data-table/cells/category-legend.js.map +1 -0
- package/dist/data-table/cells/columns-cell.js +13 -0
- package/dist/data-table/cells/columns-cell.js.map +1 -0
- package/dist/data-table/cells/heatmap-cell.js +13 -0
- package/dist/data-table/cells/heatmap-cell.js.map +1 -0
- package/dist/data-table/cells/markdown-cell.js +13 -0
- package/dist/data-table/cells/markdown-cell.js.map +1 -0
- package/dist/data-table/cells/sparkline-cell.js +13 -0
- package/dist/data-table/cells/sparkline-cell.js.map +1 -0
- package/dist/data-table/column-meta.js +12 -0
- package/dist/data-table/column-meta.js.map +1 -0
- package/dist/data-table/data-table.js +23 -0
- package/dist/data-table/data-table.js.map +1 -0
- package/dist/data-table/index.js +23 -0
- package/dist/data-table/index.js.map +1 -0
- package/dist/data-table/ranks-column.js +13 -0
- package/dist/data-table/ranks-column.js.map +1 -0
- package/dist/data-table/sticky-rows.js +10 -0
- package/dist/data-table/sticky-rows.js.map +1 -0
- package/dist/data-table/use-table-breakpoint.js +13 -0
- package/dist/data-table/use-table-breakpoint.js.map +1 -0
- package/dist/facet-filter/facet-filter.js +8 -0
- package/dist/facet-filter/facet-filter.js.map +1 -0
- package/dist/facet-filter/index.js +9 -0
- package/dist/facet-filter/index.js.map +1 -0
- package/dist/filter-bar/filter-bar.js +8 -0
- package/dist/filter-bar/filter-bar.js.map +1 -0
- package/dist/filter-bar/filter-chip.js +9 -0
- package/dist/filter-bar/filter-chip.js.map +1 -0
- package/dist/filter-bar/index.js +13 -0
- package/dist/filter-bar/index.js.map +1 -0
- package/dist/index.d.ts +554 -10
- package/dist/index.js +102 -1494
- package/dist/index.js.map +1 -1
- package/dist/search-input/index.js +9 -0
- package/dist/search-input/index.js.map +1 -0
- package/dist/search-input/search-input.js +9 -0
- package/dist/search-input/search-input.js.map +1 -0
- package/dist/to-csv.js +10 -0
- package/dist/to-csv.js.map +1 -0
- package/package.json +7 -7
- package/src/__contract__/filter-chip.contract.test.tsx +49 -0
- package/src/column-picker/column-picker.tsx +3 -2
- package/src/data-table/card-layout.test.tsx +26 -0
- package/src/data-table/card-layout.tsx +74 -0
- package/src/data-table/cell-scales.test.ts +118 -0
- package/src/data-table/cell-scales.ts +246 -0
- package/src/data-table/cells/bar-cell.test.tsx +158 -0
- package/src/data-table/cells/bar-cell.tsx +135 -0
- package/src/data-table/cells/category-legend.test.tsx +37 -0
- package/src/data-table/cells/category-legend.tsx +58 -0
- package/src/data-table/cells/columns-cell.test.tsx +27 -0
- package/src/data-table/cells/columns-cell.tsx +79 -0
- package/src/data-table/cells/heatmap-cell.test.tsx +31 -0
- package/src/data-table/cells/heatmap-cell.tsx +116 -0
- package/src/data-table/cells/markdown-cell.test.tsx +36 -0
- package/src/data-table/cells/markdown-cell.tsx +91 -0
- package/src/data-table/cells/sparkline-cell.test.tsx +43 -0
- package/src/data-table/cells/sparkline-cell.tsx +113 -0
- package/src/data-table/column-meta.test.ts +42 -0
- package/src/data-table/column-meta.ts +282 -0
- package/src/data-table/data-table.stories.tsx +748 -1
- package/src/data-table/data-table.test.tsx +547 -11
- package/src/data-table/data-table.tsx +1014 -175
- package/src/data-table/ranks-column.test.tsx +65 -0
- package/src/data-table/ranks-column.tsx +91 -0
- package/src/data-table/sticky-rows.test.ts +16 -0
- package/src/data-table/sticky-rows.ts +77 -0
- package/src/data-table/use-table-breakpoint.test.ts +14 -0
- package/src/data-table/use-table-breakpoint.ts +67 -0
- package/src/facet-filter/facet-filter.stories.tsx +4 -1
- package/src/facet-filter/facet-filter.test.tsx +3 -3
- package/src/filter-bar/filter-chip.stories.tsx +1 -0
- package/src/index.ts +64 -0
- package/src/search-input/search-input.test.tsx +35 -0
- package/src/search-input/search-input.tsx +44 -19
- package/src/templates-data-app.stories.tsx +7 -2
- package/src/to-csv.test.ts +13 -0
- 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 {
|
|
69
|
+
import {
|
|
70
|
+
Button,
|
|
71
|
+
Checkbox,
|
|
72
|
+
Skeleton,
|
|
73
|
+
Spinner,
|
|
74
|
+
StatePanel,
|
|
75
|
+
useLocale,
|
|
76
|
+
type ColorScale,
|
|
77
|
+
} from "@elabs-ai/components-ui";
|
|
68
78
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
79
|
+
import {
|
|
80
|
+
columnSizeStyle,
|
|
81
|
+
formatCellValue,
|
|
82
|
+
resolveShowAt,
|
|
83
|
+
type DataTableColumnMeta,
|
|
84
|
+
} from "./column-meta";
|
|
85
|
+
import { computeColumnScales, extentOf, labelBoxCh, seriesEnds, seriesValues } from "./cell-scales";
|
|
86
|
+
import { BarCell } from "./cells/bar-cell";
|
|
87
|
+
import { ColumnsCell } from "./cells/columns-cell";
|
|
88
|
+
import { HeatmapCell, HeatmapLegend, heatmapCellStyle } from "./cells/heatmap-cell";
|
|
89
|
+
import { CategoryLegend } from "./cells/category-legend";
|
|
90
|
+
import { MarkdownCell } from "./cells/markdown-cell";
|
|
91
|
+
import { SparklineCell } from "./cells/sparkline-cell";
|
|
92
|
+
import { DataTableCard, DataTableCardList, type DataTableCardField } from "./card-layout";
|
|
93
|
+
import { DataTableRankCell, DataTableRankHeader, computeRowRanks } from "./ranks-column";
|
|
94
|
+
import { stickyRowPinning, withoutStickyRows, type DataTableStickyRows } from "./sticky-rows";
|
|
95
|
+
import { useTableBreakpoint } from "./use-table-breakpoint";
|
|
96
|
+
|
|
97
|
+
export type { DataTableColumnMeta } from "./column-meta";
|
|
69
98
|
|
|
70
99
|
// ─── Column meta seam (#69) ─────────────────────────────────────────────────────
|
|
71
100
|
// `columnDef.meta` is where TanStack lets a caller attach column-specific,
|
|
@@ -76,32 +105,9 @@ import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
|
76
105
|
// site. Exported (not just declared) so a consumer's own `ColumnDef` literal
|
|
77
106
|
// type-checks against a NAMED type, per component-api.md § Types.
|
|
78
107
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
* end-alignment on both the `<th>` and every `<td>` (including the loading
|
|
83
|
-
* skeleton) for free.
|
|
84
|
-
*/
|
|
85
|
-
export interface DataTableColumnMeta {
|
|
86
|
-
/** Numeric column: tabular figures + end alignment on header and cells. */
|
|
87
|
-
numeric?: boolean;
|
|
88
|
-
/**
|
|
89
|
-
* Explicit alignment override for when `numeric` isn't the right cue (or
|
|
90
|
-
* to align a non-numeric column). Independent of `numeric` — `numeric`
|
|
91
|
-
* alone still drives `tabular-nums` even when `align` overrides the
|
|
92
|
-
* alignment away from `"end"`.
|
|
93
|
-
*/
|
|
94
|
-
align?: "start" | "center" | "end";
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
declare module "@tanstack/react-table" {
|
|
98
|
-
// `TData`/`TValue` must stay in the signature to match the interface being
|
|
99
|
-
// augmented, even though `DataTableColumnMeta` (deliberately) doesn't use
|
|
100
|
-
// them; the empty extends-body is how TanStack's own module-augmentation
|
|
101
|
-
// pattern for `ColumnMeta` is documented.
|
|
102
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-object-type
|
|
103
|
-
interface ColumnMeta<TData extends RowData, TValue> extends DataTableColumnMeta {}
|
|
104
|
-
}
|
|
108
|
+
// `DataTableColumnMeta` and its TanStack `ColumnMeta` augmentation live in
|
|
109
|
+
// `./column-meta` (RM-123 grew the contract: visuals, format, colorBy, showAt,
|
|
110
|
+
// sizing, markdown); they are re-exported above under the same name.
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
113
|
* `<th>`/`<td>`/skeleton-`<td>` className for a column's `meta.numeric`/`meta.align`
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
2197
|
+
{headerGroups.map((headerGroup, groupIndex) => (
|
|
1727
2198
|
<tr key={headerGroup.id} aria-rowindex={withRowIndex ? groupIndex + 1 : undefined}>
|
|
1728
2199
|
{hasGripColumn && (
|
|
1729
|
-
<th
|
|
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={
|
|
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
|
-
|
|
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
|
-
{
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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-
|
|
2019
|
-
"group-hover/row:before:bg-
|
|
2020
|
-
"group-data-[state=selected]/row:before:bg-
|
|
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-
|
|
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
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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(
|
|
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 +
|
|
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
|
|
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
|
|
2327
|
-
|
|
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={
|
|
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)
|
|
2407
|
-
//
|
|
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">
|
|
3242
|
+
<span className="sr-only">{t("data.table.loading")}</span>
|
|
2426
3243
|
</div>
|
|
2427
3244
|
)}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
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={
|
|
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">
|
|
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)
|
|
2480
|
-
`role="
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
{
|
|
2495
|
-
|
|
2496
|
-
|
|
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"
|