@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,804 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, fn, waitFor } from "storybook/test";
4
+ import { Badge, Button } from "@elabs-ai/components-ui";
5
+ import type {
6
+ ColumnDef,
7
+ SortingState,
8
+ ColumnFiltersState,
9
+ VisibilityState,
10
+ } from "@tanstack/react-table";
11
+ import { DataTable } from "./data-table";
12
+ import type { DataTableServerArgs, DataTableViewState } from "./data-table";
13
+ import { FilterBar } from "../filter-bar";
14
+ import { SearchInput } from "../search-input";
15
+ import { FacetFilter } from "../facet-filter";
16
+ import { ColumnPicker } from "../column-picker";
17
+
18
+ // ─── Shared data fixtures ─────────────────────────────────────────────────────
19
+
20
+ interface Deployment {
21
+ service: string;
22
+ env: "prod" | "staging" | "dev";
23
+ status: "healthy" | "degraded" | "down";
24
+ latencyMs: number;
25
+ }
26
+
27
+ const rows: Deployment[] = [
28
+ { service: "api-gateway", env: "prod", status: "healthy", latencyMs: 82 },
29
+ { service: "billing", env: "prod", status: "degraded", latencyMs: 240 },
30
+ { service: "search", env: "staging", status: "healthy", latencyMs: 120 },
31
+ { service: "notifications", env: "dev", status: "down", latencyMs: 0 },
32
+ { service: "auth", env: "prod", status: "healthy", latencyMs: 64 },
33
+ ];
34
+
35
+ const statusVariant = { healthy: "success", degraded: "warning", down: "destructive" } as const;
36
+
37
+ const columns: ColumnDef<Deployment>[] = [
38
+ { accessorKey: "service", header: "Service" },
39
+ { accessorKey: "env", header: "Environment" },
40
+ {
41
+ accessorKey: "status",
42
+ header: "Status",
43
+ cell: ({ row }) => {
44
+ const s = row.original.status;
45
+ return <Badge variant={statusVariant[s]}>{s}</Badge>;
46
+ },
47
+ },
48
+ { accessorKey: "latencyMs", header: "Latency (ms)" },
49
+ ];
50
+
51
+ // `columns` minus the Badge-rendering "Status" cell — for stories below whose
52
+ // play function doesn't exercise sorting/status content. `Badge variant="success"`
53
+ // has a pre-existing, already-baselined contrast finding (`data-datatable--default`
54
+ // et al. in scripts/a11y-baseline.json); the baseline ratchet is already at its
55
+ // ceiling (200/200), so new stories avoid re-triggering it rather than growing
56
+ // the ratchet.
57
+ const columnsNoBadge: ColumnDef<Deployment>[] = [columns[0]!, columns[1]!, columns[3]!];
58
+
59
+ // ─── Meta ─────────────────────────────────────────────────────────────────────
60
+
61
+ const meta = {
62
+ title: "Data/DataTable",
63
+ component: DataTable,
64
+ parameters: {
65
+ layout: "padded",
66
+ docs: {
67
+ description: {
68
+ component:
69
+ "The full data grid (TanStack Table): sorting, filtering, pagination, virtualization, row " +
70
+ "selection and column management. For a simple static table with no interaction, the lighter " +
71
+ "Table primitive (see Data/Table, @elabs-ai/components-ui) is enough.",
72
+ },
73
+ },
74
+ },
75
+ tags: ["autodocs"],
76
+ } satisfies Meta<typeof DataTable<Deployment, unknown>>;
77
+ export default meta;
78
+ type Story = StoryObj<typeof meta>;
79
+
80
+ // ─── Default ──────────────────────────────────────────────────────────────────
81
+
82
+ export const Default: Story = {
83
+ render: () => <DataTable columns={columns} data={rows} />,
84
+ };
85
+
86
+ // ─── Lines (zebra opt-out) ──────────────────────────────────────────────────
87
+
88
+ /**
89
+ * `zebra={false}` opts out of the default gentle zebra striping in favour of the
90
+ * classic line model — a `border-border-strong` divider between rows.
91
+ */
92
+ export const Lines: Story = {
93
+ render: () => <DataTable columns={columns} data={rows} zebra={false} />,
94
+ };
95
+
96
+ // ─── Sorted ───────────────────────────────────────────────────────────────────
97
+
98
+ export const Sorted: Story = {
99
+ render: () => {
100
+ // eslint-disable-next-line react-hooks/rules-of-hooks
101
+ const [sorting, setSorting] = useState<SortingState>([{ id: "latencyMs", desc: true }]);
102
+ return (
103
+ <DataTable
104
+ columns={columns}
105
+ data={rows}
106
+ sorting={sorting}
107
+ onSortingChange={(updater) =>
108
+ setSorting(typeof updater === "function" ? updater(sorting) : updater)
109
+ }
110
+ />
111
+ );
112
+ },
113
+ };
114
+
115
+ // ─── Filtered (with toolbar SearchInput + FacetFilter) ────────────────────────
116
+
117
+ export const Filtered: Story = {
118
+ render: () => {
119
+ // eslint-disable-next-line react-hooks/rules-of-hooks
120
+ const [search, setSearch] = useState("");
121
+ // eslint-disable-next-line react-hooks/rules-of-hooks
122
+ const [envs, setEnvs] = useState<string[]>([]);
123
+ const filtered = rows.filter((r) => (envs.length ? envs.includes(r.env) : true));
124
+ return (
125
+ <DataTable
126
+ columns={columns}
127
+ data={filtered}
128
+ globalFilter={search}
129
+ onGlobalFilterChange={setSearch}
130
+ toolbar={(table) => (
131
+ <FilterBar actions={<ColumnPicker table={table} />}>
132
+ <SearchInput value={search} onValueChange={setSearch} placeholder="Filter services…" />
133
+ <FacetFilter
134
+ title="Environment"
135
+ selected={envs}
136
+ onSelectedChange={setEnvs}
137
+ options={[
138
+ { label: "Production", value: "prod" },
139
+ { label: "Staging", value: "staging" },
140
+ { label: "Dev", value: "dev" },
141
+ ]}
142
+ />
143
+ </FilterBar>
144
+ )}
145
+ />
146
+ );
147
+ },
148
+ };
149
+
150
+ // ─── WithToolbar (retained from original — the interaction-tested story) ──────
151
+
152
+ export const WithToolbar: Story = {
153
+ render: () => {
154
+ // eslint-disable-next-line react-hooks/rules-of-hooks
155
+ const [search, setSearch] = useState("");
156
+ // eslint-disable-next-line react-hooks/rules-of-hooks
157
+ const [envs, setEnvs] = useState<string[]>([]);
158
+ const filtered = rows.filter((r) => (envs.length ? envs.includes(r.env) : true));
159
+ return (
160
+ <DataTable
161
+ columns={columns}
162
+ data={filtered}
163
+ enablePagination
164
+ pageSize={5}
165
+ globalFilter={search}
166
+ onGlobalFilterChange={setSearch}
167
+ toolbar={(table) => (
168
+ <FilterBar actions={<ColumnPicker table={table} />}>
169
+ <SearchInput value={search} onValueChange={setSearch} placeholder="Filter services…" />
170
+ <FacetFilter
171
+ title="Environment"
172
+ selected={envs}
173
+ onSelectedChange={setEnvs}
174
+ options={[
175
+ { label: "Production", value: "prod" },
176
+ { label: "Staging", value: "staging" },
177
+ { label: "Dev", value: "dev" },
178
+ ]}
179
+ />
180
+ </FilterBar>
181
+ )}
182
+ />
183
+ );
184
+ },
185
+ // Typing in the toolbar SearchInput drives the table's global filter: only the
186
+ // matching row should survive. Proves the render-prop toolbar and table share
187
+ // one filter state.
188
+ play: async ({ canvas, userEvent }) => {
189
+ await expect(canvas.getByText("api-gateway")).toBeVisible();
190
+ await userEvent.type(canvas.getByPlaceholderText(/Filter services/), "billing");
191
+ await waitFor(() => expect(canvas.queryByText("api-gateway")).toBeNull());
192
+ await expect(canvas.getByText("billing")).toBeVisible();
193
+ },
194
+ };
195
+
196
+ // ─── Paginated ────────────────────────────────────────────────────────────────
197
+
198
+ export const Paginated: Story = {
199
+ render: () => (
200
+ <DataTable
201
+ columns={columns}
202
+ data={[...rows, ...rows, ...rows]} // 15 rows
203
+ enablePagination
204
+ pageSize={5}
205
+ />
206
+ ),
207
+ };
208
+
209
+ // ─── Loading ──────────────────────────────────────────────────────────────────
210
+
211
+ /** Shows a spinner overlay when rows are present + loading. */
212
+ export const Loading: Story = {
213
+ render: () => <DataTable columns={columns} data={rows} loading />,
214
+ };
215
+
216
+ /** Shows skeleton placeholder rows when data is empty + loading (initial load). */
217
+ export const LoadingEmpty: Story = {
218
+ render: () => <DataTable columns={columns} data={[]} loading />,
219
+ };
220
+
221
+ /**
222
+ * Toggle between loading (skeleton) and loaded (real data) to verify there is
223
+ * no layout jump when real rows arrive.
224
+ */
225
+ export const LoadingToLoaded: Story = {
226
+ render: () => {
227
+ // eslint-disable-next-line react-hooks/rules-of-hooks
228
+ const [loading, setLoading] = useState(true);
229
+ // eslint-disable-next-line react-hooks/rules-of-hooks
230
+ const [tableData, setTableData] = useState<Deployment[]>([]);
231
+
232
+ return (
233
+ <div className="space-y-3">
234
+ <div className="flex gap-2">
235
+ <button
236
+ type="button"
237
+ onClick={() => {
238
+ setLoading(true);
239
+ setTableData([]);
240
+ setTimeout(() => {
241
+ setTableData(rows);
242
+ setLoading(false);
243
+ }, 800);
244
+ }}
245
+ className="rounded border px-3 py-1 text-body"
246
+ >
247
+ {loading ? "Loading…" : "Reload (simulate fetch)"}
248
+ </button>
249
+ </div>
250
+ <DataTable
251
+ columns={columns}
252
+ data={tableData}
253
+ loading={loading}
254
+ loadingRows={5}
255
+ id="loading-to-loaded-table"
256
+ data-testid="loading-demo"
257
+ />
258
+ </div>
259
+ );
260
+ },
261
+ };
262
+
263
+ // ─── Empty ────────────────────────────────────────────────────────────────────
264
+
265
+ export const Empty: Story = {
266
+ render: () => <DataTable columns={columns} data={[]} emptyMessage="No deployments found." />,
267
+ };
268
+
269
+ // ─── Virtualized10k ───────────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * 10 000 generated rows with row virtualization enabled.
273
+ * Only a small window of rows is rendered in the DOM at any time.
274
+ * Scroll the container to verify smooth windowing.
275
+ * (Real perf/smoothness cannot be measured in jsdom — use this story in a browser.)
276
+ */
277
+ export const Virtualized10k: Story = {
278
+ render: () => {
279
+ const bigData = Array.from({ length: 10_000 }, (_, i) => ({
280
+ service: `service-${i}`,
281
+ env: (["prod", "staging", "dev"] as const)[i % 3],
282
+ status: (["healthy", "degraded", "down"] as const)[i % 3],
283
+ latencyMs: (i * 7) % 500,
284
+ }));
285
+
286
+ return (
287
+ <DataTable
288
+ columns={columns}
289
+ data={bigData}
290
+ enableRowVirtualization
291
+ estimateRowHeight={40}
292
+ overscan={8}
293
+ maxBodyHeight="32rem"
294
+ />
295
+ );
296
+ },
297
+ };
298
+
299
+ // ─── ServerSide ───────────────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Documented server-side example.
303
+ *
304
+ * Demonstrates the manual* + onServerChange pattern:
305
+ * - `manualPagination`, `manualSorting`, `manualFiltering` are all true.
306
+ * - The component never fetches. `onServerChange` fires when any slice changes.
307
+ * - The story simulates a remote fetch with a setTimeout and updates `data`.
308
+ * - `pageCount` / `rowCount` are passed so TanStack can compute page boundaries.
309
+ *
310
+ * In a real app, replace the setTimeout with your data-fetching hook (e.g. SWR,
311
+ * React Query, or a server action) and remove the simulated data generation.
312
+ */
313
+ export const ServerSide: Story = {
314
+ render: () => {
315
+ // Total "server-side" dataset — in reality this lives on the server.
316
+ const TOTAL_ROWS = 47;
317
+ const PAGE_SIZE = 5;
318
+
319
+ // eslint-disable-next-line react-hooks/rules-of-hooks
320
+ const [serverArgs, setServerArgs] = useState<DataTableServerArgs>({
321
+ pagination: { pageIndex: 0, pageSize: PAGE_SIZE },
322
+ sorting: [],
323
+ columnFilters: [],
324
+ globalFilter: "",
325
+ });
326
+
327
+ // eslint-disable-next-line react-hooks/rules-of-hooks
328
+ const [loading, setLoading] = useState(false);
329
+ // eslint-disable-next-line react-hooks/rules-of-hooks
330
+ const [pageData, setPageData] = useState<Deployment[]>([]);
331
+
332
+ // Controlled slices — the app owns them; DataTable drives them via callbacks.
333
+ // eslint-disable-next-line react-hooks/rules-of-hooks
334
+ const [sorting, setSorting] = useState<SortingState>([]);
335
+ // eslint-disable-next-line react-hooks/rules-of-hooks
336
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
337
+ // eslint-disable-next-line react-hooks/rules-of-hooks
338
+ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE });
339
+
340
+ // Simulate a server fetch whenever serverArgs change.
341
+ // Replace this with a real data-fetching call (SWR, React Query, etc.).
342
+ // eslint-disable-next-line react-hooks/rules-of-hooks
343
+ useEffect(() => {
344
+ setLoading(true);
345
+ const timer = setTimeout(() => {
346
+ // Generate a page of fake data based on pagination
347
+ const start = serverArgs.pagination.pageIndex * serverArgs.pagination.pageSize;
348
+ const slice = Array.from(
349
+ { length: Math.min(serverArgs.pagination.pageSize, TOTAL_ROWS - start) },
350
+ (_, i) => {
351
+ const idx = start + i;
352
+ return {
353
+ service: `service-${idx}`,
354
+ env: (["prod", "staging", "dev"] as const)[idx % 3],
355
+ status: (["healthy", "degraded", "down"] as const)[idx % 3],
356
+ latencyMs: (idx * 13) % 500,
357
+ };
358
+ },
359
+ );
360
+ setPageData(slice);
361
+ setLoading(false);
362
+ }, 300); // simulated 300 ms network latency
363
+ return () => clearTimeout(timer);
364
+ }, [serverArgs]);
365
+
366
+ return (
367
+ <DataTable
368
+ columns={columns}
369
+ data={pageData}
370
+ loading={loading}
371
+ enablePagination
372
+ pageSize={PAGE_SIZE}
373
+ // Server-side model flags
374
+ manualPagination
375
+ manualSorting
376
+ manualFiltering
377
+ // Let TanStack know total rows so it can compute page count
378
+ rowCount={TOTAL_ROWS}
379
+ // Controlled slices (the app holds state; DataTable reports changes)
380
+ sorting={sorting}
381
+ onSortingChange={(u) => setSorting(typeof u === "function" ? u(sorting) : u)}
382
+ columnFilters={columnFilters}
383
+ onColumnFiltersChange={(u) =>
384
+ setColumnFilters(typeof u === "function" ? u(columnFilters) : u)
385
+ }
386
+ pagination={pagination}
387
+ onPaginationChange={(u) => setPagination(typeof u === "function" ? u(pagination) : u)}
388
+ // onServerChange — trigger the re-fetch
389
+ onServerChange={setServerArgs}
390
+ />
391
+ );
392
+ },
393
+ };
394
+
395
+ // ─── SavedViewRoundTrip ───────────────────────────────────────────────────────
396
+
397
+ /**
398
+ * Demonstrates saved-view serialize → parse → rehydrate.
399
+ *
400
+ * The user configures sorting + column visibility, clicks "Save view", and the
401
+ * state is JSON-serialized. On remount (simulated by toggling the key), the
402
+ * same state is rehydrated via `initialView` and the table looks identical.
403
+ */
404
+ export const SavedViewRoundTrip: Story = {
405
+ render: () => {
406
+ // eslint-disable-next-line react-hooks/rules-of-hooks
407
+ const [savedView, setSavedView] = useState<Partial<DataTableViewState> | null>(null);
408
+ // eslint-disable-next-line react-hooks/rules-of-hooks
409
+ const [mountKey, setMountKey] = useState(0);
410
+ // eslint-disable-next-line react-hooks/rules-of-hooks
411
+ const [sorting, setSorting] = useState<SortingState>([]);
412
+ // eslint-disable-next-line react-hooks/rules-of-hooks
413
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
414
+ // eslint-disable-next-line react-hooks/rules-of-hooks
415
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
416
+
417
+ function handleSave() {
418
+ const view: Partial<DataTableViewState> = {
419
+ sorting,
420
+ columnVisibility,
421
+ columnFilters,
422
+ globalFilter: "",
423
+ };
424
+ // Serialize → parse (proves it survives JSON round-trip)
425
+ const serialized = JSON.stringify(view);
426
+ const parsed = JSON.parse(serialized) as Partial<DataTableViewState>;
427
+ setSavedView(parsed);
428
+ // Remount with a new key to simulate re-entering the page
429
+ setMountKey((k) => k + 1);
430
+ }
431
+
432
+ return (
433
+ <div className="space-y-4">
434
+ <div className="flex gap-2">
435
+ <Button
436
+ type="button"
437
+ variant="outline"
438
+ size="sm"
439
+ onClick={() => setSorting([{ id: "latencyMs", desc: true }])}
440
+ >
441
+ Sort by Latency ↓
442
+ </Button>
443
+ <Button
444
+ type="button"
445
+ variant="outline"
446
+ size="sm"
447
+ onClick={() => setColumnVisibility({ latencyMs: false })}
448
+ >
449
+ Hide Latency column
450
+ </Button>
451
+ <Button type="button" size="sm" onClick={handleSave}>
452
+ Save view &amp; remount
453
+ </Button>
454
+ </div>
455
+
456
+ {savedView && (
457
+ <p className="text-meta text-muted-foreground">
458
+ Saved: <code>{JSON.stringify(savedView)}</code>
459
+ </p>
460
+ )}
461
+
462
+ {/* key forces a remount so initialView takes effect as one-shot rehydration */}
463
+ <DataTable
464
+ key={mountKey}
465
+ columns={columns}
466
+ data={rows}
467
+ initialView={savedView ?? undefined}
468
+ sorting={savedView ? undefined : sorting}
469
+ onSortingChange={(u) => setSorting(typeof u === "function" ? u(sorting) : u)}
470
+ columnVisibility={savedView ? undefined : columnVisibility}
471
+ onColumnVisibilityChange={(u) =>
472
+ setColumnVisibility(typeof u === "function" ? u(columnVisibility) : u)
473
+ }
474
+ columnFilters={savedView ? undefined : columnFilters}
475
+ onColumnFiltersChange={(u) =>
476
+ setColumnFilters(typeof u === "function" ? u(columnFilters) : u)
477
+ }
478
+ />
479
+ </div>
480
+ );
481
+ },
482
+ };
483
+
484
+ // ─── Caption (#338) ───────────────────────────────────────────────────────────
485
+
486
+ /**
487
+ * `caption` gives the table a real accessible name — visually hidden
488
+ * (`sr-only`) but announced by screen readers and exposed as the table's
489
+ * accessible name.
490
+ */
491
+ export const Caption: Story = {
492
+ render: () => (
493
+ <DataTable columns={columnsNoBadge} data={rows} caption="Deployment status by service" />
494
+ ),
495
+ play: async ({ canvas, canvasElement }) => {
496
+ await expect(canvas.getByRole("table", { name: "Deployment status by service" })).toBeVisible();
497
+
498
+ // #330 companion assertion, in a REAL browser with real layout: this table
499
+ // fits its container, so its scroll box must NOT be a tab stop and must NOT
500
+ // claim to be scrollable. (The overflowing counterpart is asserted in
501
+ // NarrowContainerScroll below.)
502
+ const scrollRegion = canvasElement.querySelector<HTMLElement>(
503
+ '[data-slot="data-table-scroll-region"]',
504
+ )!;
505
+ await waitFor(() =>
506
+ expect(scrollRegion.scrollWidth).toBeLessThanOrEqual(scrollRegion.clientWidth + 1),
507
+ );
508
+ await expect(scrollRegion).not.toHaveAttribute("tabindex");
509
+ await expect(scrollRegion).not.toHaveAttribute("aria-label");
510
+ },
511
+ };
512
+
513
+ // ─── ClickableRows (#337) ─────────────────────────────────────────────────────
514
+
515
+ // Declared outside `render`/`play` so both close over the SAME mock instance —
516
+ // `play` needs to assert on the exact spy `render` wired to `onRowClick`.
517
+ const clickableRowsOnRowClick = fn();
518
+
519
+ /**
520
+ * `onRowClick` gives each row exactly ONE activation target: a visually-hidden
521
+ * `<button>` in the first cell, named after the row. Pointer clicks anywhere in
522
+ * the row body resolve to the same handler, guarded so a nested control (the
523
+ * "Restart" button) or a text-selection drag never activates the row.
524
+ * `rowClassName` highlights the degraded row without disturbing the zebra stripe.
525
+ */
526
+ export const ClickableRows: Story = {
527
+ render: () => (
528
+ <DataTable
529
+ columns={[
530
+ ...columnsNoBadge,
531
+ {
532
+ id: "actions",
533
+ header: "Actions",
534
+ // Deliberately NO `stopPropagation` here: the click must really reach
535
+ // the row handler's guard, or this story would pass with the guard
536
+ // deleted and prove nothing.
537
+ cell: () => (
538
+ <Button type="button" size="sm" variant="outline">
539
+ Restart
540
+ </Button>
541
+ ),
542
+ },
543
+ ]}
544
+ data={rows}
545
+ onRowClick={clickableRowsOnRowClick}
546
+ rowClassName={(row) => (row.original.status === "degraded" ? "bg-warning/10" : "")}
547
+ />
548
+ ),
549
+ play: async ({ canvas, userEvent }) => {
550
+ clickableRowsOnRowClick.mockClear();
551
+
552
+ // Clicking the row BODY (a cell with no interactive content) activates it.
553
+ await userEvent.click(canvas.getByRole("cell", { name: "82" }));
554
+ await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(1);
555
+ await expect(clickableRowsOnRowClick.mock.calls[0]![0].original.service).toBe("api-gateway");
556
+
557
+ // Clicking a nested interactive control does NOT activate the row. The
558
+ // click genuinely bubbles to the row handler — only the guard stops it.
559
+ await userEvent.click(canvas.getAllByRole("button", { name: "Restart" })[0]!);
560
+ await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(1);
561
+
562
+ // Keyboard: the row's tab stop is its hidden activation button, named after
563
+ // the row. Enter on it fires the handler exactly once (the row's own
564
+ // pointer handler must not double-fire on the bubbled click).
565
+ const rowAction = canvas.getByRole("button", { name: "search" });
566
+ rowAction.focus();
567
+ await expect(rowAction).toHaveFocus();
568
+ await userEvent.keyboard("{Enter}");
569
+ await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(2);
570
+ await expect(clickableRowsOnRowClick.mock.calls[1]![0].original.service).toBe("search");
571
+
572
+ // The <tr> itself is NOT a competing tab stop.
573
+ const searchRow = rowAction.closest("tr")!;
574
+ await expect(searchRow).not.toHaveAttribute("tabindex");
575
+ },
576
+ };
577
+
578
+ // ─── SinglePageNoPager / MultiPagePager (#342) ───────────────────────────────
579
+
580
+ /** A table whose rows all fit on one page renders no pagination chrome. */
581
+ export const SinglePageNoPager: Story = {
582
+ render: () => <DataTable columns={columnsNoBadge} data={rows} pageSize={10} enablePagination />,
583
+ play: async ({ canvas }) => {
584
+ await expect(canvas.queryByText(/Page \d+ of \d+/)).toBeNull();
585
+ },
586
+ };
587
+
588
+ /** `hidePaginationWhenSingle={false}` forces the pager to show even at one page. */
589
+ export const SinglePagePagerForced: Story = {
590
+ render: () => (
591
+ <DataTable
592
+ columns={columnsNoBadge}
593
+ data={rows}
594
+ pageSize={10}
595
+ enablePagination
596
+ hidePaginationWhenSingle={false}
597
+ />
598
+ ),
599
+ play: async ({ canvas }) => {
600
+ await expect(canvas.getByText(/Page 1 of 1/)).toBeVisible();
601
+ },
602
+ };
603
+
604
+ // ─── NarrowContainerScroll (#330) ────────────────────────────────────────────
605
+
606
+ /**
607
+ * A many-column table in a narrow container: the plain (non-virtualized)
608
+ * branch scrolls horizontally instead of clipping columns, is keyboard-
609
+ * focusable, and shows a token-driven edge fade once scrolled.
610
+ */
611
+ export const NarrowContainerScroll: Story = {
612
+ render: () => {
613
+ const wideColumns: ColumnDef<Deployment>[] = [
614
+ ...columns,
615
+ { accessorKey: "service", id: "service2", header: "Service (again)" },
616
+ { accessorKey: "env", id: "env2", header: "Environment (again)" },
617
+ { accessorKey: "latencyMs", id: "latency2", header: "Latency again (ms)" },
618
+ ];
619
+ return (
620
+ <div style={{ width: 360 }}>
621
+ <DataTable columns={wideColumns} data={rows} />
622
+ </div>
623
+ );
624
+ },
625
+ play: async ({ canvas, userEvent }) => {
626
+ // All column headers are still in the DOM — nothing was clipped away.
627
+ await expect(canvas.getAllByRole("columnheader").length).toBeGreaterThan(4);
628
+ // This table DOES overflow its narrow container, so — unlike the Caption
629
+ // story — the scroll box is a real tab stop with a real accessible name.
630
+ const scrollRegion = await waitFor(() => canvas.getByLabelText("Table contents, scrollable"));
631
+ await expect(scrollRegion.scrollWidth).toBeGreaterThan(scrollRegion.clientWidth);
632
+ await expect(scrollRegion).toHaveAttribute("tabindex", "0");
633
+ await userEvent.click(scrollRegion); // focus the region (real keyboard/scroll target)
634
+ scrollRegion.scrollLeft = scrollRegion.scrollWidth;
635
+ },
636
+ };
637
+
638
+ // ─── PinnedColumns (#333) ─────────────────────────────────────────────────────
639
+
640
+ /**
641
+ * A wide table whose identifying column is frozen to the left edge and whose
642
+ * actions column is frozen to the right, so a horizontally-scrolled row stays
643
+ * attributable and actionable.
644
+ *
645
+ * Two columns are pinned left on purpose: the second one's sticky offset is the
646
+ * SUM of the declared sizes before it, which is the arithmetic that breaks first
647
+ * if a pinned column is left auto-width (hence the dev warning, and hence every
648
+ * pinned column here carrying an explicit `size`).
649
+ */
650
+ const pinnedColumns: ColumnDef<Deployment>[] = [
651
+ { accessorKey: "service", header: "Service", size: 160 },
652
+ { accessorKey: "env", header: "Environment", size: 120 },
653
+ { accessorKey: "latencyMs", header: "Latency (ms)" },
654
+ { accessorKey: "latencyMs", id: "p50", header: "p50 latency (ms)" },
655
+ { accessorKey: "latencyMs", id: "p95", header: "p95 latency (ms)" },
656
+ { accessorKey: "latencyMs", id: "p99", header: "p99 latency (ms)" },
657
+ { accessorKey: "service", id: "owner", header: "Owning team" },
658
+ { accessorKey: "env", id: "region", header: "Deploy region" },
659
+ {
660
+ id: "actions",
661
+ header: "Actions",
662
+ size: 120,
663
+ cell: () => (
664
+ <Button type="button" size="sm" variant="outline">
665
+ Restart
666
+ </Button>
667
+ ),
668
+ },
669
+ ];
670
+
671
+ const PINNING = { left: ["service", "env"], right: ["actions"] };
672
+
673
+ export const PinnedColumns: Story = {
674
+ parameters: {
675
+ docs: {
676
+ description: {
677
+ story:
678
+ "`columnPinning={{ left: [...], right: [...] }}` freezes columns against either edge " +
679
+ "while the rest scrolls. The frozen cells re-apply the row's zebra/hover wash over " +
680
+ "their own opaque fill instead of overpainting it (#333).",
681
+ },
682
+ },
683
+ },
684
+ render: () => (
685
+ <div style={{ width: 620 }}>
686
+ <DataTable
687
+ columns={pinnedColumns}
688
+ data={rows}
689
+ columnPinning={PINNING}
690
+ caption="Deployment status by service"
691
+ />
692
+ </div>
693
+ ),
694
+ play: async ({ canvas, canvasElement }) => {
695
+ const scrollRegion = await waitFor(() => canvas.getByLabelText("Table contents, scrollable"));
696
+ await expect(scrollRegion.scrollWidth).toBeGreaterThan(scrollRegion.clientWidth);
697
+
698
+ const pinnedHeaders = canvasElement.querySelectorAll<HTMLElement>("th[data-pinned]");
699
+ await expect(pinnedHeaders).toHaveLength(3);
700
+
701
+ // AC1 — offsets come from TanStack's declared-size arithmetic: the first
702
+ // left-pinned column sits at 0, the second at exactly the first's width.
703
+ await expect(pinnedHeaders[0]!.style.left).toBe("0px");
704
+ await expect(pinnedHeaders[1]!.style.left).toBe("160px");
705
+ await expect(pinnedHeaders[2]!.style.right).toBe("0px");
706
+
707
+ // AC2 — the z-ladder, read off real computed styles rather than class names:
708
+ // pinned header corner > sticky header row > pinned body cell > normal cell.
709
+ const pinnedBodyCell = canvasElement.querySelector<HTMLElement>(
710
+ 'tbody tr:nth-child(2) td[data-pinned="left"]',
711
+ )!;
712
+ const plainBodyCell = canvasElement.querySelector<HTMLElement>(
713
+ "tbody tr:nth-child(2) td:not([data-pinned])",
714
+ )!;
715
+ const zIndexOf = (el: HTMLElement) => Number.parseInt(getComputedStyle(el).zIndex, 10);
716
+ await expect(zIndexOf(pinnedHeaders[0]!)).toBeGreaterThan(zIndexOf(pinnedBodyCell));
717
+ await expect(getComputedStyle(plainBodyCell).zIndex).toBe("auto");
718
+ await expect(getComputedStyle(pinnedBodyCell).position).toBe("sticky");
719
+
720
+ // AC1 — the frozen column really holds during a horizontal scroll: the pinned
721
+ // cell's viewport x stays put while an unpinned cell in the same row moves.
722
+ const pinnedBefore = pinnedBodyCell.getBoundingClientRect().left;
723
+ const plainBefore = plainBodyCell.getBoundingClientRect().left;
724
+ scrollRegion.scrollLeft = scrollRegion.scrollWidth;
725
+ await waitFor(() =>
726
+ expect(plainBodyCell.getBoundingClientRect().left).toBeLessThan(plainBefore - 50),
727
+ );
728
+ await expect(Math.abs(pinnedBodyCell.getBoundingClientRect().left - pinnedBefore)).toBeLessThan(
729
+ 1,
730
+ );
731
+
732
+ // The seam must survive that scroll. It is the ONLY structural cue between
733
+ // the frozen block and the content sliding under it (and the reason the
734
+ // #330 edge fade is suppressed on a pinned edge), so "it renders at
735
+ // scrollLeft 0" is not the property worth locking — "it is still painted
736
+ // while scrolled" is. A `border-e` passed the first and failed the second:
737
+ // Preflight's collapsed-border model paints a cell border from the <table>
738
+ // at the cell's STATIC position, so it does not travel with the sticky cell.
739
+ const seamCell = canvasElement.querySelectorAll<HTMLElement>(
740
+ 'tbody tr:nth-child(2) td[data-pinned="left"]',
741
+ );
742
+ const seam = getComputedStyle(seamCell[seamCell.length - 1]!, "::after");
743
+ await expect(seam.width).toBe("1px");
744
+ await expect(seam.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
745
+ await expect(seam.position).toBe("absolute");
746
+
747
+ // AC3 — the reported bug. The pinned cell paints an OPAQUE base and
748
+ // re-applies the row's wash on a `::before` layer, so a striped row's frozen
749
+ // cell is NOT the same flat fill as an unstriped row's.
750
+ const oddPinned = canvasElement.querySelector<HTMLElement>(
751
+ 'tbody tr:nth-child(2) td[data-pinned="left"]',
752
+ )!;
753
+ const evenPinned = canvasElement.querySelector<HTMLElement>(
754
+ 'tbody tr:nth-child(1) td[data-pinned="left"]',
755
+ )!;
756
+ // Same opaque base…
757
+ await expect(getComputedStyle(oddPinned).backgroundColor).toBe(
758
+ getComputedStyle(evenPinned).backgroundColor,
759
+ );
760
+ await expect(getComputedStyle(oddPinned).backgroundColor).not.toContain("rgba(0, 0, 0, 0)");
761
+ // …and the wash layer only on the striped row: the stripe survives the fill.
762
+ await expect(getComputedStyle(oddPinned, "::before").backgroundColor).not.toBe(
763
+ getComputedStyle(evenPinned, "::before").backgroundColor,
764
+ );
765
+ await expect(getComputedStyle(evenPinned, "::before").backgroundColor).toBe("rgba(0, 0, 0, 0)");
766
+
767
+ // Leave the table where a reader expects to find it (the docs page renders
768
+ // this story's final state).
769
+ scrollRegion.scrollLeft = 0;
770
+ },
771
+ };
772
+
773
+ /**
774
+ * The same pinning under the classic line model (`zebra={false}`): the frozen
775
+ * cells carry no stripe layer, and the row's `border-border-strong` divider is
776
+ * painted over their opaque fill by the collapsed-border model.
777
+ */
778
+ export const PinnedColumnsClassicLines: Story = {
779
+ render: () => (
780
+ <div style={{ width: 620 }}>
781
+ <DataTable
782
+ columns={pinnedColumns}
783
+ data={rows}
784
+ columnPinning={PINNING}
785
+ zebra={false}
786
+ caption="Deployment status by service"
787
+ />
788
+ </div>
789
+ ),
790
+ play: async ({ canvasElement }) => {
791
+ const oddPinned = canvasElement.querySelector<HTMLElement>(
792
+ 'tbody tr:nth-child(2) td[data-pinned="left"]',
793
+ )!;
794
+ const evenPinned = canvasElement.querySelector<HTMLElement>(
795
+ 'tbody tr:nth-child(1) td[data-pinned="left"]',
796
+ )!;
797
+ // No zebra → neither row's frozen cell carries a wash layer…
798
+ await expect(getComputedStyle(oddPinned, "::before").backgroundColor).toBe(
799
+ getComputedStyle(evenPinned, "::before").backgroundColor,
800
+ );
801
+ // …and the row divider is still the visible separation cue.
802
+ await expect(getComputedStyle(oddPinned.closest("tr")!).borderBottomWidth).toBe("1px");
803
+ },
804
+ };