@elabs-ai/components-data 4.0.0 → 4.2.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.
@@ -1,4 +1,4 @@
1
- import { createRef } from "react";
1
+ import { createRef, useState } from "react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { act, render, screen, fireEvent } from "@testing-library/react";
4
4
  import type {
@@ -8,7 +8,8 @@ import type {
8
8
  Table as TanstackTable,
9
9
  VisibilityState,
10
10
  } from "@tanstack/react-table";
11
- import { DataTable } from "./data-table";
11
+ import { LocaleProvider } from "@elabs-ai/components-ui";
12
+ import { DataTable, createSelectionColumn } from "./data-table";
12
13
  import type { DataTableServerArgs } from "./data-table";
13
14
 
14
15
  // ─── Shared fixtures ─────────────────────────────────────────────────────────
@@ -542,7 +543,7 @@ describe("DataTable — virtualized a11y + composability", () => {
542
543
  );
543
544
  const scroll = container.querySelector(".overflow-auto");
544
545
  expect(scroll).toHaveAttribute("tabindex", "0");
545
- expect(scroll?.className).toMatch(/focus-visible:ring-2/);
546
+ expect(scroll?.className).toMatch(/focus-ring/);
546
547
  // A focusable element must have an accessible name (WCAG 4.1.2).
547
548
  expect(scroll).toHaveAttribute("aria-label");
548
549
  });
@@ -591,11 +592,35 @@ describe("DataTable — zebra striping (default) vs lines", () => {
591
592
  const rows = container.querySelectorAll("tbody tr");
592
593
  expect(rows.length).toBe(3);
593
594
  // 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"));
595
+ expect(rows[0]?.className).not.toContain("bg-table-stripe");
596
+ expect(rows[1]?.className).toContain("bg-table-stripe");
597
+ expect(rows[2]?.className).not.toContain("bg-table-stripe");
598
+ // No row carries a fixed divider (a border on a striped region would be
599
+ // redundant). The only row rule is the theme-gated width, `0px` by default,
600
+ // for a theme that turns the stripe off.
601
+ rows.forEach((r) => {
602
+ const classes = r.className.split(/\s+/);
603
+ expect(classes).not.toContain("border-b");
604
+ expect(classes).toContain("border-b-(length:--table-row-rule-width)");
605
+ });
606
+ });
607
+
608
+ it("draws column dividers only when asked", () => {
609
+ const plain = render(<DataTable columns={columns} data={data} />);
610
+ plain.container
611
+ .querySelectorAll("th, td")
612
+ .forEach((c) => expect(c.className.split(/\s+/)).not.toContain("border-e"));
613
+ plain.unmount();
614
+
615
+ const { container } = render(<DataTable columns={columns} data={data} columnDividers />);
616
+ const cells = container.querySelectorAll("thead th, tbody td");
617
+ expect(cells.length).toBeGreaterThan(0);
618
+ cells.forEach((c) => {
619
+ const classes = c.className.split(/\s+/);
620
+ expect(classes).toContain("border-e");
621
+ expect(classes).toContain("border-rule");
622
+ expect(classes).toContain("last:border-e-0");
623
+ });
599
624
  });
600
625
 
601
626
  it("draws border-strong dividers and no stripes when zebra is disabled", () => {
@@ -604,7 +629,7 @@ describe("DataTable — zebra striping (default) vs lines", () => {
604
629
  rows.forEach((r) => {
605
630
  expect(r.className).toContain("border-b");
606
631
  expect(r.className).toContain("border-border-strong");
607
- expect(r.className).not.toContain("bg-foreground/5");
632
+ expect(r.className).not.toContain("bg-table-stripe");
608
633
  });
609
634
  // Last row drops its divider so it doesn't double with the container border.
610
635
  expect(rows[rows.length - 1]?.className).toContain("last:border-b-0");
@@ -819,7 +844,7 @@ describe("DataTable — #330 plain-branch scroll container is overflow-auto, not
819
844
  const scrollRegion = scrollRegionOf(container);
820
845
  expect(scrollRegion.className).toMatch(/overflow-auto/);
821
846
  expect(scrollRegion.className).not.toMatch(/overflow-hidden/);
822
- expect(scrollRegion.className).toMatch(/focus-visible:ring-2/);
847
+ expect(scrollRegion.className).toMatch(/focus-ring-inset/);
823
848
 
824
849
  // The OUTER chrome div (border/rounded/bg-card) stays overflow-hidden (it
825
850
  // clips to the rounded corners) — only the SCROLL region changed.
@@ -865,6 +890,32 @@ describe("DataTable — #330 the scroll tab stop exists only while the region ov
865
890
  simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 900, scrollLeft: 0 });
866
891
  expect(scrollRegion).not.toHaveAttribute("tabindex");
867
892
  });
893
+
894
+ it('gains role="group" once it overflows — a named, non-landmark stop', () => {
895
+ const { container } = render(<DataTable columns={columns} data={data} />);
896
+ const scrollRegion = scrollRegionOf(container);
897
+ // `aria-label` on a plain `<div>` (role `generic`) is not guaranteed to
898
+ // produce an accessible name at all — the stop needs a naming-capable
899
+ // role, and `group` (not the `region` landmark, which collides under axe
900
+ // `landmark-unique` across tables) is it.
901
+ expect(scrollRegion).not.toHaveAttribute("role");
902
+ simulateScrollMetrics(scrollRegion, { scrollWidth: 800, clientWidth: 300, scrollLeft: 0 });
903
+ expect(scrollRegion).toHaveAttribute("role", "group");
904
+ });
905
+ });
906
+
907
+ describe("DataTable — virtualized scroll region has a naming-capable role", () => {
908
+ it('the always-focusable virtualized scroll container carries role="group"', () => {
909
+ const { container } = render(
910
+ <DataTable columns={columns} data={data} enableRowVirtualization />,
911
+ );
912
+ const scroll = container.querySelector(".overflow-auto");
913
+ expect(scroll).toHaveAttribute("tabindex", "0");
914
+ expect(scroll).toHaveAttribute("aria-label");
915
+ // Same reasoning as the non-virtualized branch above: `aria-label` alone
916
+ // on a role-less `<div>` is not guaranteed to compute an accessible name.
917
+ expect(scroll).toHaveAttribute("role", "group");
918
+ });
868
919
  });
869
920
 
870
921
  describe("DataTable — #330 horizontal-scroll edge-fade affordance", () => {
@@ -1112,15 +1163,37 @@ describe("DataTable — #337 onRowClick + rowClassName", () => {
1112
1163
  expect(screen.getByRole("button", { name: "Open Alpha details" })).toBeInTheDocument();
1113
1164
  });
1114
1165
 
1115
- it("falls back to the localized generic name when the first cell has no primitive value", () => {
1166
+ it("skips a leading display column with no accessor and names the button from the first DATA column (#11 I6)", () => {
1116
1167
  const nonPrimitiveFirstColumn: ColumnDef<Row>[] = [
1117
1168
  { id: "avatar", header: "Avatar", cell: () => <span aria-hidden="true">◆</span> },
1118
1169
  { accessorKey: "name", header: "Name" },
1119
1170
  ];
1120
1171
  render(<DataTable columns={nonPrimitiveFirstColumn} data={data} onRowClick={vi.fn()} />);
1172
+ // The leading column has no `accessorKey`/`accessorFn`, so `rowActionName`
1173
+ // does not stop at it and falls through to `name` — a real per-row name,
1174
+ // not the generic fallback every row would otherwise share.
1175
+ expect(screen.getByRole("button", { name: "Alpha" })).toBeInTheDocument();
1176
+ expect(screen.getByRole("button", { name: "Beta" })).toBeInTheDocument();
1177
+ expect(screen.getByRole("button", { name: "Gamma" })).toBeInTheDocument();
1178
+ });
1179
+
1180
+ it("falls back to the localized generic name when NO visible column has a data accessor", () => {
1181
+ const allDisplayColumns: ColumnDef<Row>[] = [
1182
+ { id: "avatar", header: "Avatar", cell: () => <span aria-hidden="true">◆</span> },
1183
+ { id: "spacer", header: "", cell: () => null },
1184
+ ];
1185
+ render(<DataTable columns={allDisplayColumns} data={data} onRowClick={vi.fn()} />);
1121
1186
  expect(screen.getAllByRole("button", { name: "Activate row" })).toHaveLength(data.length);
1122
1187
  });
1123
1188
 
1189
+ it("#11 I6: a leading selection column does not degrade the row-activation name to the generic fallback", () => {
1190
+ const withSelection: ColumnDef<Row>[] = [createSelectionColumn<Row>(), ...columns];
1191
+ render(<DataTable columns={withSelection} data={data} onRowClick={vi.fn()} />);
1192
+ expect(screen.getByRole("button", { name: "Alpha" })).toBeInTheDocument();
1193
+ expect(screen.getByRole("button", { name: "Beta" })).toBeInTheDocument();
1194
+ expect(screen.getByRole("button", { name: "Gamma" })).toBeInTheDocument();
1195
+ });
1196
+
1124
1197
  it("is keyboard-operable: activating the row's button fires onRowClick exactly once", () => {
1125
1198
  const onRowClick = vi.fn();
1126
1199
  render(<DataTable columns={columns} data={data} onRowClick={onRowClick} />);
@@ -1154,7 +1227,7 @@ describe("DataTable — #337 onRowClick + rowClassName", () => {
1154
1227
  const betaRow = screen.getByText("Beta").closest("tr")!;
1155
1228
  expect(betaRow).toHaveClass("is-highlighted");
1156
1229
  // Beta is row index 1 — the zebra stripe class must still be present.
1157
- expect(betaRow.className).toContain("bg-foreground/5");
1230
+ expect(betaRow.className).toContain("bg-table-stripe");
1158
1231
  });
1159
1232
 
1160
1233
  it("gives a clickable row a pointer cursor and a focus ring driven by its activation button", () => {
@@ -1163,10 +1236,29 @@ describe("DataTable — #337 onRowClick + rowClassName", () => {
1163
1236
  expect(row.className).toMatch(/cursor-pointer/);
1164
1237
  // Focus lives on the sr-only button; the visible indicator paints on the
1165
1238
  // ROW via `:has()`, so the user sees which row they are about to activate.
1239
+ // …and it is the SHARED compound indicator (#67), not a hand-rolled ring:
1240
+ // `focus-ring-static-inset` is the static-trigger, inset-geometry flavour,
1241
+ // because the focused element is the sr-only button and an outside ring
1242
+ // would be clipped by the scroll viewport.
1166
1243
  expect(row.className).toMatch(
1167
- /has-\[\[data-slot=data-table-row-action\]:focus-visible\]:outline-2/,
1244
+ /has-\[\[data-slot=data-table-row-action\]:focus-visible\]:focus-ring-static-inset/,
1168
1245
  );
1169
1246
  });
1247
+
1248
+ it("suppresses the proxy button's OWN native focus ring (#311) — the row paints the only indicator", () => {
1249
+ const { container } = render(<DataTable columns={columns} data={data} onRowClick={vi.fn()} />);
1250
+ const row = firstBodyRow(container);
1251
+ const proxy = row.querySelector<HTMLElement>('[data-slot="data-table-row-action"]')!;
1252
+ proxy.focus();
1253
+ expect(document.activeElement).toBe(proxy);
1254
+ // sr-only removes the proxy from the visual layout but NOT the platform's
1255
+ // own outline painting — that must be suppressed explicitly, or it leaks
1256
+ // as a stray dot next to the row's deliberate compound indicator above.
1257
+ // (This package's vitest config sets `css: false`, so `getComputedStyle`
1258
+ // never resolves Tailwind here — the real-browser assertion lives in the
1259
+ // `ClickableRows` story's play function, which DOES run under real CSS.)
1260
+ expect(proxy.className).toMatch(/focus-visible:outline-none/);
1261
+ });
1170
1262
  });
1171
1263
 
1172
1264
  // ─── #333: column pinning ────────────────────────────────────────────────────
@@ -1351,10 +1443,10 @@ describe("DataTable — #333 pinned cells compose with the row wash, not overpai
1351
1443
  expect(odd!.className).toContain("bg-card");
1352
1444
  // Only the striped row re-applies the wash, on the decorative ::before layer
1353
1445
  // — 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");
1446
+ expect(odd!.className).toContain("before:bg-table-stripe");
1447
+ expect(even!.className).not.toContain("before:bg-table-stripe");
1356
1448
  // Hover/selected are re-applied from the row group in both cases.
1357
- expect(even!.className).toContain("group-hover/row:before:bg-foreground/10");
1449
+ expect(even!.className).toContain("group-hover/row:before:bg-table-row-hover");
1358
1450
  expect(container.querySelector("tbody tr")!.className).toContain("group/row");
1359
1451
  });
1360
1452
 
@@ -1369,7 +1461,7 @@ describe("DataTable — #333 pinned cells compose with the row wash, not overpai
1369
1461
  );
1370
1462
  for (const cell of pinned(container, "left")) {
1371
1463
  expect(cell.className).toContain("bg-card");
1372
- expect(cell.className).not.toContain("before:bg-foreground/5");
1464
+ expect(cell.className).not.toContain("before:bg-table-stripe");
1373
1465
  }
1374
1466
  // The row divider is still the separation cue and is untouched by pinning.
1375
1467
  expect(container.querySelector("tbody tr")!.className).toContain("border-border-strong");
@@ -1511,3 +1603,1325 @@ describe("DataTable — #333 dev warning for a pinned column with no explicit si
1511
1603
  }
1512
1604
  });
1513
1605
  });
1606
+
1607
+ // ─── #12: column resizing ─────────────────────────────────────────────────────
1608
+ //
1609
+ // TanStack's resize handler (`ColumnSizing.ts`) computes, for a leaf (non-grouped)
1610
+ // header, `newSize = round((startSize + startSize * deltaOffset / startSize) * 100) / 100`,
1611
+ // which for a single leaf column simplifies to `startSize + deltaOffset` where
1612
+ // `deltaOffset = moveClientX - mouseDownClientX`. Starting the drag at `clientX: 0`
1613
+ // makes the move's `clientX` equal to `deltaOffset` directly, which is why these
1614
+ // tests drag from `0`.
1615
+
1616
+ /** Columns wide enough to resize meaningfully; both declare an explicit `size`. */
1617
+ const resizableColumns: ColumnDef<Row>[] = [
1618
+ { accessorKey: "name", header: "Name", size: 150 },
1619
+ { accessorKey: "value", header: "Value", size: 100 },
1620
+ ];
1621
+
1622
+ describe("DataTable — #12 column resizing", () => {
1623
+ it("is a no-op (no resize handle, no inline width) when enableColumnResizing is unset", () => {
1624
+ const { container } = render(<DataTable columns={resizableColumns} data={data} />);
1625
+ expect(container.querySelectorAll('[data-slot="data-table-resize-handle"]')).toHaveLength(0);
1626
+ const th = container.querySelector("thead th")!;
1627
+ expect(th.getAttribute("style")).toBeNull();
1628
+ });
1629
+
1630
+ it("resizes a column via pointer drag (uncontrolled)", () => {
1631
+ const { container } = render(
1632
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />,
1633
+ );
1634
+ const th = container.querySelector<HTMLElement>("thead th")!;
1635
+ expect(th.style.width).toBe("150px");
1636
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1637
+ fireEvent.mouseDown(handle, { clientX: 0 });
1638
+ fireEvent.mouseMove(document, { clientX: 40 });
1639
+ fireEvent.mouseUp(document, { clientX: 40 });
1640
+ expect(th.style.width).toBe("190px");
1641
+ // The `<td>`s in every row follow the same resolved `getSize()`.
1642
+ for (const td of container.querySelectorAll("tbody tr td:first-child")) {
1643
+ expect((td as HTMLElement).style.width).toBe("190px");
1644
+ }
1645
+ });
1646
+
1647
+ it("resizes via the keyboard (ArrowRight/ArrowLeft) and keeps aria-valuenow in sync", () => {
1648
+ const { container } = render(
1649
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />,
1650
+ );
1651
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1652
+ expect(handle).toHaveAttribute("aria-valuenow", "150");
1653
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1654
+ expect(handle).toHaveAttribute("aria-valuenow", "160");
1655
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("160px");
1656
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
1657
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
1658
+ expect(handle).toHaveAttribute("aria-valuenow", "140");
1659
+ });
1660
+
1661
+ // #51 — the resize handle's accessible VALUE lacked a unit: a screen reader
1662
+ // announced a bare number ("150"), which reads as a dimensionless ordinal
1663
+ // rather than a size. `aria-valuetext` supplies the unit while
1664
+ // `aria-valuenow` stays the plain numeric value TanStack/AT expect.
1665
+ it("exposes aria-valuetext with an explicit unit, kept in sync with aria-valuenow across a keyboard resize", () => {
1666
+ const { container } = render(
1667
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />,
1668
+ );
1669
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1670
+ expect(handle).toHaveAttribute("aria-valuenow", "150");
1671
+ expect(handle).toHaveAttribute("aria-valuetext", "150 pixels");
1672
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1673
+ expect(handle).toHaveAttribute("aria-valuenow", "160");
1674
+ expect(handle).toHaveAttribute("aria-valuetext", "160 pixels");
1675
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("160px");
1676
+ });
1677
+
1678
+ // PR #81 review, "Format the announced resize value for the active
1679
+ // locale": the announced value must go through `formatNumber` (so a
1680
+ // non-Latin-digit locale doesn't hear raw JS-number Latin digits) and pass
1681
+ // the numeric `count` a `PluralMessage` override needs to select its own
1682
+ // plural category, not just interpolate `{size}` into a fixed "other" form.
1683
+ it("formats the announced resize value for the active locale (non-Latin digits) and reaches PluralMessage's singular form", () => {
1684
+ const singularSizeColumns: ColumnDef<Row>[] = [
1685
+ { accessorKey: "name", header: "Name", size: 1, minSize: 1 },
1686
+ { accessorKey: "value", header: "Value", size: 100 },
1687
+ ];
1688
+ render(
1689
+ <LocaleProvider
1690
+ locale="ar-EG"
1691
+ messages={{
1692
+ "data.table.resizeColumnValue": { one: "{size} بكسل واحد", other: "{size} بكسل" },
1693
+ }}
1694
+ >
1695
+ <DataTable columns={singularSizeColumns} data={data} enableColumnResizing />
1696
+ </LocaleProvider>,
1697
+ );
1698
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1699
+ // aria-valuenow stays a plain numeric value for AT/TanStack regardless of
1700
+ // locale — only aria-valuetext is localized.
1701
+ expect(handle).toHaveAttribute("aria-valuenow", "1");
1702
+ // ar-EG renders "1" as the Arabic-Indic digit "١", proving the value went
1703
+ // through `formatNumber` rather than a raw `String(1)` interpolation —
1704
+ // and the "one" plural form fired, proving `count` reached the message.
1705
+ expect(handle).toHaveAttribute("aria-valuetext", "١ بكسل واحد");
1706
+ });
1707
+
1708
+ it("is a controlled/uncontrolled slice: a keyboard resize notifies the caller but the DOM only moves once the prop does", () => {
1709
+ const onColumnSizingChange = vi.fn();
1710
+ const { container, rerender } = render(
1711
+ <DataTable
1712
+ columns={resizableColumns}
1713
+ data={data}
1714
+ enableColumnResizing
1715
+ columnSizing={{ name: 150 }}
1716
+ onColumnSizingChange={onColumnSizingChange}
1717
+ />,
1718
+ );
1719
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1720
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1721
+ expect(onColumnSizingChange).toHaveBeenCalledTimes(1);
1722
+ // Controlled: the prop still says 150, so the DOM must not have moved.
1723
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("150px");
1724
+ rerender(
1725
+ <DataTable
1726
+ columns={resizableColumns}
1727
+ data={data}
1728
+ enableColumnResizing
1729
+ columnSizing={{ name: 220 }}
1730
+ onColumnSizingChange={onColumnSizingChange}
1731
+ />,
1732
+ );
1733
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("220px");
1734
+ });
1735
+
1736
+ // #51 — a resize handle can only be dragged wider/narrower; there was no way
1737
+ // to snap a column back to its authored width short of reloading the table.
1738
+ // Double-click is the platform convention (spreadsheets, file managers) for
1739
+ // "reset this to its intrinsic size".
1740
+ it("double-clicking the handle resets a resized column back to its declared size", () => {
1741
+ const { container } = render(
1742
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />,
1743
+ );
1744
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1745
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1746
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1747
+ expect(handle).toHaveAttribute("aria-valuenow", "170");
1748
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("170px");
1749
+
1750
+ fireEvent.doubleClick(handle);
1751
+ expect(handle).toHaveAttribute("aria-valuenow", "150");
1752
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("150px");
1753
+ });
1754
+
1755
+ it("double-clicking the handle resets an undeclared-size column to TanStack's own default (150)", () => {
1756
+ const undeclaredSizeColumns: ColumnDef<Row>[] = [
1757
+ { accessorKey: "name", header: "Name" },
1758
+ { accessorKey: "value", header: "Value" },
1759
+ ];
1760
+ const { container } = render(
1761
+ <DataTable columns={undeclaredSizeColumns} data={data} enableColumnResizing />,
1762
+ );
1763
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1764
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1765
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("160px");
1766
+
1767
+ fireEvent.doubleClick(handle);
1768
+ expect(handle).toHaveAttribute("aria-valuenow", "150");
1769
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("150px");
1770
+ });
1771
+
1772
+ it("double-click reset goes through table.setColumnSizing: a controlled caller observes it via onColumnSizingChange and the DOM only moves once the prop does", () => {
1773
+ const onColumnSizingChange = vi.fn();
1774
+ const { container, rerender } = render(
1775
+ <DataTable
1776
+ columns={resizableColumns}
1777
+ data={data}
1778
+ enableColumnResizing
1779
+ columnSizing={{ name: 220 }}
1780
+ onColumnSizingChange={onColumnSizingChange}
1781
+ />,
1782
+ );
1783
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1784
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("220px");
1785
+
1786
+ fireEvent.doubleClick(handle);
1787
+ expect(onColumnSizingChange).toHaveBeenCalledTimes(1);
1788
+ // Controlled: the prop still says 220, so the DOM must not have moved yet.
1789
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("220px");
1790
+
1791
+ rerender(
1792
+ <DataTable
1793
+ columns={resizableColumns}
1794
+ data={data}
1795
+ enableColumnResizing
1796
+ columnSizing={{ name: 150 }}
1797
+ onColumnSizingChange={onColumnSizingChange}
1798
+ />,
1799
+ );
1800
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("150px");
1801
+ });
1802
+
1803
+ // PR #81 review, "Remove the sizing override when resetting a column": a
1804
+ // double-click reset must actually RESET (remove the override), not pin the
1805
+ // column to whatever its declared size happened to be at reset time — else
1806
+ // the column stops tracking a later authored `size` change, unlike a column
1807
+ // that was never resized at all.
1808
+ it("double-click reset does not pin the column — it still tracks a LATER change to the column's declared size", () => {
1809
+ const { container, rerender } = render(
1810
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />,
1811
+ );
1812
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1813
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1814
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("160px");
1815
+
1816
+ fireEvent.doubleClick(handle);
1817
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("150px");
1818
+
1819
+ // The `columns` prop now declares a DIFFERENT authored size for "name" —
1820
+ // e.g. a caller switching table configurations. A column that was never
1821
+ // resized would pick this up for free; the double-click-reset column must
1822
+ // too, because the reset should have removed its override rather than
1823
+ // freezing it at 150.
1824
+ const resizedDeclaredColumns: ColumnDef<Row>[] = [
1825
+ { accessorKey: "name", header: "Name", size: 300 },
1826
+ { accessorKey: "value", header: "Value", size: 100 },
1827
+ ];
1828
+ rerender(<DataTable columns={resizedDeclaredColumns} data={data} enableColumnResizing />);
1829
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("300px");
1830
+ });
1831
+
1832
+ it("seeds an uncontrolled slice once from initialView.columnSizing", () => {
1833
+ const { container } = render(
1834
+ <DataTable
1835
+ columns={resizableColumns}
1836
+ data={data}
1837
+ enableColumnResizing
1838
+ initialView={{ columnSizing: { name: 300 } }}
1839
+ />,
1840
+ );
1841
+ expect(container.querySelector<HTMLElement>("thead th")!.style.width).toBe("300px");
1842
+ });
1843
+
1844
+ it("composes with column pinning — a pinned column's sticky offset already tracks getSize()", () => {
1845
+ let table: TanstackTable<Row> | undefined;
1846
+ const { container } = render(
1847
+ <DataTable
1848
+ columns={pinnableColumns}
1849
+ data={data}
1850
+ enableColumnResizing
1851
+ columnPinning={{ left: ["name", "value"] }}
1852
+ toolbar={(t) => {
1853
+ table = t;
1854
+ return null;
1855
+ }}
1856
+ />,
1857
+ );
1858
+ const pinnedHeaders = Array.from(
1859
+ container.querySelectorAll<HTMLElement>("th[data-pinned='left']"),
1860
+ );
1861
+ // Before resizing: `value` (the second pinned column) sits at the declared
1862
+ // size of `name` (150) per the #333 offset arithmetic.
1863
+ expect(pinnedHeaders[1]!.style.left).toBe("150px");
1864
+ act(() => table!.setColumnSizing((old) => ({ ...old, name: 210 })));
1865
+ // After resizing `name` to 210, `value`'s sticky offset follows it —
1866
+ // proving pinning already composes with resizing via `column.getSize()`,
1867
+ // with no changes needed on the pinning side.
1868
+ expect(pinnedHeaders[1]!.style.left).toBe("210px");
1869
+ });
1870
+
1871
+ // #51 — the handle's hit box was `w-2` (8px), well under any recognized
1872
+ // touch-target minimum, and it SHRANK further under `data-density="compact"`
1873
+ // because `w-2` compiles to `calc(var(--spacing) * 2)` and density rescales
1874
+ // `--spacing`. `w-[min(24px,50%)]` is a literal pixel value clamped to half
1875
+ // the header cell, so it is density-independent by construction. jsdom can
1876
+ // only assert the class is present — the actual rendered geometry (and the
1877
+ // compact-density case specifically) is measured in a real browser by the
1878
+ // `WithColumnResizingCompactDensity` story's play function.
1879
+ it("gives the resize handle a density-independent, wider-than-8px hit box class", () => {
1880
+ const { container } = render(
1881
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />,
1882
+ );
1883
+ const handle = container.querySelector('[data-slot="data-table-resize-handle"]')!;
1884
+ // Split into literal Tailwind class tokens so a variant-prefixed sibling
1885
+ // (`hover:after:w-2` / `focus-visible:after:w-2`, the DRAWN seam width —
1886
+ // deliberately unchanged) can't masquerade as the base hit-box class.
1887
+ const classes = handle.className.split(/\s+/);
1888
+ expect(classes).toContain("w-[min(24px,50%)]");
1889
+ expect(classes).not.toContain("w-2");
1890
+ });
1891
+ });
1892
+
1893
+ // ─── #82 gap 2: resize handle vs the header's own controls ───────────────────
1894
+ // The resize handle's 24px hit box (`w-[min(24px,50%)]`, absolutely positioned
1895
+ // at the header cell's trailing edge) sits over part of the SAME header cell a
1896
+ // sortable column's toggle button occupies. jsdom dispatches `fireEvent.click`
1897
+ // straight to the target node with no real hit-testing, so this test can only
1898
+ // prove the button still RESPONDS to a click addressed to it — it cannot prove
1899
+ // a real screen click at the button's own on-screen coordinates lands on the
1900
+ // button rather than the overlapping handle. That geometric claim is covered
1901
+ // by the `WithColumnResizingSortToggleHitTest` story's play function (real
1902
+ // browser, coordinate-targeted click) alongside this regression lock.
1903
+ describe("DataTable — #82 resize handle vs header's own controls", () => {
1904
+ it("the sort-toggle button stays clickable when the column is ALSO resizable (24px handle present)", () => {
1905
+ const onSortingChange = vi.fn();
1906
+ render(
1907
+ <DataTable
1908
+ columns={resizableColumns}
1909
+ data={data}
1910
+ enableColumnResizing
1911
+ sorting={[]}
1912
+ onSortingChange={onSortingChange}
1913
+ />,
1914
+ );
1915
+ // Column is both resizable (prop) and sortable (resizableColumns sets no
1916
+ // `enableSorting: false`, and the table applies no table-wide override —
1917
+ // TanStack's own default is `true`), so the handle and the sort button
1918
+ // are both present on the same header cell.
1919
+ expect(screen.getByRole("separator", { name: /Resize column, Name/i })).toBeInTheDocument();
1920
+ const sortButton = screen.getByRole("button", { name: /Sort by Name/i });
1921
+ fireEvent.click(sortButton);
1922
+ expect(onSortingChange).toHaveBeenCalledTimes(1);
1923
+ });
1924
+ });
1925
+
1926
+ // #12 code-review finding (P1): the resize handle sits at the column's logical
1927
+ // `end` edge (`end-0`), which renders on the physical LEFT under `dir="rtl"` —
1928
+ // but TanStack's own pointer-drag math and the hand-rolled keyboard path both
1929
+ // default to LTR unless told the active direction, so dragging/pressing an
1930
+ // arrow moved the width opposite the visible boundary. Fixed by threading
1931
+ // `useLocale().dir` into `columnResizeDirection` (pointer path) and reversing
1932
+ // the keyboard delta.
1933
+ describe('DataTable — #12 review P1: column resizing under dir="rtl"', () => {
1934
+ it("reverses the keyboard resize delta — ArrowRight shrinks, ArrowLeft grows", () => {
1935
+ render(
1936
+ <LocaleProvider dir="rtl">
1937
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />
1938
+ </LocaleProvider>,
1939
+ );
1940
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1941
+ expect(handle).toHaveAttribute("aria-valuenow", "150");
1942
+
1943
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
1944
+ expect(handle).toHaveAttribute("aria-valuenow", "140");
1945
+
1946
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
1947
+ fireEvent.keyDown(handle, { key: "ArrowLeft" });
1948
+ expect(handle).toHaveAttribute("aria-valuenow", "160");
1949
+ });
1950
+
1951
+ it("reverses the pointer-drag resize direction (columnResizeDirection wired to useReactTable)", () => {
1952
+ const { container } = render(
1953
+ <LocaleProvider dir="rtl">
1954
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />
1955
+ </LocaleProvider>,
1956
+ );
1957
+ const th = container.querySelector<HTMLElement>("thead th")!;
1958
+ expect(th.style.width).toBe("150px");
1959
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1960
+ // Same drag as the LTR pointer-drag test above (0 → 40, which GROWS the
1961
+ // column to 190px there) — under RTL it must SHRINK instead.
1962
+ fireEvent.mouseDown(handle, { clientX: 0 });
1963
+ fireEvent.mouseMove(document, { clientX: 40 });
1964
+ fireEvent.mouseUp(document, { clientX: 40 });
1965
+ expect(th.style.width).toBe("110px");
1966
+ });
1967
+
1968
+ it("keyboard and pointer resizing stay in agreement under RTL (never diverge)", () => {
1969
+ const { container } = render(
1970
+ <LocaleProvider dir="rtl">
1971
+ <DataTable columns={resizableColumns} data={data} enableColumnResizing />
1972
+ </LocaleProvider>,
1973
+ );
1974
+ const th = container.querySelector<HTMLElement>("thead th")!;
1975
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1976
+ fireEvent.keyDown(handle, { key: "ArrowLeft" }); // grows under RTL
1977
+ expect(th.style.width).toBe("160px");
1978
+ fireEvent.mouseDown(handle, { clientX: 0 });
1979
+ fireEvent.mouseMove(document, { clientX: -20 }); // physical-left drag also grows
1980
+ fireEvent.mouseUp(document, { clientX: -20 });
1981
+ expect(th.style.width).toBe("180px");
1982
+ });
1983
+ });
1984
+
1985
+ // #12 code-review finding (P2): a resizable column with no explicit `maxSize`
1986
+ // omitted `aria-valuemax` entirely, so WAI-ARIA's implicit default of 100
1987
+ // applied — a column at its ordinary 150px starting width announced as
1988
+ // "150 of 100", out of its own stated range. Fixed by always supplying a
1989
+ // numeric ceiling that contains the live value.
1990
+ describe("DataTable — #12 review P2: resize separator aria-valuemax stays in range", () => {
1991
+ it("supplies an explicit aria-valuemax containing the current size when the column declares no maxSize", () => {
1992
+ render(<DataTable columns={resizableColumns} data={data} enableColumnResizing />);
1993
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
1994
+ expect(handle).toHaveAttribute("aria-valuenow", "150");
1995
+ const max = Number(handle.getAttribute("aria-valuemax"));
1996
+ expect(Number.isFinite(max)).toBe(true);
1997
+ expect(max).toBeGreaterThanOrEqual(150);
1998
+ });
1999
+
2000
+ it("keeps raising the announced ceiling as the column grows past it", () => {
2001
+ const wideColumns: ColumnDef<Row>[] = [
2002
+ { accessorKey: "name", header: "Name", size: 2500 },
2003
+ { accessorKey: "value", header: "Value", size: 100 },
2004
+ ];
2005
+ render(<DataTable columns={wideColumns} data={data} enableColumnResizing />);
2006
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
2007
+ expect(handle).toHaveAttribute("aria-valuenow", "2500");
2008
+ const max = Number(handle.getAttribute("aria-valuemax"));
2009
+ expect(max).toBeGreaterThanOrEqual(2500);
2010
+ });
2011
+
2012
+ it("still honors an explicit columnDef.maxSize unchanged", () => {
2013
+ const cappedColumns: ColumnDef<Row>[] = [
2014
+ { accessorKey: "name", header: "Name", size: 150, maxSize: 300 },
2015
+ { accessorKey: "value", header: "Value", size: 100 },
2016
+ ];
2017
+ render(<DataTable columns={cappedColumns} data={data} enableColumnResizing />);
2018
+ const handle = screen.getByRole("separator", { name: /Resize column, Name/i });
2019
+ expect(handle).toHaveAttribute("aria-valuemax", "300");
2020
+ });
2021
+ });
2022
+
2023
+ // ─── #11: row selection ───────────────────────────────────────────────────────
2024
+
2025
+ const selectableColumns: ColumnDef<Row>[] = [createSelectionColumn<Row>(), ...columns];
2026
+
2027
+ function selectAllCheckbox(container: HTMLElement): HTMLElement {
2028
+ const el = container.querySelector('thead [data-slot="data-table-select-all"]');
2029
+ if (!el) throw new Error("select-all checkbox not found");
2030
+ return el as HTMLElement;
2031
+ }
2032
+
2033
+ function rowCheckboxes(container: HTMLElement): HTMLElement[] {
2034
+ return Array.from(container.querySelectorAll('tbody [data-slot="data-table-select-cell"]'));
2035
+ }
2036
+
2037
+ describe("DataTable — #11 row selection: uncontrolled", () => {
2038
+ it("select-all toggles every row, and the header itself reports checked", () => {
2039
+ const { container } = render(<DataTable columns={selectableColumns} data={data} />);
2040
+ fireEvent.click(selectAllCheckbox(container));
2041
+ for (const tr of container.querySelectorAll("tbody tr")) {
2042
+ expect(tr).toHaveAttribute("data-state", "selected");
2043
+ }
2044
+ expect(selectAllCheckbox(container)).toHaveAttribute("data-state", "checked");
2045
+
2046
+ // Toggling again clears every row.
2047
+ fireEvent.click(selectAllCheckbox(container));
2048
+ for (const tr of container.querySelectorAll("tbody tr")) {
2049
+ expect(tr).not.toHaveAttribute("data-state", "selected");
2050
+ }
2051
+ });
2052
+
2053
+ it("a per-row toggle updates only that row", () => {
2054
+ const { container } = render(<DataTable columns={selectableColumns} data={data} />);
2055
+ fireEvent.click(rowCheckboxes(container)[1]!); // Beta
2056
+ const trs = Array.from(container.querySelectorAll("tbody tr"));
2057
+ expect(trs[0]).not.toHaveAttribute("data-state", "selected");
2058
+ expect(trs[1]).toHaveAttribute("data-state", "selected");
2059
+ expect(trs[2]).not.toHaveAttribute("data-state", "selected");
2060
+ });
2061
+ });
2062
+
2063
+ describe("DataTable — #11 row selection: controlled", () => {
2064
+ it("never mutates its own state when controlled — it re-renders from the prop", () => {
2065
+ const onRowSelectionChange = vi.fn();
2066
+ const { container, rerender } = render(
2067
+ <DataTable
2068
+ columns={selectableColumns}
2069
+ data={data}
2070
+ rowSelection={{}}
2071
+ onRowSelectionChange={onRowSelectionChange}
2072
+ />,
2073
+ );
2074
+ fireEvent.click(rowCheckboxes(container)[0]!);
2075
+ expect(onRowSelectionChange).toHaveBeenCalledTimes(1);
2076
+ // Controlled: the prop hasn't moved, so the DOM must not have either.
2077
+ expect(container.querySelectorAll('tbody tr[data-state="selected"]')).toHaveLength(0);
2078
+
2079
+ rerender(
2080
+ <DataTable
2081
+ columns={selectableColumns}
2082
+ data={data}
2083
+ rowSelection={{ "0": true }}
2084
+ onRowSelectionChange={onRowSelectionChange}
2085
+ />,
2086
+ );
2087
+ const trs = Array.from(container.querySelectorAll("tbody tr"));
2088
+ expect(trs[0]).toHaveAttribute("data-state", "selected");
2089
+ expect(trs[1]).not.toHaveAttribute("data-state", "selected");
2090
+ });
2091
+ });
2092
+
2093
+ describe("DataTable — #11 select-all / indeterminate", () => {
2094
+ it("reports indeterminate for a partial selection, checked once every row is selected", () => {
2095
+ const { container } = render(<DataTable columns={selectableColumns} data={data} />);
2096
+ const header = selectAllCheckbox(container);
2097
+ expect(header).toHaveAttribute("data-state", "unchecked");
2098
+ expect(header).toHaveAttribute("aria-checked", "false");
2099
+
2100
+ fireEvent.click(rowCheckboxes(container)[0]!);
2101
+ expect(header).toHaveAttribute("data-state", "indeterminate");
2102
+ expect(header).toHaveAttribute("aria-checked", "mixed");
2103
+
2104
+ fireEvent.click(rowCheckboxes(container)[1]!);
2105
+ fireEvent.click(rowCheckboxes(container)[2]!);
2106
+ expect(header).toHaveAttribute("data-state", "checked");
2107
+ expect(header).toHaveAttribute("aria-checked", "true");
2108
+ });
2109
+ });
2110
+
2111
+ describe("DataTable — #11 row selection is a controlled/uncontrolled slice", () => {
2112
+ it("seeds an uncontrolled slice once from initialView.rowSelection", () => {
2113
+ const { container } = render(
2114
+ <DataTable
2115
+ columns={selectableColumns}
2116
+ data={data}
2117
+ initialView={{ rowSelection: { "1": true } }}
2118
+ />,
2119
+ );
2120
+ const trs = Array.from(container.querySelectorAll("tbody tr"));
2121
+ expect(trs[0]).not.toHaveAttribute("data-state", "selected");
2122
+ expect(trs[1]).toHaveAttribute("data-state", "selected");
2123
+ expect(trs[2]).not.toHaveAttribute("data-state", "selected");
2124
+ });
2125
+
2126
+ it("keeps selection out of the server-change payload — it is layout, not a query", () => {
2127
+ const onServerChange = vi.fn();
2128
+ const { container } = render(
2129
+ <DataTable
2130
+ columns={selectableColumns}
2131
+ data={data}
2132
+ manualSorting
2133
+ manualFiltering
2134
+ manualPagination
2135
+ rowCount={3}
2136
+ onServerChange={onServerChange}
2137
+ />,
2138
+ );
2139
+ fireEvent.click(rowCheckboxes(container)[0]!);
2140
+ expect(onServerChange).not.toHaveBeenCalled();
2141
+ });
2142
+ });
2143
+
2144
+ describe("DataTable — #11 a client-side sort never disturbs selection identity (with or without getRowId)", () => {
2145
+ // TanStack's default row id is assigned ONCE per row object when the core
2146
+ // row model is built, then reused by reference through the sorted row
2147
+ // model — sorting reorders which `Row` objects appear where, it never
2148
+ // reassigns their ids. So this holds identically with `getRowId` supplied
2149
+ // or omitted; it is NOT evidence that `getRowId` did anything (#11 I1 — the
2150
+ // discriminating case is the `data` object-replacement describe below).
2151
+ function expectSortPreservesSelection(getRowId: ((row: Row) => string) | undefined) {
2152
+ const { container } = render(
2153
+ <DataTable columns={selectableColumns} data={data} getRowId={getRowId} />,
2154
+ );
2155
+ // Initial order: Alpha, Beta, Gamma — select Beta (index 1).
2156
+ fireEvent.click(rowCheckboxes(container)[1]!);
2157
+ expect(Array.from(container.querySelectorAll("tbody tr"))[1]).toHaveTextContent("Beta");
2158
+ expect(Array.from(container.querySelectorAll("tbody tr"))[1]).toHaveAttribute(
2159
+ "data-state",
2160
+ "selected",
2161
+ );
2162
+
2163
+ // Sort by Value ascending: Beta(1), Gamma(2), Alpha(3) — Beta moves to index 0.
2164
+ fireEvent.click(screen.getByRole("button", { name: "Sort by Value, not sorted" }));
2165
+ const trs = Array.from(container.querySelectorAll("tbody tr"));
2166
+ const betaRow = trs.find((tr) => tr.textContent?.includes("Beta"));
2167
+ expect(betaRow).toHaveAttribute("data-state", "selected");
2168
+ for (const tr of trs) {
2169
+ if (tr !== betaRow) expect(tr).not.toHaveAttribute("data-state", "selected");
2170
+ }
2171
+ }
2172
+
2173
+ it("keeps selection attached to the right row across a sort, WITH getRowId", () => {
2174
+ expectSortPreservesSelection((row: Row) => row.name);
2175
+ });
2176
+
2177
+ it("keeps selection attached to the right row across a sort, WITHOUT getRowId too", () => {
2178
+ expectSortPreservesSelection(undefined);
2179
+ });
2180
+ });
2181
+
2182
+ describe("DataTable — #11 getRowId keeps selection keyed to a stable id, not row index", () => {
2183
+ it("WITHOUT getRowId, a data prop replacement re-keys selection by index, not identity (negative control)", () => {
2184
+ const { container, rerender } = render(<DataTable columns={selectableColumns} data={data} />);
2185
+ fireEvent.click(rowCheckboxes(container)[1]!); // select Beta (index 1)
2186
+ expect(Array.from(container.querySelectorAll("tbody tr"))[1]).toHaveAttribute(
2187
+ "data-state",
2188
+ "selected",
2189
+ );
2190
+
2191
+ // Same new-object-reference reorder as the positive case below, but with
2192
+ // no `getRowId` — the default index-based id means the "selected" id (1)
2193
+ // now belongs to whatever row the new array put at index 1: Alpha, not
2194
+ // Beta. This is the exact footgun `getRowId` exists to prevent.
2195
+ const reordered: Row[] = [
2196
+ { name: "Gamma", value: 2 },
2197
+ { name: "Alpha", value: 3 },
2198
+ { name: "Beta", value: 1 },
2199
+ ];
2200
+ rerender(<DataTable columns={selectableColumns} data={reordered} />);
2201
+ const trs = Array.from(container.querySelectorAll("tbody tr"));
2202
+ const alphaRow = trs.find((tr) => tr.textContent?.includes("Alpha"));
2203
+ const betaRow = trs.find((tr) => tr.textContent?.includes("Beta"));
2204
+ expect(alphaRow).toHaveAttribute("data-state", "selected");
2205
+ expect(betaRow).not.toHaveAttribute("data-state", "selected");
2206
+ });
2207
+
2208
+ it("survives a data prop replacement with new object references, same ids", () => {
2209
+ const { container, rerender } = render(
2210
+ <DataTable columns={selectableColumns} data={data} getRowId={(row: Row) => row.name} />,
2211
+ );
2212
+ fireEvent.click(rowCheckboxes(container)[1]!); // select Beta
2213
+ expect(Array.from(container.querySelectorAll("tbody tr"))[1]).toHaveAttribute(
2214
+ "data-state",
2215
+ "selected",
2216
+ );
2217
+
2218
+ // A brand-new `data` array — new object references, reordered — the shape a
2219
+ // re-fetch would hand back. Without a stable id, TanStack would key
2220
+ // selection by array index and "select" whatever object now sits at index 1
2221
+ // (this fixture's whole point: Gamma) instead of Beta.
2222
+ const reordered: Row[] = [
2223
+ { name: "Gamma", value: 2 },
2224
+ { name: "Alpha", value: 3 },
2225
+ { name: "Beta", value: 1 },
2226
+ ];
2227
+ rerender(
2228
+ <DataTable columns={selectableColumns} data={reordered} getRowId={(row: Row) => row.name} />,
2229
+ );
2230
+ const trs = Array.from(container.querySelectorAll("tbody tr"));
2231
+ const betaRow = trs.find((tr) => tr.textContent?.includes("Beta"));
2232
+ expect(betaRow).toHaveAttribute("data-state", "selected");
2233
+ for (const tr of trs) {
2234
+ if (tr !== betaRow) expect(tr).not.toHaveAttribute("data-state", "selected");
2235
+ }
2236
+ });
2237
+ });
2238
+
2239
+ describe("DataTable — #11 selection survives row virtualization windowing", () => {
2240
+ it("keeps a selected row in the selection MODEL even when its <tr> isn't mounted", () => {
2241
+ const manyRows: Row[] = Array.from({ length: 200 }, (_, i) => ({ name: `Row ${i}`, value: i }));
2242
+ let table: TanstackTable<Row> | undefined;
2243
+ const { container } = render(
2244
+ <DataTable
2245
+ columns={selectableColumns}
2246
+ data={manyRows}
2247
+ getRowId={(row) => row.name}
2248
+ enableRowVirtualization
2249
+ estimateRowHeight={32}
2250
+ maxBodyHeight="200px"
2251
+ toolbar={(t) => {
2252
+ table = t;
2253
+ return null;
2254
+ }}
2255
+ />,
2256
+ );
2257
+ // jsdom reports zero client height, so only a handful of rows mount near
2258
+ // the top — "Row 150" is well outside that window.
2259
+ expect(container.querySelector('tr[data-index="150"]')).toBeNull();
2260
+
2261
+ act(() => table!.getRow("Row 150")!.toggleSelected(true));
2262
+
2263
+ expect(table!.getSelectedRowModel().rows.map((r) => r.id)).toEqual(["Row 150"]);
2264
+ });
2265
+ });
2266
+
2267
+ describe("DataTable — #11 C1: enableMultiRowSelection={false} suppresses the select-all header", () => {
2268
+ it("renders no select-all checkbox in single-select mode", () => {
2269
+ const { container } = render(
2270
+ <DataTable columns={selectableColumns} data={data} enableMultiRowSelection={false} />,
2271
+ );
2272
+ expect(container.querySelector('thead [data-slot="data-table-select-all"]')).toBeNull();
2273
+ // The per-row checkboxes are unaffected.
2274
+ expect(rowCheckboxes(container)).toHaveLength(data.length);
2275
+ });
2276
+
2277
+ it("selecting a second row deselects the first (single-select semantics)", () => {
2278
+ const { container } = render(
2279
+ <DataTable columns={selectableColumns} data={data} enableMultiRowSelection={false} />,
2280
+ );
2281
+ fireEvent.click(rowCheckboxes(container)[0]!); // Alpha
2282
+ expect(Array.from(container.querySelectorAll("tbody tr"))[0]).toHaveAttribute(
2283
+ "data-state",
2284
+ "selected",
2285
+ );
2286
+
2287
+ fireEvent.click(rowCheckboxes(container)[1]!); // Beta
2288
+ const trs = Array.from(container.querySelectorAll("tbody tr"));
2289
+ expect(trs[0]).not.toHaveAttribute("data-state", "selected");
2290
+ expect(trs[1]).toHaveAttribute("data-state", "selected");
2291
+ });
2292
+ });
2293
+
2294
+ describe("DataTable — #11 I5: enableRowSelection restricts which rows can be selected", () => {
2295
+ it("disables the checkbox for rows the predicate excludes", () => {
2296
+ const { container } = render(
2297
+ <DataTable
2298
+ columns={selectableColumns}
2299
+ data={data}
2300
+ enableRowSelection={(row) => row.original.value !== 1}
2301
+ />,
2302
+ );
2303
+ const checkboxes = rowCheckboxes(container);
2304
+ // data[0] = Alpha/value 3, data[1] = Beta/value 1 (excluded), data[2] = Gamma/value 2.
2305
+ expect(checkboxes[0]).not.toHaveAttribute("data-disabled");
2306
+ expect(checkboxes[1]).toHaveAttribute("data-disabled");
2307
+ expect(checkboxes[2]).not.toHaveAttribute("data-disabled");
2308
+
2309
+ // Clicking the disabled checkbox does not select its row.
2310
+ fireEvent.click(checkboxes[1]!);
2311
+ expect(Array.from(container.querySelectorAll("tbody tr"))[1]).not.toHaveAttribute(
2312
+ "data-state",
2313
+ "selected",
2314
+ );
2315
+ });
2316
+
2317
+ it("disables every row's checkbox when enableRowSelection is false", () => {
2318
+ const { container } = render(
2319
+ <DataTable columns={selectableColumns} data={data} enableRowSelection={false} />,
2320
+ );
2321
+ for (const checkbox of rowCheckboxes(container)) {
2322
+ expect(checkbox).toHaveAttribute("data-disabled");
2323
+ }
2324
+ });
2325
+ });
2326
+
2327
+ describe("DataTable — #11 I4: each row checkbox gets a distinguishing accessible name", () => {
2328
+ it("names every row's checkbox from its own data, not an identical generic label", () => {
2329
+ render(<DataTable columns={selectableColumns} data={data} />);
2330
+ expect(screen.getByRole("checkbox", { name: "Select Alpha" })).toBeInTheDocument();
2331
+ expect(screen.getByRole("checkbox", { name: "Select Beta" })).toBeInTheDocument();
2332
+ expect(screen.getByRole("checkbox", { name: "Select Gamma" })).toBeInTheDocument();
2333
+ });
2334
+
2335
+ it("falls back to the generic name when no data column value is derivable", () => {
2336
+ const allDisplayColumns: ColumnDef<Row>[] = [
2337
+ createSelectionColumn<Row>(),
2338
+ { id: "avatar", header: "Avatar", cell: () => <span aria-hidden="true">◆</span> },
2339
+ ];
2340
+ const { container } = render(<DataTable columns={allDisplayColumns} data={data} />);
2341
+ expect(rowCheckboxes(container)).toHaveLength(data.length);
2342
+ expect(screen.getAllByRole("checkbox", { name: "Select row" })).toHaveLength(data.length);
2343
+ });
2344
+ });
2345
+
2346
+ // ─── #69: columnDef.meta numeric-column seam ─────────────────────────────────
2347
+
2348
+ describe("DataTable — #69 columnDef.meta numeric column seam", () => {
2349
+ const metaColumns: ColumnDef<Row>[] = [
2350
+ { accessorKey: "name", header: "Name" },
2351
+ { accessorKey: "value", header: "Value", meta: { numeric: true } },
2352
+ ];
2353
+
2354
+ it("applies tabular-nums + end-alignment to the numeric column's <th> AND <td>, leaving the plain column unchanged", () => {
2355
+ render(<DataTable columns={metaColumns} data={data} />);
2356
+
2357
+ const headers = screen.getAllByRole("columnheader");
2358
+ const [nameHeader, valueHeader] = headers;
2359
+ // A column without `meta.numeric` stays the default: start-aligned, proportional.
2360
+ expect(nameHeader!.className).not.toContain("text-end");
2361
+ expect(nameHeader!.className).not.toContain("tabular-nums");
2362
+ // `meta.numeric` reaches the header too — a fix that only aligns the cells
2363
+ // and leaves the header start-aligned looks worse than no fix.
2364
+ expect(valueHeader!.className).toContain("text-end");
2365
+ expect(valueHeader!.className).toContain("tabular-nums");
2366
+
2367
+ const cells = screen.getAllByRole("cell");
2368
+ // First data row's pair, in column order: [name, value].
2369
+ const [nameCell, valueCell] = cells;
2370
+ expect(nameCell!.className).not.toContain("text-end");
2371
+ expect(nameCell!.className).not.toContain("tabular-nums");
2372
+ expect(valueCell!.className).toContain("text-end");
2373
+ expect(valueCell!.className).toContain("tabular-nums");
2374
+ });
2375
+
2376
+ it("mirrors the same numeric alignment on the loading skeleton, so no column shifts when data arrives", () => {
2377
+ const { container } = render(
2378
+ <DataTable columns={metaColumns} data={[]} loading loadingRows={1} />,
2379
+ );
2380
+ const skeletonCells = container.querySelectorAll<HTMLElement>(
2381
+ 'tbody tr[aria-hidden="true"] td',
2382
+ );
2383
+ expect(skeletonCells).toHaveLength(2);
2384
+ const [nameSkeleton, valueSkeleton] = skeletonCells;
2385
+ expect(nameSkeleton!.className).not.toContain("text-end");
2386
+ expect(valueSkeleton!.className).toContain("text-end");
2387
+ expect(valueSkeleton!.className).toContain("tabular-nums");
2388
+ });
2389
+
2390
+ it("meta.align alone controls alignment without pulling in tabular-nums", () => {
2391
+ const alignColumns: ColumnDef<Row>[] = [
2392
+ { accessorKey: "name", header: "Name", meta: { align: "center" } },
2393
+ { accessorKey: "value", header: "Value" },
2394
+ ];
2395
+ render(<DataTable columns={alignColumns} data={data} />);
2396
+ const [nameHeader] = screen.getAllByRole("columnheader");
2397
+ expect(nameHeader!.className).toContain("text-center");
2398
+ expect(nameHeader!.className).not.toContain("tabular-nums");
2399
+ });
2400
+ });
2401
+
2402
+ // ─── #13: row drag-reorder ───────────────────────────────────────────────────
2403
+
2404
+ describe("DataTable — #13 row drag-reorder", () => {
2405
+ /** Every mounted data `<tr>`, in DOM order. */
2406
+ function bodyRows(container: HTMLElement): HTMLTableRowElement[] {
2407
+ return Array.from(container.querySelectorAll<HTMLTableRowElement>("tbody tr"));
2408
+ }
2409
+
2410
+ /**
2411
+ * `sortableKeyboardCoordinates` resolves an arrow-key move by comparing
2412
+ * `getBoundingClientRect()` of the sortable `<tr>`s — jsdom's default rect
2413
+ * is all-zero, so every row would collide at the same point. Stub distinct,
2414
+ * vertically-stacked rects keyed by each row's position in the DOM; any
2415
+ * other element (headers, buttons) falls back to the real implementation.
2416
+ */
2417
+ function mockRowRects() {
2418
+ const original = HTMLElement.prototype.getBoundingClientRect;
2419
+ return vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
2420
+ this: HTMLElement,
2421
+ ) {
2422
+ const table = this.closest("table");
2423
+ const rows = table ? Array.from(table.querySelectorAll("tbody tr")) : [];
2424
+ const index = rows.indexOf(this as HTMLTableRowElement);
2425
+ if (this.tagName !== "TR" || index === -1) {
2426
+ return original.call(this);
2427
+ }
2428
+ const top = index * 40;
2429
+ return {
2430
+ top,
2431
+ bottom: top + 40,
2432
+ left: 0,
2433
+ right: 200,
2434
+ width: 200,
2435
+ height: 40,
2436
+ x: 0,
2437
+ y: top,
2438
+ toJSON() {
2439
+ return {};
2440
+ },
2441
+ } as DOMRect;
2442
+ });
2443
+ }
2444
+
2445
+ /**
2446
+ * `KeyboardSensor.attach()` picks up the row SYNCHRONOUSLY, then defers
2447
+ * attaching its own document-level keydown listener (for the subsequent
2448
+ * move/drop/cancel keys) by one macrotask (`setTimeout(fn, 0)`). A move
2449
+ * key fired before that tick is silently dropped.
2450
+ */
2451
+ async function tick() {
2452
+ await act(async () => {
2453
+ await new Promise((resolve) => setTimeout(resolve, 0));
2454
+ });
2455
+ }
2456
+
2457
+ it("is opt-in — an existing table renders no grip column and no live region by default", () => {
2458
+ const { container } = render(<DataTable columns={columns} data={data} />);
2459
+ expect(screen.queryByRole("button", { name: /reorder/i })).not.toBeInTheDocument();
2460
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
2461
+ expect(bodyRows(container)).toHaveLength(3);
2462
+ });
2463
+
2464
+ it("renders a focusable grip handle per row with an accessible name (cell mode, the default)", () => {
2465
+ render(<DataTable columns={columns} data={data} enableRowReorder />);
2466
+ expect(screen.getByRole("button", { name: "Reorder Alpha" })).toBeInTheDocument();
2467
+ expect(screen.getByRole("button", { name: "Reorder Beta" })).toBeInTheDocument();
2468
+ expect(screen.getByRole("button", { name: "Reorder Gamma" })).toBeInTheDocument();
2469
+ });
2470
+
2471
+ it("warns once (dev) when enableRowReorder is combined with active sorting", () => {
2472
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
2473
+ try {
2474
+ const sorting: SortingState = [{ id: "name", desc: false }];
2475
+ const { rerender } = render(
2476
+ <DataTable columns={columns} data={data} enableRowReorder sorting={sorting} />,
2477
+ );
2478
+ expect(warnSpy).toHaveBeenCalledTimes(1);
2479
+ expect(warnSpy.mock.calls[0]?.[0]).toMatch(/enableRowReorder.*sort/is);
2480
+
2481
+ rerender(<DataTable columns={columns} data={data} enableRowReorder sorting={sorting} />);
2482
+ expect(warnSpy).toHaveBeenCalledTimes(1);
2483
+ } finally {
2484
+ warnSpy.mockRestore();
2485
+ }
2486
+ });
2487
+
2488
+ it("does NOT warn about sorting when sorting is empty", () => {
2489
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
2490
+ try {
2491
+ render(<DataTable columns={columns} data={data} enableRowReorder sorting={[]} />);
2492
+ expect(warnSpy).not.toHaveBeenCalled();
2493
+ } finally {
2494
+ warnSpy.mockRestore();
2495
+ }
2496
+ });
2497
+
2498
+ it("warns (dev) and disables reorder when combined with enableRowVirtualization", () => {
2499
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
2500
+ try {
2501
+ render(<DataTable columns={columns} data={data} enableRowReorder enableRowVirtualization />);
2502
+ expect(warnSpy).toHaveBeenCalledTimes(1);
2503
+ expect(warnSpy.mock.calls[0]?.[0]).toMatch(/enableRowReorder.*enableRowVirtualization/is);
2504
+ // Virtualization wins — no grip column, no live region.
2505
+ expect(screen.queryByRole("button", { name: /reorder/i })).not.toBeInTheDocument();
2506
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
2507
+ } finally {
2508
+ warnSpy.mockRestore();
2509
+ }
2510
+ });
2511
+
2512
+ /**
2513
+ * dnd-kit's own live region (`role="status" aria-live="assertive"`) is
2514
+ * still rendered — `DndContext`'s `accessibility` prop has no way to
2515
+ * remove it — but round-1 finding 4 silences its TEXT permanently, so
2516
+ * `getByRole("status")` would now match it AND DataTable's own region
2517
+ * ambiguously. Scope to the one this feature actually drives.
2518
+ */
2519
+ function getReorderLiveRegion(container: HTMLElement): HTMLElement {
2520
+ const region = container.querySelector('[data-slot="data-table-reorder-live-region"]');
2521
+ if (!region) throw new Error("reorder live region not found");
2522
+ return region as HTMLElement;
2523
+ }
2524
+
2525
+ it("supports the full keyboard flow — Space picks up, ArrowDown moves, Space drops, and onRowReorder fires with the new indices", async () => {
2526
+ const rectSpy = mockRowRects();
2527
+ const onRowReorder = vi.fn();
2528
+ try {
2529
+ const { container } = render(
2530
+ <DataTable columns={columns} data={data} enableRowReorder onRowReorder={onRowReorder} />,
2531
+ );
2532
+ const live = getReorderLiveRegion(container);
2533
+ // Round-1 finding 4 fix: the live region is `polite`, never `assertive`.
2534
+ expect(live).toHaveAttribute("aria-live", "polite");
2535
+ const handle = screen.getByRole("button", { name: "Reorder Alpha" });
2536
+
2537
+ fireEvent.keyDown(handle, { code: "Space" });
2538
+ // Round-1 finding 4 also fixed the race that used to make this
2539
+ // untestable: dnd-kit fires an immediate self-collision `onDragOver`
2540
+ // (over === active, same position) in the same synchronous batch as
2541
+ // `onDragStart`, which used to stomp this message before it was ever
2542
+ // observable. `handleRowDragOver` now seeds
2543
+ // `reorderLastAnnouncedPositionRef` from the pickup position and skips
2544
+ // a same-position re-fire, so "Picked up" is the actually-committed text.
2545
+ expect(live).toHaveTextContent("Picked up Alpha.");
2546
+ expect(handle).toHaveAttribute("aria-pressed", "true");
2547
+
2548
+ await tick();
2549
+
2550
+ fireEvent.keyDown(document, { code: "ArrowDown" });
2551
+ await tick();
2552
+ // A real, single-position move announces exactly once (not once per
2553
+ // keystroke merged with a no-op self-collision).
2554
+ expect(live).toHaveTextContent("Alpha moved to position 2 of 3.");
2555
+
2556
+ fireEvent.keyDown(document, { code: "Space" });
2557
+
2558
+ expect(onRowReorder).toHaveBeenCalledTimes(1);
2559
+ expect(onRowReorder).toHaveBeenCalledWith(0, 1, data[0]);
2560
+ expect(live).toHaveTextContent(/alpha dropped at position 2 of 3/i);
2561
+ expect(handle).not.toHaveAttribute("aria-pressed");
2562
+ } finally {
2563
+ rectSpy.mockRestore();
2564
+ }
2565
+ });
2566
+
2567
+ it("Escape cancels the drag — onRowReorder does not fire and the live region announces the cancellation", async () => {
2568
+ const rectSpy = mockRowRects();
2569
+ const onRowReorder = vi.fn();
2570
+ try {
2571
+ const { container } = render(
2572
+ <DataTable columns={columns} data={data} enableRowReorder onRowReorder={onRowReorder} />,
2573
+ );
2574
+ const live = getReorderLiveRegion(container);
2575
+ const handle = screen.getByRole("button", { name: "Reorder Beta" });
2576
+
2577
+ fireEvent.keyDown(handle, { code: "Space" });
2578
+ expect(live).toHaveTextContent("Picked up Beta.");
2579
+ expect(handle).toHaveAttribute("aria-pressed", "true");
2580
+
2581
+ await tick();
2582
+
2583
+ fireEvent.keyDown(document, { code: "Escape" });
2584
+
2585
+ expect(onRowReorder).not.toHaveBeenCalled();
2586
+ expect(live).toHaveTextContent(/reordering cancelled\. beta returned to position 2 of 3/i);
2587
+ expect(handle).not.toHaveAttribute("aria-pressed");
2588
+ } finally {
2589
+ rectSpy.mockRestore();
2590
+ }
2591
+ });
2592
+
2593
+ it("does not re-announce when an arrow key hits the list boundary (no real position change) — round-1 finding 4", async () => {
2594
+ const rectSpy = mockRowRects();
2595
+ try {
2596
+ const { container } = render(
2597
+ <DataTable columns={columns} data={data} enableRowReorder onRowReorder={vi.fn()} />,
2598
+ );
2599
+ const live = getReorderLiveRegion(container);
2600
+ const handle = screen.getByRole("button", { name: "Reorder Alpha" });
2601
+
2602
+ fireEvent.keyDown(handle, { code: "Space" });
2603
+ expect(live).toHaveTextContent("Picked up Alpha.");
2604
+ await tick();
2605
+
2606
+ // Alpha is already first — ArrowUp has nowhere to go, so dnd-kit
2607
+ // reports the SAME position again. That must not overwrite the
2608
+ // "Picked up" message with a redundant "moved to position 1" — a
2609
+ // screen-reader user gets one meaningful announcement, not a repeat.
2610
+ fireEvent.keyDown(document, { code: "ArrowUp" });
2611
+ await tick();
2612
+ expect(live).toHaveTextContent("Picked up Alpha.");
2613
+
2614
+ fireEvent.keyDown(document, { code: "Escape" });
2615
+ } finally {
2616
+ rectSpy.mockRestore();
2617
+ }
2618
+ });
2619
+
2620
+ it("resolves onRowReorder indices against the full `data` array, not the sorted VIEW — round-1 finding 1 (data corruption)", async () => {
2621
+ const rectSpy = mockRowRects();
2622
+ const onRowReorder = vi.fn();
2623
+ try {
2624
+ // Sorted desc by name: view order is Gamma, Beta, Alpha — i.e. the
2625
+ // FIRST rendered row is `data[2]`, not `data[0]`.
2626
+ const sorting: SortingState = [{ id: "name", desc: true }];
2627
+ render(
2628
+ <DataTable
2629
+ columns={columns}
2630
+ data={data}
2631
+ enableRowReorder
2632
+ onRowReorder={onRowReorder}
2633
+ sorting={sorting}
2634
+ onSortingChange={vi.fn()}
2635
+ />,
2636
+ );
2637
+ const handle = screen.getByRole("button", { name: "Reorder Gamma" });
2638
+ fireEvent.keyDown(handle, { code: "Space" });
2639
+ await tick();
2640
+ fireEvent.keyDown(document, { code: "ArrowDown" });
2641
+ await tick();
2642
+ fireEvent.keyDown(document, { code: "Space" });
2643
+
2644
+ // View-relative positions would report (0, 1, Gamma) — a caller doing
2645
+ // `arrayMove(data, 0, 1)` (the idiom both shipped stories use) would
2646
+ // then swap `data[0]`/`data[1]` (Alpha/Beta), touching neither row the
2647
+ // user actually dragged. Resolved against `data`, Gamma is `data[2]`
2648
+ // and the row it was dropped onto (Beta) is `data[1]`.
2649
+ expect(onRowReorder).toHaveBeenCalledTimes(1);
2650
+ expect(onRowReorder).toHaveBeenCalledWith(2, 1, data[2]);
2651
+ } finally {
2652
+ rectSpy.mockRestore();
2653
+ }
2654
+ });
2655
+
2656
+ it("resolves onRowReorder indices against the full `data` array under client-side pagination — round-1 finding 1 (data corruption)", async () => {
2657
+ const rectSpy = mockRowRects();
2658
+ const manyRows: Row[] = Array.from({ length: 10 }, (_, i) => ({
2659
+ name: `svc-${i}`,
2660
+ value: i,
2661
+ }));
2662
+ const onRowReorder = vi.fn();
2663
+ try {
2664
+ render(
2665
+ <DataTable
2666
+ columns={columns}
2667
+ data={manyRows}
2668
+ enableRowReorder
2669
+ onRowReorder={onRowReorder}
2670
+ enablePagination
2671
+ pageSize={5}
2672
+ pagination={{ pageIndex: 1, pageSize: 5 }}
2673
+ onPaginationChange={vi.fn()}
2674
+ />,
2675
+ );
2676
+ // Page 2 renders svc-5..svc-9 at VIEW positions 0..4.
2677
+ const handle = screen.getByRole("button", { name: "Reorder svc-5" });
2678
+ fireEvent.keyDown(handle, { code: "Space" });
2679
+ await tick();
2680
+ fireEvent.keyDown(document, { code: "ArrowDown" });
2681
+ await tick();
2682
+ fireEvent.keyDown(document, { code: "Space" });
2683
+
2684
+ // View-relative positions would report (0, 1, svc-5) — a caller doing
2685
+ // `arrayMove(data, 0, 1)` would corrupt svc-0/svc-1 on page 1, which
2686
+ // the user never touched. svc-5 is `data[5]`; the row it landed on
2687
+ // (svc-6) is `data[6]`.
2688
+ expect(onRowReorder).toHaveBeenCalledTimes(1);
2689
+ expect(onRowReorder).toHaveBeenCalledWith(5, 6, manyRows[5]);
2690
+ } finally {
2691
+ rectSpy.mockRestore();
2692
+ }
2693
+ });
2694
+
2695
+ it("keeps keyboard focus on the SAME row that moved after a drop, across a `data` array replacement — round-1 finding 3 (focus loss)", async () => {
2696
+ const rectSpy = mockRowRects();
2697
+ // Mirrors the exact "controlled slice" harness the `RowReorder` story
2698
+ // uses: `onRowReorder` re-orders the caller's OWN `data`, which means a
2699
+ // NEW array is passed back down on every drop — TanStack's default,
2700
+ // index-based row id gets reassigned by POSITION when that happens, so
2701
+ // this only fails without the round-1 stable-identity fix.
2702
+ function Harness() {
2703
+ const [items, setItems] = useState(data);
2704
+ return (
2705
+ <DataTable
2706
+ columns={columns}
2707
+ data={items}
2708
+ enableRowReorder
2709
+ onRowReorder={(from, to) => {
2710
+ setItems((current) => {
2711
+ const next = current.slice();
2712
+ const [moved] = next.splice(from, 1);
2713
+ next.splice(to, 0, moved!);
2714
+ return next;
2715
+ });
2716
+ }}
2717
+ />
2718
+ );
2719
+ }
2720
+ try {
2721
+ render(<Harness />);
2722
+ const handle = screen.getByRole("button", { name: "Reorder Alpha" });
2723
+ handle.focus();
2724
+ fireEvent.keyDown(handle, { code: "Space" });
2725
+ await tick();
2726
+ fireEvent.keyDown(document, { code: "ArrowDown" });
2727
+ await tick();
2728
+ fireEvent.keyDown(document, { code: "Space" });
2729
+ // dnd-kit's own focus-restore effect (`accessibility.restoreFocus`,
2730
+ // on by default) re-focuses the activator node via
2731
+ // `requestAnimationFrame` after the drop commits — give it a tick.
2732
+ await act(async () => {
2733
+ await new Promise((resolve) => requestAnimationFrame(resolve));
2734
+ });
2735
+
2736
+ // Alpha moved from view position 0 to 1. A POSITIONAL row id (reissued
2737
+ // when `data` is replaced by the splice above) would leave the grip
2738
+ // DOM NODE — and the focus it carries — at index 0, which now renders
2739
+ // Beta. This asserts focus followed the RECORD, not the slot.
2740
+ expect(document.activeElement).toHaveAccessibleName("Reorder Alpha");
2741
+ expect(screen.getAllByRole("button", { name: /^Reorder /i })[0]).toHaveAccessibleName(
2742
+ "Reorder Beta",
2743
+ );
2744
+ } finally {
2745
+ rectSpy.mockRestore();
2746
+ }
2747
+ });
2748
+
2749
+ // A jsdom pointer-drag unit test was attempted here and dropped: this
2750
+ // environment has no global `PointerEvent` constructor at all (verified
2751
+ // directly — `typeof PointerEvent === "undefined"`), so `PointerSensor`'s
2752
+ // own activator gate (`!event.isPrimary || event.button !== 0` in
2753
+ // `@dnd-kit/core`) rejects every synthetic pointerdown before dnd-kit does
2754
+ // anything else; no test-authoring fix changes that. Pointer drag is
2755
+ // dnd-kit's own well-tested mechanism (not new code this issue adds) and is
2756
+ // exercised for real in `data-table.stories.tsx`'s `RowReorder` story,
2757
+ // which runs in an actual browser via Storybook's interaction test runner.
2758
+ // The keyboard path above is the one this issue's `accessibility` label
2759
+ // makes mandatory, and it is covered without this gap.
2760
+
2761
+ it('rowReorderHandle="row" makes the whole row the activator instead of a grip column', () => {
2762
+ const { container } = render(
2763
+ <DataTable columns={columns} data={data} enableRowReorder rowReorderHandle="row" />,
2764
+ );
2765
+ // No dedicated grip button anywhere.
2766
+ expect(screen.queryByRole("button", { name: /reorder/i })).not.toBeInTheDocument();
2767
+
2768
+ const [firstRow] = bodyRows(container);
2769
+ // dnd-kit's default activator role is overridden back to the table's own
2770
+ // `row`, and `aria-pressed` (meaningless off a button) is stripped.
2771
+ expect(firstRow!.getAttribute("role")).toBe("row");
2772
+ expect(firstRow!.hasAttribute("aria-pressed")).toBe(false);
2773
+ expect(firstRow!.getAttribute("tabindex")).toBe("0");
2774
+ });
2775
+
2776
+ // ── Round-2 finding 6: a `data` array that repeats a record ───────────────
2777
+ // The SAME object reference at two positions (a record shown twice by
2778
+ // design, a list that only LOOKS de-duplicated) used to collapse onto its
2779
+ // FIRST index on drop: the drag-end handler resolved `from`/`to` through a
2780
+ // `Map` keyed by `row.original`, which can only hold one index per value.
2781
+ // A caller running this component's own documented `arrayMove(data, from,
2782
+ // to)` idiom then moved a row the user never touched, silently — no error,
2783
+ // no warning, no visual sign. Both identity paths are locked, because they
2784
+ // fail for different reasons: without `getRowId` the drag IDs themselves
2785
+ // collide, with it only the index lookup does.
2786
+ const repeatedRecord: Row = { name: "Alpha", value: 3 };
2787
+ const dataWithRepeat: Row[] = [repeatedRecord, { name: "Beta", value: 1 }, repeatedRecord];
2788
+
2789
+ it("gives each occurrence of a repeated record its own drag identity — round-2 finding 6", () => {
2790
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
2791
+ try {
2792
+ render(<DataTable columns={columns} data={dataWithRepeat} enableRowReorder />);
2793
+ // Both occurrences mount as real, separately addressable rows...
2794
+ expect(screen.getAllByRole("button", { name: "Reorder Alpha" })).toHaveLength(2);
2795
+ // ...and React sees two distinct keys, not one repeated one. A shared
2796
+ // key is the visible symptom of a shared dnd-kit id: one `<tr>`
2797
+ // registration for two rows, so the drop cannot tell them apart.
2798
+ const duplicateKeyWarnings = errorSpy.mock.calls.filter((call) =>
2799
+ String(call[0]).includes("same key"),
2800
+ );
2801
+ expect(duplicateKeyWarnings).toEqual([]);
2802
+ } finally {
2803
+ errorSpy.mockRestore();
2804
+ }
2805
+ });
2806
+
2807
+ it("reports the dragged occurrence's OWN index when `data` repeats a record — round-2 finding 6 (data corruption)", async () => {
2808
+ const rectSpy = mockRowRects();
2809
+ const onRowReorder = vi.fn();
2810
+ try {
2811
+ render(
2812
+ <DataTable
2813
+ columns={columns}
2814
+ data={dataWithRepeat}
2815
+ enableRowReorder
2816
+ onRowReorder={onRowReorder}
2817
+ />,
2818
+ );
2819
+ // The SECOND "Alpha" grip — the row rendered from `data[2]`.
2820
+ const handles = screen.getAllByRole("button", { name: "Reorder Alpha" });
2821
+ fireEvent.keyDown(handles[1]!, { code: "Space" });
2822
+ await tick();
2823
+ fireEvent.keyDown(document, { code: "ArrowUp" });
2824
+ await tick();
2825
+ fireEvent.keyDown(document, { code: "Space" });
2826
+
2827
+ // Value-keyed indices reported (0, 1, Alpha): `arrayMove(data, 0, 1)`
2828
+ // would swap `data[0]`/`data[1]`, leaving `data[2]` — the row actually
2829
+ // dragged — where it was. The dragged occupant is `data[2]`; the row it
2830
+ // landed on (Beta) is `data[1]`.
2831
+ expect(onRowReorder).toHaveBeenCalledTimes(1);
2832
+ expect(onRowReorder).toHaveBeenCalledWith(2, 1, repeatedRecord);
2833
+ } finally {
2834
+ rectSpy.mockRestore();
2835
+ }
2836
+ });
2837
+
2838
+ it("reports the dragged occurrence's OWN index when `data` repeats a record and the caller supplies `getRowId` — round-2 finding 6", async () => {
2839
+ const rectSpy = mockRowRects();
2840
+ const onRowReorder = vi.fn();
2841
+ try {
2842
+ render(
2843
+ <DataTable
2844
+ columns={columns}
2845
+ data={dataWithRepeat}
2846
+ enableRowReorder
2847
+ onRowReorder={onRowReorder}
2848
+ getRowId={(_row, index) => `row-${index}`}
2849
+ />,
2850
+ );
2851
+ // Caller-supplied ids already disambiguate the two occurrences, so this
2852
+ // isolates the index lookup itself.
2853
+ const handles = screen.getAllByRole("button", { name: "Reorder Alpha" });
2854
+ fireEvent.keyDown(handles[1]!, { code: "Space" });
2855
+ await tick();
2856
+ fireEvent.keyDown(document, { code: "ArrowUp" });
2857
+ await tick();
2858
+ fireEvent.keyDown(document, { code: "Space" });
2859
+
2860
+ expect(onRowReorder).toHaveBeenCalledTimes(1);
2861
+ expect(onRowReorder).toHaveBeenCalledWith(2, 1, repeatedRecord);
2862
+ } finally {
2863
+ rectSpy.mockRestore();
2864
+ }
2865
+ });
2866
+
2867
+ // ── Issue #98: dnd-kit's own AT strings (keyboard instructions,
2868
+ // aria-roledescription) were never localized — the six sibling `reorder*`
2869
+ // strings all go through `t()`, but these two are produced INSIDE dnd-kit
2870
+ // and injected into our DOM, so they shipped hardcoded English regardless
2871
+ // of locale. The German wrapper below supplies all three reorder keys that
2872
+ // touch AT-visible reorder text (`reorderHandle` for the grip's accessible
2873
+ // name, plus the two new `reorder*` keys this fix adds).
2874
+ const germanReorderMessages = {
2875
+ "data.table.reorderHandle": "Sortieren {name}",
2876
+ "data.table.reorderInstructions":
2877
+ "Um eine Zeile aufzunehmen, drücken Sie die Leertaste oder die Eingabetaste. Verwenden Sie beim Ziehen die Pfeiltasten, um die Zeile zu verschieben. Drücken Sie erneut die Leertaste oder die Eingabetaste, um die Zeile an ihrer neuen Position abzulegen, oder drücken Sie die Escape-Taste, um abzubrechen.",
2878
+ "data.table.reorderRoleDescription": "sortierbar",
2879
+ };
2880
+
2881
+ it("localizes dnd-kit's keyboard instructions and role description (cell mode) — #98", () => {
2882
+ render(
2883
+ <LocaleProvider locale="de-DE" messages={germanReorderMessages}>
2884
+ <DataTable columns={columns} data={data} enableRowReorder />
2885
+ </LocaleProvider>,
2886
+ );
2887
+ const handle = screen.getByRole("button", { name: "Sortieren Alpha" });
2888
+ expect(handle).toHaveAttribute("aria-roledescription", "sortierbar");
2889
+
2890
+ const describedById = handle.getAttribute("aria-describedby");
2891
+ expect(describedById).toBeTruthy();
2892
+ const describedByText = document.getElementById(describedById!)?.textContent ?? "";
2893
+ expect(describedByText).toContain("Leertaste");
2894
+ // Load-bearing negative assertion: dnd-kit's verbatim English default
2895
+ // instructions must NOT be present alongside (or instead of) the
2896
+ // localized text.
2897
+ expect(describedByText).not.toMatch(/To pick up|space bar/i);
2898
+ });
2899
+
2900
+ it('keeps role="row" while localizing the role description in row-handle mode — #98', () => {
2901
+ const { container } = render(
2902
+ <LocaleProvider locale="de-DE" messages={germanReorderMessages}>
2903
+ <DataTable columns={columns} data={data} enableRowReorder rowReorderHandle="row" />
2904
+ </LocaleProvider>,
2905
+ );
2906
+ const [firstRow] = bodyRows(container);
2907
+ expect(firstRow!.getAttribute("role")).toBe("row");
2908
+ expect(firstRow!.getAttribute("aria-roledescription")).toBe("sortierbar");
2909
+
2910
+ const describedById = firstRow!.getAttribute("aria-describedby");
2911
+ expect(describedById).toBeTruthy();
2912
+ const describedByText = document.getElementById(describedById!)?.textContent ?? "";
2913
+ expect(describedByText).toContain("Leertaste");
2914
+ expect(describedByText).not.toMatch(/To pick up|space bar/i);
2915
+ });
2916
+
2917
+ it("falls back to the English defaults with no LocaleProvider override — #98", () => {
2918
+ render(<DataTable columns={columns} data={data} enableRowReorder />);
2919
+ const handle = screen.getByRole("button", { name: "Reorder Alpha" });
2920
+ expect(handle).toHaveAttribute("aria-roledescription", "sortable");
2921
+
2922
+ const describedById = handle.getAttribute("aria-describedby");
2923
+ expect(describedById).toBeTruthy();
2924
+ const describedByText = document.getElementById(describedById!)?.textContent ?? "";
2925
+ expect(describedByText).toMatch(/To pick up|space bar/i);
2926
+ });
2927
+ });