@marimo-team/islands 0.25.1-dev44 → 0.25.1-dev45

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 (26) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CwMXfDQR.js → ConnectedDataExplorerComponent-C2d9ysUI.js} +5 -6
  2. package/dist/{ErrorBoundary-VUuyxCWc.js → ErrorBoundary-pBYdeBnS.js} +1 -1
  3. package/dist/{chat-ui-DLSF8a04.js → chat-ui-fVyVOhYN.js} +4 -4
  4. package/dist/{code-visibility-A4d1wosh.js → code-visibility-BtQ8kX30.js} +1 -1
  5. package/dist/{common-p1O4ctAw.js → common-CT8H5t1V.js} +6722 -6426
  6. package/dist/{data-grid-overlay-editor-CasvLZPZ.js → data-grid-overlay-editor-BLIYSMps.js} +1 -1
  7. package/dist/{dropdown-menu-CZgjYSAh.js → dropdown-menu-CZy06LAq.js} +12 -12
  8. package/dist/{feedback-button-DrJQ0USU.js → feedback-button-Oixw7nP7.js} +2 -2
  9. package/dist/{glide-data-editor-C55qOCCK.js → glide-data-editor-u2Z6Gofm.js} +5 -5
  10. package/dist/{globals-CUD2Xo__.js → globals-JfO03feH.js} +1 -1
  11. package/dist/{html-to-image-CGVgroef.js → html-to-image-CmkwfNkf.js} +1 -1
  12. package/dist/{useNumberFormatter-BfDpa21j.js → label-BslhXqL6.js} +108 -81
  13. package/dist/main.js +60 -54
  14. package/dist/{number-overlay-editor-CZb24BVz.js → number-overlay-editor-Da3rqdsN.js} +1 -1
  15. package/dist/{process-output-CGfg-JmZ.js → process-output-CYN5E-iO.js} +1 -1
  16. package/dist/{reveal-component-_Fc3Tdml.js → reveal-component-DvSplL6e.js} +3 -3
  17. package/dist/{spec-CAYwFeV6.js → spec-DQBRQSba.js} +123 -102
  18. package/dist/style.css +1 -1
  19. package/dist/{useDateFormatter-BRTPsUK7.js → useDateFormatter-BXQGu86d.js} +28 -49
  20. package/package.json +1 -1
  21. package/src/components/data-table/TableTopBar.tsx +2 -2
  22. package/src/components/data-table/__tests__/export-actions.test.tsx +662 -0
  23. package/src/components/data-table/data-table.tsx +1 -1
  24. package/src/components/data-table/export-actions.tsx +642 -176
  25. package/src/components/data-table/schemas.ts +25 -3
  26. package/dist/label-BJ49YTvk.js +0 -30
@@ -0,0 +1,662 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ act,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ within,
10
+ } from "@testing-library/react";
11
+ import { createStore, Provider } from "jotai";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+ import { TooltipProvider } from "@/components/ui/tooltip";
14
+ import { toast } from "@/components/ui/use-toast";
15
+ import { viewStateAtom, type AppMode } from "@/core/mode";
16
+ import { copyToClipboard } from "@/utils/copy";
17
+ import { downloadByURL } from "@/utils/download";
18
+ import { jsonToMarkdown } from "@/utils/json/json-parser";
19
+ import { downloadSizeLimitAtom } from "../download-policy/atoms";
20
+ import { ExportActions, type ExportActionProps } from "../export-actions";
21
+
22
+ const mocks = vi.hoisted(() => ({
23
+ handleInstallPackages: vi.fn(),
24
+ }));
25
+
26
+ vi.mock("@/core/packages/useInstallPackage", () => ({
27
+ useInstallPackages: () => ({
28
+ handleInstallPackages: mocks.handleInstallPackages,
29
+ }),
30
+ }));
31
+
32
+ vi.mock("@/utils/copy", () => ({
33
+ copyToClipboard: vi.fn().mockResolvedValue(undefined),
34
+ }));
35
+
36
+ vi.mock("@/utils/download", () => ({
37
+ downloadByURL: vi.fn(),
38
+ withLoadingToast: vi.fn(
39
+ async (_title: string, callback: () => Promise<unknown>) => callback(),
40
+ ),
41
+ }));
42
+
43
+ vi.mock("@/components/ui/use-toast", () => ({
44
+ toast: vi.fn(() => ({
45
+ dismiss: vi.fn(),
46
+ update: vi.fn(),
47
+ })),
48
+ }));
49
+
50
+ const downloadAs = vi.fn<ExportActionProps["downloadAs"]>();
51
+
52
+ function renderExportActions(
53
+ props: Partial<ExportActionProps> = {},
54
+ sizeLimit: { limitBytes: number; unavailableMessage: string } | null = null,
55
+ mode: AppMode = "edit",
56
+ ) {
57
+ const store = createStore();
58
+ store.set(downloadSizeLimitAtom, sizeLimit);
59
+ store.set(viewStateAtom, { mode, cellAnchor: null });
60
+
61
+ return render(
62
+ <Provider store={store}>
63
+ <TooltipProvider delayDuration={0}>
64
+ <ExportActions downloadAs={downloadAs} {...props} />
65
+ </TooltipProvider>
66
+ </Provider>,
67
+ );
68
+ }
69
+
70
+ async function openDialog() {
71
+ fireEvent.click(screen.getByTestId("export-button"));
72
+ return screen.findByRole("dialog", { name: "Export table" });
73
+ }
74
+
75
+ describe("ExportActions dialog", () => {
76
+ beforeEach(() => {
77
+ vi.clearAllMocks();
78
+ downloadAs.mockResolvedValue({
79
+ url: "https://example.test/export",
80
+ filename: "table",
81
+ });
82
+ });
83
+
84
+ afterEach(() => {
85
+ vi.unstubAllGlobals();
86
+ });
87
+
88
+ it("shows formats in the approved order with an options toggle where supported", async () => {
89
+ renderExportActions();
90
+ const dialog = await openDialog();
91
+ const list = within(dialog).getByRole("list", { name: "Export formats" });
92
+ const rows = within(list).getAllByRole("listitem");
93
+
94
+ expect(rows.map((row) => row.dataset.testid)).toEqual([
95
+ "export-row-csv",
96
+ "export-row-tsv",
97
+ "export-row-json",
98
+ "export-row-parquet",
99
+ "export-row-markdown",
100
+ ]);
101
+ for (const label of ["CSV", "TSV", "JSON"]) {
102
+ expect(
103
+ screen.getByRole("button", { name: `${label} options` }),
104
+ ).toHaveAttribute("aria-expanded", "false");
105
+ }
106
+ for (const label of ["Parquet", "Markdown"]) {
107
+ expect(
108
+ screen.queryByRole("button", { name: `${label} options` }),
109
+ ).not.toBeInTheDocument();
110
+ }
111
+ expect(within(dialog).queryByRole("group")).not.toBeInTheDocument();
112
+ });
113
+
114
+ it("expands one options panel at a time", async () => {
115
+ renderExportActions();
116
+ const dialog = await openDialog();
117
+
118
+ fireEvent.click(screen.getByRole("button", { name: "CSV options" }));
119
+ expect(
120
+ within(dialog).getByRole("group", { name: "CSV options" }),
121
+ ).toBeInTheDocument();
122
+ expect(screen.getByRole("combobox", { name: "Delimiter" })).toHaveValue(
123
+ ",",
124
+ );
125
+ expect(screen.getByRole("combobox", { name: "Encoding" })).toHaveValue(
126
+ "utf-8",
127
+ );
128
+
129
+ fireEvent.click(screen.getByRole("button", { name: "JSON options" }));
130
+ expect(
131
+ within(dialog).queryByRole("group", { name: "CSV options" }),
132
+ ).not.toBeInTheDocument();
133
+ expect(
134
+ within(dialog).getByRole("group", { name: "JSON options" }),
135
+ ).toBeInTheDocument();
136
+ expect(
137
+ screen.getByRole("switch", { name: "Escape non-ASCII" }),
138
+ ).toBeChecked();
139
+
140
+ fireEvent.click(screen.getByRole("button", { name: "JSON options" }));
141
+ expect(within(dialog).queryByRole("group")).not.toBeInTheDocument();
142
+ });
143
+
144
+ it("sends only changed CSV options with a download", async () => {
145
+ renderExportActions();
146
+ await openDialog();
147
+ expect(screen.getByTestId("export-summary-csv")).toHaveTextContent(
148
+ "Comma · UTF-8",
149
+ );
150
+ expect(screen.queryByTestId("export-summary-parquet")).toBeNull();
151
+
152
+ fireEvent.click(screen.getByRole("button", { name: "CSV options" }));
153
+ fireEvent.change(screen.getByRole("combobox", { name: "Delimiter" }), {
154
+ target: { value: ";" },
155
+ });
156
+ fireEvent.change(screen.getByRole("combobox", { name: "Encoding" }), {
157
+ target: { value: "utf-8-sig" },
158
+ });
159
+ expect(screen.getByTestId("export-summary-csv")).toHaveTextContent(
160
+ "Semicolon · UTF-8 with BOM",
161
+ );
162
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
163
+
164
+ await waitFor(() => {
165
+ expect(downloadByURL).toHaveBeenCalled();
166
+ });
167
+ expect(downloadAs).toHaveBeenCalledWith({
168
+ format: "csv",
169
+ options: { separator: ";", encoding: "utf-8-sig" },
170
+ });
171
+ });
172
+
173
+ it("keeps the delimiter but drops the encoding for a CSV copy", async () => {
174
+ vi.stubGlobal(
175
+ "fetch",
176
+ vi.fn().mockResolvedValue({
177
+ ok: true,
178
+ text: vi.fn().mockResolvedValue("name;value\n"),
179
+ }),
180
+ );
181
+ renderExportActions();
182
+ await openDialog();
183
+
184
+ fireEvent.click(screen.getByRole("button", { name: "CSV options" }));
185
+ fireEvent.change(screen.getByRole("combobox", { name: "Delimiter" }), {
186
+ target: { value: ";" },
187
+ });
188
+ fireEvent.change(screen.getByRole("combobox", { name: "Encoding" }), {
189
+ target: { value: "latin-1" },
190
+ });
191
+ fireEvent.click(screen.getByRole("button", { name: "Copy CSV" }));
192
+
193
+ await waitFor(() => {
194
+ expect(copyToClipboard).toHaveBeenCalledWith("name;value\n");
195
+ });
196
+ expect(downloadAs).toHaveBeenCalledWith({
197
+ format: "csv",
198
+ options: { separator: ";" },
199
+ });
200
+ });
201
+
202
+ it("sends the JSON escape setting only when turned off", async () => {
203
+ renderExportActions();
204
+ await openDialog();
205
+
206
+ fireEvent.click(screen.getByRole("button", { name: "JSON options" }));
207
+ fireEvent.click(screen.getByRole("switch", { name: "Escape non-ASCII" }));
208
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
209
+
210
+ await waitFor(() => {
211
+ expect(downloadByURL).toHaveBeenCalled();
212
+ });
213
+ expect(downloadAs).toHaveBeenCalledWith({
214
+ format: "json",
215
+ options: { ensure_ascii: false },
216
+ });
217
+ });
218
+
219
+ it("resets options and panels when the dialog closes", async () => {
220
+ renderExportActions();
221
+ let dialog = await openDialog();
222
+
223
+ fireEvent.click(screen.getByRole("button", { name: "CSV options" }));
224
+ fireEvent.change(screen.getByRole("combobox", { name: "Delimiter" }), {
225
+ target: { value: "|" },
226
+ });
227
+ fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
228
+ await waitFor(() => {
229
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
230
+ });
231
+
232
+ dialog = await openDialog();
233
+ expect(within(dialog).queryByRole("group")).not.toBeInTheDocument();
234
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
235
+ await waitFor(() => {
236
+ expect(downloadAs).toHaveBeenCalledWith({ format: "csv" });
237
+ });
238
+ });
239
+
240
+ it("opens without showing an action tooltip", async () => {
241
+ renderExportActions();
242
+ const dialog = await openDialog();
243
+ await act(async () => {
244
+ await new Promise((resolve) => setTimeout(resolve, 10));
245
+ });
246
+
247
+ expect(dialog).toHaveFocus();
248
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
249
+ });
250
+
251
+ it("uses named icon buttons and reserves unavailable actions", async () => {
252
+ renderExportActions();
253
+ await openDialog();
254
+
255
+ for (const format of ["CSV", "TSV", "JSON"]) {
256
+ expect(
257
+ screen.getByRole("button", { name: `Download ${format}` }),
258
+ ).toBeEnabled();
259
+ expect(
260
+ screen.getByRole("button", { name: `Copy ${format}` }),
261
+ ).toBeEnabled();
262
+ }
263
+
264
+ expect(
265
+ screen.getByRole("button", { name: "Download Parquet" }),
266
+ ).toBeEnabled();
267
+ expect(screen.getByRole("button", { name: "Copy Parquet" })).toBeDisabled();
268
+ expect(
269
+ screen.getByRole("button", { name: "Download Markdown" }),
270
+ ).toBeDisabled();
271
+ expect(screen.getByRole("button", { name: "Copy Markdown" })).toBeEnabled();
272
+ });
273
+
274
+ it.each([
275
+ ["CSV", "csv"],
276
+ ["TSV", "tsv"],
277
+ ["JSON", "json"],
278
+ ["Parquet", "parquet"],
279
+ ] as const)(
280
+ "downloads %s with its existing URL contract",
281
+ async (label, format) => {
282
+ downloadAs.mockResolvedValue({
283
+ url: "https://example.test/export?token=abc",
284
+ filename: "table.source",
285
+ });
286
+ renderExportActions();
287
+ await openDialog();
288
+
289
+ fireEvent.click(
290
+ screen.getByRole("button", { name: `Download ${label}` }),
291
+ );
292
+
293
+ await waitFor(() => {
294
+ expect(downloadByURL).toHaveBeenCalledWith(
295
+ `https://example.test/export?token=abc&download=1&filename=table.${format}`,
296
+ `table.${format}`,
297
+ );
298
+ });
299
+ expect(downloadAs).toHaveBeenCalledWith({ format });
300
+ expect(toast).toHaveBeenCalledWith({
301
+ title: `${label} download started`,
302
+ });
303
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
304
+ },
305
+ );
306
+
307
+ it("keeps data URLs unchanged", async () => {
308
+ downloadAs.mockResolvedValue({
309
+ url: "data:text/csv;base64,YQ==",
310
+ filename: "table",
311
+ });
312
+ renderExportActions();
313
+ await openDialog();
314
+
315
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
316
+
317
+ await waitFor(() => {
318
+ expect(downloadByURL).toHaveBeenCalledWith(
319
+ "data:text/csv;base64,YQ==",
320
+ "table.csv",
321
+ );
322
+ });
323
+ });
324
+
325
+ it.each([
326
+ {
327
+ label: "CSV",
328
+ sourceFormat: "csv" as const,
329
+ sourceText: "name,value\nalpha,1\n",
330
+ expectedText: "name,value\nalpha,1\n",
331
+ },
332
+ {
333
+ label: "TSV",
334
+ sourceFormat: "tsv" as const,
335
+ sourceText: "name\tvalue\nalpha\t1\n",
336
+ expectedText: "name\tvalue\nalpha\t1\n",
337
+ },
338
+ {
339
+ label: "JSON",
340
+ sourceFormat: "json" as const,
341
+ sourceText: JSON.stringify([{ name: "alpha", value: 1 }]),
342
+ expectedText: JSON.stringify([{ name: "alpha", value: 1 }], null, 2),
343
+ },
344
+ {
345
+ label: "Markdown",
346
+ sourceFormat: "json" as const,
347
+ sourceText: JSON.stringify([{ name: "alpha", value: 1 }]),
348
+ expectedText: jsonToMarkdown([{ name: "alpha", value: 1 }]),
349
+ },
350
+ ])(
351
+ "copies $label with its existing conversion",
352
+ async ({ label, sourceFormat, sourceText, expectedText }) => {
353
+ vi.stubGlobal(
354
+ "fetch",
355
+ vi.fn().mockResolvedValue({
356
+ ok: true,
357
+ text: vi.fn().mockResolvedValue(sourceText),
358
+ }),
359
+ );
360
+ renderExportActions();
361
+ await openDialog();
362
+
363
+ fireEvent.click(screen.getByRole("button", { name: `Copy ${label}` }));
364
+
365
+ await waitFor(() => {
366
+ expect(copyToClipboard).toHaveBeenCalledWith(expectedText);
367
+ });
368
+ expect(downloadAs).toHaveBeenCalledWith({ format: sourceFormat });
369
+ expect(toast).toHaveBeenCalledWith({ title: "Copied to clipboard" });
370
+ await waitFor(() => {
371
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
372
+ });
373
+ },
374
+ );
375
+
376
+ it("shows the failure inside the affected format row", async () => {
377
+ downloadAs.mockRejectedValueOnce(new Error("Kernel disconnected"));
378
+ renderExportActions();
379
+ await openDialog();
380
+
381
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
382
+
383
+ const alert = await screen.findByRole("alert");
384
+ expect(screen.getByTestId("export-row-json")).toContainElement(alert);
385
+ expect(screen.getByTestId("export-row-csv")).not.toContainElement(alert);
386
+ });
387
+
388
+ it("shows download request failures inside the dialog", async () => {
389
+ downloadAs.mockRejectedValueOnce(new Error("Kernel disconnected"));
390
+ renderExportActions();
391
+ await openDialog();
392
+
393
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
394
+
395
+ const alert = await screen.findByRole("alert");
396
+ expect(alert).toHaveTextContent("Failed to download");
397
+ expect(alert).toHaveTextContent("Kernel disconnected");
398
+ expect(downloadByURL).not.toHaveBeenCalled();
399
+ });
400
+
401
+ it("shows file delivery failures inside the dialog", async () => {
402
+ vi.mocked(downloadByURL).mockImplementationOnce(() => {
403
+ throw new Error("Download blocked");
404
+ });
405
+ renderExportActions();
406
+ await openDialog();
407
+
408
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
409
+
410
+ const alert = await screen.findByRole("alert");
411
+ expect(alert).toHaveTextContent("Failed to download");
412
+ expect(alert).toHaveTextContent("Download blocked");
413
+ });
414
+
415
+ it("shows fetch failures inside the dialog", async () => {
416
+ vi.stubGlobal(
417
+ "fetch",
418
+ vi.fn().mockResolvedValue({
419
+ ok: false,
420
+ statusText: "Forbidden",
421
+ }),
422
+ );
423
+ renderExportActions();
424
+ await openDialog();
425
+
426
+ fireEvent.click(screen.getByRole("button", { name: "Copy CSV" }));
427
+
428
+ const alert = await screen.findByRole("alert");
429
+ expect(alert).toHaveTextContent("Failed to copy to clipboard");
430
+ expect(alert).toHaveTextContent("Forbidden");
431
+ });
432
+
433
+ it("shows clipboard failures inside the dialog", async () => {
434
+ vi.stubGlobal(
435
+ "fetch",
436
+ vi.fn().mockResolvedValue({
437
+ ok: true,
438
+ text: vi.fn().mockResolvedValue("name\nalpha\n"),
439
+ }),
440
+ );
441
+ vi.mocked(copyToClipboard).mockRejectedValueOnce(
442
+ new Error("Clipboard permission denied"),
443
+ );
444
+ renderExportActions();
445
+ await openDialog();
446
+
447
+ fireEvent.click(screen.getByRole("button", { name: "Copy CSV" }));
448
+
449
+ const alert = await screen.findByRole("alert");
450
+ expect(alert).toHaveTextContent("Failed to copy to clipboard");
451
+ expect(alert).toHaveTextContent("Clipboard permission denied");
452
+ });
453
+
454
+ it("shows backend failures before success fields", async () => {
455
+ downloadAs.mockResolvedValueOnce({
456
+ url: "https://example.test/partial",
457
+ filename: "partial",
458
+ error: "Export was rejected",
459
+ });
460
+ renderExportActions();
461
+ await openDialog();
462
+
463
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
464
+
465
+ const alert = await screen.findByRole("alert");
466
+ expect(alert).toHaveTextContent("Export was rejected");
467
+ expect(downloadByURL).not.toHaveBeenCalled();
468
+ });
469
+
470
+ it("installs missing packages and retries the same action", async () => {
471
+ downloadAs
472
+ .mockResolvedValueOnce({
473
+ url: "",
474
+ filename: "",
475
+ error: "Parquet export requires pyarrow.",
476
+ missing_packages: ["pyarrow"],
477
+ })
478
+ .mockResolvedValueOnce({
479
+ url: "https://example.test/export",
480
+ filename: "table",
481
+ });
482
+ renderExportActions();
483
+ await openDialog();
484
+
485
+ fireEvent.click(screen.getByRole("button", { name: "Download Parquet" }));
486
+
487
+ const alert = await screen.findByRole("alert");
488
+ expect(alert).toHaveTextContent("Parquet export requires pyarrow.");
489
+ fireEvent.click(
490
+ within(alert).getByRole("button", { name: "Install pyarrow" }),
491
+ );
492
+ expect(mocks.handleInstallPackages).toHaveBeenCalledWith(
493
+ ["pyarrow"],
494
+ expect.any(Function),
495
+ );
496
+
497
+ const onInstall = mocks.handleInstallPackages.mock
498
+ .calls[0][1] as () => void;
499
+ onInstall();
500
+
501
+ await waitFor(() => {
502
+ expect(downloadAs).toHaveBeenCalledTimes(2);
503
+ expect(downloadByURL).toHaveBeenCalledWith(
504
+ "https://example.test/export?download=1&filename=table.parquet",
505
+ "table.parquet",
506
+ );
507
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
508
+ });
509
+ });
510
+
511
+ it("hides package details and installation in read mode", async () => {
512
+ downloadAs.mockResolvedValueOnce({
513
+ url: "",
514
+ filename: "",
515
+ error: "Install pyarrow to export Parquet.",
516
+ missing_packages: ["pyarrow"],
517
+ });
518
+ renderExportActions({}, null, "read");
519
+ await openDialog();
520
+
521
+ fireEvent.click(screen.getByRole("button", { name: "Download Parquet" }));
522
+
523
+ const alert = await screen.findByRole("alert");
524
+ expect(alert).toHaveTextContent(
525
+ "Parquet export isn't available in this notebook",
526
+ );
527
+ expect(alert).not.toHaveTextContent("pyarrow");
528
+ expect(within(alert).queryByRole("button")).not.toBeInTheDocument();
529
+ });
530
+
531
+ it("clears stale failures before a new action and after close", async () => {
532
+ downloadAs.mockRejectedValueOnce(new Error("First failure"));
533
+ renderExportActions();
534
+ let dialog = await openDialog();
535
+
536
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
537
+ expect(await screen.findByRole("alert")).toHaveTextContent("First failure");
538
+
539
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
540
+ await waitFor(() => {
541
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
542
+ expect(downloadByURL).toHaveBeenCalled();
543
+ });
544
+
545
+ downloadAs.mockRejectedValueOnce(new Error("Second failure"));
546
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
547
+ expect(await screen.findByRole("alert")).toHaveTextContent(
548
+ "Second failure",
549
+ );
550
+
551
+ fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
552
+ await waitFor(() => {
553
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
554
+ });
555
+ dialog = await openDialog();
556
+ expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument();
557
+ });
558
+
559
+ it("ignores a late failure from an earlier action", async () => {
560
+ let rejectFirst: (reason: Error) => void = () => undefined;
561
+ downloadAs
562
+ .mockImplementationOnce(
563
+ () =>
564
+ new Promise((_, reject) => {
565
+ rejectFirst = reject;
566
+ }),
567
+ )
568
+ .mockResolvedValueOnce({
569
+ url: "https://example.test/export",
570
+ filename: "table",
571
+ });
572
+ renderExportActions();
573
+ await openDialog();
574
+
575
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
576
+ fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
577
+ await waitFor(() => {
578
+ expect(downloadByURL).toHaveBeenCalled();
579
+ });
580
+
581
+ await act(async () => {
582
+ rejectFirst(new Error("Late failure"));
583
+ });
584
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
585
+ });
586
+
587
+ it("ignores a late failure after the dialog closes", async () => {
588
+ let rejectDownload: (reason: Error) => void = () => undefined;
589
+ downloadAs.mockImplementationOnce(
590
+ () =>
591
+ new Promise((_, reject) => {
592
+ rejectDownload = reject;
593
+ }),
594
+ );
595
+ renderExportActions();
596
+ const dialog = await openDialog();
597
+
598
+ fireEvent.click(screen.getByRole("button", { name: "Download CSV" }));
599
+ fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
600
+ await waitFor(() => {
601
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
602
+ });
603
+ await openDialog();
604
+
605
+ await act(async () => {
606
+ rejectDownload(new Error("Late failure"));
607
+ });
608
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
609
+ });
610
+
611
+ it("closes with the close button and restores trigger focus", async () => {
612
+ renderExportActions();
613
+ const trigger = screen.getByTestId("export-button");
614
+ const dialog = await openDialog();
615
+
616
+ fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
617
+
618
+ await waitFor(() => {
619
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
620
+ expect(trigger).toHaveFocus();
621
+ });
622
+ });
623
+
624
+ it("closes with Escape and restores trigger focus", async () => {
625
+ renderExportActions();
626
+ const trigger = screen.getByTestId("export-button");
627
+ trigger.focus();
628
+ const dialog = await openDialog();
629
+ const closeButton = within(dialog).getByRole("button", { name: "Close" });
630
+ closeButton.focus();
631
+
632
+ fireEvent.keyDown(closeButton, { key: "Escape" });
633
+
634
+ await waitFor(() => {
635
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
636
+ expect(trigger).toHaveFocus();
637
+ });
638
+ });
639
+
640
+ it.each([
641
+ {
642
+ name: "the size check is pending",
643
+ props: { sizeBytesIsLoading: true },
644
+ },
645
+ {
646
+ name: "the export is over the host limit",
647
+ props: { sizeBytes: 101 },
648
+ },
649
+ ])("keeps the trigger disabled when $name", ({ props }) => {
650
+ renderExportActions(props, {
651
+ limitBytes: 100,
652
+ unavailableMessage: "This export is too large.",
653
+ });
654
+ const trigger = screen.getByTestId("export-button");
655
+
656
+ expect(trigger).toBeDisabled();
657
+ expect(trigger).toHaveClass("print:hidden");
658
+ expect(trigger.parentElement).toHaveAttribute("tabindex", "0");
659
+ fireEvent.click(trigger);
660
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
661
+ });
662
+ });
@@ -86,7 +86,7 @@ interface DataTableProps<TData> extends Partial<ExportActionProps> {
86
86
  // Pagination
87
87
  totalRows: number | TooManyRows;
88
88
  // JSON-serialized size of the currently-rendered data. Forwarded to
89
- // ExportMenu so hosts can size-gate the Export button via downloadSizeLimitAtom.
89
+ // ExportActions so hosts can size-gate the Export button via downloadSizeLimitAtom.
90
90
  sizeBytes?: number | null;
91
91
  sizeBytesIsLoading?: boolean;
92
92
  totalColumns: number;