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