@marimo-team/islands 0.23.17-dev1 → 0.23.17-dev4

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 (27) hide show
  1. package/dist/{common-BGCQJb-W.js → common-BcQ4kHDP.js} +4 -4
  2. package/dist/main.js +2 -2
  3. package/dist/{reveal-component-NNi374so.js → reveal-component-9QGZq7uF.js} +1 -1
  4. package/dist/style.css +1 -1
  5. package/package.json +1 -1
  6. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
  7. package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
  8. package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +423 -0
  9. package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
  10. package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +234 -0
  11. package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
  12. package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
  13. package/src/components/editor/actions/export-dialog/export-dialog.tsx +190 -0
  14. package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
  15. package/src/components/editor/actions/export-dialog/format-notice.tsx +138 -0
  16. package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
  17. package/src/components/editor/actions/export-dialog/state.ts +321 -0
  18. package/src/components/editor/actions/export-dialog/use-export-dialog.ts +380 -0
  19. package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
  20. package/src/components/editor/actions/useNotebookActions.tsx +72 -170
  21. package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
  22. package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
  23. package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
  24. package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
  25. package/src/utils/__tests__/download.test.tsx +6 -4
  26. package/src/utils/download.ts +3 -1
  27. package/src/utils/shell.ts +17 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { assertNever } from "@/utils/assertNever";
4
4
  import { KnownQueryParams } from "@/core/constants";
5
+ import { shellQuote } from "@/utils/shell";
5
6
 
6
7
  export type AgentTab = "claude" | "codex" | "opencode" | "prompt";
7
8
 
@@ -23,27 +24,6 @@ export function getMarimoCommand(): string {
23
24
  return import.meta.env.DEV ? "uv run marimo" : "uvx marimo@latest";
24
25
  }
25
26
 
26
- /**
27
- * POSIX-quote a value for safe embedding in a shell command. These commands are
28
- * meant to be copied into a terminal, so a url/token containing shell
29
- * metacharacters (`'`, `&`, `$(...)`, ...) must not break out of its argument.
30
- *
31
- * Mirrors Python's `shlex.quote` (used on the CLI side in
32
- * `marimo/_cli/pair/commands.py`) so both sides produce identical commands:
33
- * values that are already shell-safe are left as-is for readability, and
34
- * anything else is single-quoted with embedded quotes escaped as `'"'"'`.
35
- */
36
- export function shellQuote(value: string): string {
37
- if (value === "") {
38
- return "''";
39
- }
40
- // Same "safe" character set as CPython's shlex.quote (ASCII \w plus a few).
41
- if (/^[\w@%+=:,./-]+$/.test(value)) {
42
- return value;
43
- }
44
- return `'${value.replaceAll("'", `'"'"'`)}'`;
45
- }
46
-
47
27
  /** Return the server file key from a page URL, preserving its decoded value. */
48
28
  export function getFileFromURL(href: string): string | undefined {
49
29
  const file = new URL(href).searchParams.get(KnownQueryParams.filePath);
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { useAtom, useAtomValue, useSetAtom } from "jotai";
4
+ import type { RefObject } from "react";
4
5
  import {
5
6
  BookMarkedIcon,
6
7
  CheckIcon,
@@ -51,7 +52,6 @@ import { GitHubIcon } from "@/components/icons/github";
51
52
  import { MarimoPlusIcon } from "@/components/icons/marimo-icons";
52
53
  import { YouTubeIcon } from "@/components/icons/youtube";
53
54
  import { useImperativeModal } from "@/components/modal/ImperativeModal";
54
- import { renderShortcut } from "@/components/shortcuts/renderShortcut";
55
55
  import { PairWithAgentModal } from "@/components/editor/actions/pair-with-agent-modal";
56
56
  import { ShareStaticNotebookModal } from "@/components/static-html/share-modal";
57
57
  import { toast } from "@/components/ui/use-toast";
@@ -66,30 +66,15 @@ import { disabledCellIds } from "@/core/cells/utils";
66
66
  import { capabilitiesAtom } from "@/core/config/capabilities";
67
67
  import { aiEnabledAtom, useResolvedMarimoConfig } from "@/core/config/config";
68
68
  import { Constants } from "@/core/constants";
69
- import {
70
- updateCellOutputsWithScreenshots,
71
- useEnrichCellOutputs,
72
- } from "@/core/export/hooks";
73
69
  import { useLayoutActions, useLayoutState } from "@/core/layout/layout";
74
70
  import { useTogglePresenting } from "@/core/layout/useTogglePresenting";
75
71
  import { kioskModeAtom, viewStateAtom } from "@/core/mode";
76
72
  import { useRequestClient } from "@/core/network/requests";
77
73
  import { useFilename } from "@/core/saving/filename";
78
- import { downloadAsHTML } from "@/core/static/download-html";
79
74
  import { createShareableLink } from "@/core/wasm/share";
80
75
  import { isWasm } from "@/core/wasm/utils";
81
76
  import { copyToClipboard } from "@/utils/copy";
82
- import {
83
- ADD_PRINTING_CLASS,
84
- downloadAsPDF,
85
- downloadBlob,
86
- downloadExportedFile,
87
- downloadHTMLAsImage,
88
- withLoadingToast,
89
- } from "@/utils/download";
90
- import { Filenames } from "@/utils/filenames";
91
77
  import { Objects } from "@/utils/objects";
92
- import type { ProgressState } from "@/utils/progress";
93
78
  import { Strings } from "@/utils/strings";
94
79
  import { newNotebookURL } from "@/utils/urls";
95
80
  import { useRunAllCells } from "../cell/useRunCells";
@@ -100,7 +85,13 @@ import { keyboardShortcutsAtom } from "../controls/keyboard-shortcuts";
100
85
  import { commandPaletteAtom } from "../controls/state";
101
86
  import { displayLayoutName, getLayoutIcon } from "../renderers/layout-select";
102
87
  import { LAYOUT_TYPES } from "../renderers/types";
103
- import { runServerSidePDFDownload } from "./pdf-export";
88
+ import { ExportDialog } from "./export-dialog/export-dialog";
89
+ import {
90
+ applyExportOptionOverrides,
91
+ exportOptionsAtom,
92
+ type ExportFormat,
93
+ type ExportOptionOverrides,
94
+ } from "./export-dialog/state";
104
95
  import type { ActionButton } from "./types";
105
96
  import { useCopyNotebook } from "./useCopyNotebook";
106
97
  import { useRestartKernel } from "./useRestartKernel";
@@ -111,7 +102,11 @@ const NOOP_HANDLER = (event?: Event) => {
111
102
  event?.stopPropagation();
112
103
  };
113
104
 
114
- export function useNotebookActions() {
105
+ export function useNotebookActions({
106
+ exportDialogReturnFocusRef,
107
+ }: {
108
+ exportDialogReturnFocusRef?: RefObject<HTMLElement | null>;
109
+ } = {}) {
115
110
  const filename = useFilename();
116
111
  const { openModal, closeModal } = useImperativeModal();
117
112
  const { toggleApplication } = useChromeActions();
@@ -138,15 +133,8 @@ export function useNotebookActions() {
138
133
  const setSettingsDialogOpen = useSetAtom(settingDialogAtom);
139
134
  const { handleClick: openSettings } = useOpenSettingsToTab();
140
135
  const setKeyboardShortcutsOpen = useSetAtom(keyboardShortcutsAtom);
141
- const {
142
- exportAsIPYNB,
143
- exportAsMarkdown,
144
- exportAsScript,
145
- readCode,
146
- saveCellConfig,
147
- updateCellOutputs,
148
- } = useRequestClient();
149
- const takeScreenshots = useEnrichCellOutputs();
136
+ const setExportOptions = useSetAtom(exportOptionsAtom);
137
+ const { readCode, saveCellConfig } = useRequestClient();
150
138
 
151
139
  const hasDisabledCells = useAtomValue(hasDisabledCellsAtom);
152
140
  const canUndoDeletes = useAtomValue(canUndoDeletesAtom);
@@ -158,10 +146,6 @@ export function useNotebookActions() {
158
146
  const sharingHtmlEnabled = resolvedConfig.sharing?.html ?? true;
159
147
  const sharingWasmEnabled = resolvedConfig.sharing?.wasm ?? true;
160
148
  const sharingMolabEnabled = resolvedConfig.sharing?.molab ?? true;
161
-
162
- // Server-side PDF export is always available outside WASM.
163
- // Browser print fallback is used in WASM.
164
- const serverSidePdfEnabled = !isWasm();
165
149
  const isSlidesLayout = selectedLayout === "slides";
166
150
 
167
151
  const renderCheckboxElement = (checked: boolean) => (
@@ -170,82 +154,39 @@ export function useNotebookActions() {
170
154
  </div>
171
155
  );
172
156
 
173
- const renderRecommendedElement = (recommended: boolean) => {
174
- if (!recommended) {
175
- return null;
157
+ const openExportDialog = (
158
+ initialFormat?: ExportFormat,
159
+ optionOverrides?: ExportOptionOverrides,
160
+ ) => {
161
+ if (optionOverrides) {
162
+ setExportOptions((current) =>
163
+ applyExportOptionOverrides(current, optionOverrides),
164
+ );
176
165
  }
177
- return (
178
- <span className="ml-3 shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
179
- Recommended
180
- </span>
166
+ openModal(
167
+ <ExportDialog
168
+ initialFormat={initialFormat}
169
+ onClose={closeModal}
170
+ returnFocusRef={exportDialogReturnFocusRef}
171
+ />,
181
172
  );
182
173
  };
183
174
 
184
- const downloadServerSidePDF = async ({
185
- preset,
186
- title,
187
- }: {
188
- preset: "document" | "slides";
189
- title: string;
190
- }) => {
191
- if (!filename) {
192
- toastNotebookMustBeNamed();
193
- return;
194
- }
195
-
196
- const runDownload = async (progress: ProgressState) => {
197
- await runServerSidePDFDownload({
198
- exportOptions: {
199
- webpdf: false,
200
- preset,
201
- includeInputs: true,
202
- includeOutputs: true,
203
- },
204
- captureOutputs: () =>
205
- updateCellOutputsWithScreenshots({
206
- takeScreenshots: () => takeScreenshots({ progress }),
207
- updateCellOutputs,
208
- }),
209
- downloadPDF: downloadAsPDF,
210
- });
211
- };
212
- await withLoadingToast(title, runDownload);
213
- };
214
-
215
- const handleDocumentPDF = async () => {
216
- if (serverSidePdfEnabled) {
217
- await downloadServerSidePDF({
218
- preset: "document",
219
- title: "Downloading Document PDF...",
220
- });
221
- return;
222
- }
223
- const beforeprint = new Event("export-beforeprint");
224
- const afterprint = new Event("export-afterprint");
225
- window.dispatchEvent(beforeprint);
226
- setTimeout(() => window.print(), 0);
227
- setTimeout(() => window.dispatchEvent(afterprint), 0);
228
- };
229
-
230
- const handleDownloadAsIPYNB = async () => {
231
- if (!filename) {
232
- toastNotebookMustBeNamed();
233
- return;
234
- }
235
-
236
- const runDownload = async (progress: ProgressState) => {
237
- await updateCellOutputsWithScreenshots({
238
- takeScreenshots: () => takeScreenshots({ progress }),
239
- updateCellOutputs,
240
- });
241
- const exportedFile = await exportAsIPYNB({ download: false });
242
- downloadExportedFile(exportedFile);
243
- };
244
-
245
- await withLoadingToast("Downloading IPYNB...", runDownload);
246
- };
247
-
248
175
  const actions: ActionButton[] = [
176
+ {
177
+ icon: <DownloadIcon size={14} strokeWidth={1.5} />,
178
+ label: "Export…",
179
+ additionalKeywords: [
180
+ "download",
181
+ "html",
182
+ "markdown",
183
+ "ipynb",
184
+ "pdf",
185
+ "script",
186
+ "png",
187
+ ],
188
+ handle: () => openExportDialog(),
189
+ },
249
190
  {
250
191
  icon: <DownloadIcon size={14} strokeWidth={1.5} />,
251
192
  label: "Download",
@@ -254,83 +195,44 @@ export function useNotebookActions() {
254
195
  {
255
196
  icon: <FolderDownIcon size={14} strokeWidth={1.5} />,
256
197
  label: "Download as HTML",
257
- handle: async () => {
258
- if (!filename) {
259
- toastNotebookMustBeNamed();
260
- return;
261
- }
262
- await downloadAsHTML({ includeCode: true });
263
- },
198
+ handle: () =>
199
+ openExportDialog("html", { html: { includeCode: true } }),
264
200
  },
265
201
  {
266
202
  icon: <FolderDownIcon size={14} strokeWidth={1.5} />,
267
203
  label: "Download as HTML (exclude code)",
268
- handle: async () => {
269
- if (!filename) {
270
- toastNotebookMustBeNamed();
271
- return;
272
- }
273
- await downloadAsHTML({ includeCode: false });
274
- },
204
+ handle: () =>
205
+ openExportDialog("html", { html: { includeCode: false } }),
275
206
  },
276
207
  {
277
208
  icon: (
278
209
  <MarkdownIcon strokeWidth={1.5} style={{ width: 14, height: 14 }} />
279
210
  ),
280
211
  label: "Download as Markdown",
281
- handle: async () => {
282
- const exportedFile = await exportAsMarkdown({ download: false });
283
- downloadExportedFile(exportedFile);
284
- },
212
+ handle: () => openExportDialog("markdown"),
285
213
  },
286
214
  {
287
215
  icon: <NotebookIcon size={14} strokeWidth={1.5} />,
288
216
  label: "Download as ipynb",
289
- handle: handleDownloadAsIPYNB,
217
+ handle: () => openExportDialog("ipynb"),
290
218
  },
291
219
  {
292
220
  icon: <CodeIcon size={14} strokeWidth={1.5} />,
293
221
  label: "Download notebook source",
294
- handle: async () => {
295
- const code = await readCode();
296
- downloadBlob(
297
- new Blob([code.contents], { type: "text/plain" }),
298
- Filenames.toPY(document.title),
299
- );
300
- },
222
+ handle: () =>
223
+ openExportDialog("script", { script: { type: "source" } }),
301
224
  },
302
225
  {
303
226
  icon: <CodeIcon size={14} strokeWidth={1.5} />,
304
227
  label: "Download flat script",
305
- handle: async () => {
306
- const exportedFile = await exportAsScript({ download: false });
307
- downloadExportedFile(exportedFile);
308
- },
228
+ handle: () =>
229
+ openExportDialog("script", { script: { type: "flat" } }),
309
230
  },
310
231
  {
311
232
  divider: true,
312
233
  icon: <ImageIcon size={14} strokeWidth={1.5} />,
313
234
  label: "Download as PNG",
314
- disabled: viewState.mode !== "present",
315
- tooltip:
316
- viewState.mode === "present" ? undefined : (
317
- <span>
318
- Only available in app view. <br />
319
- Toggle with: {renderShortcut("global.hideCode", false)}
320
- </span>
321
- ),
322
- handle: async () => {
323
- const app = document.getElementById("App");
324
- if (!app) {
325
- return;
326
- }
327
- await downloadHTMLAsImage({
328
- element: app,
329
- filename: document.title,
330
- // Add body.printing ONLY when converting the whole notebook to a screenshot
331
- prepare: ADD_PRINTING_CLASS,
332
- });
333
- },
235
+ handle: () => openExportDialog("png"),
334
236
  },
335
237
  isSlidesLayout
336
238
  ? {
@@ -342,19 +244,24 @@ export function useNotebookActions() {
342
244
  {
343
245
  icon: <FileIcon size={14} strokeWidth={1.5} />,
344
246
  label: "Document Layout",
345
- handle: handleDocumentPDF,
247
+ handle: () =>
248
+ openExportDialog("pdf", {
249
+ pdf: { preset: "document" },
250
+ }),
346
251
  },
347
252
  {
348
253
  icon: <FileIcon size={14} strokeWidth={1.5} />,
349
254
  label: "Slides Layout",
350
- rightElement: renderRecommendedElement(true),
351
- hidden: !serverSidePdfEnabled,
352
- handle: async () => {
353
- await downloadServerSidePDF({
354
- preset: "slides",
355
- title: "Downloading Slides PDF...",
356
- });
357
- },
255
+ hidden: isWasm(),
256
+ rightElement: (
257
+ <span className="ml-3 shrink-0 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
258
+ Recommended
259
+ </span>
260
+ ),
261
+ handle: () =>
262
+ openExportDialog("pdf", {
263
+ pdf: { preset: "slides" },
264
+ }),
358
265
  },
359
266
  ],
360
267
  }
@@ -362,7 +269,10 @@ export function useNotebookActions() {
362
269
  divider: true,
363
270
  icon: <FileIcon size={14} strokeWidth={1.5} />,
364
271
  label: "Download as PDF",
365
- handle: handleDocumentPDF,
272
+ handle: () =>
273
+ openExportDialog("pdf", {
274
+ pdf: { preset: "document" },
275
+ }),
366
276
  },
367
277
  ],
368
278
  },
@@ -740,11 +650,3 @@ export function useNotebookActions() {
740
650
  return action;
741
651
  });
742
652
  }
743
-
744
- function toastNotebookMustBeNamed() {
745
- toast({
746
- title: "Error",
747
- description: "Notebooks must be named to be exported.",
748
- variant: "danger",
749
- });
750
- }
@@ -87,7 +87,7 @@ export const AddConnectionDialogContent: React.FC<{
87
87
  <span className="block">{codeSnippetHint}</span>
88
88
  </DialogDescription>
89
89
  </DialogHeader>
90
- <AutoDiscoveredDataSources onSubmit={onClose} />
90
+ <AutoDiscoveredDataSources onSubmit={onClose} className="-mt-2" />
91
91
  <Tabs
92
92
  value={activeTab}
93
93
  onValueChange={(v) => setActiveTab(v as ConnectionTab)}
@@ -5,11 +5,13 @@ import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";
5
5
  import type { DetectedDataSource } from "@/core/datasets/data-source-discovery";
6
6
  import { useDataSourceDiscovery } from "@/hooks/useDataSourceDiscovery";
7
7
  import { useInsertCode } from "./components";
8
+ import { cn } from "@/utils/cn";
8
9
 
9
10
  export const QuickAddDataSources: React.FC<{
11
+ className?: string;
10
12
  sources: DetectedDataSource[];
11
13
  onAdd: (source: DetectedDataSource) => void;
12
- }> = ({ sources, onAdd }) => {
14
+ }> = ({ className, sources, onAdd }) => {
13
15
  if (sources.length === 0) {
14
16
  return null;
15
17
  }
@@ -17,12 +19,15 @@ export const QuickAddDataSources: React.FC<{
17
19
  return (
18
20
  <section
19
21
  aria-labelledby="quick-add-data-sources-title"
20
- className="rounded-md border bg-muted/30 px-3 py-2"
22
+ className={cn(
23
+ "rounded-full bg-[linear-gradient(135deg,var(--blue-2),var(--purple-3))] px-3 py-2",
24
+ className,
25
+ )}
21
26
  >
22
27
  <div className="flex flex-wrap items-center gap-2">
23
28
  <div className="mr-1 flex items-center gap-1.5">
24
- <SparklesIcon className="h-3.5 w-3.5 text-muted-foreground" />
25
- <h3 id="quick-add-data-sources-title" className="text-sm font-medium">
29
+ <SparklesIcon className="h-3.5 w-3.5 text-(--blue-9)" />
30
+ <h3 id="quick-add-data-sources-title" className="text-sm">
26
31
  Quick add
27
32
  </h3>
28
33
  </div>
@@ -36,10 +41,10 @@ export const QuickAddDataSources: React.FC<{
36
41
  <button
37
42
  type="button"
38
43
  aria-label={`Add ${source.displayName} connection`}
39
- className="inline-flex items-center gap-1 rounded-full border border-(--blue-8) bg-(--blue-2) px-2.5 py-1 text-xs font-semibold text-(--blue-11) transition-colors hover:bg-(--blue-3) focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
44
+ className="inline-flex items-center gap-1 rounded-full border border-border/60 bg-background px-2.5 py-1 text-xs font-medium text-foreground/90 transition-colors hover:border-(--blue-7) focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
40
45
  onClick={() => onAdd(source)}
41
46
  >
42
- <PlusIcon className="h-3 w-3" />
47
+ <PlusIcon className="h-3 w-3 text-muted-foreground" />
43
48
  {source.displayName}
44
49
  </button>
45
50
  </Tooltip>
@@ -90,12 +95,14 @@ const DetectedDataSourceDetails: React.FC<{
90
95
 
91
96
  export const AutoDiscoveredDataSources: React.FC<{
92
97
  onSubmit: () => void;
93
- }> = ({ onSubmit }) => {
98
+ className?: string;
99
+ }> = ({ onSubmit, className }) => {
94
100
  const insertCode = useInsertCode();
95
101
  const { data } = useDataSourceDiscovery();
96
102
 
97
103
  return (
98
104
  <QuickAddDataSources
105
+ className={className}
99
106
  sources={data ?? []}
100
107
  onAdd={(source) => {
101
108
  insertCode(source.code);
@@ -0,0 +1,187 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { Provider } from "jotai";
5
+ import type React from "react";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { MockRequestClient } from "@/__mocks__/requests";
8
+ import { ModalProvider } from "@/components/modal/ImperativeModal";
9
+ import { TooltipProvider } from "@/components/ui/tooltip";
10
+ import { layoutStateAtom } from "@/core/layout/layout";
11
+ import { kioskModeAtom, viewStateAtom } from "@/core/mode";
12
+ import { requestClientAtom } from "@/core/network/requests";
13
+ import { filenameAtom } from "@/core/saving/file-state";
14
+ import { store } from "@/core/state/jotai";
15
+ import { isWasm } from "@/core/wasm/utils";
16
+ import {
17
+ DEFAULT_EXPORT_OPTIONS,
18
+ exportOptionsAtom,
19
+ lastExportFormatAtom,
20
+ } from "../../actions/export-dialog/state";
21
+ import { NotebookMenuDropdown } from "../notebook-menu-dropdown";
22
+
23
+ vi.mock("@/core/wasm/utils", async (importOriginal) => {
24
+ const actual = await importOriginal<typeof import("@/core/wasm/utils")>();
25
+ return { ...actual, isWasm: vi.fn(() => false) };
26
+ });
27
+
28
+ function wrapper({ children }: { children: React.ReactNode }) {
29
+ return (
30
+ <Provider store={store}>
31
+ <TooltipProvider>
32
+ <ModalProvider>{children}</ModalProvider>
33
+ </TooltipProvider>
34
+ </Provider>
35
+ );
36
+ }
37
+
38
+ function openNotebookMenu() {
39
+ fireEvent.pointerDown(screen.getByTestId("notebook-menu-dropdown"), {
40
+ button: 0,
41
+ ctrlKey: false,
42
+ });
43
+ }
44
+
45
+ async function openDownloadMenu() {
46
+ openNotebookMenu();
47
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Download" }));
48
+ }
49
+
50
+ async function selectDownload(name: string | RegExp) {
51
+ await openDownloadMenu();
52
+ fireEvent.click(await screen.findByRole("menuitem", { name }));
53
+ }
54
+
55
+ describe("NotebookMenuDropdown", () => {
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ localStorage.clear();
59
+ store.set(requestClientAtom, MockRequestClient.create());
60
+ store.set(filenameAtom, "/project/notebook.py");
61
+ store.set(viewStateAtom, { mode: "edit", cellAnchor: null });
62
+ store.set(kioskModeAtom, false);
63
+ store.set(layoutStateAtom, {
64
+ selectedLayout: "vertical",
65
+ layoutData: {},
66
+ });
67
+ store.set(exportOptionsAtom, DEFAULT_EXPORT_OPTIONS);
68
+ store.set(lastExportFormatAtom, "html");
69
+ vi.mocked(isWasm).mockReturnValue(false);
70
+ vi.stubGlobal("PointerEvent", MouseEvent);
71
+ vi.stubGlobal("matchMedia", () => ({
72
+ matches: false,
73
+ addEventListener: vi.fn(),
74
+ removeEventListener: vi.fn(),
75
+ }));
76
+ });
77
+
78
+ afterEach(() => {
79
+ document.title = "";
80
+ vi.unstubAllGlobals();
81
+ });
82
+
83
+ it("returns focus to the notebook menu when the dialog closes", async () => {
84
+ render(<NotebookMenuDropdown />, { wrapper });
85
+ const menuButton = screen.getByTestId("notebook-menu-dropdown");
86
+
87
+ openNotebookMenu();
88
+ fireEvent.click(
89
+ await screen.findByRole("menuitem", {
90
+ name: "Export…",
91
+ }),
92
+ );
93
+ fireEvent.click(await screen.findByRole("button", { name: "Close" }));
94
+
95
+ await waitFor(() => expect(menuButton).toHaveFocus());
96
+ });
97
+
98
+ it("opens the HTML shortcut with code excluded", async () => {
99
+ render(<NotebookMenuDropdown />, { wrapper });
100
+
101
+ await selectDownload("Download as HTML (exclude code)");
102
+
103
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
104
+ expect(screen.getByTestId("export-format-html")).toHaveAttribute(
105
+ "aria-selected",
106
+ "true",
107
+ );
108
+ expect(
109
+ screen.getByRole("switch", { name: "Include code" }),
110
+ ).not.toBeChecked();
111
+ });
112
+
113
+ it("opens the slides PDF shortcut with the slides layout", async () => {
114
+ store.set(layoutStateAtom, {
115
+ selectedLayout: "slides",
116
+ layoutData: {},
117
+ });
118
+ render(<NotebookMenuDropdown />, { wrapper });
119
+
120
+ await openDownloadMenu();
121
+ fireEvent.click(
122
+ await screen.findByRole("menuitem", {
123
+ name: "Download as PDF",
124
+ }),
125
+ );
126
+ fireEvent.click(
127
+ await screen.findByRole("menuitem", {
128
+ name: /Slides Layout/,
129
+ }),
130
+ );
131
+
132
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
133
+ expect(screen.getByTestId("export-format-pdf")).toHaveAttribute(
134
+ "aria-selected",
135
+ "true",
136
+ );
137
+ expect(screen.getByRole("radio", { name: "Slides" })).toBeChecked();
138
+ });
139
+
140
+ it("hides the slides PDF shortcut in WebAssembly", async () => {
141
+ vi.mocked(isWasm).mockReturnValue(true);
142
+ store.set(layoutStateAtom, {
143
+ selectedLayout: "slides",
144
+ layoutData: {},
145
+ });
146
+ render(<NotebookMenuDropdown />, { wrapper });
147
+
148
+ await openDownloadMenu();
149
+ fireEvent.click(
150
+ await screen.findByRole("menuitem", {
151
+ name: "Download as PDF",
152
+ }),
153
+ );
154
+
155
+ expect(
156
+ screen.getByRole("menuitem", { name: "Document Layout" }),
157
+ ).toBeVisible();
158
+ expect(
159
+ screen.queryByRole("menuitem", { name: /Slides Layout/ }),
160
+ ).not.toBeInTheDocument();
161
+ });
162
+
163
+ it("preselects each Python format from its download shortcut", async () => {
164
+ render(<NotebookMenuDropdown />, { wrapper });
165
+
166
+ await selectDownload("Download notebook source");
167
+
168
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
169
+ expect(screen.getByTestId("export-format-script")).toHaveAttribute(
170
+ "aria-selected",
171
+ "true",
172
+ );
173
+ expect(
174
+ screen.getByRole("radio", { name: "Notebook source" }),
175
+ ).toBeChecked();
176
+
177
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
178
+ await waitFor(() =>
179
+ expect(screen.queryByTestId("export-dialog")).not.toBeInTheDocument(),
180
+ );
181
+
182
+ await selectDownload("Download flat script");
183
+
184
+ expect(await screen.findByTestId("export-dialog")).toBeVisible();
185
+ expect(screen.getByRole("radio", { name: "Flat script" })).toBeChecked();
186
+ });
187
+ });
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { MenuIcon } from "lucide-react";
4
- import React from "react";
4
+ import React, { useRef } from "react";
5
5
  import { useLocale } from "react-aria";
6
6
  import { Button } from "@/components/editor/inputs/Inputs";
7
7
  import {
@@ -33,7 +33,8 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
33
33
  disabled = false,
34
34
  tooltip = "Actions",
35
35
  }) => {
36
- const actions = useNotebookActions();
36
+ const exportDialogReturnFocusRef = useRef<HTMLButtonElement>(null);
37
+ const actions = useNotebookActions({ exportDialogReturnFocusRef });
37
38
  const { locale } = useLocale();
38
39
  // Create tooltip content with keyboard shortcut decoration
39
40
  const tooltipContent = (
@@ -50,6 +51,7 @@ export const NotebookMenuDropdown: React.FC<Props> = ({
50
51
 
51
52
  const button = (
52
53
  <Button
54
+ ref={exportDialogReturnFocusRef}
53
55
  aria-label="Config"
54
56
  shape="circle"
55
57
  size="small"