@elabs-ai/components-data 4.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.
@@ -0,0 +1,1375 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
4
+ import {
5
+ flexRender,
6
+ getCoreRowModel,
7
+ getFilteredRowModel,
8
+ getPaginationRowModel,
9
+ getSortedRowModel,
10
+ useReactTable,
11
+ type Column,
12
+ type ColumnDef,
13
+ type ColumnFiltersState,
14
+ type ColumnPinningState,
15
+ type OnChangeFn,
16
+ type PaginationState,
17
+ type Row,
18
+ type SortingState,
19
+ type Table as TanstackTable,
20
+ type VisibilityState,
21
+ } from "@tanstack/react-table";
22
+ import { useVirtualizer } from "@tanstack/react-virtual";
23
+ import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
24
+ import { Button, Skeleton, Spinner, useLocale } from "@elabs-ai/components-ui";
25
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
26
+
27
+ // ─── Public types ─────────────────────────────────────────────────────────────
28
+
29
+ /** Snapshot of table slice state — used for saved-view serialise/rehydrate. */
30
+ export interface DataTableViewState {
31
+ sorting: SortingState;
32
+ columnVisibility: VisibilityState;
33
+ columnFilters: ColumnFiltersState;
34
+ globalFilter?: string;
35
+ pagination?: PaginationState;
36
+ /**
37
+ * Which columns are frozen to the left/right edge (#333). OPTIONAL on purpose:
38
+ * the other members predate it, and a required key would break every consumer
39
+ * that already constructs a `DataTableViewState` literal.
40
+ */
41
+ columnPinning?: ColumnPinningState;
42
+ }
43
+
44
+ /**
45
+ * Argument object fired by `onServerChange` whenever a manual slice changes.
46
+ * The consuming app should re-fetch with these params and update `data`.
47
+ */
48
+ export interface DataTableServerArgs {
49
+ pagination: PaginationState;
50
+ sorting: SortingState;
51
+ columnFilters: ColumnFiltersState;
52
+ globalFilter: string;
53
+ }
54
+
55
+ /**
56
+ * Fires when a row is activated (#337).
57
+ *
58
+ * Both activation paths deliver a `click`: a pointer click on the row body, and
59
+ * a keyboard Enter/Space on the row's hidden activation `<button>` (which the
60
+ * browser dispatches as a click). So the handler takes ONE event type — there is
61
+ * nothing for the caller to branch on.
62
+ */
63
+ export type DataTableRowClickHandler<TData> = (
64
+ row: Row<TData>,
65
+ event: React.MouseEvent<HTMLElement>,
66
+ ) => void;
67
+
68
+ // ─── Props ────────────────────────────────────────────────────────────────────
69
+
70
+ export interface DataTableProps<TData, TValue> extends Omit<
71
+ React.HTMLAttributes<HTMLDivElement>,
72
+ "children"
73
+ > {
74
+ columns: ColumnDef<TData, TValue>[];
75
+ data: TData[];
76
+ /** Render a toolbar above the table; receives the table instance. */
77
+ toolbar?: (table: TanstackTable<TData>) => ReactNode;
78
+ /** Enable client-side pagination. */
79
+ enablePagination?: boolean;
80
+ pageSize?: number;
81
+ /**
82
+ * Hide the pager once there's genuinely only one page
83
+ * (`table.getPageCount() <= 1`). Default `true`. When `manualPagination` is
84
+ * set without `rowCount`/`pageCount`, the page count isn't knowable (TanStack
85
+ * falls back to the current page's row count) — in that ambiguous case the
86
+ * pager still renders regardless of this flag, so the existing dev warning
87
+ * (#227) stays the diagnostic instead of a silently-hidden pager. Set to
88
+ * `false` to always show the pager (e.g. while a server total is still
89
+ * loading and you'd rather show a disabled pager than none).
90
+ */
91
+ hidePaginationWhenSingle?: boolean;
92
+
93
+ /**
94
+ * Controlled global filter value. When provided, the table reflects this
95
+ * value and the component manages no internal filter state. Keep the source
96
+ * of truth in the app and pass it down — never mutate the filter during
97
+ * render (e.g. `table.setGlobalFilter()` in `toolbar`), which loops.
98
+ */
99
+ globalFilter?: string;
100
+ /** Fires when the table requests a global-filter change (e.g. from typeahead). */
101
+ onGlobalFilterChange?: (value: string) => void;
102
+
103
+ // ── Controlled slices for saved views ─────────────────────────────────────
104
+ /** Controlled sorting state. When provided the component is sorted-controlled. */
105
+ sorting?: SortingState;
106
+ onSortingChange?: OnChangeFn<SortingState>;
107
+
108
+ /** Controlled column-visibility state. */
109
+ columnVisibility?: VisibilityState;
110
+ onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
111
+
112
+ /** Controlled column-filters state. */
113
+ columnFilters?: ColumnFiltersState;
114
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
115
+
116
+ /** Controlled pagination state. */
117
+ pagination?: PaginationState;
118
+ onPaginationChange?: OnChangeFn<PaginationState>;
119
+
120
+ /**
121
+ * Controlled column-pinning state (#333) — the columns frozen against the
122
+ * left and/or right edge while the rest of the table scrolls horizontally.
123
+ * When provided the component is pinning-controlled; otherwise it manages the
124
+ * slice internally and can be seeded once via `initialView.columnPinning`.
125
+ *
126
+ * A pinned column MUST declare an explicit `size` in its `ColumnDef`: the
127
+ * sticky offset is computed from TanStack's `column.getStart("left")` /
128
+ * `getAfter("right")`, which sum the DECLARED sizes, so an auto-width column
129
+ * would render at a width that doesn't match its own offset. A dev-only
130
+ * warning fires for a pinned column with no `size`.
131
+ *
132
+ * Pinning is a LAYOUT concern, not a query concern — it is client-only and
133
+ * never joins `DataTableServerArgs` / `onServerChange`.
134
+ */
135
+ columnPinning?: ColumnPinningState;
136
+ onColumnPinningChange?: OnChangeFn<ColumnPinningState>;
137
+
138
+ /**
139
+ * One-shot rehydrate for uncontrolled slices only (ignored for any slice
140
+ * whose corresponding controlled prop is set). Maps to `useReactTable`'s
141
+ * `initialState`.
142
+ */
143
+ initialView?: Partial<DataTableViewState>;
144
+
145
+ // ── Server-side data model ──────────────────────────────────────────────────
146
+ /**
147
+ * When true, sorting is handled by the server. Pass `sorting` (controlled)
148
+ * and handle `onServerChange` to re-fetch with the new sort params.
149
+ * NOTE: controlled ≠ manual — a controlled `sorting` with `manualSorting:false`
150
+ * still sorts locally.
151
+ */
152
+ manualSorting?: boolean;
153
+ /**
154
+ * When true, filtering is handled by the server.
155
+ * NOTE: a controlled `columnFilters` with `manualFiltering:false` still
156
+ * filters locally.
157
+ */
158
+ manualFiltering?: boolean;
159
+ /** When true, pagination is handled by the server. */
160
+ manualPagination?: boolean;
161
+
162
+ /**
163
+ * Total row count — used by the server model so TanStack can derive
164
+ * page count. Required when `manualPagination` is true and `pageCount` is
165
+ * not provided.
166
+ */
167
+ rowCount?: number;
168
+ /**
169
+ * Total page count — alternative to `rowCount` for server pagination. When
170
+ * both are provided, `pageCount` wins.
171
+ */
172
+ pageCount?: number;
173
+
174
+ /**
175
+ * Fired after any manual-slice change with the current {pagination, sorting,
176
+ * columnFilters, globalFilter}. The component never fetches; the app must
177
+ * re-fetch and update `data`.
178
+ */
179
+ onServerChange?: (args: DataTableServerArgs) => void;
180
+
181
+ /** When true: overlay spinner; on empty+loading show skeleton rows instead of empty message. */
182
+ loading?: boolean;
183
+
184
+ // ── Virtualization ─────────────────────────────────────────────────────────
185
+ /**
186
+ * Opt-in to row virtualization (for very large lists). Mutually exclusive
187
+ * with enablePagination in practice — if both are set, virtualization wins
188
+ * and pagination is silently ignored.
189
+ */
190
+ enableRowVirtualization?: boolean;
191
+ /** Estimated row height in px (used by the virtualizer). Default: 40. */
192
+ estimateRowHeight?: number;
193
+ /** Virtualizer overscan (rows rendered above/below the visible window). Default: 8. */
194
+ overscan?: number;
195
+ /** CSS max-height of the scroll container in virtualized mode. Default: "32rem". */
196
+ maxBodyHeight?: string;
197
+
198
+ /**
199
+ * Number of skeleton placeholder rows to render while loading.
200
+ * Defaults to `pageSize` (non-virtualized) or `min(10, pageSize)` (virtualized).
201
+ */
202
+ loadingRows?: number;
203
+
204
+ /**
205
+ * Gentle alternating row stripes ("zebra") as the row-separation cue, instead
206
+ * of a hairline divider between every row. Default `true` — the stripe is the
207
+ * single separation gesture, so rows carry no divider (a divider on a striped
208
+ * row would be a redundant boundary). Set `false` for the classic line model
209
+ * (a `border-border-strong` divider between rows, no stripes).
210
+ */
211
+ zebra?: boolean;
212
+
213
+ /**
214
+ * Fires when a row is activated (#337). Setting it adds ONE activation
215
+ * target per row: a visually-hidden `<button>` rendered inside the row's
216
+ * first cell. That button is the row's keyboard tab stop and its accessible
217
+ * name; a pointer click anywhere else in the row resolves to the same
218
+ * handler, so mouse and keyboard converge on one control instead of two
219
+ * competing ones (a focusable `<tr>` cannot carry an activation role without
220
+ * destroying `row` table semantics).
221
+ *
222
+ * Guarded: a click that originates on a nested interactive control
223
+ * (button/link/input/checkbox/…) or is the tail end of a text-selection drag
224
+ * does NOT fire it. Optional; omitting it renders rows exactly as before.
225
+ */
226
+ onRowClick?: DataTableRowClickHandler<TData>;
227
+ /**
228
+ * Accessible name for the row's hidden activation button (#337). Only read
229
+ * when `onRowClick` is set. Defaults to the row's first visible cell value
230
+ * when that is a string/number (the row's primary identifier — the same
231
+ * naming a link in that cell would get), else the localized
232
+ * `data.table.rowAction` fallback. Supply it whenever the first cell isn't a
233
+ * good name for the row.
234
+ */
235
+ rowActionLabel?: (row: Row<TData>) => string;
236
+ /**
237
+ * Per-row className, merged alongside the existing zebra/line/hover/selected
238
+ * classes via `cn()` (so it can't accidentally clobber them) (#337).
239
+ */
240
+ rowClassName?: (row: Row<TData>) => string;
241
+
242
+ /**
243
+ * Accessible name for the table, rendered as a visually-hidden (`sr-only`)
244
+ * `<caption>` — the first child of `<table>`. Screen readers announce it as
245
+ * the table's name and it makes column-header navigation meaningful.
246
+ * Optional; omit it only when the surrounding page already labels the table
247
+ * unambiguously (e.g. an adjacent heading) (#338).
248
+ */
249
+ caption?: ReactNode;
250
+
251
+ /** Message shown when there are no rows and not loading. */
252
+ emptyMessage?: ReactNode;
253
+ className?: string;
254
+ }
255
+
256
+ // ─── Row-click guards (module-level — shared by every renderRow call) ────────
257
+
258
+ /**
259
+ * CSS selector for anything inside a row that owns its own click/keyboard
260
+ * behavior. A row click must not fire when the user actually meant to
261
+ * activate one of these — the row is the activation target for everything
262
+ * ELSE in the row, not a second competing target (#337).
263
+ */
264
+ const ROW_CLICK_GUARD_SELECTOR =
265
+ 'button, a[href], input, select, textarea, label, summary, [role="button"], [role="link"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [contenteditable="true"]';
266
+
267
+ function isInteractiveEventTarget(target: EventTarget | null): boolean {
268
+ return target instanceof Element && target.closest(ROW_CLICK_GUARD_SELECTOR) !== null;
269
+ }
270
+
271
+ /**
272
+ * True while the user is completing a text-selection drag — a row click must
273
+ * not fire for the mouseup/click that ends a selection (#337).
274
+ */
275
+ function isActiveTextSelection(): boolean {
276
+ if (typeof window === "undefined" || typeof window.getSelection !== "function") return false;
277
+ return window.getSelection()?.type === "Range";
278
+ }
279
+
280
+ // ─── Pinning helpers (module-level) ──────────────────────────────────────────
281
+
282
+ /**
283
+ * The 1px seam between the frozen block and the scrolling block (#333), minus
284
+ * the side — `pinnedCellGeometry` appends `after:end-0` or `after:start-0`.
285
+ *
286
+ * A pseudo-element rather than a `border-e`/`border-s` on purpose: see the note
287
+ * in `pinnedCellGeometry`. Token-backed (`bg-border-strong`, the strong rung per
288
+ * ADR 0010) and no shadow, so a shadowless surface (
289
+ * `data-decoration="8|9|10"`) cannot delete it.
290
+ */
291
+ const PINNED_SEAM_CLASS =
292
+ "after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-border-strong after:content-['']";
293
+
294
+ /**
295
+ * Ids of leaf columns whose ORIGINAL `ColumnDef` declares no `size` (#333).
296
+ *
297
+ * Deliberately reads the raw `columns` prop rather than `column.columnDef`:
298
+ * TanStack merges its `defaultColumnSizing` (`size: 150`) into every resolved
299
+ * column def, so the resolved def can never distinguish "the author sized this"
300
+ * from "the author left it to the default" — and the whole point of the pinned
301
+ * `size` warning is to catch the second case.
302
+ *
303
+ * Mirrors TanStack's own id resolution: `columnDef.id`, else the `accessorKey`
304
+ * with `.` → `_`, else a string `header`.
305
+ */
306
+ function unsizedColumnIds<TData, TValue>(defs: readonly ColumnDef<TData, TValue>[]): Set<string> {
307
+ const out = new Set<string>();
308
+ const walk = (list: readonly ColumnDef<TData, TValue>[]) => {
309
+ for (const def of list) {
310
+ const group = def as { columns?: ColumnDef<TData, TValue>[] };
311
+ if (group.columns) {
312
+ walk(group.columns);
313
+ continue;
314
+ }
315
+ if (def.size !== undefined) continue;
316
+ const accessorKey = (def as { accessorKey?: string | number }).accessorKey;
317
+ const id =
318
+ def.id ??
319
+ (accessorKey !== undefined
320
+ ? String(accessorKey).replace(/\./gu, "_")
321
+ : typeof def.header === "string"
322
+ ? def.header
323
+ : undefined);
324
+ if (id) out.add(id);
325
+ }
326
+ };
327
+ walk(defs);
328
+ return out;
329
+ }
330
+
331
+ // ─── Component (inner, generic) ───────────────────────────────────────────────
332
+
333
+ /**
334
+ * Branded TanStack Table wrapper with sorting, global filtering, column
335
+ * visibility and optional pagination. The toolbar render-prop hands you the
336
+ * table instance so SearchInput / FacetFilter / ColumnPicker can drive it.
337
+ *
338
+ * Every slice (sorting / columnVisibility / columnFilters / pagination) is
339
+ * independently controllable. Uncontrolled slices are managed internally.
340
+ * Pass `manualSorting` / `manualFiltering` / `manualPagination` to opt into
341
+ * server-driven data; `onServerChange` fires after each slice change so the
342
+ * app can re-fetch.
343
+ *
344
+ * Accepts a forwarded `ref` to the outermost wrapper `<div>` and spreads any
345
+ * additional HTML div props (e.g. `id`, `aria-*`, `data-*`) onto that element.
346
+ */
347
+ function DataTableInner<TData, TValue>(
348
+ {
349
+ columns,
350
+ data,
351
+ toolbar,
352
+ enablePagination = false,
353
+ pageSize = 10,
354
+ hidePaginationWhenSingle = true,
355
+
356
+ // Global filter
357
+ globalFilter: globalFilterProp,
358
+ onGlobalFilterChange,
359
+
360
+ // Controlled slices
361
+ sorting: sortingProp,
362
+ onSortingChange: onSortingChangeProp,
363
+ columnVisibility: columnVisibilityProp,
364
+ onColumnVisibilityChange: onColumnVisibilityChangeProp,
365
+ columnFilters: columnFiltersProp,
366
+ onColumnFiltersChange: onColumnFiltersChangeProp,
367
+ pagination: paginationProp,
368
+ onPaginationChange: onPaginationChangeProp,
369
+ columnPinning: columnPinningProp,
370
+ onColumnPinningChange: onColumnPinningChangeProp,
371
+
372
+ // Saved views rehydration
373
+ initialView,
374
+
375
+ // Server-side model
376
+ manualSorting = false,
377
+ manualFiltering = false,
378
+ manualPagination = false,
379
+ rowCount,
380
+ pageCount,
381
+ onServerChange,
382
+
383
+ // Loading
384
+ loading = false,
385
+ loadingRows,
386
+
387
+ // Virtualization
388
+ enableRowVirtualization = false,
389
+ estimateRowHeight = 40,
390
+ overscan = 8,
391
+ maxBodyHeight = "32rem",
392
+
393
+ zebra = true,
394
+ onRowClick,
395
+ rowActionLabel,
396
+ rowClassName,
397
+ caption,
398
+ emptyMessage = "No results.",
399
+ className,
400
+ ...rest
401
+ }: DataTableProps<TData, TValue>,
402
+ ref: React.Ref<HTMLDivElement>,
403
+ ) {
404
+ // Component microcopy goes through the locale seam (ADR 0017) — a screen-reader
405
+ // user in a non-English locale has no workaround for a hardcoded accessible name.
406
+ const { t } = useLocale();
407
+
408
+ // ── Controlled/uncontrolled detection ────────────────────────────────────
409
+ const isSortingControlled = sortingProp !== undefined;
410
+ const isColumnVisibilityControlled = columnVisibilityProp !== undefined;
411
+ const isColumnFiltersControlled = columnFiltersProp !== undefined;
412
+ const isPaginationControlled = paginationProp !== undefined;
413
+ const isFilterControlled = globalFilterProp !== undefined;
414
+ const isColumnPinningControlled = columnPinningProp !== undefined;
415
+
416
+ // ── Internal state (only drives a slice when uncontrolled) ───────────────
417
+ const [internalSorting, setInternalSorting] = useState<SortingState>(
418
+ () => initialView?.sorting ?? [],
419
+ );
420
+ const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(
421
+ () => initialView?.columnVisibility ?? {},
422
+ );
423
+ const [internalColumnFilters, setInternalColumnFilters] = useState<ColumnFiltersState>(
424
+ () => initialView?.columnFilters ?? [],
425
+ );
426
+ const [internalPagination, setInternalPagination] = useState<PaginationState>(
427
+ () =>
428
+ initialView?.pagination ?? {
429
+ pageIndex: 0,
430
+ pageSize,
431
+ },
432
+ );
433
+ const [internalGlobalFilter, setInternalGlobalFilter] = useState<string>(
434
+ () => initialView?.globalFilter ?? "",
435
+ );
436
+ const [internalColumnPinning, setInternalColumnPinning] = useState<ColumnPinningState>(
437
+ () => initialView?.columnPinning ?? { left: [], right: [] },
438
+ );
439
+
440
+ // ── Resolved state (controlled wins over internal) ───────────────────────
441
+ const sorting = isSortingControlled ? sortingProp : internalSorting;
442
+ const columnVisibility = isColumnVisibilityControlled
443
+ ? columnVisibilityProp
444
+ : internalColumnVisibility;
445
+ const columnFilters = isColumnFiltersControlled ? columnFiltersProp : internalColumnFilters;
446
+ const pagination = isPaginationControlled ? paginationProp : internalPagination;
447
+ const globalFilter = isFilterControlled ? globalFilterProp : internalGlobalFilter;
448
+ const columnPinning = isColumnPinningControlled ? columnPinningProp : internalColumnPinning;
449
+
450
+ // ── Refs for post-change server callback ─────────────────────────────────
451
+ // We need the current values of ALL slices when any one fires; use refs to
452
+ // avoid stale closures without adding them as deps.
453
+ const sortingRef = useRef(sorting);
454
+ sortingRef.current = sorting;
455
+ const columnFiltersRef = useRef(columnFilters);
456
+ columnFiltersRef.current = columnFilters;
457
+ const paginationRef = useRef(pagination);
458
+ paginationRef.current = pagination;
459
+ const globalFilterRef = useRef(globalFilter);
460
+ globalFilterRef.current = globalFilter;
461
+ const columnVisibilityRef = useRef(columnVisibility);
462
+ columnVisibilityRef.current = columnVisibility;
463
+ const columnPinningRef = useRef(columnPinning);
464
+ columnPinningRef.current = columnPinning;
465
+
466
+ // ── Dev-only guard: manualPagination needs a total to compute page count ──
467
+ // Without `rowCount` (or `pageCount`), TanStack's `getPageCount()` falls back
468
+ // to the CURRENT PAGE's row count (manual mode has no full row model), so the
469
+ // pager silently reads "Page 1 of 1" with Next permanently disabled. Warn
470
+ // once per mount so the missing prop is diagnosable instead of silent (#227).
471
+ const warnedMissingRowCountRef = useRef(false);
472
+ useEffect(() => {
473
+ if (
474
+ process.env.NODE_ENV !== "production" &&
475
+ manualPagination &&
476
+ rowCount === undefined &&
477
+ pageCount === undefined &&
478
+ !warnedMissingRowCountRef.current
479
+ ) {
480
+ warnedMissingRowCountRef.current = true;
481
+ console.warn(
482
+ "[DataTable] `manualPagination` is true but neither `rowCount` nor `pageCount` was " +
483
+ 'provided — the pager will appear stuck ("Page 1 of 1", Next disabled). Pass ' +
484
+ "`rowCount` (or `pageCount`) so the pager can compute the total.",
485
+ );
486
+ }
487
+ }, [manualPagination, rowCount, pageCount]);
488
+
489
+ /** Fire onServerChange with the LATEST slice values (post-update). */
490
+ function fireServerChange(overrides: Partial<DataTableServerArgs> = {}) {
491
+ if (!onServerChange) return;
492
+ onServerChange({
493
+ pagination: paginationRef.current,
494
+ sorting: sortingRef.current,
495
+ columnFilters: columnFiltersRef.current,
496
+ globalFilter: globalFilterRef.current,
497
+ ...overrides,
498
+ });
499
+ }
500
+
501
+ // ── Updater helpers — all five slices resolve a functional updater against
502
+ // their *Ref.current (the post-update value), never the render-closure
503
+ // variable, so the resolution stays correct once these callbacks are
504
+ // memoized (a useCallback wrap or the React Compiler) ──────────────────────
505
+ function resolveSorting(updater: Parameters<OnChangeFn<SortingState>>[0]): SortingState {
506
+ return typeof updater === "function" ? updater(sortingRef.current) : updater;
507
+ }
508
+ function resolveColumnVisibility(
509
+ updater: Parameters<OnChangeFn<VisibilityState>>[0],
510
+ ): VisibilityState {
511
+ return typeof updater === "function" ? updater(columnVisibilityRef.current) : updater;
512
+ }
513
+ function resolveColumnFilters(
514
+ updater: Parameters<OnChangeFn<ColumnFiltersState>>[0],
515
+ ): ColumnFiltersState {
516
+ return typeof updater === "function" ? updater(columnFiltersRef.current) : updater;
517
+ }
518
+ function resolvePagination(updater: Parameters<OnChangeFn<PaginationState>>[0]): PaginationState {
519
+ return typeof updater === "function" ? updater(paginationRef.current) : updater;
520
+ }
521
+ function resolveGlobalFilter(updater: Parameters<OnChangeFn<string>>[0]): string {
522
+ return typeof updater === "function" ? updater(globalFilterRef.current) : updater;
523
+ }
524
+ function resolveColumnPinning(
525
+ updater: Parameters<OnChangeFn<ColumnPinningState>>[0],
526
+ ): ColumnPinningState {
527
+ return typeof updater === "function" ? updater(columnPinningRef.current) : updater;
528
+ }
529
+
530
+ // ── Row models — omit client model for manual slices ─────────────────────
531
+ const sortedRowModel = manualSorting ? {} : { getSortedRowModel: getSortedRowModel() };
532
+ const filteredRowModel = manualFiltering ? {} : { getFilteredRowModel: getFilteredRowModel() };
533
+ // Only attach the client pagination row model when we actually paginate locally.
534
+ // Under `manualPagination`, TanStack ignores a supplied `getPaginationRowModel`
535
+ // (it returns the pre-pagination rows — i.e. the page the app already fetched),
536
+ // so attaching it there is dead per-render work. `(A && !B) || B === A || B`,
537
+ // but the honest single-branch form documents that manual mode needs no model.
538
+ const paginationRowModel =
539
+ enablePagination && !manualPagination ? { getPaginationRowModel: getPaginationRowModel() } : {};
540
+
541
+ // ── Table instance ────────────────────────────────────────────────────────
542
+ const table = useReactTable({
543
+ data,
544
+ columns,
545
+ state: { sorting, columnVisibility, columnFilters, globalFilter, pagination, columnPinning },
546
+
547
+ // Sorting
548
+ onSortingChange: (updater) => {
549
+ const next = resolveSorting(updater);
550
+ if (!isSortingControlled) setInternalSorting(next);
551
+ onSortingChangeProp?.(updater);
552
+ if (manualSorting) {
553
+ sortingRef.current = next;
554
+ fireServerChange({ sorting: next });
555
+ }
556
+ },
557
+
558
+ // Column visibility
559
+ onColumnVisibilityChange: (updater) => {
560
+ const next = resolveColumnVisibility(updater);
561
+ if (!isColumnVisibilityControlled) setInternalColumnVisibility(next);
562
+ onColumnVisibilityChangeProp?.(updater);
563
+ // column visibility is never a "manual" server concern
564
+ },
565
+
566
+ // Column filters
567
+ onColumnFiltersChange: (updater) => {
568
+ const next = resolveColumnFilters(updater);
569
+ if (!isColumnFiltersControlled) setInternalColumnFilters(next);
570
+ onColumnFiltersChangeProp?.(updater);
571
+ if (manualFiltering) {
572
+ columnFiltersRef.current = next;
573
+ fireServerChange({ columnFilters: next });
574
+ }
575
+ },
576
+
577
+ // Global filter
578
+ onGlobalFilterChange: (updater) => {
579
+ const next = resolveGlobalFilter(updater);
580
+ if (!isFilterControlled) setInternalGlobalFilter(next);
581
+ onGlobalFilterChange?.(next);
582
+ if (manualFiltering) {
583
+ globalFilterRef.current = next;
584
+ fireServerChange({ globalFilter: next });
585
+ }
586
+ },
587
+
588
+ // Pagination
589
+ onPaginationChange: (updater) => {
590
+ const next = resolvePagination(updater);
591
+ if (!isPaginationControlled) setInternalPagination(next);
592
+ onPaginationChangeProp?.(updater);
593
+ if (manualPagination) {
594
+ paginationRef.current = next;
595
+ fireServerChange({ pagination: next });
596
+ }
597
+ },
598
+
599
+ // Column pinning — a LAYOUT slice, so unlike sorting/filtering/pagination it
600
+ // never fires `onServerChange`: freezing a column changes nothing the server
601
+ // would need to re-query.
602
+ onColumnPinningChange: (updater) => {
603
+ const next = resolveColumnPinning(updater);
604
+ if (!isColumnPinningControlled) setInternalColumnPinning(next);
605
+ onColumnPinningChangeProp?.(updater);
606
+ },
607
+
608
+ getCoreRowModel: getCoreRowModel(),
609
+ ...sortedRowModel,
610
+ ...filteredRowModel,
611
+ ...paginationRowModel,
612
+
613
+ // Server-side options
614
+ manualSorting,
615
+ manualFiltering,
616
+ manualPagination,
617
+ ...(rowCount !== undefined ? { rowCount } : {}),
618
+ ...(pageCount !== undefined ? { pageCount } : {}),
619
+ // No `initialState`: every slice is driven explicitly via `state` above
620
+ // (internal slices are seeded from `initialView` at useState init), so a
621
+ // TanStack `initialState` would be dead/misleading.
622
+ });
623
+
624
+ const rows = table.getRowModel().rows;
625
+ // colSpan for spacer / empty / skeleton cells must match the number of cells a
626
+ // real data row renders (`row.getVisibleCells()`) — use VISIBLE leaf columns so a
627
+ // hidden column (a first-class slice here via columnVisibility + ColumnPicker)
628
+ // doesn't make those rows over-span.
629
+ const colCount = table.getVisibleLeafColumns().length;
630
+ // Virtualized-table ARIA: only a window of rows is mounted, so assistive tech
631
+ // can't infer the true size from the DOM. aria-rowcount counts the header row(s)
632
+ // plus every data row; rendered data rows carry an absolute 1-based aria-rowindex
633
+ // (header rows occupy 1..headerRowCount). Falls back to rows.length for the
634
+ // client path; uses the server `rowCount` total when provided.
635
+ const headerRowCount = table.getHeaderGroups().length;
636
+ const ariaRowCount = (rowCount ?? rows.length) + headerRowCount;
637
+
638
+ // ── Pinning (#333) ────────────────────────────────────────────────────────
639
+ // Are there any pinned columns at all? Everything pinning-related is gated on
640
+ // this so a table with no pinning renders byte-identical markup to before.
641
+ const hasLeftPinned = (columnPinning.left?.length ?? 0) > 0;
642
+ const hasRightPinned = (columnPinning.right?.length ?? 0) > 0;
643
+
644
+ // Keep keyboard focus out from UNDER the frozen block (WCAG 2.2 SC 2.4.11,
645
+ // "Focus Not Obscured"). Tabbing to a control in a centre column that is
646
+ // currently scrolled under the frozen columns makes the browser scroll it to
647
+ // the SCROLLPORT edge — and the browser has no idea a sticky column is parked
648
+ // there, so the focused control lands behind it, invisibly. Measured on
649
+ // `PinnedColumns`: at scrollLeft 295 the "Latency (ms)" / p50 / p95 sort
650
+ // buttons focused at viewport x 15 / 100 / 183, all inside the 17…297 frozen
651
+ // block. `scroll-padding` is the platform's answer — it is exactly the "don't
652
+ // scroll content to here" inset that `scrollIntoView` honours. Emitted only
653
+ // when something IS pinned, so an unpinned table keeps its previous DOM.
654
+ const pinnedScrollPadding: React.CSSProperties = {
655
+ ...(hasLeftPinned ? { scrollPaddingInlineStart: table.getLeftTotalSize() } : {}),
656
+ ...(hasRightPinned ? { scrollPaddingInlineEnd: table.getRightTotalSize() } : {}),
657
+ };
658
+
659
+ // Dev-only guard: a pinned column's sticky offset is `getStart("left")` /
660
+ // `getAfter("right")`, i.e. the SUM OF DECLARED SIZES of the columns beside
661
+ // it. The table is auto-layout, so a pinned column with no `size` renders at
662
+ // whatever width its content wants while its neighbours are offset by
663
+ // TanStack's 150px default — the pinned block then overlaps or gaps. Warn
664
+ // once per mount so that mismatch is diagnosable instead of silent (same
665
+ // idiom as the #227 warning above).
666
+ //
667
+ // Read off the RAW `columns` prop, not `column.columnDef`: TanStack merges a
668
+ // default `size: 150` into every resolved column def, so the merged def can
669
+ // never tell us whether the author actually declared one.
670
+ const warnedUnsizedPinnedRef = useRef(false);
671
+ const pinnedIds = [...(columnPinning.left ?? []), ...(columnPinning.right ?? [])];
672
+ const unsizedIds =
673
+ process.env.NODE_ENV === "production" || pinnedIds.length === 0
674
+ ? null
675
+ : unsizedColumnIds(columns);
676
+ const pinnedWithoutSizeKey = unsizedIds
677
+ ? pinnedIds.filter((id) => unsizedIds.has(id)).join(",")
678
+ : "";
679
+ useEffect(() => {
680
+ if (
681
+ process.env.NODE_ENV !== "production" &&
682
+ pinnedWithoutSizeKey !== "" &&
683
+ !warnedUnsizedPinnedRef.current
684
+ ) {
685
+ warnedUnsizedPinnedRef.current = true;
686
+ console.warn(
687
+ "[DataTable] Pinned column(s) without an explicit `size` in their `ColumnDef`: " +
688
+ `${pinnedWithoutSizeKey}. Sticky offsets are computed from the declared sizes, so an ` +
689
+ "auto-width pinned column will render at a width that doesn't match its own offset. " +
690
+ "Give every pinned column a `size`.",
691
+ );
692
+ }
693
+ }, [pinnedWithoutSizeKey]);
694
+
695
+ /**
696
+ * Sticky positioning for one pinned header/body cell (#333).
697
+ *
698
+ * Returns `null` for an unpinned column so the caller emits no `style`, no
699
+ * `data-pinned` and no extra classes — that is what keeps a table with no
700
+ * pinning identical to how it rendered before this feature existed.
701
+ *
702
+ * The offset comes from TanStack (`getStart("left")` sums the widths of the
703
+ * left-pinned columns before this one; `getAfter("right")` sums the
704
+ * right-pinned columns after it), and the same declared `size` is forced onto
705
+ * the cell as `width`/`min`/`max` so the rendered width and the offset agree
706
+ * under the table's auto layout.
707
+ */
708
+ function pinnedCellGeometry(column: Column<TData, unknown>) {
709
+ const pinned = column.getIsPinned();
710
+ if (pinned === false) return null;
711
+ const size = column.getSize();
712
+ const style: React.CSSProperties = {
713
+ width: size,
714
+ minWidth: size,
715
+ maxWidth: size,
716
+ ...(pinned === "left"
717
+ ? { left: column.getStart("left") }
718
+ : { right: column.getAfter("right") }),
719
+ };
720
+ return {
721
+ pinned,
722
+ style,
723
+ // The seam between the frozen block and the scrolling block is the SOLE
724
+ // structural cue between two regions that share one row fill and one
725
+ // zebra stripe — delete it and a sighted user cannot tell them apart — so
726
+ // it takes the strong rung (ADR 0010 decision test). No shadow: ADR 0020's
727
+ // `--shadow-strength: 0` (`data-decoration="8|9|10"`) would
728
+ // erase a shadow-only cue entirely.
729
+ //
730
+ // It is drawn as a 1px `::after` INSIDE the cell, NOT as `border-e` /
731
+ // `border-s`. A real border cannot work here: Tailwind's Preflight puts
732
+ // the table in the COLLAPSED border model, and a collapsed border is
733
+ // painted by the <table> at the cell's STATIC position — it does not
734
+ // travel with a `position: sticky` cell, and the cell's own opaque fill
735
+ // (which it needs, see `pinnedCellFillClass`) then paints over it. Measured
736
+ // in Chromium on `Data/DataTable → PinnedColumns`: with `border-e` the
737
+ // seam pixel read `143,143,143` (light `--border-strong`) at
738
+ // scrollLeft 0 and `245,245,245` (the plain cell fill — i.e. GONE) once
739
+ // scrolled, in all three themes and on both edges. So the one cue vanished
740
+ // exactly when the freeze was doing something. The `::after` lives in the
741
+ // sticky cell's own stacking context, so it moves with it.
742
+ edgeClass:
743
+ pinned === "left"
744
+ ? column.getIsLastColumn("left")
745
+ ? PINNED_SEAM_CLASS + " after:end-0"
746
+ : ""
747
+ : column.getIsFirstColumn("right")
748
+ ? PINNED_SEAM_CLASS + " after:start-0"
749
+ : "",
750
+ };
751
+ }
752
+
753
+ // ── Scroll container ref for virtualizer ─────────────────────────────────
754
+ const scrollRef = useRef<HTMLDivElement>(null);
755
+
756
+ // ── Virtualizer (only active in virtualized branch) ───────────────────────
757
+ const virtualizer = useVirtualizer({
758
+ count: enableRowVirtualization ? rows.length : 0,
759
+ getScrollElement: () => (enableRowVirtualization ? scrollRef.current : null),
760
+ estimateSize: () => estimateRowHeight,
761
+ overscan,
762
+ enabled: enableRowVirtualization,
763
+ });
764
+
765
+ const virtualItems = enableRowVirtualization ? virtualizer.getVirtualItems() : [];
766
+ const totalSize = enableRowVirtualization ? virtualizer.getTotalSize() : 0;
767
+ const paddingTop = virtualItems.length > 0 ? (virtualItems[0]?.start ?? 0) : 0;
768
+ const paddingBottom =
769
+ totalSize > 0 ? totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0) : 0;
770
+
771
+ // ── Plain-branch scroll container: overflow measurement ────────────────────
772
+ // #330: the non-virtualized branch's scroll box is `overflow-auto` (it used to
773
+ // clip). Everything that box exposes is gated on MEASURED overflow, because a
774
+ // table that fits must stay exactly as it was:
775
+ // - the keyboard tab stop + its accessible name (WCAG 2.1.1 / axe
776
+ // `scrollable-region-focusable`) — a table that doesn't scroll must NOT
777
+ // gain a focus stop that does nothing and announces "scrollable" falsely;
778
+ // - the edge fades, which only make sense when content continues off-edge.
779
+ // So a desktop-width table is a total no-op: no tab stop, no label, no fade.
780
+ const plainScrollRef = useRef<HTMLDivElement>(null);
781
+ const [scrollOverflows, setScrollOverflows] = useState(false);
782
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
783
+ const [canScrollRight, setCanScrollRight] = useState(false);
784
+
785
+ const updateScrollAffordance = useCallback(() => {
786
+ const el = plainScrollRef.current;
787
+ if (!el) return;
788
+ // 1px tolerance absorbs sub-pixel layout rounding, which would otherwise
789
+ // report a permanent 0.5px overflow on a table that visually fits.
790
+ setScrollOverflows(
791
+ el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
792
+ );
793
+ setCanScrollLeft(el.scrollLeft > 0);
794
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
795
+ }, []);
796
+
797
+ useEffect(() => {
798
+ const el = plainScrollRef.current;
799
+ if (!el) return;
800
+ updateScrollAffordance();
801
+ if (typeof ResizeObserver === "undefined") return;
802
+ const observer = new ResizeObserver(updateScrollAffordance);
803
+ // Observe the CONTAINER (viewport changes) and the <table> inside it
804
+ // (content changes its intrinsic width without resizing the container).
805
+ observer.observe(el);
806
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
807
+ return () => observer.disconnect();
808
+ // Column/row-count changes can also change the table's intrinsic width.
809
+ }, [updateScrollAffordance, colCount, rows.length]);
810
+
811
+ // ─── Empty / loading state ───────────────────────────────────────────────
812
+ const showEmpty = !loading && rows.length === 0;
813
+ const showSkeletons = loading && rows.length === 0;
814
+
815
+ // Number of skeleton rows to show — caller can override via `loadingRows`.
816
+ const skeletonRowCount = loadingRows ?? pageSize;
817
+
818
+ // ─── Render helpers ───────────────────────────────────────────────────────
819
+
820
+ /**
821
+ * thead — sticky in virtualized mode, normal otherwise.
822
+ * `withRowIndex` (virtualized only) sets the header row's `aria-rowindex` so the
823
+ * windowed `aria-rowcount` on the table stays internally consistent with the
824
+ * absolute indices on the data rows.
825
+ */
826
+ function renderThead(sticky: boolean, withRowIndex = false) {
827
+ return (
828
+ <thead
829
+ className={cn(
830
+ // #173: header bottom is the only cue between header and first data row → border-strong
831
+ "border-b border-border-strong",
832
+ // A sticky header scrolls OVER the body, so its fill must be opaque or data
833
+ // rows bleed through the labels; the non-sticky header keeps the /60 wash.
834
+ // z-20 (raised from z-10 for #333) puts the header row above the pinned
835
+ // body cells (z-10) and below the pinned header corner (z-30). No visual
836
+ // delta: nothing else in the table sits between those rungs.
837
+ sticky ? "sticky top-0 z-20 bg-surface-muted" : "bg-surface-muted/60",
838
+ )}
839
+ >
840
+ {table.getHeaderGroups().map((headerGroup, groupIndex) => (
841
+ <tr key={headerGroup.id} aria-rowindex={withRowIndex ? groupIndex + 1 : undefined}>
842
+ {headerGroup.headers.map((header) => {
843
+ const geometry = pinnedCellGeometry(header.column);
844
+ const canSort = header.column.getCanSort();
845
+ const sorted = header.column.getIsSorted();
846
+ // String-header fallback (`column.id`) so an icon-only / non-text
847
+ // header still yields a named button (#230).
848
+ const headerLabel =
849
+ typeof header.column.columnDef.header === "string"
850
+ ? header.column.columnDef.header
851
+ : header.column.id;
852
+ const sortStateLabel =
853
+ sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : "not sorted";
854
+ const SortIcon =
855
+ sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
856
+ return (
857
+ <th
858
+ key={header.id}
859
+ scope="col"
860
+ aria-sort={
861
+ canSort
862
+ ? sorted === "asc"
863
+ ? "ascending"
864
+ : sorted === "desc"
865
+ ? "descending"
866
+ : "none"
867
+ : undefined
868
+ }
869
+ data-pinned={geometry?.pinned ?? undefined}
870
+ style={geometry?.style}
871
+ className={cn(
872
+ "h-10 px-3 text-start align-middle font-medium text-muted-foreground",
873
+ // A pinned HEADER cell is the corner where both freezes meet,
874
+ // so it stacks above the sticky header row (z-20) which is
875
+ // above the pinned body cells (z-10). It needs an OPAQUE
876
+ // fill (scrolled header cells pass underneath it), and that
877
+ // fill has to composite to exactly what its unpinned
878
+ // neighbours show — same problem, same two-layer answer as
879
+ // `pinnedCellFillClass`:
880
+ // sticky branch → the row is already opaque `surface-muted`, so match it.
881
+ // plain branch → the row is `surface-muted/60` over the
882
+ // container's `card`, so paint `card` and
883
+ // re-apply the /60 wash on `::before`.
884
+ // Painting the plain branch's corner solid `surface-muted`
885
+ // read 4-5/255 darker than the header beside it in every
886
+ // theme (measured: 242 vs 247 light, 43 vs 40
887
+ // dark) — the same "floating pill"
888
+ // artefact #333 was filed about, moved into the header.
889
+ geometry && "sticky z-30",
890
+ geometry &&
891
+ (sticky
892
+ ? "bg-surface-muted"
893
+ : "bg-card before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:bg-surface-muted/60 before:content-['']"),
894
+ // Separate cn() argument on purpose: the seam is the sole
895
+ // structural cue between the frozen and scrolling blocks, so
896
+ // it must not read as a "boundary + fill in one class string"
897
+ // redundancy (separation:check).
898
+ geometry?.edgeClass,
899
+ )}
900
+ >
901
+ {header.isPlaceholder ? null : canSort ? (
902
+ <button
903
+ type="button"
904
+ onClick={header.column.getToggleSortingHandler()}
905
+ aria-label={`Sort by ${headerLabel}, ${sortStateLabel}`}
906
+ className="inline-flex items-center gap-1 rounded-sm transition-colors duration-fast ease-standard hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
907
+ >
908
+ {flexRender(header.column.columnDef.header, header.getContext())}
909
+ <SortIcon
910
+ aria-hidden="true"
911
+ className="size-3 shrink-0 transition-colors duration-fast ease-standard"
912
+ />
913
+ </button>
914
+ ) : (
915
+ flexRender(header.column.columnDef.header, header.getContext())
916
+ )}
917
+ </th>
918
+ );
919
+ })}
920
+ </tr>
921
+ ))}
922
+ </thead>
923
+ );
924
+ }
925
+
926
+ /**
927
+ * Row separation cue, keyed off the absolute row index so it stays stable
928
+ * under virtualization (a CSS `even:`/`odd:` variant would "swim" as the
929
+ * windowed `<tr>`s recycle).
930
+ *
931
+ * - zebra (default): a gentle `foreground/5` wash on alternate rows is the ONE
932
+ * separation gesture; rows carry NO divider (#173's strong divider was the cue
933
+ * only because nothing else was — the stripe replaces it, so a border would now
934
+ * be redundant per the surface-separation rule).
935
+ * - lines (`zebra={false}`): the classic `border-border-strong` divider between
936
+ * rows; `last:border-b-0` so the final divider doesn't double with the
937
+ * container's own bottom border (which reads as a heavy edge / shadow).
938
+ */
939
+ function rowSeparationClass(rowIndex: number): string {
940
+ if (!zebra) return "border-b border-border-strong last:border-b-0";
941
+ return rowIndex % 2 === 1 ? "bg-foreground/5" : "";
942
+ }
943
+
944
+ /**
945
+ * Fill for a PINNED body cell (#333) — the twin of `rowSeparationClass` above,
946
+ * and the fix for the bug this issue reports.
947
+ *
948
+ * A pinned cell sits above horizontally-scrolling content, so it needs an
949
+ * OPAQUE paint or the scrolled columns read straight through its text. But the
950
+ * row's own cues — the zebra stripe, hover, selected — are TRANSLUCENT washes
951
+ * that live on the `<tr>`, and a single opaque `background-color` on the
952
+ * `<td>` hides all three: that is the "seam / floating pill" the issue
953
+ * describes.
954
+ *
955
+ * So the cell paints the opaque `bg-card` base and re-applies the row's wash on
956
+ * a decorative `::before` layer at a NEGATIVE stack level. Inside the cell's own
957
+ * stacking context (it has one — `sticky` + a `z-` rung) that layer paints
958
+ * ABOVE the cell's background and BELOW its text, which is exactly the order an
959
+ * unpinned cell gets from the `<tr>`'s translucent background.
960
+ *
961
+ * The wash must NOT be a background-IMAGE gradient on the cell itself: under
962
+ * `[data-decoration]`, `decoration.css` gives every
963
+ * `.bg-card` element the ambient grid AS a `background-image`, so a gradient
964
+ * would overwrite it and punch a flat, ungridded rectangle into the sheet
965
+ * exactly where the frozen column is.
966
+ *
967
+ * Hover and selected stay in CSS (`group-hover/row:` / `group-data-…/row:`
968
+ * against the `group/row` on the `<tr>`) because only the browser knows the
969
+ * pointer is over a SIBLING cell of the same row.
970
+ *
971
+ * Keep this in sync with `rowSeparationClass`. Known limit: a caller's own
972
+ * `rowClassName` background is NOT mirrored here — the component can't know
973
+ * which part of an arbitrary class string is a fill.
974
+ */
975
+ function pinnedCellFillClass(rowIndex: number): string {
976
+ return cn(
977
+ "bg-card",
978
+ "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:content-['']",
979
+ zebra && rowIndex % 2 === 1 && "before:bg-foreground/5",
980
+ "group-hover/row:before:bg-foreground/10",
981
+ "group-data-[state=selected]/row:before:bg-accent",
982
+ );
983
+ }
984
+
985
+ /**
986
+ * Accessible name for a row's hidden activation button (#337). Prefers the
987
+ * caller's `rowActionLabel`, then the first visible cell's primitive value
988
+ * (the row's primary identifier — the same name a link in that cell would
989
+ * get, so screen-reader users hear "billing, button", not five identically
990
+ * named buttons), then the localized generic fallback.
991
+ */
992
+ function rowActionName(row: (typeof rows)[number]): string {
993
+ const explicit = rowActionLabel?.(row);
994
+ if (explicit) return explicit;
995
+ const firstValue = row.getVisibleCells()[0]?.getValue();
996
+ if (typeof firstValue === "string" && firstValue.trim() !== "") return firstValue;
997
+ if (typeof firstValue === "number") return String(firstValue);
998
+ return t("data.table.rowAction");
999
+ }
1000
+
1001
+ /** A single data row */
1002
+ function renderRow(
1003
+ row: (typeof rows)[number],
1004
+ rowIndex: number,
1005
+ extras?: React.HTMLAttributes<HTMLTableRowElement>,
1006
+ ) {
1007
+ // #337: `onRowClick` adds exactly ONE activation target per row — a
1008
+ // visually-hidden <button> in the first cell. The <tr> stays a plain `row`
1009
+ // (a focusable <tr> would be a tab stop with no activation semantics: it
1010
+ // can't take role="button" without breaking the table's row/rowgroup
1011
+ // structure, so AT would announce a row and never that Enter does anything).
1012
+ const clickable = Boolean(onRowClick);
1013
+
1014
+ function handleRowClick(event: React.MouseEvent<HTMLTableRowElement>) {
1015
+ // The hidden activation button matches this guard too, so a keyboard
1016
+ // Enter/Space — which the browser dispatches as a click that bubbles to
1017
+ // the row — is handled once, by the button, not twice.
1018
+ if (isInteractiveEventTarget(event.target)) return;
1019
+ if (isActiveTextSelection()) return;
1020
+ onRowClick?.(row, event);
1021
+ }
1022
+
1023
+ return (
1024
+ <tr
1025
+ key={row.id}
1026
+ data-state={row.getIsSelected() ? "selected" : undefined}
1027
+ onClick={clickable ? handleRowClick : undefined}
1028
+ // Hover/selected are foreground-tint washes so they read more prominent than
1029
+ // the zebra stripe in the SAME direction across light/dark themes (the old
1030
+ // surface-muted/50 hover went the wrong way over a striped row).
1031
+ className={cn(
1032
+ // Color-only feedback (no transform/movement) → per
1033
+ // docs/MOTION_GUIDELINES.md item 3 this stays under OS reduced-motion
1034
+ // (only movement is neutralized); the gated duration-fast/ease-standard
1035
+ // pair already collapses toward ~0ms via --motion-factor when the user
1036
+ // or OS asks for reduced motion, matching the header sort button.
1037
+ "transition-colors duration-fast ease-standard hover:bg-foreground/10 data-[state=selected]:bg-accent",
1038
+ // Named group (#333) so a PINNED cell can re-apply the row's hover /
1039
+ // selected wash on top of its own opaque fill — only CSS knows the
1040
+ // pointer is over a sibling cell. Purely a selector hook: `group/row`
1041
+ // emits no style of its own.
1042
+ "group/row",
1043
+ rowSeparationClass(rowIndex),
1044
+ // `<tr>` isn't in the global auto-cursor-pointer role list (button/
1045
+ // menuitem/tab/…), so a clickable row needs its own cursor. The focus
1046
+ // ring is driven off the hidden button's `:focus-visible` (same
1047
+ // `has-[[data-slot=…]:focus-visible]` pattern as InputGroup) so the
1048
+ // ring paints on the ROW the user is about to activate, even though
1049
+ // focus lives on the sr-only control inside it.
1050
+ clickable &&
1051
+ "cursor-pointer has-[[data-slot=data-table-row-action]:focus-visible]:outline-2 has-[[data-slot=data-table-row-action]:focus-visible]:-outline-offset-2 has-[[data-slot=data-table-row-action]:focus-visible]:outline-ring",
1052
+ rowClassName?.(row),
1053
+ )}
1054
+ {...extras}
1055
+ >
1056
+ {row.getVisibleCells().map((cell, cellIndex) => {
1057
+ const geometry = pinnedCellGeometry(cell.column);
1058
+ return (
1059
+ <td
1060
+ key={cell.id}
1061
+ data-pinned={geometry?.pinned ?? undefined}
1062
+ style={geometry?.style}
1063
+ className={cn(
1064
+ "px-3 py-2 align-middle",
1065
+ // z-10: above the normal (unpositioned) cells it scrolls over,
1066
+ // below the sticky header row (z-20) and the pinned corner (z-30).
1067
+ geometry && "sticky z-10",
1068
+ geometry && pinnedCellFillClass(rowIndex),
1069
+ // Separate cn() argument — see pinnedCellGeometry's edgeClass.
1070
+ geometry?.edgeClass,
1071
+ )}
1072
+ >
1073
+ {clickable && cellIndex === 0 && (
1074
+ <button
1075
+ type="button"
1076
+ data-slot="data-table-row-action"
1077
+ className="sr-only"
1078
+ onClick={(event) => onRowClick?.(row, event)}
1079
+ >
1080
+ {rowActionName(row)}
1081
+ </button>
1082
+ )}
1083
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
1084
+ </td>
1085
+ );
1086
+ })}
1087
+ </tr>
1088
+ );
1089
+ }
1090
+
1091
+ /**
1092
+ * Skeleton placeholder `<tr>`s — shared by the normal and virtualized tbody
1093
+ * renderers so a markup/token/a11y fix only needs to be made once (#231).
1094
+ */
1095
+ function renderSkeletonBody(count: number) {
1096
+ return Array.from({ length: count }).map((_, i) => (
1097
+ <tr key={`skeleton-${i}`} aria-hidden="true" className={rowSeparationClass(i)}>
1098
+ {Array.from({ length: colCount }).map((_, j) => (
1099
+ <td key={j} className="px-3 py-2 align-middle">
1100
+ <Skeleton className="h-4 w-full" />
1101
+ </td>
1102
+ ))}
1103
+ </tr>
1104
+ ));
1105
+ }
1106
+
1107
+ /**
1108
+ * Empty-state `<tr>` — shared by the normal and virtualized tbody renderers
1109
+ * (#231).
1110
+ */
1111
+ function renderEmptyBody() {
1112
+ return (
1113
+ <tr>
1114
+ <td colSpan={colCount} className="h-24 px-3 text-center text-muted-foreground">
1115
+ {emptyMessage}
1116
+ </td>
1117
+ </tr>
1118
+ );
1119
+ }
1120
+
1121
+ // ─── Non-virtualized tbody ────────────────────────────────────────────────
1122
+ function renderTbodyNormal() {
1123
+ if (showSkeletons) {
1124
+ return <tbody>{renderSkeletonBody(skeletonRowCount)}</tbody>;
1125
+ }
1126
+
1127
+ return <tbody>{showEmpty ? renderEmptyBody() : rows.map((row, i) => renderRow(row, i))}</tbody>;
1128
+ }
1129
+
1130
+ // ─── Virtualized tbody ────────────────────────────────────────────────────
1131
+ function renderTbodyVirtualized() {
1132
+ if (showSkeletons) {
1133
+ // For virtualized mode, cap the visible skeleton rows at 10 unless caller
1134
+ // has explicitly set loadingRows.
1135
+ const virtualSkeletonCount = loadingRows ?? Math.min(10, pageSize);
1136
+ return <tbody>{renderSkeletonBody(virtualSkeletonCount)}</tbody>;
1137
+ }
1138
+
1139
+ return (
1140
+ <tbody>
1141
+ {showEmpty ? (
1142
+ renderEmptyBody()
1143
+ ) : (
1144
+ <>
1145
+ {/* Top spacer — real <tr> so table layout is preserved */}
1146
+ {paddingTop > 0 && (
1147
+ <tr aria-hidden="true">
1148
+ <td style={{ height: paddingTop }} colSpan={colCount} />
1149
+ </tr>
1150
+ )}
1151
+ {virtualItems.map((virtualRow) => {
1152
+ const row = rows[virtualRow.index];
1153
+ // row is guaranteed present because virtualizer.count === rows.length,
1154
+ // but TypeScript doesn't know array indexing is safe here.
1155
+ if (!row) return null;
1156
+ return renderRow(row, virtualRow.index, {
1157
+ ref: virtualizer.measureElement as React.Ref<HTMLTableRowElement>,
1158
+ "data-index": virtualRow.index,
1159
+ // Absolute 1-based row position; header row(s) occupy 1..headerRowCount.
1160
+ "aria-rowindex": headerRowCount + virtualRow.index + 1,
1161
+ } as React.HTMLAttributes<HTMLTableRowElement>);
1162
+ })}
1163
+ {/* Bottom spacer */}
1164
+ {paddingBottom > 0 && (
1165
+ <tr aria-hidden="true">
1166
+ <td style={{ height: paddingBottom }} colSpan={colCount} />
1167
+ </tr>
1168
+ )}
1169
+ </>
1170
+ )}
1171
+ </tbody>
1172
+ );
1173
+ }
1174
+
1175
+ // ─── Pagination controls ──────────────────────────────────────────────────
1176
+ function renderPagination() {
1177
+ // Virtualization wins over pagination per spec — don't render controls
1178
+ if (enableRowVirtualization) return null;
1179
+ if (!enablePagination && !manualPagination) return null;
1180
+
1181
+ // #342: a genuinely single-page table renders a permanently-disabled
1182
+ // pager ("Page 1 of 1", both buttons disabled) — hide it, UNLESS the page
1183
+ // count isn't actually knowable: under `manualPagination` without a
1184
+ // `rowCount`/`pageCount`, TanStack's `getPageCount()` falls back to the
1185
+ // CURRENT page's row count, so "<= 1" there is a false positive for
1186
+ // "really one page" — the #227 dev warning above stays the diagnostic for
1187
+ // exactly that ambiguous case, so this flag doesn't also mask it.
1188
+ const pageCountUnknown = manualPagination && rowCount === undefined && pageCount === undefined;
1189
+ if (hidePaginationWhenSingle && !pageCountUnknown && table.getPageCount() <= 1) return null;
1190
+
1191
+ return (
1192
+ <div className="flex items-center justify-between">
1193
+ <p className="text-body text-muted-foreground">
1194
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount() || 1}
1195
+ </p>
1196
+ <div className="flex gap-2">
1197
+ <Button
1198
+ variant="outline"
1199
+ size="sm"
1200
+ onClick={() => table.previousPage()}
1201
+ disabled={!table.getCanPreviousPage()}
1202
+ >
1203
+ Previous
1204
+ </Button>
1205
+ <Button
1206
+ variant="outline"
1207
+ size="sm"
1208
+ onClick={() => table.nextPage()}
1209
+ disabled={!table.getCanNextPage()}
1210
+ >
1211
+ Next
1212
+ </Button>
1213
+ </div>
1214
+ </div>
1215
+ );
1216
+ }
1217
+
1218
+ // ─── Render ───────────────────────────────────────────────────────────────
1219
+
1220
+ // #338: visually-hidden accessible name for the table. Must be the FIRST
1221
+ // child of <table> per the HTML spec (caption immediately follows the
1222
+ // opening tag) — both branches place it before their thead.
1223
+ const captionElement = caption != null ? <caption className="sr-only">{caption}</caption> : null;
1224
+
1225
+ if (enableRowVirtualization) {
1226
+ // Virtualized branch: scroll container wraps the whole table
1227
+ // If both enablePagination and enableRowVirtualization are set,
1228
+ // virtualization wins; pagination controls are silently suppressed.
1229
+ return (
1230
+ <div ref={ref} className={cn("space-y-3", className)} {...rest}>
1231
+ {toolbar ? toolbar(table) : null}
1232
+ {/* Outer border is redundant (surface change) → plain border per #173 spec.
1233
+ tabIndex={0} makes the windowed scroll region keyboard-operable — the rows
1234
+ themselves aren't focusable, so without it the off-screen rows are
1235
+ unreachable by keyboard (WCAG 2.1.1 / axe `scrollable-region-focusable`). */}
1236
+ <div
1237
+ ref={scrollRef}
1238
+ tabIndex={0}
1239
+ // Names the focus stop (WCAG 4.1.2) without a landmark role — a `role="region"`
1240
+ // here would add a redundant landmark over the inner real <table>.
1241
+ aria-label={t("data.table.scrollRegion")}
1242
+ aria-busy={loading || undefined}
1243
+ className="relative overflow-auto rounded-lg border bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1244
+ style={{ maxHeight: maxBodyHeight, ...pinnedScrollPadding }}
1245
+ >
1246
+ {/* Loading overlay */}
1247
+ {loading && rows.length > 0 && (
1248
+ <div
1249
+ role="status"
1250
+ aria-live="polite"
1251
+ // z-40 (raised from z-20 for #333): the overlay covers the WHOLE
1252
+ // table, so it has to sit above the pinned-column ladder (body z-10,
1253
+ // sticky header z-20, pinned header corner z-30) or a frozen column
1254
+ // would punch through the "loading" scrim.
1255
+ className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
1256
+ >
1257
+ <Spinner aria-hidden="true" className="text-foreground" />
1258
+ <span className="sr-only">Loading table data…</span>
1259
+ </div>
1260
+ )}
1261
+ <table
1262
+ aria-busy={loading || undefined}
1263
+ aria-rowcount={ariaRowCount}
1264
+ className="w-full caption-bottom text-body"
1265
+ >
1266
+ {captionElement}
1267
+ {renderThead(true, true)}
1268
+ {renderTbodyVirtualized()}
1269
+ </table>
1270
+ </div>
1271
+ </div>
1272
+ );
1273
+ }
1274
+
1275
+ // Non-virtualized branch.
1276
+ // #330: the scroll box is `overflow-auto` (was `overflow-hidden`, silently
1277
+ // clipping columns that didn't fit instead of letting them scroll) and
1278
+ // keyboard-focusable, parity with the virtualized branch above. Split into
1279
+ // an OUTER non-scrolling wrapper (keeps the rounded/border/bg chrome +
1280
+ // clip, and is the positioning context for the loading overlay + edge
1281
+ // fades) and an INNER scrolling div (the focusable, `overflow-auto` scroll
1282
+ // region) so the edge-fade affordance can stay pinned to the visible edges
1283
+ // instead of scrolling away with the table content.
1284
+ return (
1285
+ <div ref={ref} className={cn("space-y-3", className)} {...rest}>
1286
+ {toolbar ? toolbar(table) : null}
1287
+ {/* Outer border is redundant (surface change) → plain border per #173 spec */}
1288
+ <div
1289
+ aria-busy={loading || undefined}
1290
+ className="relative overflow-hidden rounded-lg border bg-card"
1291
+ >
1292
+ {/* Loading overlay */}
1293
+ {loading && rows.length > 0 && (
1294
+ <div
1295
+ role="status"
1296
+ aria-live="polite"
1297
+ // z-40 (raised from z-20 for #333): the overlay covers the WHOLE
1298
+ // table, so it has to sit above the pinned-column ladder (body z-10,
1299
+ // sticky header z-20, pinned header corner z-30) or a frozen column
1300
+ // would punch through the "loading" scrim.
1301
+ className="absolute inset-0 z-40 flex items-center justify-center rounded-lg bg-card/80"
1302
+ >
1303
+ <Spinner aria-hidden="true" className="text-foreground" />
1304
+ <span className="sr-only">Loading table data…</span>
1305
+ </div>
1306
+ )}
1307
+ {/* The tab stop exists ONLY while the region measurably overflows: without
1308
+ it, columns beyond the viewport are unreachable by keyboard (WCAG 2.1.1 /
1309
+ axe `scrollable-region-focusable`) — but adding it unconditionally would
1310
+ give every table that FITS a focus stop that does nothing and announces
1311
+ "scrollable" when it isn't. `aria-label` moves with it (WCAG 4.1.2:
1312
+ a name for a stop that exists, none for one that doesn't). No
1313
+ `role="region"` — that would add a redundant landmark over the real
1314
+ <table> inside it. */}
1315
+ <div
1316
+ ref={plainScrollRef}
1317
+ data-slot="data-table-scroll-region"
1318
+ tabIndex={scrollOverflows ? 0 : undefined}
1319
+ aria-label={scrollOverflows ? t("data.table.scrollRegion") : undefined}
1320
+ onScroll={updateScrollAffordance}
1321
+ className="overflow-auto rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
1322
+ style={hasLeftPinned || hasRightPinned ? pinnedScrollPadding : undefined}
1323
+ >
1324
+ <table aria-busy={loading || undefined} className="w-full caption-bottom text-body">
1325
+ {captionElement}
1326
+ {renderThead(false)}
1327
+ {renderTbodyNormal()}
1328
+ </table>
1329
+ </div>
1330
+ {/* Horizontal-scroll edge fade — a token-driven affordance that only
1331
+ appears once the table actually overflows its container in that
1332
+ direction, so a desktop/wide table renders neither (visual no-op).
1333
+
1334
+ #333: an edge with a PINNED column renders no fade. The fade lives
1335
+ outside the scroll region and would paint a 32px wash straight over
1336
+ the frozen column's own text; and the affordance is already carried
1337
+ there by the pinned block's `border-border-strong` seam, which is
1338
+ what a frozen column means ("content slides under this edge"). So
1339
+ the fade stays the cue for a FREE edge only. */}
1340
+ {canScrollLeft && !hasLeftPinned && (
1341
+ <div
1342
+ aria-hidden="true"
1343
+ data-slot="data-table-scroll-fade-left"
1344
+ className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 rounded-lg bg-gradient-to-r from-card to-transparent"
1345
+ />
1346
+ )}
1347
+ {canScrollRight && !hasRightPinned && (
1348
+ <div
1349
+ aria-hidden="true"
1350
+ data-slot="data-table-scroll-fade-right"
1351
+ className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 rounded-lg bg-gradient-to-l from-card to-transparent"
1352
+ />
1353
+ )}
1354
+ </div>
1355
+
1356
+ {renderPagination()}
1357
+ </div>
1358
+ );
1359
+ }
1360
+
1361
+ // ─── Public export with forwardRef + generic cast ─────────────────────────────
1362
+ //
1363
+ // React.forwardRef strips the generic parameter. The cast below restores it so
1364
+ // callers get full type inference on `columns` / `data` while still being able
1365
+ // to forward a ref to the root <div>.
1366
+ //
1367
+ // The ref prop is already declared in DataTableProps (optional) so existing
1368
+ // consumers are backward-compatible; the forwardRef call means passing a ref
1369
+ // object also works.
1370
+
1371
+ const DataTableWithRef = forwardRef(DataTableInner) as <TData, TValue>(
1372
+ props: DataTableProps<TData, TValue> & { ref?: React.Ref<HTMLDivElement> },
1373
+ ) => React.ReactElement | null;
1374
+
1375
+ export { DataTableWithRef as DataTable };