@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.
- package/README.md +7 -8
- package/dist/index.d.ts +192 -3
- package/dist/index.js +710 -130
- package/dist/index.js.map +1 -1
- package/package.json +10 -7
- package/src/column-picker/column-picker.tsx +3 -1
- package/src/data-table/data-table.stories.tsx +481 -2
- package/src/data-table/data-table.test.tsx +1370 -6
- package/src/data-table/data-table.tsx +1231 -25
- package/src/data-table/index.ts +10 -0
- package/src/facet-filter/facet-filter.tsx +5 -1
- package/src/filter-bar/filter-chip.stories.tsx +144 -0
- package/src/filter-bar/filter-chip.test.tsx +137 -0
- package/src/filter-bar/filter-chip.tsx +92 -0
- package/src/filter-bar/index.ts +1 -0
- package/src/index.ts +10 -1
- package/src/search-input/search-input.stories.tsx +3 -0
- package/src/search-input/search-input.tsx +1 -1
- package/src/templates-data-app.stories.tsx +6 -4
|
@@ -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 {
|
|
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-
|
|
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-
|
|
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("
|
|
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\]:
|
|
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
|
+
});
|