@multiplatform.one/table 7.10.0 → 7.11.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.
Files changed (84) hide show
  1. package/README.md +60 -0
  2. package/dist/cjs/components/DataTable/DataTableCardControls.cjs +90 -54
  3. package/dist/cjs/components/DataTable/DataTableCardControls.native.js +93 -56
  4. package/dist/cjs/components/DataTable/DataTableCardControls.native.js.map +1 -1
  5. package/dist/cjs/components/DataTable/DataTableRoot.cjs +51 -8
  6. package/dist/cjs/components/DataTable/DataTableRoot.native.js +53 -9
  7. package/dist/cjs/components/DataTable/DataTableRoot.native.js.map +1 -1
  8. package/dist/cjs/components/DataTableFilterButton/index.cjs +14 -0
  9. package/dist/cjs/components/DataTableFilterButton/index.native.js +15 -0
  10. package/dist/cjs/components/DataTableFilterButton/index.native.js.map +1 -1
  11. package/dist/cjs/components/DataTableToolbar/index.cjs +178 -101
  12. package/dist/cjs/components/DataTableToolbar/index.native.js +187 -109
  13. package/dist/cjs/components/DataTableToolbar/index.native.js.map +1 -1
  14. package/dist/cjs/hooks/useDataTable.cjs +32 -1
  15. package/dist/cjs/hooks/useDataTable.native.js +37 -1
  16. package/dist/cjs/hooks/useDataTable.native.js.map +1 -1
  17. package/dist/esm/components/DataTable/DataTableCardControls.mjs +89 -54
  18. package/dist/esm/components/DataTable/DataTableCardControls.mjs.map +1 -1
  19. package/dist/esm/components/DataTable/DataTableCardControls.native.js +92 -56
  20. package/dist/esm/components/DataTable/DataTableCardControls.native.js.map +1 -1
  21. package/dist/esm/components/DataTable/DataTableRoot.mjs +53 -10
  22. package/dist/esm/components/DataTable/DataTableRoot.mjs.map +1 -1
  23. package/dist/esm/components/DataTable/DataTableRoot.native.js +55 -11
  24. package/dist/esm/components/DataTable/DataTableRoot.native.js.map +1 -1
  25. package/dist/esm/components/DataTableFilterButton/index.mjs +14 -1
  26. package/dist/esm/components/DataTableFilterButton/index.mjs.map +1 -1
  27. package/dist/esm/components/DataTableFilterButton/index.native.js +15 -1
  28. package/dist/esm/components/DataTableFilterButton/index.native.js.map +1 -1
  29. package/dist/esm/components/DataTableToolbar/index.mjs +180 -104
  30. package/dist/esm/components/DataTableToolbar/index.mjs.map +1 -1
  31. package/dist/esm/components/DataTableToolbar/index.native.js +189 -112
  32. package/dist/esm/components/DataTableToolbar/index.native.js.map +1 -1
  33. package/dist/esm/hooks/useDataTable.mjs +32 -1
  34. package/dist/esm/hooks/useDataTable.mjs.map +1 -1
  35. package/dist/esm/hooks/useDataTable.native.js +37 -1
  36. package/dist/esm/hooks/useDataTable.native.js.map +1 -1
  37. package/dist/jsx/components/DataTable/DataTableCardControls.mjs +89 -54
  38. package/dist/jsx/components/DataTable/DataTableCardControls.mjs.map +1 -1
  39. package/dist/jsx/components/DataTable/DataTableCardControls.native.js +93 -56
  40. package/dist/jsx/components/DataTable/DataTableCardControls.native.js.map +1 -1
  41. package/dist/jsx/components/DataTable/DataTableRoot.mjs +53 -10
  42. package/dist/jsx/components/DataTable/DataTableRoot.mjs.map +1 -1
  43. package/dist/jsx/components/DataTable/DataTableRoot.native.js +53 -9
  44. package/dist/jsx/components/DataTable/DataTableRoot.native.js.map +1 -1
  45. package/dist/jsx/components/DataTableFilterButton/index.mjs +14 -1
  46. package/dist/jsx/components/DataTableFilterButton/index.mjs.map +1 -1
  47. package/dist/jsx/components/DataTableFilterButton/index.native.js +15 -0
  48. package/dist/jsx/components/DataTableFilterButton/index.native.js.map +1 -1
  49. package/dist/jsx/components/DataTableToolbar/index.mjs +180 -104
  50. package/dist/jsx/components/DataTableToolbar/index.mjs.map +1 -1
  51. package/dist/jsx/components/DataTableToolbar/index.native.js +187 -109
  52. package/dist/jsx/components/DataTableToolbar/index.native.js.map +1 -1
  53. package/dist/jsx/hooks/useDataTable.mjs +32 -1
  54. package/dist/jsx/hooks/useDataTable.mjs.map +1 -1
  55. package/dist/jsx/hooks/useDataTable.native.js +37 -1
  56. package/dist/jsx/hooks/useDataTable.native.js.map +1 -1
  57. package/package.json +14 -14
  58. package/src/components/DataTable/DataTable.stories.tsx +59 -0
  59. package/src/components/DataTable/DataTableCardControls.stories.tsx +61 -2
  60. package/src/components/DataTable/DataTableCardControls.tsx +92 -66
  61. package/src/components/DataTable/DataTableRoot.tsx +82 -14
  62. package/src/components/DataTable/cardLayout.spec.tsx +4 -3
  63. package/src/components/DataTable/cardToolbar.spec.tsx +238 -0
  64. package/src/components/DataTable/chromeInterplay.spec.tsx +7 -5
  65. package/src/components/DataTable/columnWidthLayout.spec.tsx +189 -0
  66. package/src/components/DataTable/dataTableCardControls.spec.tsx +3 -2
  67. package/src/components/DataTable/footerLayout.spec.tsx +250 -0
  68. package/src/components/DataTable/pinningGeometry.spec.tsx +66 -1
  69. package/src/components/DataTableExport/DataTableExport.spec.tsx +42 -18
  70. package/src/components/DataTableFilterButton/index.tsx +9 -0
  71. package/src/components/DataTableToolbar/index.tsx +184 -105
  72. package/src/hooks/useDataTable.spec.ts +114 -0
  73. package/src/hooks/useDataTable.ts +23 -1
  74. package/src/types.ts +14 -12
  75. package/types/components/DataTable/DataTableCardControls.d.ts +3 -0
  76. package/types/components/DataTable/DataTableCardControls.d.ts.map +1 -1
  77. package/types/components/DataTable/DataTableRoot.d.ts.map +1 -1
  78. package/types/components/DataTableFilterButton/index.d.ts +2 -1
  79. package/types/components/DataTableFilterButton/index.d.ts.map +1 -1
  80. package/types/components/DataTableToolbar/index.d.ts +1 -0
  81. package/types/components/DataTableToolbar/index.d.ts.map +1 -1
  82. package/types/hooks/useDataTable.d.ts.map +1 -1
  83. package/types/types.d.ts +14 -12
  84. package/types/types.d.ts.map +1 -1
@@ -42,7 +42,7 @@ function renderCards(props: Record<string, any> = {}) {
42
42
  describe("DataTableCardControls", () => {
43
43
  it("is a labelled toolbar above the card list", () => {
44
44
  renderCards({ enableSorting: true, enableFiltering: true });
45
- const toolbar = screen.getByRole("toolbar", { name: "Card list controls" });
45
+ const toolbar = screen.getByRole("toolbar", { name: "Table toolbar" });
46
46
  expect(toolbar).toBeTruthy();
47
47
  expect(screen.getByLabelText("Sort rows")).toBeTruthy();
48
48
  });
@@ -61,7 +61,8 @@ describe("DataTableCardControls", () => {
61
61
  it("filtering only: the filter chrome is there, the sort menu is not", () => {
62
62
  renderCards({ enableSorting: false, enableFiltering: true });
63
63
  expect(screen.queryByLabelText("Sort rows")).toBeNull();
64
- expect(screen.getByText("Add Filter")).toBeTruthy();
64
+ expect(screen.getByRole("button", { name: "Filter" })).toBeTruthy();
65
+ expect(screen.queryByText("Add Filter")).toBeNull();
65
66
  });
66
67
 
67
68
  it("with no sort applied the trigger reads the neutral label", () => {
@@ -0,0 +1,250 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { Preset } from "@multiplatform.one/theme";
3
+ import { fireEvent, waitFor } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { DataTableColumn } from "../../types";
6
+ import { DataTable } from "./index";
7
+
8
+ // Happy DOM has no viewport measurements. Keep the actual virtualizer and row renderer.
9
+ vi.mock("@tanstack/react-virtual", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import("@tanstack/react-virtual")>();
11
+ return {
12
+ ...actual,
13
+ useVirtualizer: (options: Parameters<typeof actual.useVirtualizer>[0]) =>
14
+ actual.useVirtualizer({
15
+ ...options,
16
+ initialRect: { width: 800, height: 240 },
17
+ observeElementRect: (_instance, callback) => {
18
+ callback({ width: 800, height: 240 });
19
+ return () => {};
20
+ },
21
+ observeElementOffset: (_instance, callback) => {
22
+ callback(0, false);
23
+ return () => {};
24
+ },
25
+ scrollToFn: () => {},
26
+ }),
27
+ };
28
+ });
29
+
30
+ const data = [{ id: "1", account: "Account 1", balance: "10.00" }];
31
+ const columns: DataTableColumn<(typeof data)[number]>[] = [
32
+ { accessorKey: "account", header: "Account", size: 320, footer: "Totals" },
33
+ { accessorKey: "balance", header: "Balance", size: 80, footer: "10.00" },
34
+ ];
35
+ const allAuxiliaries = {
36
+ enableRowSelection: true,
37
+ showRowNumbers: true,
38
+ enableRowExpansion: true,
39
+ showDeleteRow: true,
40
+ };
41
+
42
+ function renderTable(props = {}, size: "small" | "medium" | "large" = "medium") {
43
+ return renderWithProviders(
44
+ <Preset overrides={{ size }}>
45
+ <DataTable
46
+ data={data}
47
+ columns={columns}
48
+ layout="table"
49
+ columnLayout="scroll"
50
+ enablePagination={false}
51
+ {...props}
52
+ />
53
+ </Preset>,
54
+ );
55
+ }
56
+
57
+ function cells(row: Element): HTMLElement[] {
58
+ return [...row.children].filter((element) =>
59
+ ["cell", "columnheader"].includes(element.getAttribute("role") ?? ""),
60
+ ) as HTMLElement[];
61
+ }
62
+
63
+ function width(cell: HTMLElement): number {
64
+ return Number.parseFloat(
65
+ cell.style.width || /(?:^| )_w-([\d.]+)px(?: |$)/.exec(cell.className)?.[1] || "NaN",
66
+ );
67
+ }
68
+
69
+ function rows(container: HTMLElement) {
70
+ const header = container.querySelector('thead [role="row"]')!;
71
+ const body = container.querySelector('[aria-label="Row 1"]')!;
72
+ const footer = container.querySelector('tfoot [role="row"]')!;
73
+ expect(header).not.toBeNull();
74
+ expect(body).not.toBeNull();
75
+ expect(footer).not.toBeNull();
76
+ return { header: cells(header), body: cells(body), footer: cells(footer) };
77
+ }
78
+
79
+ function expectPinned(cell: HTMLElement, side: "start" | "end", offset: number) {
80
+ expect(cell.style.position).toBe("sticky");
81
+ expect(cell.style[side === "start" ? "insetInlineStart" : "insetInlineEnd"]).toBe(`${offset}px`);
82
+ expect(cell.style.zIndex).toBe("2");
83
+ expect(cell.style.backgroundColor).toBe("var(--color2)");
84
+ }
85
+
86
+ let previousDirection: string | null;
87
+ beforeEach(() => {
88
+ previousDirection = document.documentElement.getAttribute("dir");
89
+ });
90
+ afterEach(() => {
91
+ if (previousDirection === null) document.documentElement.removeAttribute("dir");
92
+ else document.documentElement.setAttribute("dir", previousDirection);
93
+ });
94
+
95
+ describe.each(["ltr", "rtl"])("footer layout in %s", (direction) => {
96
+ beforeEach(() => document.documentElement.setAttribute("dir", direction));
97
+
98
+ it.each([
99
+ { name: "no auxiliaries", props: {}, leading: 0, trailing: 0 },
100
+ { name: "selection", props: { enableRowSelection: true }, leading: 1, trailing: 0 },
101
+ { name: "row numbers", props: { showRowNumbers: true }, leading: 1, trailing: 0 },
102
+ { name: "expansion", props: { enableRowExpansion: true }, leading: 1, trailing: 0 },
103
+ { name: "delete", props: { showDeleteRow: true }, leading: 0, trailing: 1 },
104
+ { name: "all auxiliaries", props: allAuxiliaries, leading: 3, trailing: 1 },
105
+ {
106
+ name: "read-only auxiliaries",
107
+ props: { ...allAuxiliaries, readOnly: true },
108
+ leading: 3,
109
+ trailing: 0,
110
+ },
111
+ {
112
+ name: "read-only delete suppressed",
113
+ props: { showDeleteRow: true, readOnly: true },
114
+ leading: 0,
115
+ trailing: 0,
116
+ },
117
+ {
118
+ name: "compact auxiliaries",
119
+ props: { ...allAuxiliaries, compact: true },
120
+ leading: 3,
121
+ trailing: 1,
122
+ },
123
+ ])("matches resolved header/body slots with $name", ({ props, leading, trailing }) => {
124
+ const { container } = renderTable(props);
125
+ const row = rows(container);
126
+ const headerWidths = row.header.map(width);
127
+ expect(row.header).toHaveLength(leading + 2 + trailing);
128
+ expect(headerWidths.every((value) => Number.isFinite(value) && value > 0)).toBe(true);
129
+ expect(headerWidths.slice(leading, leading + 2)).toEqual([320, 80]);
130
+ expect(row.body.map(width)).toEqual(headerWidths);
131
+ expect(row.footer.map(width)).toEqual(headerWidths);
132
+ expect(row.footer[leading]).toHaveTextContent("Totals");
133
+ expect(row.footer[leading + 1]).toHaveTextContent("10.00");
134
+ const auxiliaryCells = [...row.footer.slice(0, leading), ...row.footer.slice(leading + 2)];
135
+ for (const cell of auxiliaryCells) {
136
+ expect(cell.textContent).toBe("");
137
+ expect(cell.tabIndex).toBe(-1);
138
+ expect(
139
+ cell.querySelector('button, input, [role="button"], [role="checkbox"], [tabindex]'),
140
+ ).toBeNull();
141
+ }
142
+ });
143
+
144
+ it.each(["left", "right"] as const)(
145
+ "resolves pinned %s footer paint like header/body",
146
+ async (side) => {
147
+ const utils = renderTable({ enableColumnPinning: true });
148
+ fireEvent.click(utils.getByLabelText("Pin Account column left"));
149
+ if (side === "right") fireEvent.click(utils.getByLabelText("Pin Account column right"));
150
+ await waitFor(() =>
151
+ expect(
152
+ (utils.container.querySelector("#account-header") as HTMLElement).style.position,
153
+ ).toBe("sticky"),
154
+ );
155
+ const row = rows(utils.container);
156
+ const index = row.header.findIndex((cell) => cell.id === "account-header");
157
+ const logicalSide = side === "left" ? "start" : "end";
158
+ expectPinned(row.header[index], logicalSide, 0);
159
+ expectPinned(row.body[index], logicalSide, 0);
160
+ expectPinned(row.footer[index], logicalSide, 0);
161
+ expect(row.footer[1 - index].style.position).not.toBe("sticky");
162
+ },
163
+ );
164
+
165
+ it("follows resolved auxiliary widths at small and large preset sizes", () => {
166
+ const widthsBySize = ["small", "large"].map((size) => {
167
+ const utils = renderTable(allAuxiliaries, size as "small" | "large");
168
+ const row = rows(utils.container);
169
+ const headerWidths = row.header.map(width);
170
+ expect(headerWidths).toHaveLength(6);
171
+ expect(headerWidths.slice(3, 5)).toEqual([320, 80]);
172
+ expect(row.body.map(width)).toEqual(headerWidths);
173
+ expect(row.footer.map(width)).toEqual(headerWidths);
174
+ utils.unmount();
175
+ return headerWidths;
176
+ });
177
+ for (const index of [0, 1, 2, 5]) {
178
+ expect(widthsBySize[0][index]).toBeLessThan(widthsBySize[1][index]);
179
+ }
180
+ });
181
+
182
+ it.each([false, true])(
183
+ "reserves auxiliary flow around both pin sides, readOnly=%s",
184
+ async (readOnly) => {
185
+ const utils = renderTable({ ...allAuxiliaries, readOnly, enableColumnPinning: true });
186
+ fireEvent.click(utils.getByLabelText("Pin Account column left"));
187
+ fireEvent.click(utils.getByLabelText("Pin Balance column left"));
188
+ fireEvent.click(utils.getByLabelText("Pin Balance column right"));
189
+ await waitFor(() => expect(rows(utils.container).header[3].style.position).toBe("sticky"));
190
+ const row = rows(utils.container);
191
+ const headerWidths = row.header.map(width);
192
+ const leadingWidth = headerWidths.slice(0, 3).reduce((sum, value) => sum + value, 0);
193
+ const trailingWidth = readOnly ? 0 : headerWidths[5];
194
+ expect(leadingWidth).toBeGreaterThan(0);
195
+ expect(row.body.map(width)).toEqual(headerWidths);
196
+ expect(row.footer.map(width)).toEqual(headerWidths);
197
+ for (const section of [row.header, row.body, row.footer]) {
198
+ expectPinned(section[3], "start", leadingWidth);
199
+ expectPinned(section[4], "end", trailingWidth);
200
+ }
201
+ },
202
+ );
203
+
204
+ it("keeps actual virtual rows and auxiliary footer slots aligned after pinned resize", async () => {
205
+ const utils = renderWithProviders(
206
+ <DataTable
207
+ data={[
208
+ { id: "1", rank: 1, label: "Record 1" },
209
+ { id: "2", rank: 2, label: "Record 2" },
210
+ ]}
211
+ columns={[
212
+ { accessorKey: "rank", header: "Rank", size: 48, minSize: 32, maxSize: 64, footer: "2" },
213
+ { accessorKey: "label", header: "Label", size: 320, footer: "Total" },
214
+ ]}
215
+ layout="table"
216
+ columnLayout="scroll"
217
+ enableColumnResizing
218
+ enableColumnPinning
219
+ enableVirtualization
220
+ virtualizationHeight={240}
221
+ enablePagination={false}
222
+ {...allAuxiliaries}
223
+ />,
224
+ );
225
+ const virtualRows = () =>
226
+ utils.container.querySelectorAll('tbody[role="presentation"] [role="rowgroup"] [role="row"]');
227
+ await waitFor(() => expect(virtualRows()).toHaveLength(2));
228
+ fireEvent.click(utils.getByLabelText("Pin Rank column left"));
229
+ fireEvent.click(utils.getByLabelText("Pin Label column left"));
230
+ const header = () => cells(utils.container.querySelector('thead [role="row"]')!);
231
+ const leadingWidth = header()
232
+ .slice(0, 3)
233
+ .reduce((sum, cell) => sum + width(cell), 0);
234
+ expect(width(header()[3])).toBe(48);
235
+ expectPinned(header()[4], "start", leadingWidth + 48);
236
+ const handle = utils.getByRole("separator", { name: "Resize Rank column" });
237
+ fireEvent.mouseDown(handle, { clientX: 100 });
238
+ fireEvent.mouseMove(document, { clientX: 130 });
239
+ fireEvent.mouseUp(document, { clientX: 130 });
240
+ expect(width(header()[3])).toBe(64);
241
+ expectPinned(header()[4], "start", leadingWidth + 64);
242
+ const footer = cells(utils.container.querySelector('tfoot [role="row"]')!);
243
+ expect(virtualRows()).toHaveLength(2);
244
+ for (const section of [...virtualRows()].map(cells).concat([footer])) {
245
+ expect(section.map(width)).toEqual(header().map(width));
246
+ expectPinned(section[3], "start", leadingWidth);
247
+ expectPinned(section[4], "start", leadingWidth + 64);
248
+ }
249
+ });
250
+ });
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { renderWithProviders } from "@multiplatform.one/test-utils";
18
18
  import { fireEvent, waitFor } from "@testing-library/react";
19
- import { describe, expect, it, vi } from "vitest";
19
+ import { afterEach, describe, expect, it, vi } from "vitest";
20
20
  import type { DataTableColumn } from "../../types";
21
21
  import { DataTable } from "./index";
22
22
 
@@ -142,3 +142,68 @@ describe("column pinning geometry", () => {
142
142
  });
143
143
  });
144
144
  });
145
+
146
+ afterEach(() => document.documentElement.removeAttribute("dir"));
147
+
148
+ it.each(["ltr", "rtl"])(
149
+ "keeps declared small pinned boxes and logical offsets aligned in %s",
150
+ async (direction) => {
151
+ document.documentElement.setAttribute("dir", direction);
152
+ const utils = renderPinned({
153
+ columns: columns.map((column, index) => ({
154
+ ...column,
155
+ footer: String(column.header),
156
+ width: index === 0 ? 48 : 320,
157
+ })),
158
+ });
159
+ await pinIdColumn(utils);
160
+ fireEvent.click(utils.getByLabelText("Pin First Name column left"));
161
+ await waitFor(() => {
162
+ const first = utils.container.querySelector("#id-header") as HTMLElement;
163
+ const second = utils.container.querySelector("#firstName-header") as HTMLElement;
164
+ expect(hasStyle(first, "width", "48px", "_w-48px")).toBe(true);
165
+ expect(hasStyle(first, "minWidth", "48px", "_miw-48px")).toBe(true);
166
+ expect(hasStyle(first, "maxWidth", "48px", "_maw-48px")).toBe(true);
167
+ expect(second.style.insetInlineStart).toBe("48px");
168
+ const body = utils.container.querySelector('[role="row"][aria-label="Row 1"]')!;
169
+ const cells = body.querySelectorAll('[role="cell"]');
170
+ expect(hasStyle(cells[0] as HTMLElement, "width", "48px", "_w-48px")).toBe(true);
171
+ expect((cells[1] as HTMLElement).style.insetInlineStart).toBe("48px");
172
+ const footer = utils.container.querySelectorAll('tfoot [role="cell"]');
173
+ expect(hasStyle(footer[0] as HTMLElement, "width", "48px", "_w-48px")).toBe(true);
174
+ expect((footer[1] as HTMLElement).style.insetInlineStart).toBe("48px");
175
+ });
176
+ },
177
+ );
178
+
179
+ it.each(["ltr", "rtl"])(
180
+ "aligns right-pinned headers, rows and footers in %s",
181
+ async (direction) => {
182
+ document.documentElement.setAttribute("dir", direction);
183
+ const utils = renderPinned({
184
+ columns: columns.map((column, index) => ({
185
+ ...column,
186
+ footer: String(column.header),
187
+ width: index === 3 ? 76 : 320,
188
+ })),
189
+ });
190
+ for (const label of ["Last Name", "Age"]) {
191
+ fireEvent.click(utils.getByLabelText(`Pin ${label} column left`));
192
+ fireEvent.click(utils.getByLabelText(`Pin ${label} column right`));
193
+ }
194
+ await waitFor(() => {
195
+ const last = utils.container.querySelector("#age-header") as HTMLElement;
196
+ const previous = utils.container.querySelector("#lastName-header") as HTMLElement;
197
+ expect(hasStyle(last, "width", "76px", "_w-76px")).toBe(true);
198
+ expect(last.style.insetInlineEnd).toBe("0px");
199
+ expect(previous.style.insetInlineEnd).toBe("76px");
200
+ const body = utils.container.querySelector('[role="row"][aria-label="Row 1"]')!;
201
+ const cells = body.querySelectorAll('[role="cell"]');
202
+ expect(hasStyle(cells[3] as HTMLElement, "width", "76px", "_w-76px")).toBe(true);
203
+ expect((cells[2] as HTMLElement).style.insetInlineEnd).toBe("76px");
204
+ const footer = utils.container.querySelectorAll('tfoot [role="cell"]');
205
+ expect(hasStyle(footer[3] as HTMLElement, "width", "76px", "_w-76px")).toBe(true);
206
+ expect((footer[2] as HTMLElement).style.insetInlineEnd).toBe("76px");
207
+ });
208
+ },
209
+ );
@@ -1,4 +1,4 @@
1
- import { fireEvent, screen, waitFor } from "@testing-library/react";
1
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
2
2
  import { renderWithProviders } from "@multiplatform.one/test-utils";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { DataTable } from "../DataTable";
@@ -203,16 +203,30 @@ describe("DataTableExport", () => {
203
203
  });
204
204
 
205
205
  it("shows loading state when export is in progress", async () => {
206
- const mockOnExport = vi.fn(
207
- () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
208
- ) as unknown as ReturnType<typeof vi.fn> & ((options: ExportOptions) => Promise<void>);
206
+ let finishExport!: () => void;
207
+ const pending = new Promise<void>((resolve) => {
208
+ finishExport = resolve;
209
+ });
210
+ const mockOnExport = vi.fn(() => pending) as unknown as ReturnType<typeof vi.fn> &
211
+ ((options: ExportOptions) => Promise<void>);
209
212
 
210
213
  renderWithProviders(
211
214
  <DataTableExport data={mockData} columns={mockColumns} onExport={mockOnExport} />,
212
215
  );
213
216
 
214
- fireEvent.click(screen.getByText("CSV"));
215
- expect(screen.getByText("...")).toBeInTheDocument();
217
+ const exportButton = screen.getByRole("button", { name: /Export as CSV/i });
218
+ try {
219
+ fireEvent.click(exportButton);
220
+ expect(screen.getByText("...")).toBeInTheDocument();
221
+ expect(exportButton).toHaveAttribute("aria-disabled", "true");
222
+ } finally {
223
+ await act(async () => {
224
+ finishExport();
225
+ await pending;
226
+ });
227
+ }
228
+ expect(exportButton).toHaveTextContent("CSV");
229
+ expect(exportButton).not.toHaveAttribute("aria-disabled", "true");
216
230
  });
217
231
 
218
232
  it("disables buttons when disabled prop is true", () => {
@@ -599,9 +613,12 @@ describe("DataTableExport", () => {
599
613
  });
600
614
 
601
615
  it("prevents multiple simultaneous exports", async () => {
602
- const mockOnExport = vi.fn(
603
- () => new Promise<void>((resolve) => setTimeout(resolve, 200)),
604
- ) as unknown as ReturnType<typeof vi.fn> & ((options: ExportOptions) => Promise<void>);
616
+ let finishExport!: () => void;
617
+ const pending = new Promise<void>((resolve) => {
618
+ finishExport = resolve;
619
+ });
620
+ const mockOnExport = vi.fn(() => pending) as unknown as ReturnType<typeof vi.fn> &
621
+ ((options: ExportOptions) => Promise<void>);
605
622
 
606
623
  renderWithProviders(
607
624
  <DataTableExport data={mockData} columns={mockColumns} onExport={mockOnExport} />,
@@ -609,13 +626,22 @@ describe("DataTableExport", () => {
609
626
 
610
627
  // Click export twice quickly - button shows "..." when loading
611
628
  const exportButton = screen.getByRole("button", { name: /Export as CSV/i });
612
- fireEvent.click(exportButton);
613
- fireEvent.click(exportButton);
629
+ try {
630
+ fireEvent.click(exportButton);
631
+ fireEvent.click(exportButton);
614
632
 
615
- // Should only call once
616
- await waitFor(() => {
617
- expect(mockOnExport).toHaveBeenCalledTimes(1);
618
- });
633
+ // Should only call once
634
+ await waitFor(() => {
635
+ expect(mockOnExport).toHaveBeenCalledTimes(1);
636
+ });
637
+ } finally {
638
+ await act(async () => {
639
+ finishExport();
640
+ await pending;
641
+ });
642
+ }
643
+ expect(exportButton).toHaveTextContent("CSV");
644
+ expect(exportButton).not.toHaveAttribute("aria-disabled", "true");
619
645
  });
620
646
 
621
647
  it("surfaces an inline error when Blob URLs are unavailable", async () => {
@@ -629,9 +655,7 @@ describe("DataTableExport", () => {
629
655
  renderWithProviders(<DataTableExport data={mockData} columns={mockColumns} />);
630
656
  fireEvent.click(screen.getByRole("button", { name: /Export as CSV/i }));
631
657
  await waitFor(() => {
632
- expect(screen.getByRole("alert").textContent).toMatch(
633
- /not available on this platform/i,
634
- );
658
+ expect(screen.getByRole("alert").textContent).toMatch(/not available on this platform/i);
635
659
  });
636
660
  expect(mockCreateObjectURL).not.toHaveBeenCalled();
637
661
  } finally {
@@ -91,6 +91,14 @@ function GroupHeader({ children }: { children: string }) {
91
91
  }
92
92
 
93
93
  export function DataTableFilterButton() {
94
+ return <FilterButton />;
95
+ }
96
+
97
+ export function DataTableCardFilterButton() {
98
+ return <FilterButton contentTrigger />;
99
+ }
100
+
101
+ function FilterButton({ contentTrigger = false }: { contentTrigger?: boolean }) {
94
102
  const ctx = useDataTableState();
95
103
  const { knobProps } = useResolvedKnobs();
96
104
  // "Back to field list" points toward the panel's start edge — directional,
@@ -315,6 +323,7 @@ export function DataTableFilterButton() {
315
323
  open={open}
316
324
  onOpenChange={handleOpenChange}
317
325
  trigger={trigger}
326
+ {...(contentTrigger ? { triggerSizing: "content" as const } : {})}
318
327
  fitContent
319
328
  scrollable={false}
320
329
  >