@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,1513 @@
1
+ import { createRef } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { act, render, screen, fireEvent } from "@testing-library/react";
4
+ import type {
5
+ ColumnDef,
6
+ SortingState,
7
+ ColumnFiltersState,
8
+ Table as TanstackTable,
9
+ VisibilityState,
10
+ } from "@tanstack/react-table";
11
+ import { DataTable } from "./data-table";
12
+ import type { DataTableServerArgs } from "./data-table";
13
+
14
+ // ─── Shared fixtures ─────────────────────────────────────────────────────────
15
+
16
+ interface Row {
17
+ name: string;
18
+ value: number;
19
+ }
20
+
21
+ const columns: ColumnDef<Row>[] = [
22
+ { accessorKey: "name", header: "Name", enableSorting: true },
23
+ { accessorKey: "value", header: "Value", enableSorting: true },
24
+ ];
25
+
26
+ const data: Row[] = [
27
+ { name: "Alpha", value: 3 },
28
+ { name: "Beta", value: 1 },
29
+ { name: "Gamma", value: 2 },
30
+ ];
31
+
32
+ // ─── Original smoke tests (must remain green) ─────────────────────────────────
33
+
34
+ describe("DataTable — original smoke tests", () => {
35
+ it("renders rows", () => {
36
+ render(
37
+ <DataTable
38
+ columns={columns}
39
+ data={[
40
+ { name: "Alpha", value: 1 },
41
+ { name: "Beta", value: 2 },
42
+ ]}
43
+ />,
44
+ );
45
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
46
+ expect(screen.getByText("Beta")).toBeInTheDocument();
47
+ });
48
+
49
+ it("shows empty message when no data", () => {
50
+ render(<DataTable columns={columns} data={[]} emptyMessage="Nothing here" />);
51
+ expect(screen.getByText("Nothing here")).toBeInTheDocument();
52
+ });
53
+ });
54
+
55
+ // ─── B: Controlled slices ─────────────────────────────────────────────────────
56
+
57
+ describe("DataTable — controlled sorting", () => {
58
+ it("reflects controlled sorting prop in state", () => {
59
+ const sorting: SortingState = [{ id: "name", desc: false }];
60
+ render(<DataTable columns={columns} data={data} sorting={sorting} onSortingChange={vi.fn()} />);
61
+ // With ascending sort, Alpha < Beta < Gamma — first row should be Alpha
62
+ const cells = screen.getAllByRole("cell");
63
+ // First data cell = "Alpha"
64
+ expect(cells[0]).toHaveTextContent("Alpha");
65
+ });
66
+
67
+ it("calls onSortingChange when user clicks a sortable header", () => {
68
+ const onSortingChange = vi.fn();
69
+ render(
70
+ <DataTable columns={columns} data={data} sorting={[]} onSortingChange={onSortingChange} />,
71
+ );
72
+ // Click the "Name" sort button
73
+ fireEvent.click(screen.getByText("Name"));
74
+ expect(onSortingChange).toHaveBeenCalled();
75
+ });
76
+ });
77
+
78
+ describe("DataTable — controlled columnVisibility", () => {
79
+ it("hides a column when columnVisibility says false", () => {
80
+ const columnVisibility: VisibilityState = { value: false };
81
+ render(
82
+ <DataTable
83
+ columns={columns}
84
+ data={data}
85
+ columnVisibility={columnVisibility}
86
+ onColumnVisibilityChange={vi.fn()}
87
+ />,
88
+ );
89
+ // "Value" header should not appear
90
+ expect(screen.queryByText("Value")).toBeNull();
91
+ // "Name" header should still appear
92
+ expect(screen.getByText("Name")).toBeInTheDocument();
93
+ });
94
+
95
+ it("calls onColumnVisibilityChange when ColumnPicker toggles a column", () => {
96
+ // We verify that the callback prop is wired by confirming it's referenced
97
+ // (a full ColumnPicker integration test is in the stories). Here we just
98
+ // confirm the prop type is accepted and renders without error.
99
+ const onColumnVisibilityChange = vi.fn();
100
+ render(
101
+ <DataTable
102
+ columns={columns}
103
+ data={data}
104
+ columnVisibility={{}}
105
+ onColumnVisibilityChange={onColumnVisibilityChange}
106
+ />,
107
+ );
108
+ expect(screen.getByText("Name")).toBeInTheDocument();
109
+ });
110
+
111
+ it("empty-state cell spans only VISIBLE columns when a column is hidden", () => {
112
+ // colSpan derives from getVisibleLeafColumns() so spacer/empty/skeleton cells
113
+ // match the cell count of real data rows (getVisibleCells()) — not getAllColumns().
114
+ render(
115
+ <DataTable
116
+ columns={columns}
117
+ data={[]}
118
+ columnVisibility={{ value: false }}
119
+ onColumnVisibilityChange={vi.fn()}
120
+ emptyMessage="None"
121
+ />,
122
+ );
123
+ const emptyCell = screen.getByText("None").closest("td");
124
+ // Only "name" remains visible → colSpan must be 1, not 2.
125
+ expect(emptyCell).toHaveAttribute("colspan", "1");
126
+ });
127
+ });
128
+
129
+ describe("DataTable — controlled columnFilters", () => {
130
+ it("reflects controlled columnFilters in the rendered rows (local filtering)", () => {
131
+ // manualFiltering is NOT set → local filtering is active
132
+ const columnFilters: ColumnFiltersState = [{ id: "name", value: "Alpha" }];
133
+ render(
134
+ <DataTable
135
+ columns={columns}
136
+ data={data}
137
+ columnFilters={columnFilters}
138
+ onColumnFiltersChange={vi.fn()}
139
+ // manualFiltering omitted → false (local)
140
+ />,
141
+ );
142
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
143
+ expect(screen.queryByText("Beta")).toBeNull();
144
+ expect(screen.queryByText("Gamma")).toBeNull();
145
+ });
146
+
147
+ it("controlled-but-not-manual columnFilters STILL filters locally", () => {
148
+ // Explicit regression: controlled ≠ manual; local getFilteredRowModel must run.
149
+ // Filter on the "name" string column (default includesString filter works on strings).
150
+ const columnFilters: ColumnFiltersState = [{ id: "name", value: "Beta" }];
151
+ render(
152
+ <DataTable
153
+ columns={columns}
154
+ data={data}
155
+ columnFilters={columnFilters}
156
+ onColumnFiltersChange={vi.fn()}
157
+ manualFiltering={false} // explicit no-manual
158
+ />,
159
+ );
160
+ // Only Beta should be visible — client filtering ran despite controlled prop
161
+ expect(screen.getByText("Beta")).toBeInTheDocument();
162
+ expect(screen.queryByText("Alpha")).toBeNull();
163
+ expect(screen.queryByText("Gamma")).toBeNull();
164
+ });
165
+ });
166
+
167
+ // ─── B: Server-side (manual) mode ────────────────────────────────────────────
168
+
169
+ describe("DataTable — manual/server-side mode", () => {
170
+ it("calls onServerChange when manualSorting is true and sort changes", () => {
171
+ const onServerChange = vi.fn<(args: DataTableServerArgs) => void>();
172
+ render(
173
+ <DataTable
174
+ columns={columns}
175
+ data={data}
176
+ sorting={[]}
177
+ onSortingChange={vi.fn()}
178
+ manualSorting
179
+ onServerChange={onServerChange}
180
+ />,
181
+ );
182
+ fireEvent.click(screen.getByText("Name"));
183
+ expect(onServerChange).toHaveBeenCalled();
184
+ const args = onServerChange.mock.calls[0]![0];
185
+ expect(args).toHaveProperty("sorting");
186
+ expect(args).toHaveProperty("pagination");
187
+ expect(args).toHaveProperty("columnFilters");
188
+ expect(args).toHaveProperty("globalFilter");
189
+ // Payload must carry the NEW slice value (post-update), not just the key —
190
+ // a clicked "Name" header toggles to ascending. This locks fireServerChange
191
+ // reading the post-update ref rather than a stale closure.
192
+ expect(args.sorting).toEqual([{ id: "name", desc: false }]);
193
+ });
194
+
195
+ it("does NOT locally re-sort rows when manualSorting is true", () => {
196
+ // With manualSorting the component delegates sorting to the server;
197
+ // data prop order is preserved in the DOM.
198
+ const onServerChange = vi.fn();
199
+ const sortedData = [
200
+ { name: "Gamma", value: 2 },
201
+ { name: "Alpha", value: 3 },
202
+ { name: "Beta", value: 1 },
203
+ ];
204
+ render(
205
+ <DataTable
206
+ columns={columns}
207
+ data={sortedData}
208
+ sorting={[{ id: "name", desc: false }]}
209
+ onSortingChange={vi.fn()}
210
+ manualSorting
211
+ onServerChange={onServerChange}
212
+ />,
213
+ );
214
+ // Row order should match the data prop (server-controlled), not alphabetical
215
+ const cells = screen.getAllByRole("cell");
216
+ expect(cells[0]).toHaveTextContent("Gamma");
217
+ });
218
+
219
+ it("does NOT locally re-filter when manualFiltering is true", () => {
220
+ const onServerChange = vi.fn();
221
+ // All 3 rows supplied — manual means server already filtered; table shows all
222
+ render(
223
+ <DataTable
224
+ columns={columns}
225
+ data={data}
226
+ columnFilters={[{ id: "name", value: "Alpha" }]}
227
+ onColumnFiltersChange={vi.fn()}
228
+ manualFiltering
229
+ onServerChange={onServerChange}
230
+ />,
231
+ );
232
+ // Without client filtering all rows remain visible
233
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
234
+ expect(screen.getByText("Beta")).toBeInTheDocument();
235
+ expect(screen.getByText("Gamma")).toBeInTheDocument();
236
+ });
237
+
238
+ it("calls onServerChange when manualFiltering and columnFilters change", () => {
239
+ const onServerChange = vi.fn<(args: DataTableServerArgs) => void>();
240
+ // We simulate an external filter change by re-rendering with new columnFilters
241
+ const { rerender } = render(
242
+ <DataTable
243
+ columns={columns}
244
+ data={data}
245
+ columnFilters={[]}
246
+ onColumnFiltersChange={vi.fn()}
247
+ manualFiltering
248
+ onServerChange={onServerChange}
249
+ />,
250
+ );
251
+ // Re-render with updated filters — in real usage the controlled prop changes
252
+ rerender(
253
+ <DataTable
254
+ columns={columns}
255
+ data={data}
256
+ columnFilters={[{ id: "name", value: "Beta" }]}
257
+ onColumnFiltersChange={vi.fn()}
258
+ manualFiltering
259
+ onServerChange={onServerChange}
260
+ />,
261
+ );
262
+ // onServerChange is fired inside the TanStack updater callbacks.
263
+ // Because we changed the *controlled* prop externally (no TanStack updater fires),
264
+ // onServerChange is NOT called — the app owns the fetch. Confirm no spurious call.
265
+ // This is correct: the app changed the prop → it already knows to re-fetch.
266
+ expect(onServerChange).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it("calls onServerChange with the new pageIndex when manualPagination and Next is clicked", () => {
270
+ // Server pagination is the headline use case of the server model; lock its callback.
271
+ const onServerChange = vi.fn<(args: DataTableServerArgs) => void>();
272
+ render(
273
+ <DataTable
274
+ columns={columns}
275
+ data={data}
276
+ enablePagination
277
+ manualPagination
278
+ rowCount={20}
279
+ pagination={{ pageIndex: 0, pageSize: 5 }}
280
+ onPaginationChange={vi.fn()}
281
+ onServerChange={onServerChange}
282
+ />,
283
+ );
284
+ fireEvent.click(screen.getByRole("button", { name: /Next/i }));
285
+ expect(onServerChange).toHaveBeenCalled();
286
+ const args = onServerChange.mock.calls.at(-1)![0];
287
+ expect(args.pagination.pageIndex).toBe(1);
288
+ });
289
+
290
+ it("calls onServerChange with the new globalFilter when manualFiltering and the filter changes", () => {
291
+ const onServerChange = vi.fn<(args: DataTableServerArgs) => void>();
292
+ render(
293
+ <DataTable
294
+ columns={columns}
295
+ data={data}
296
+ manualFiltering
297
+ globalFilter=""
298
+ onGlobalFilterChange={vi.fn()}
299
+ onServerChange={onServerChange}
300
+ toolbar={(t) => (
301
+ <button type="button" onClick={() => t.setGlobalFilter("beta")}>
302
+ apply-filter
303
+ </button>
304
+ )}
305
+ />,
306
+ );
307
+ fireEvent.click(screen.getByText("apply-filter"));
308
+ expect(onServerChange).toHaveBeenCalled();
309
+ const args = onServerChange.mock.calls.at(-1)![0];
310
+ expect(args.globalFilter).toBe("beta");
311
+ });
312
+ });
313
+
314
+ // ─── B: Saved-view serialize/rehydrate ───────────────────────────────────────
315
+
316
+ describe("DataTable — saved-view round-trip via initialView", () => {
317
+ it("rehydrates uncontrolled slices from initialView and renders the same state", () => {
318
+ // Capture a view snapshot
319
+ const savedView = {
320
+ sorting: [{ id: "name", desc: true }] as SortingState,
321
+ columnVisibility: { value: false } as VisibilityState,
322
+ columnFilters: [] as ColumnFiltersState,
323
+ globalFilter: "",
324
+ };
325
+
326
+ // Serialize and parse (proving it's a plain serializable object)
327
+ const serialized = JSON.stringify(savedView);
328
+ const deserialized = JSON.parse(serialized);
329
+
330
+ render(<DataTable columns={columns} data={data} initialView={deserialized} />);
331
+
332
+ // desc:true sort on name → Gamma > Beta > Alpha (descending)
333
+ const cells = screen.getAllByRole("cell");
334
+ expect(cells[0]).toHaveTextContent("Gamma");
335
+
336
+ // "value" column hidden
337
+ expect(screen.queryByText("Value")).toBeNull();
338
+ });
339
+ });
340
+
341
+ // ─── B: Loading state ─────────────────────────────────────────────────────────
342
+
343
+ describe("DataTable — loading state", () => {
344
+ it("shows skeleton rows (not empty message) when loading and no data", () => {
345
+ render(<DataTable columns={columns} data={[]} loading emptyMessage="No results." />);
346
+ expect(screen.queryByText("No results.")).toBeNull();
347
+ // Skeleton divs rendered (aria-hidden, so query by class presence via container)
348
+ // Skeletons are <div aria-hidden="true" class="... animate-pulse ...">
349
+ // We verify by checking the table still renders without empty state
350
+ expect(screen.queryByRole("status")).toBeNull(); // spinner only with rows present
351
+ });
352
+
353
+ it("shows overlay spinner when loading with existing rows", () => {
354
+ render(<DataTable columns={columns} data={data} loading />);
355
+ // Spinner has role="status" via the aria-live="polite" attribute in the DOM
356
+ // and the Spinner component itself has role="status"
357
+ const status = screen.queryByRole("status");
358
+ expect(status).toBeInTheDocument();
359
+ });
360
+
361
+ it("shows empty message when not loading and no rows", () => {
362
+ render(<DataTable columns={columns} data={[]} loading={false} emptyMessage="Nothing here" />);
363
+ expect(screen.getByText("Nothing here")).toBeInTheDocument();
364
+ });
365
+ });
366
+
367
+ // ─── D: forwardRef + prop-spread + aria-busy + loadingRows ───────────────────
368
+
369
+ describe("DataTable — forwardRef, prop-spread, aria-busy, loadingRows", () => {
370
+ it("forwards a ref to the outermost wrapper <div>", () => {
371
+ const ref = createRef<HTMLDivElement>();
372
+ const { container } = render(<DataTable columns={columns} data={data} ref={ref} />);
373
+ // The ref should point to the first div child of the container
374
+ expect(ref.current).not.toBeNull();
375
+ expect(ref.current).toBe(container.firstChild);
376
+ });
377
+
378
+ it("spreads an id onto the root element", () => {
379
+ const { container } = render(<DataTable columns={columns} data={data} id="my-table" />);
380
+ expect(container.firstChild).toHaveAttribute("id", "my-table");
381
+ });
382
+
383
+ it("spreads a data-* attribute onto the root element", () => {
384
+ const { container } = render(<DataTable columns={columns} data={data} data-testid="dt-root" />);
385
+ expect(container.firstChild).toHaveAttribute("data-testid", "dt-root");
386
+ });
387
+
388
+ it("merges a caller className onto the root element", () => {
389
+ const { container } = render(
390
+ <DataTable columns={columns} data={data} className="extra-class" />,
391
+ );
392
+ expect(container.firstChild).toHaveClass("extra-class");
393
+ // Base class must also be present
394
+ expect(container.firstChild).toHaveClass("space-y-3");
395
+ });
396
+
397
+ it("sets aria-busy on the inner scroll container while loading with existing rows", () => {
398
+ const { container } = render(<DataTable columns={columns} data={data} loading />);
399
+ // The inner div wrapping the <table> carries aria-busy (not the root wrapper)
400
+ const busyEl = container.querySelector("[aria-busy='true']");
401
+ expect(busyEl).toBeInTheDocument();
402
+ });
403
+
404
+ it("sets aria-busy on the inner scroll container while loading with no data (skeleton mode)", () => {
405
+ const { container } = render(<DataTable columns={columns} data={[]} loading />);
406
+ const busyEl = container.querySelector("[aria-busy='true']");
407
+ expect(busyEl).toBeInTheDocument();
408
+ });
409
+
410
+ it("does NOT set aria-busy when not loading", () => {
411
+ const { container } = render(<DataTable columns={columns} data={data} loading={false} />);
412
+ expect(container.querySelector("[aria-busy='true']")).toBeNull();
413
+ });
414
+
415
+ it("renders exactly loadingRows skeleton rows when specified", () => {
416
+ const { container } = render(<DataTable columns={columns} data={[]} loading loadingRows={3} />);
417
+ // Each skeleton row is a <tr> in the tbody
418
+ const tbodyRows = container.querySelectorAll("tbody tr");
419
+ expect(tbodyRows.length).toBe(3);
420
+ });
421
+
422
+ it("renders pageSize skeleton rows by default (no loadingRows prop)", () => {
423
+ // Default pageSize is 10
424
+ const { container } = render(<DataTable columns={columns} data={[]} loading />);
425
+ const tbodyRows = container.querySelectorAll("tbody tr");
426
+ expect(tbodyRows.length).toBe(10);
427
+ });
428
+
429
+ it("skeleton cells are aria-hidden (decorative)", () => {
430
+ const { container } = render(<DataTable columns={columns} data={[]} loading loadingRows={2} />);
431
+ // Skeleton component always renders aria-hidden="true" on its root div
432
+ const skeletonDivs = container.querySelectorAll("[aria-hidden='true']");
433
+ // 2 rows × 2 columns = 4 skeleton divs (each Skeleton sets aria-hidden)
434
+ expect(skeletonDivs.length).toBeGreaterThanOrEqual(4);
435
+ });
436
+
437
+ it("marks skeleton placeholder rows aria-hidden so AT skips them", () => {
438
+ // The loading state is announced via aria-busy; the skeleton <tr>s are a pure
439
+ // visual affordance and must not be read as empty data rows.
440
+ const { container } = render(<DataTable columns={columns} data={[]} loading loadingRows={2} />);
441
+ const hiddenRows = container.querySelectorAll('tbody tr[aria-hidden="true"]');
442
+ expect(hiddenRows.length).toBe(2);
443
+ });
444
+
445
+ it("shows real rows after transitioning from loading to loaded", () => {
446
+ const { rerender } = render(<DataTable columns={columns} data={[]} loading />);
447
+ // Loading: no real data rows
448
+ expect(screen.queryByText("Alpha")).toBeNull();
449
+ // Loaded: real data arrives
450
+ rerender(<DataTable columns={columns} data={data} loading={false} />);
451
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
452
+ expect(screen.getByText("Beta")).toBeInTheDocument();
453
+ });
454
+ });
455
+
456
+ // ─── C: Row virtualization DOM proof ─────────────────────────────────────────
457
+
458
+ describe("DataTable — row virtualization", () => {
459
+ it("renders far fewer than 10 000 DOM rows when enableRowVirtualization is true", () => {
460
+ // jsdom has no layout engine, so the virtualizer measures nothing and
461
+ // renders zero virtual items. The count will be 0 (spacers only) — but
462
+ // that is STILL far fewer than 10 000, which proves windowing is active.
463
+ // Real smoothness with actual scrolling is verified in Storybook
464
+ // (Virtualized10k story) because jsdom cannot simulate scroll/layout.
465
+ const bigData: Row[] = Array.from({ length: 10_000 }, (_, i) => ({
466
+ name: `Row ${i}`,
467
+ value: i,
468
+ }));
469
+
470
+ const { container } = render(
471
+ <DataTable
472
+ columns={columns}
473
+ data={bigData}
474
+ enableRowVirtualization
475
+ estimateRowHeight={40}
476
+ overscan={8}
477
+ maxBodyHeight="32rem"
478
+ />,
479
+ );
480
+
481
+ const tbodyRows = container.querySelectorAll("tbody tr");
482
+ // Must be MUCH less than 10 000 — proves windowing, not full render.
483
+ // In jsdom it will be 0 real rows + at most 2 spacers = ≤ 2.
484
+ // We assert < 100 to be robust against any jsdom partial layout.
485
+ expect(tbodyRows.length).toBeLessThan(100);
486
+ // And definitely not all 10k
487
+ expect(tbodyRows.length).not.toBe(10_000);
488
+ });
489
+
490
+ it("renders normally (non-virtualized) without enableRowVirtualization", () => {
491
+ // Baseline: 3 rows → 3 tr elements in tbody
492
+ const { container } = render(<DataTable columns={columns} data={data} />);
493
+ const tbodyRows = container.querySelectorAll("tbody tr");
494
+ expect(tbodyRows.length).toBe(3);
495
+ });
496
+ });
497
+
498
+ // ─── C: Virtualized a11y + composability (issue-01 hardening) ─────────────────
499
+
500
+ describe("DataTable — virtualized a11y + composability", () => {
501
+ const bigData: Row[] = Array.from({ length: 100 }, (_, i) => ({
502
+ name: `Row ${i}`,
503
+ value: i,
504
+ }));
505
+
506
+ it("sets aria-rowcount (data + header rows) on the virtualized table so AT sees the true size", () => {
507
+ const { container } = render(
508
+ <DataTable columns={columns} data={bigData} enableRowVirtualization />,
509
+ );
510
+ // 100 data rows + 1 header row
511
+ expect(container.querySelector("table")).toHaveAttribute(
512
+ "aria-rowcount",
513
+ String(bigData.length + 1),
514
+ );
515
+ });
516
+
517
+ it("does NOT set aria-rowcount on the non-virtualized table (the DOM already reflects every row)", () => {
518
+ const { container } = render(<DataTable columns={columns} data={data} />);
519
+ expect(container.querySelector("table")).not.toHaveAttribute("aria-rowcount");
520
+ });
521
+
522
+ it("sets aria-rowindex=1 on the virtualized header row", () => {
523
+ const { container } = render(
524
+ <DataTable columns={columns} data={bigData} enableRowVirtualization />,
525
+ );
526
+ expect(container.querySelector("thead tr")).toHaveAttribute("aria-rowindex", "1");
527
+ });
528
+
529
+ it("mounts only a small window of indexed data rows (≪ the full dataset)", () => {
530
+ // jsdom has no layout engine, so the virtualizer mounts ~0 data rows; the point is
531
+ // that the windowed count is far below the total while aria-rowcount reports the total.
532
+ const { container } = render(
533
+ <DataTable columns={columns} data={bigData} enableRowVirtualization />,
534
+ );
535
+ const indexedRows = container.querySelectorAll("tbody tr[aria-rowindex]");
536
+ expect(indexedRows.length).toBeLessThan(bigData.length);
537
+ });
538
+
539
+ it("makes the virtualized scroll region keyboard-focusable with a visible focus ring", () => {
540
+ const { container } = render(
541
+ <DataTable columns={columns} data={bigData} enableRowVirtualization />,
542
+ );
543
+ const scroll = container.querySelector(".overflow-auto");
544
+ expect(scroll).toHaveAttribute("tabindex", "0");
545
+ expect(scroll?.className).toMatch(/focus-visible:ring-2/);
546
+ // A focusable element must have an accessible name (WCAG 4.1.2).
547
+ expect(scroll).toHaveAttribute("aria-label");
548
+ });
549
+
550
+ it("forwards ref + spreads id/data-* + merges className + sets aria-busy on the virtualized branch", () => {
551
+ const ref = createRef<HTMLDivElement>();
552
+ const { container } = render(
553
+ <DataTable
554
+ columns={columns}
555
+ data={bigData}
556
+ enableRowVirtualization
557
+ loading
558
+ ref={ref}
559
+ id="virt-table"
560
+ data-testid="virt-root"
561
+ className="virt-extra"
562
+ />,
563
+ );
564
+ expect(ref.current).toBe(container.firstChild);
565
+ expect(container.firstChild).toHaveAttribute("id", "virt-table");
566
+ expect(container.firstChild).toHaveAttribute("data-testid", "virt-root");
567
+ expect(container.firstChild).toHaveClass("virt-extra");
568
+ expect(container.querySelector("[aria-busy='true']")).toBeInTheDocument();
569
+ });
570
+
571
+ it("suppresses pagination controls when virtualization and pagination are both enabled (virtualization wins)", () => {
572
+ render(
573
+ <DataTable
574
+ columns={columns}
575
+ data={bigData}
576
+ enableRowVirtualization
577
+ enablePagination
578
+ pageSize={5}
579
+ />,
580
+ );
581
+ expect(screen.queryByRole("button", { name: /Next/i })).toBeNull();
582
+ expect(screen.queryByRole("button", { name: /Previous/i })).toBeNull();
583
+ });
584
+ });
585
+
586
+ // ─── Zebra striping (default) vs line dividers ────────────────────────────────
587
+
588
+ describe("DataTable — zebra striping (default) vs lines", () => {
589
+ it("stripes alternate rows and draws no divider by default (zebra on)", () => {
590
+ const { container } = render(<DataTable columns={columns} data={data} />);
591
+ const rows = container.querySelectorAll("tbody tr");
592
+ expect(rows.length).toBe(3);
593
+ // 2nd row (index 1) is striped; 1st/3rd are not — the stripe is the cue.
594
+ expect(rows[0]?.className).not.toContain("bg-foreground/5");
595
+ expect(rows[1]?.className).toContain("bg-foreground/5");
596
+ expect(rows[2]?.className).not.toContain("bg-foreground/5");
597
+ // No row carries a divider (a border on a striped region would be redundant).
598
+ rows.forEach((r) => expect(r.className).not.toContain("border-b"));
599
+ });
600
+
601
+ it("draws border-strong dividers and no stripes when zebra is disabled", () => {
602
+ const { container } = render(<DataTable columns={columns} data={data} zebra={false} />);
603
+ const rows = container.querySelectorAll("tbody tr");
604
+ rows.forEach((r) => {
605
+ expect(r.className).toContain("border-b");
606
+ expect(r.className).toContain("border-border-strong");
607
+ expect(r.className).not.toContain("bg-foreground/5");
608
+ });
609
+ // Last row drops its divider so it doesn't double with the container border.
610
+ expect(rows[rows.length - 1]?.className).toContain("last:border-b-0");
611
+ });
612
+
613
+ it("#229 — row hover transition uses gated motion tokens (duration-fast / ease-standard), not the bare default", () => {
614
+ const { container } = render(<DataTable columns={columns} data={data} />);
615
+ const rows = container.querySelectorAll("tbody tr");
616
+ rows.forEach((r) => {
617
+ expect(r.className).toContain("transition-colors");
618
+ expect(r.className).toContain("duration-fast");
619
+ expect(r.className).toContain("ease-standard");
620
+ });
621
+ });
622
+ });
623
+
624
+ // ─── #228: onGlobalFilterChange resolves against the ref, not the closure ────
625
+
626
+ describe("DataTable — #228 global-filter functional updater resolves against the ref", () => {
627
+ it("resolves a functional globalFilter updater against the latest ref value across two synchronous calls in one handler", () => {
628
+ // Two functional updates fired synchronously in the SAME event handler —
629
+ // before React re-renders, the render-closure `globalFilter` variable is
630
+ // stale for the second call; only `globalFilterRef.current` is guaranteed
631
+ // fresh. This is the regression the fix (resolveGlobalFilter) locks:
632
+ // buggy code resolves both calls against the same stale "" and reports
633
+ // "a" twice; the fix reports "a" then "aa".
634
+ const onGlobalFilterChange = vi.fn<(value: string) => void>();
635
+ render(
636
+ <DataTable
637
+ columns={columns}
638
+ data={data}
639
+ manualFiltering
640
+ onGlobalFilterChange={onGlobalFilterChange}
641
+ toolbar={(t) => (
642
+ <button
643
+ type="button"
644
+ onClick={() => {
645
+ t.setGlobalFilter((prev: string) => `${prev ?? ""}a`);
646
+ t.setGlobalFilter((prev: string) => `${prev ?? ""}a`);
647
+ }}
648
+ >
649
+ apply-filter
650
+ </button>
651
+ )}
652
+ />,
653
+ );
654
+ fireEvent.click(screen.getByText("apply-filter"));
655
+ expect(onGlobalFilterChange).toHaveBeenCalledTimes(2);
656
+ expect(onGlobalFilterChange.mock.calls[0]?.[0]).toBe("a");
657
+ // The second call must resolve against the just-updated ref ("a" + "a"),
658
+ // not the stale render-closure value ("" + "a" = "a").
659
+ expect(onGlobalFilterChange.mock.calls[1]?.[0]).toBe("aa");
660
+ });
661
+ });
662
+
663
+ // ─── #227: manualPagination without rowCount/pageCount warns once (dev) ─────
664
+
665
+ describe("DataTable — #227 manualPagination without rowCount/pageCount dev warning", () => {
666
+ it("warns once when manualPagination is true and neither rowCount nor pageCount is supplied", () => {
667
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
668
+ try {
669
+ const { rerender } = render(
670
+ <DataTable columns={columns} data={data} enablePagination manualPagination />,
671
+ );
672
+ expect(warnSpy).toHaveBeenCalledTimes(1);
673
+ expect(warnSpy.mock.calls[0]?.[0]).toMatch(/manualPagination.*rowCount.*pageCount/is);
674
+
675
+ // Re-rendering (e.g. a parent re-render) must NOT warn again — "once" holds.
676
+ rerender(<DataTable columns={columns} data={data} enablePagination manualPagination />);
677
+ expect(warnSpy).toHaveBeenCalledTimes(1);
678
+ } finally {
679
+ warnSpy.mockRestore();
680
+ }
681
+ });
682
+
683
+ it("does NOT warn when rowCount is supplied", () => {
684
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
685
+ try {
686
+ render(
687
+ <DataTable columns={columns} data={data} enablePagination manualPagination rowCount={20} />,
688
+ );
689
+ expect(warnSpy).not.toHaveBeenCalled();
690
+ } finally {
691
+ warnSpy.mockRestore();
692
+ }
693
+ });
694
+
695
+ it("does NOT warn when pageCount is supplied", () => {
696
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
697
+ try {
698
+ render(
699
+ <DataTable columns={columns} data={data} enablePagination manualPagination pageCount={4} />,
700
+ );
701
+ expect(warnSpy).not.toHaveBeenCalled();
702
+ } finally {
703
+ warnSpy.mockRestore();
704
+ }
705
+ });
706
+
707
+ it("does NOT warn when manualPagination is false", () => {
708
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
709
+ try {
710
+ render(<DataTable columns={columns} data={data} enablePagination />);
711
+ expect(warnSpy).not.toHaveBeenCalled();
712
+ } finally {
713
+ warnSpy.mockRestore();
714
+ }
715
+ });
716
+ });
717
+
718
+ // ─── #230: sort header uses Lucide icons + a directional accessible name ────
719
+
720
+ describe("DataTable — #230 sort header icon + accessible name", () => {
721
+ it("gives the sort button an accessible name that changes with sort state", () => {
722
+ const { rerender } = render(
723
+ <DataTable columns={columns} data={data} sorting={[]} onSortingChange={vi.fn()} />,
724
+ );
725
+ expect(screen.getByRole("button", { name: "Sort by Name, not sorted" })).toBeInTheDocument();
726
+
727
+ rerender(
728
+ <DataTable
729
+ columns={columns}
730
+ data={data}
731
+ sorting={[{ id: "name", desc: false }]}
732
+ onSortingChange={vi.fn()}
733
+ />,
734
+ );
735
+ expect(screen.getByRole("button", { name: "Sort by Name, ascending" })).toBeInTheDocument();
736
+
737
+ rerender(
738
+ <DataTable
739
+ columns={columns}
740
+ data={data}
741
+ sorting={[{ id: "name", desc: true }]}
742
+ onSortingChange={vi.fn()}
743
+ />,
744
+ );
745
+ expect(screen.getByRole("button", { name: "Sort by Name, descending" })).toBeInTheDocument();
746
+ });
747
+
748
+ it("falls back to the column id for the accessible name when the header is a non-text ReactNode", () => {
749
+ const iconHeaderColumns: ColumnDef<Row>[] = [
750
+ {
751
+ accessorKey: "name",
752
+ id: "name",
753
+ header: () => <span aria-hidden="true">🔤</span>,
754
+ enableSorting: true,
755
+ },
756
+ { accessorKey: "value", header: "Value", enableSorting: true },
757
+ ];
758
+ render(<DataTable columns={iconHeaderColumns} data={data} />);
759
+ // Non-text header → falls back to the column id ("name") so the button
760
+ // still has a real accessible name (WCAG 4.1.2), not an empty one.
761
+ expect(screen.getByRole("button", { name: "Sort by name, not sorted" })).toBeInTheDocument();
762
+ });
763
+
764
+ it("renders a Lucide sort-direction icon (svg), not the raw ▲/▼/↕ glyphs", () => {
765
+ render(<DataTable columns={columns} data={data} sorting={[]} onSortingChange={vi.fn()} />);
766
+ const sortButton = screen.getByRole("button", { name: "Sort by Name, not sorted" });
767
+ expect(sortButton.querySelector("svg")).toBeInTheDocument();
768
+ expect(sortButton.textContent).not.toMatch(/[▲▼↕]/);
769
+ });
770
+ });
771
+
772
+ // ─── #330: plain (non-virtualized) branch scroll box is overflow-auto ───────
773
+
774
+ /** The plain branch's scroll box, addressed by its stable selector. */
775
+ function scrollRegionOf(container: HTMLElement): HTMLElement {
776
+ const el = container.querySelector<HTMLElement>('[data-slot="data-table-scroll-region"]');
777
+ if (!el) throw new Error("no [data-slot=data-table-scroll-region] in the rendered output");
778
+ return el;
779
+ }
780
+
781
+ /**
782
+ * jsdom reports 0 for every layout metric, so overflow has to be simulated.
783
+ * Re-measurement is driven through the component's own `onScroll` handler —
784
+ * the same path a real scroll takes — rather than by poking at state.
785
+ */
786
+ function simulateScrollMetrics(
787
+ el: HTMLElement,
788
+ {
789
+ scrollWidth,
790
+ clientWidth,
791
+ scrollLeft,
792
+ }: { scrollWidth: number; clientWidth: number; scrollLeft: number },
793
+ ) {
794
+ Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth });
795
+ Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth });
796
+ Object.defineProperty(el, "scrollLeft", {
797
+ configurable: true,
798
+ writable: true,
799
+ value: scrollLeft,
800
+ });
801
+ fireEvent.scroll(el);
802
+ }
803
+
804
+ describe("DataTable — #330 plain-branch scroll container is overflow-auto, not overflow-hidden", () => {
805
+ interface WideRow {
806
+ [key: string]: string;
807
+ }
808
+ const manyColumns: ColumnDef<WideRow>[] = Array.from({ length: 9 }, (_, i) => ({
809
+ accessorKey: `col${i}`,
810
+ header: `Col ${i}`,
811
+ }));
812
+ const wideRow: WideRow = Object.fromEntries(manyColumns.map((_, i) => [`col${i}`, `value-${i}`]));
813
+
814
+ it("does not clip columns — all headers stay in the DOM and the container is overflow-auto", () => {
815
+ const { container } = render(<DataTable columns={manyColumns} data={[wideRow]} />);
816
+ // All 9 columns are present — nothing was clipped out of existence.
817
+ expect(screen.getAllByRole("columnheader")).toHaveLength(9);
818
+
819
+ const scrollRegion = scrollRegionOf(container);
820
+ expect(scrollRegion.className).toMatch(/overflow-auto/);
821
+ expect(scrollRegion.className).not.toMatch(/overflow-hidden/);
822
+ expect(scrollRegion.className).toMatch(/focus-visible:ring-2/);
823
+
824
+ // The OUTER chrome div (border/rounded/bg-card) stays overflow-hidden (it
825
+ // clips to the rounded corners) — only the SCROLL region changed.
826
+ const outer = container.querySelector(".border.bg-card");
827
+ expect(outer?.className).toMatch(/overflow-hidden/);
828
+ });
829
+
830
+ it("keeps the virtualized branch's scroll container unaffected (still overflow-auto)", () => {
831
+ const { container } = render(
832
+ <DataTable columns={manyColumns} data={[wideRow]} enableRowVirtualization />,
833
+ );
834
+ const scroll = container.querySelector(".overflow-auto");
835
+ expect(scroll).not.toBeNull();
836
+ });
837
+ });
838
+
839
+ describe("DataTable — #330 the scroll tab stop exists only while the region overflows", () => {
840
+ it("adds NO tab stop and NO accessible name to a table that fits its container", () => {
841
+ const { container } = render(<DataTable columns={columns} data={data} />);
842
+ const scrollRegion = scrollRegionOf(container);
843
+ simulateScrollMetrics(scrollRegion, { scrollWidth: 300, clientWidth: 300, scrollLeft: 0 });
844
+ // A table that doesn't scroll must not gain a focus stop that does nothing,
845
+ // nor announce itself as "scrollable" — axe's `scrollable-region-focusable`
846
+ // only fires the other way round, so this is the locking assertion for it.
847
+ expect(scrollRegion).not.toHaveAttribute("tabindex");
848
+ expect(scrollRegion).not.toHaveAttribute("aria-label");
849
+ expect(screen.queryByLabelText("Table contents, scrollable")).toBeNull();
850
+ });
851
+
852
+ it("gains the tab stop + accessible name once the region measurably overflows", () => {
853
+ const { container } = render(<DataTable columns={columns} data={data} />);
854
+ const scrollRegion = scrollRegionOf(container);
855
+ simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 300, scrollLeft: 0 });
856
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
857
+ expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
858
+ });
859
+
860
+ it("drops the tab stop again when the overflow goes away (e.g. the container grows)", () => {
861
+ const { container } = render(<DataTable columns={columns} data={data} />);
862
+ const scrollRegion = scrollRegionOf(container);
863
+ simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 300, scrollLeft: 0 });
864
+ expect(scrollRegion).toHaveAttribute("tabindex", "0");
865
+ simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 900, scrollLeft: 0 });
866
+ expect(scrollRegion).not.toHaveAttribute("tabindex");
867
+ });
868
+ });
869
+
870
+ describe("DataTable — #330 horizontal-scroll edge-fade affordance", () => {
871
+ it("shows neither fade when the table fits its container (no overflow) — visual no-op", () => {
872
+ const { container } = render(<DataTable columns={columns} data={data} />);
873
+ simulateScrollMetrics(scrollRegionOf(container), {
874
+ scrollWidth: 300,
875
+ clientWidth: 300,
876
+ scrollLeft: 0,
877
+ });
878
+ expect(container.querySelector('[data-slot="data-table-scroll-fade-left"]')).toBeNull();
879
+ expect(container.querySelector('[data-slot="data-table-scroll-fade-right"]')).toBeNull();
880
+ });
881
+
882
+ it("shows only the right-edge fade when scrolled to the start of an overflowing table", () => {
883
+ const { container } = render(<DataTable columns={columns} data={data} />);
884
+ simulateScrollMetrics(scrollRegionOf(container), {
885
+ scrollWidth: 800,
886
+ clientWidth: 300,
887
+ scrollLeft: 0,
888
+ });
889
+ expect(
890
+ container.querySelector('[data-slot="data-table-scroll-fade-right"]'),
891
+ ).toBeInTheDocument();
892
+ expect(container.querySelector('[data-slot="data-table-scroll-fade-left"]')).toBeNull();
893
+ });
894
+
895
+ it("shows only the left-edge fade once scrolled to the end of an overflowing table", () => {
896
+ const { container } = render(<DataTable columns={columns} data={data} />);
897
+ simulateScrollMetrics(scrollRegionOf(container), {
898
+ scrollWidth: 800,
899
+ clientWidth: 300,
900
+ scrollLeft: 500,
901
+ });
902
+ expect(
903
+ container.querySelector('[data-slot="data-table-scroll-fade-left"]'),
904
+ ).toBeInTheDocument();
905
+ expect(container.querySelector('[data-slot="data-table-scroll-fade-right"]')).toBeNull();
906
+ });
907
+
908
+ it("the fade overlays are decorative (aria-hidden + pointer-events-none)", () => {
909
+ const { container } = render(<DataTable columns={columns} data={data} />);
910
+ simulateScrollMetrics(scrollRegionOf(container), {
911
+ scrollWidth: 800,
912
+ clientWidth: 300,
913
+ scrollLeft: 0,
914
+ });
915
+ const fade = container.querySelector('[data-slot="data-table-scroll-fade-right"]');
916
+ expect(fade).toHaveAttribute("aria-hidden", "true");
917
+ expect(fade?.className).toMatch(/pointer-events-none/);
918
+ });
919
+ });
920
+
921
+ // ─── #338: caption + scope="col" ─────────────────────────────────────────────
922
+
923
+ describe("DataTable — #338 caption prop + scope=col header cells", () => {
924
+ it("renders a visually-hidden <caption> and gives the table an accessible name when caption is set", () => {
925
+ render(<DataTable columns={columns} data={data} caption="Issues" />);
926
+ expect(screen.getByRole("table", { name: "Issues" })).toBeInTheDocument();
927
+ const caption = screen.getByText("Issues");
928
+ expect(caption.tagName).toBe("CAPTION");
929
+ expect(caption.className).toMatch(/sr-only/);
930
+ });
931
+
932
+ it("renders no <caption> element when caption is omitted (no visual/DOM regression)", () => {
933
+ const { container } = render(<DataTable columns={columns} data={data} />);
934
+ expect(container.querySelector("caption")).toBeNull();
935
+ });
936
+
937
+ it('gives every <th> in the rendered output scope="col"', () => {
938
+ const { container } = render(<DataTable columns={columns} data={data} />);
939
+ const headers = container.querySelectorAll("th");
940
+ expect(headers.length).toBeGreaterThan(0);
941
+ headers.forEach((th) => expect(th).toHaveAttribute("scope", "col"));
942
+ });
943
+
944
+ it("also renders the caption + scope=col on the virtualized branch", () => {
945
+ const { container } = render(
946
+ <DataTable columns={columns} data={data} enableRowVirtualization caption="Big table" />,
947
+ );
948
+ expect(screen.getByRole("table", { name: "Big table" })).toBeInTheDocument();
949
+ container.querySelectorAll("th").forEach((th) => expect(th).toHaveAttribute("scope", "col"));
950
+ });
951
+ });
952
+
953
+ // ─── #342: hide the pager for a genuinely single-page table ─────────────────
954
+
955
+ describe("DataTable — #342 hides the pager when there's only one page", () => {
956
+ it("renders no pagination chrome when all rows fit on one page", () => {
957
+ render(<DataTable columns={columns} data={data} pageSize={10} enablePagination />);
958
+ expect(screen.queryByText(/Page \d+ of \d+/)).toBeNull();
959
+ expect(screen.queryByRole("button", { name: /Next/i })).toBeNull();
960
+ expect(screen.queryByRole("button", { name: /Previous/i })).toBeNull();
961
+ });
962
+
963
+ it("still renders the pager (Next enabled) when there is more than one page", () => {
964
+ const manyRows = Array.from({ length: 25 }, (_, i) => ({ name: `Row ${i}`, value: i }));
965
+ render(<DataTable columns={columns} data={manyRows} pageSize={10} enablePagination />);
966
+ expect(screen.getByText(/Page 1 of 3/)).toBeInTheDocument();
967
+ expect(screen.getByRole("button", { name: /Next/i })).toBeEnabled();
968
+ });
969
+
970
+ it("hidePaginationWhenSingle={false} forces the pager to show even at one page", () => {
971
+ render(
972
+ <DataTable
973
+ columns={columns}
974
+ data={data}
975
+ pageSize={10}
976
+ enablePagination
977
+ hidePaginationWhenSingle={false}
978
+ />,
979
+ );
980
+ expect(screen.getByText(/Page 1 of 1/)).toBeInTheDocument();
981
+ });
982
+
983
+ it("still shows the (stuck) pager under manualPagination without rowCount/pageCount — the ambiguous case stays diagnosable", () => {
984
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
985
+ try {
986
+ render(<DataTable columns={columns} data={data} enablePagination manualPagination />);
987
+ // getPageCount() falls back to the current page's row count here (page
988
+ // count isn't knowable) — this flag must NOT also hide the pager, or the
989
+ // #227 dev warning becomes the only signal something is misconfigured.
990
+ expect(screen.getByText(/Page 1 of 1/)).toBeInTheDocument();
991
+ // The spy silences the warning in test output; it must still FIRE — the
992
+ // whole point of leaving the pager visible here is that #227's diagnostic
993
+ // stays the signal (asserted, not merely muted).
994
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("[DataTable]"));
995
+ } finally {
996
+ warnSpy.mockRestore();
997
+ }
998
+ });
999
+
1000
+ it("hides the pager under manualPagination when rowCount confirms a single page", () => {
1001
+ render(
1002
+ <DataTable
1003
+ columns={columns}
1004
+ data={data}
1005
+ enablePagination
1006
+ manualPagination
1007
+ rowCount={3}
1008
+ pagination={{ pageIndex: 0, pageSize: 10 }}
1009
+ onPaginationChange={vi.fn()}
1010
+ />,
1011
+ );
1012
+ expect(screen.queryByText(/Page \d+ of \d+/)).toBeNull();
1013
+ });
1014
+ });
1015
+
1016
+ // ─── #337: onRowClick + rowClassName ─────────────────────────────────────────
1017
+
1018
+ describe("DataTable — #337 onRowClick + rowClassName", () => {
1019
+ /** The first data row's `<tr>` (index 0 of `<tbody>`). */
1020
+ function firstBodyRow(container: HTMLElement): HTMLTableRowElement {
1021
+ const row = container.querySelector<HTMLTableRowElement>("tbody tr");
1022
+ if (!row) throw new Error("no data row rendered");
1023
+ return row;
1024
+ }
1025
+
1026
+ /**
1027
+ * Click the row BODY — a cell with no interactive content — so the assertion
1028
+ * exercises the delegated row-click path and not the hidden activation
1029
+ * button that also lives in the row.
1030
+ */
1031
+ function clickRowBody(container: HTMLElement) {
1032
+ fireEvent.click(firstBodyRow(container).cells[1]!);
1033
+ }
1034
+
1035
+ it("fires onRowClick with (row, event) on a row click", () => {
1036
+ const onRowClick = vi.fn();
1037
+ const { container } = render(
1038
+ <DataTable columns={columns} data={data} onRowClick={onRowClick} />,
1039
+ );
1040
+ clickRowBody(container);
1041
+ expect(onRowClick).toHaveBeenCalledTimes(1);
1042
+ const [row, event] = onRowClick.mock.calls[0]!;
1043
+ expect(row.original).toEqual({ name: "Alpha", value: 3 });
1044
+ expect(event).toBeTruthy();
1045
+ });
1046
+
1047
+ it("does NOT fire onRowClick when the click originates on a nested interactive control", () => {
1048
+ const onRowClick = vi.fn();
1049
+ const interactiveColumns: ColumnDef<Row>[] = [
1050
+ { accessorKey: "name", header: "Name" },
1051
+ {
1052
+ id: "actions",
1053
+ header: "Actions",
1054
+ cell: () => <button type="button">Edit</button>,
1055
+ },
1056
+ ];
1057
+ render(<DataTable columns={interactiveColumns} data={data} onRowClick={onRowClick} />);
1058
+ fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
1059
+ expect(onRowClick).not.toHaveBeenCalled();
1060
+ });
1061
+
1062
+ it("does NOT fire onRowClick when the click is the tail of a text-selection drag", () => {
1063
+ const onRowClick = vi.fn();
1064
+ const getSelectionSpy = vi
1065
+ .spyOn(window, "getSelection")
1066
+ .mockReturnValue({ type: "Range" } as unknown as Selection);
1067
+ try {
1068
+ const { container } = render(
1069
+ <DataTable columns={columns} data={data} onRowClick={onRowClick} />,
1070
+ );
1071
+ clickRowBody(container);
1072
+ expect(onRowClick).not.toHaveBeenCalled();
1073
+ } finally {
1074
+ getSelectionSpy.mockRestore();
1075
+ }
1076
+ });
1077
+
1078
+ it("puts the row's tab stop on a real <button> inside the row, NOT on the <tr>", () => {
1079
+ const { container } = render(<DataTable columns={columns} data={data} onRowClick={vi.fn()} />);
1080
+ const row = firstBodyRow(container);
1081
+ // The <tr> keeps plain `row` semantics: no tabIndex, no bogus role. A
1082
+ // focusable <tr> is a tab stop AT cannot interpret as activatable, and it
1083
+ // competes with the controls inside the row (#337).
1084
+ expect(row).not.toHaveAttribute("tabindex");
1085
+ expect(row).not.toHaveAttribute("role");
1086
+ const action = row.querySelector<HTMLElement>('[data-slot="data-table-row-action"]')!;
1087
+ expect(action.tagName).toBe("BUTTON");
1088
+ expect(action).toHaveAttribute("type", "button");
1089
+ // Visually hidden, but a real focusable control (not `display:none`).
1090
+ expect(action.className).toMatch(/sr-only/);
1091
+ });
1092
+
1093
+ it("names the row's activation button from the row's first cell value (WCAG 4.1.2)", () => {
1094
+ render(<DataTable columns={columns} data={data} onRowClick={vi.fn()} />);
1095
+ // Spec-compliant accessible-name computation via testing-library's role
1096
+ // query — one uniquely-named activation control per row, not five
1097
+ // identically-named ones.
1098
+ expect(screen.getByRole("button", { name: "Alpha" })).toBeInTheDocument();
1099
+ expect(screen.getByRole("button", { name: "Beta" })).toBeInTheDocument();
1100
+ expect(screen.getByRole("button", { name: "Gamma" })).toBeInTheDocument();
1101
+ });
1102
+
1103
+ it("lets rowActionLabel override the activation button's accessible name", () => {
1104
+ render(
1105
+ <DataTable
1106
+ columns={columns}
1107
+ data={data}
1108
+ onRowClick={vi.fn()}
1109
+ rowActionLabel={(row) => `Open ${row.original.name} details`}
1110
+ />,
1111
+ );
1112
+ expect(screen.getByRole("button", { name: "Open Alpha details" })).toBeInTheDocument();
1113
+ });
1114
+
1115
+ it("falls back to the localized generic name when the first cell has no primitive value", () => {
1116
+ const nonPrimitiveFirstColumn: ColumnDef<Row>[] = [
1117
+ { id: "avatar", header: "Avatar", cell: () => <span aria-hidden="true">◆</span> },
1118
+ { accessorKey: "name", header: "Name" },
1119
+ ];
1120
+ render(<DataTable columns={nonPrimitiveFirstColumn} data={data} onRowClick={vi.fn()} />);
1121
+ expect(screen.getAllByRole("button", { name: "Activate row" })).toHaveLength(data.length);
1122
+ });
1123
+
1124
+ it("is keyboard-operable: activating the row's button fires onRowClick exactly once", () => {
1125
+ const onRowClick = vi.fn();
1126
+ render(<DataTable columns={columns} data={data} onRowClick={onRowClick} />);
1127
+ const action = screen.getByRole("button", { name: "Alpha" });
1128
+ action.focus();
1129
+ expect(document.activeElement).toBe(action);
1130
+ // Enter/Space on a focused <button> is dispatched by the browser as a
1131
+ // click; the row's own handler must not ALSO fire (the interactive-target
1132
+ // guard covers the activation button too) — hence "exactly once".
1133
+ fireEvent.click(action);
1134
+ expect(onRowClick).toHaveBeenCalledTimes(1);
1135
+ expect(onRowClick.mock.calls[0]![0].original).toEqual({ name: "Alpha", value: 3 });
1136
+ });
1137
+
1138
+ it("renders no activation button and no click handler when onRowClick is not set", () => {
1139
+ const { container } = render(<DataTable columns={columns} data={data} />);
1140
+ const row = firstBodyRow(container);
1141
+ expect(row).not.toHaveAttribute("tabindex");
1142
+ expect(row.querySelector('[data-slot="data-table-row-action"]')).toBeNull();
1143
+ expect(row.className).not.toMatch(/cursor-pointer/);
1144
+ });
1145
+
1146
+ it("merges rowClassName alongside the existing zebra separation classes", () => {
1147
+ render(
1148
+ <DataTable
1149
+ columns={columns}
1150
+ data={data}
1151
+ rowClassName={(row) => (row.original.name === "Beta" ? "is-highlighted" : "")}
1152
+ />,
1153
+ );
1154
+ const betaRow = screen.getByText("Beta").closest("tr")!;
1155
+ expect(betaRow).toHaveClass("is-highlighted");
1156
+ // Beta is row index 1 — the zebra stripe class must still be present.
1157
+ expect(betaRow.className).toContain("bg-foreground/5");
1158
+ });
1159
+
1160
+ it("gives a clickable row a pointer cursor and a focus ring driven by its activation button", () => {
1161
+ const { container } = render(<DataTable columns={columns} data={data} onRowClick={vi.fn()} />);
1162
+ const row = firstBodyRow(container);
1163
+ expect(row.className).toMatch(/cursor-pointer/);
1164
+ // Focus lives on the sr-only button; the visible indicator paints on the
1165
+ // ROW via `:has()`, so the user sees which row they are about to activate.
1166
+ expect(row.className).toMatch(
1167
+ /has-\[\[data-slot=data-table-row-action\]:focus-visible\]:outline-2/,
1168
+ );
1169
+ });
1170
+ });
1171
+
1172
+ // ─── #333: column pinning ────────────────────────────────────────────────────
1173
+ //
1174
+ // jsdom does no layout, so these lock the STRUCTURE the browser then lays out:
1175
+ // which cells are sticky, what offsets they carry, that the offsets are the
1176
+ // declared-size arithmetic TanStack computes, and that the pinned fill still
1177
+ // carries the row's wash. The MEASURED proof (the frozen column actually holding
1178
+ // during a horizontal scroll, the z-ladder, the wash reading through) lives in
1179
+ // the `PinnedColumns` story's play function, which runs in a real browser.
1180
+
1181
+ /** Columns wide enough that pinning has something to freeze against. */
1182
+ const pinnableColumns: ColumnDef<Row>[] = [
1183
+ { accessorKey: "name", header: "Name", size: 150 },
1184
+ { accessorKey: "value", header: "Value" },
1185
+ { accessorKey: "value", id: "value2", header: "Value again" },
1186
+ { id: "actions", header: "Actions", size: 90, cell: () => "…" },
1187
+ ];
1188
+
1189
+ const pinned = (container: HTMLElement, position: "left" | "right") =>
1190
+ Array.from(container.querySelectorAll<HTMLElement>(`td[data-pinned="${position}"]`));
1191
+
1192
+ describe("DataTable — #333 column pinning: no-op when unused", () => {
1193
+ it("emits no pinning markup at all when neither the prop nor initialView sets it", () => {
1194
+ const { container } = render(<DataTable columns={pinnableColumns} data={data} />);
1195
+ expect(container.querySelectorAll("[data-pinned]")).toHaveLength(0);
1196
+ // Not one cell gains a sticky class — the byte-identical-DOM guarantee.
1197
+ for (const cell of container.querySelectorAll("th, td")) {
1198
+ expect(cell.className).not.toMatch(/\bsticky\b/);
1199
+ }
1200
+ });
1201
+ });
1202
+
1203
+ describe("DataTable — #333 column pinning: sticky geometry", () => {
1204
+ it("marks pinned header and body cells with data-pinned and a sticky class", () => {
1205
+ const { container } = render(
1206
+ <DataTable
1207
+ columns={pinnableColumns}
1208
+ data={data}
1209
+ columnPinning={{ left: ["name"], right: ["actions"] }}
1210
+ />,
1211
+ );
1212
+ const leftHeader = container.querySelector<HTMLElement>('th[data-pinned="left"]')!;
1213
+ const rightHeader = container.querySelector<HTMLElement>('th[data-pinned="right"]')!;
1214
+ expect(leftHeader).toHaveTextContent("Name");
1215
+ expect(rightHeader).toHaveTextContent("Actions");
1216
+ expect(leftHeader.className).toMatch(/\bsticky\b/);
1217
+ // One pinned body cell per row, per side.
1218
+ expect(pinned(container, "left")).toHaveLength(data.length);
1219
+ expect(pinned(container, "right")).toHaveLength(data.length);
1220
+ });
1221
+
1222
+ it("offsets a left-pinned column by the SUM of the declared sizes before it", () => {
1223
+ const { container } = render(
1224
+ <DataTable
1225
+ columns={[
1226
+ { accessorKey: "name", header: "Name", size: 150 },
1227
+ { accessorKey: "value", header: "Value", size: 80 },
1228
+ { accessorKey: "value", id: "value2", header: "Value again" },
1229
+ ]}
1230
+ data={data}
1231
+ columnPinning={{ left: ["name", "value"] }}
1232
+ />,
1233
+ );
1234
+ const [first, second] = Array.from(container.querySelectorAll<HTMLElement>("th[data-pinned]"));
1235
+ // TanStack's getStart("left"): 0 for the first pinned column, then the
1236
+ // running total of the declared sizes — the arithmetic the explicit-`size`
1237
+ // requirement exists to keep honest.
1238
+ expect(first!.style.left).toBe("0px");
1239
+ expect(first!.style.width).toBe("150px");
1240
+ expect(second!.style.left).toBe("150px");
1241
+ expect(second!.style.width).toBe("80px");
1242
+ });
1243
+
1244
+ it("offsets a right-pinned column from the right edge and draws the seam on its inner side", () => {
1245
+ const { container } = render(
1246
+ <DataTable
1247
+ columns={pinnableColumns}
1248
+ data={data}
1249
+ columnPinning={{ left: ["name"], right: ["actions"] }}
1250
+ />,
1251
+ );
1252
+ const rightHeader = container.querySelector<HTMLElement>('th[data-pinned="right"]')!;
1253
+ expect(rightHeader.style.right).toBe("0px");
1254
+ // Sole structural cue between the frozen block and the scrolling block →
1255
+ // the strong rung, on the inner (start) edge of the right-pinned block.
1256
+ // Drawn as a 1px `::after`, NOT a `border-e`/`border-s`: a COLLAPSED border
1257
+ // (Preflight's table model) is painted by the <table> at the cell's static
1258
+ // position and does not travel with the sticky cell, so the seam vanished
1259
+ // the moment the table was actually scrolled.
1260
+ expect(rightHeader.className).toContain("after:bg-border-strong");
1261
+ expect(rightHeader.className).toContain("after:start-0");
1262
+ expect(rightHeader.className).not.toMatch(/\bborder-s\b/);
1263
+ // …and on the end edge of the left-pinned block.
1264
+ const leftHeader = container.querySelector<HTMLElement>('th[data-pinned="left"]')!;
1265
+ expect(leftHeader.className).toContain("after:bg-border-strong");
1266
+ expect(leftHeader.className).toContain("after:end-0");
1267
+ expect(leftHeader.className).not.toMatch(/\bborder-e\b/);
1268
+ });
1269
+
1270
+ it("keeps keyboard focus out from under the frozen block via scroll-padding", () => {
1271
+ const { container } = render(
1272
+ <DataTable
1273
+ columns={pinnableColumns}
1274
+ data={data}
1275
+ columnPinning={{ left: ["name"], right: ["actions"] }}
1276
+ />,
1277
+ );
1278
+ const region = container.querySelector<HTMLElement>('[data-slot="data-table-scroll-region"]')!;
1279
+ // `name` is 150 wide, `actions` 90 — the frozen blocks' declared totals.
1280
+ expect(region.style.scrollPaddingInlineStart).toBe("150px");
1281
+ expect(region.style.scrollPaddingInlineEnd).toBe("90px");
1282
+ });
1283
+
1284
+ it("emits no scroll-padding when nothing is pinned", () => {
1285
+ const { container } = render(<DataTable columns={pinnableColumns} data={data} />);
1286
+ const region = container.querySelector<HTMLElement>('[data-slot="data-table-scroll-region"]')!;
1287
+ expect(region.getAttribute("style")).toBeNull();
1288
+ });
1289
+
1290
+ it("stacks the pinned header corner above the pinned body cells", () => {
1291
+ const { container } = render(
1292
+ <DataTable columns={pinnableColumns} data={data} columnPinning={{ left: ["name"] }} />,
1293
+ );
1294
+ expect(container.querySelector<HTMLElement>('th[data-pinned="left"]')!.className).toContain(
1295
+ "z-30",
1296
+ );
1297
+ expect(pinned(container, "left")[0]!.className).toContain("z-10");
1298
+ });
1299
+
1300
+ it("keeps the virtualized sticky header row between those two rungs", () => {
1301
+ const { container } = render(
1302
+ <DataTable
1303
+ columns={pinnableColumns}
1304
+ data={data}
1305
+ enableRowVirtualization
1306
+ columnPinning={{ left: ["name"] }}
1307
+ />,
1308
+ );
1309
+ // Corner (z-30) > sticky header row (z-20) > pinned body cells (z-10).
1310
+ expect(container.querySelector("thead")!.className).toContain("z-20");
1311
+ expect(container.querySelector<HTMLElement>('th[data-pinned="left"]')!.className).toContain(
1312
+ "z-30",
1313
+ );
1314
+ });
1315
+
1316
+ it("gives the pinned header corner the SAME composite its unpinned neighbours show", () => {
1317
+ // Plain branch: the header row is `surface-muted/60` over the container's
1318
+ // `card`, so the opaque corner has to be card + that wash on `::before` —
1319
+ // a solid `bg-surface-muted` read 4-5/255 darker in every theme.
1320
+ const plain = render(
1321
+ <DataTable columns={pinnableColumns} data={data} columnPinning={{ left: ["name"] }} />,
1322
+ );
1323
+ const plainTh = plain.container.querySelector<HTMLElement>('th[data-pinned="left"]')!;
1324
+ expect(plainTh.className).toContain("bg-card");
1325
+ expect(plainTh.className).toContain("before:bg-surface-muted/60");
1326
+
1327
+ // Virtualized branch: the header row is already opaque `surface-muted`, so
1328
+ // the corner matches it directly and needs no wash layer.
1329
+ const sticky = render(
1330
+ <DataTable
1331
+ columns={pinnableColumns}
1332
+ data={data}
1333
+ enableRowVirtualization
1334
+ columnPinning={{ left: ["name"] }}
1335
+ />,
1336
+ );
1337
+ const stickyTh = sticky.container.querySelector<HTMLElement>('th[data-pinned="left"]')!;
1338
+ expect(stickyTh.className).toContain("bg-surface-muted");
1339
+ expect(stickyTh.className).not.toContain("before:bg-surface-muted/60");
1340
+ });
1341
+ });
1342
+
1343
+ describe("DataTable — #333 pinned cells compose with the row wash, not overpaint it", () => {
1344
+ it("carries BOTH the opaque base and the zebra layer on an odd row, base only on an even row", () => {
1345
+ const { container } = render(
1346
+ <DataTable columns={pinnableColumns} data={data} columnPinning={{ left: ["name"] }} />,
1347
+ );
1348
+ const [even, odd] = pinned(container, "left");
1349
+ // Both rows: the opaque base that makes the cell hide scrolled content.
1350
+ expect(even!.className).toContain("bg-card");
1351
+ expect(odd!.className).toContain("bg-card");
1352
+ // Only the striped row re-applies the wash, on the decorative ::before layer
1353
+ // — this is the bug #333 reports: a single opaque fill erased the stripe.
1354
+ expect(odd!.className).toContain("before:bg-foreground/5");
1355
+ expect(even!.className).not.toContain("before:bg-foreground/5");
1356
+ // Hover/selected are re-applied from the row group in both cases.
1357
+ expect(even!.className).toContain("group-hover/row:before:bg-foreground/10");
1358
+ expect(container.querySelector("tbody tr")!.className).toContain("group/row");
1359
+ });
1360
+
1361
+ it("carries no zebra layer at all under the classic line model", () => {
1362
+ const { container } = render(
1363
+ <DataTable
1364
+ columns={pinnableColumns}
1365
+ data={data}
1366
+ zebra={false}
1367
+ columnPinning={{ left: ["name"] }}
1368
+ />,
1369
+ );
1370
+ for (const cell of pinned(container, "left")) {
1371
+ expect(cell.className).toContain("bg-card");
1372
+ expect(cell.className).not.toContain("before:bg-foreground/5");
1373
+ }
1374
+ // The row divider is still the separation cue and is untouched by pinning.
1375
+ expect(container.querySelector("tbody tr")!.className).toContain("border-border-strong");
1376
+ });
1377
+ });
1378
+
1379
+ describe("DataTable — #333 column pinning is a controlled/uncontrolled slice", () => {
1380
+ it("seeds an uncontrolled slice once from initialView.columnPinning", () => {
1381
+ const { container } = render(
1382
+ <DataTable
1383
+ columns={pinnableColumns}
1384
+ data={data}
1385
+ initialView={{ columnPinning: { left: ["name"] } }}
1386
+ />,
1387
+ );
1388
+ expect(container.querySelector('th[data-pinned="left"]')).toHaveTextContent("Name");
1389
+ });
1390
+
1391
+ it("never mutates its own state when controlled — it re-renders from the prop", () => {
1392
+ const onColumnPinningChange = vi.fn();
1393
+ const { container, rerender } = render(
1394
+ <DataTable
1395
+ columns={pinnableColumns}
1396
+ data={data}
1397
+ columnPinning={{ left: ["name"] }}
1398
+ onColumnPinningChange={onColumnPinningChange}
1399
+ />,
1400
+ );
1401
+ expect(container.querySelector('th[data-pinned="left"]')).toHaveTextContent("Name");
1402
+ rerender(
1403
+ <DataTable
1404
+ columns={pinnableColumns}
1405
+ data={data}
1406
+ columnPinning={{ right: ["actions"] }}
1407
+ onColumnPinningChange={onColumnPinningChange}
1408
+ />,
1409
+ );
1410
+ expect(container.querySelector('th[data-pinned="left"]')).toBeNull();
1411
+ expect(container.querySelector('th[data-pinned="right"]')).toHaveTextContent("Actions");
1412
+ });
1413
+
1414
+ it("drives pinning through the caller's handler without the component flipping modes", () => {
1415
+ const onColumnPinningChange = vi.fn();
1416
+ let table: TanstackTable<Row> | undefined;
1417
+ const { container } = render(
1418
+ <DataTable
1419
+ columns={pinnableColumns}
1420
+ data={data}
1421
+ columnPinning={{ left: ["name"] }}
1422
+ onColumnPinningChange={onColumnPinningChange}
1423
+ toolbar={(t) => {
1424
+ table = t;
1425
+ return null;
1426
+ }}
1427
+ />,
1428
+ );
1429
+ table!.getColumn("actions")!.pin("right");
1430
+ expect(onColumnPinningChange).toHaveBeenCalledTimes(1);
1431
+ // Controlled: the prop still says left-only, so the DOM must not have moved.
1432
+ expect(container.querySelector('th[data-pinned="right"]')).toBeNull();
1433
+ });
1434
+
1435
+ it("updates its own state when uncontrolled, and still notifies the caller", () => {
1436
+ const onColumnPinningChange = vi.fn();
1437
+ let table: TanstackTable<Row> | undefined;
1438
+ const { container } = render(
1439
+ <DataTable
1440
+ columns={pinnableColumns}
1441
+ data={data}
1442
+ onColumnPinningChange={onColumnPinningChange}
1443
+ toolbar={(t) => {
1444
+ table = t;
1445
+ return null;
1446
+ }}
1447
+ />,
1448
+ );
1449
+ expect(container.querySelectorAll("[data-pinned]")).toHaveLength(0);
1450
+ act(() => table!.getColumn("name")!.pin("left"));
1451
+ expect(onColumnPinningChange).toHaveBeenCalledTimes(1);
1452
+ expect(container.querySelector('th[data-pinned="left"]')).toHaveTextContent("Name");
1453
+ });
1454
+
1455
+ it("keeps pinning out of the server-change payload — it is layout, not a query", () => {
1456
+ const onServerChange = vi.fn();
1457
+ let table: TanstackTable<Row> | undefined;
1458
+ render(
1459
+ <DataTable
1460
+ columns={pinnableColumns}
1461
+ data={data}
1462
+ manualSorting
1463
+ manualFiltering
1464
+ manualPagination
1465
+ rowCount={3}
1466
+ onServerChange={onServerChange}
1467
+ toolbar={(t) => {
1468
+ table = t;
1469
+ return null;
1470
+ }}
1471
+ />,
1472
+ );
1473
+ act(() => table!.getColumn("name")!.pin("left"));
1474
+ expect(onServerChange).not.toHaveBeenCalled();
1475
+ });
1476
+ });
1477
+
1478
+ describe("DataTable — #333 dev warning for a pinned column with no explicit size", () => {
1479
+ it("warns once, naming the offending column", () => {
1480
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1481
+ try {
1482
+ const { rerender } = render(
1483
+ <DataTable columns={pinnableColumns} data={data} columnPinning={{ left: ["value"] }} />,
1484
+ );
1485
+ expect(warnSpy).toHaveBeenCalledTimes(1);
1486
+ expect(warnSpy.mock.calls[0]?.[0]).toMatch(/Pinned column\(s\) without an explicit `size`/);
1487
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("value");
1488
+
1489
+ rerender(
1490
+ <DataTable columns={pinnableColumns} data={data} columnPinning={{ left: ["value"] }} />,
1491
+ );
1492
+ expect(warnSpy).toHaveBeenCalledTimes(1);
1493
+ } finally {
1494
+ warnSpy.mockRestore();
1495
+ }
1496
+ });
1497
+
1498
+ it("does NOT warn when every pinned column declares a size", () => {
1499
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1500
+ try {
1501
+ render(
1502
+ <DataTable
1503
+ columns={pinnableColumns}
1504
+ data={data}
1505
+ columnPinning={{ left: ["name"], right: ["actions"] }}
1506
+ />,
1507
+ );
1508
+ expect(warnSpy).not.toHaveBeenCalled();
1509
+ } finally {
1510
+ warnSpy.mockRestore();
1511
+ }
1512
+ });
1513
+ });