@marimo-team/frontend 0.25.3-dev129 → 0.25.3-dev134

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 (166) hide show
  1. package/dist/assets/{CellStatus-6xDEj3Hs.js → CellStatus-DC6FFQOg.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-DCcLAktu.js → ConnectedDataExplorerComponent-BV1mVpaE.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-98Xo04DS.js → ErrorBoundary-Ce-Ujlwj.js} +2 -2
  4. package/dist/assets/{JsonOutput-V47aiTIK.js → JsonOutput-CfEBzaqW.js} +4 -4
  5. package/dist/assets/{MarimoErrorOutput-DeWZNBgL.js → MarimoErrorOutput-Bh6o9zle.js} +1 -1
  6. package/dist/assets/{RenderHTML-Bjvcs70-.js → RenderHTML-COGZsXQV.js} +1 -1
  7. package/dist/assets/{RunButton-CnvvXD9d.js → RunButton-DQ51plUv.js} +1 -1
  8. package/dist/assets/{add-cell-with-ai-BbuqzITi.js → add-cell-with-ai-D7gtGUsg.js} +1 -1
  9. package/dist/assets/{add-connection-dialog-DIC7owvZ.js → add-connection-dialog-CpMCc97S.js} +1 -1
  10. package/dist/assets/{agent-panel-D8hO2P1x.js → agent-panel-BSFYqJ9B.js} +2 -2
  11. package/dist/assets/{ai-model-dropdown-DgmiG_5j.js → ai-model-dropdown-D575Vid8.js} +3 -3
  12. package/dist/assets/{app-config-button-UZHv1Iim.js → app-config-button-Cfcg_5Bz.js} +1 -1
  13. package/dist/assets/{blob-BPknzzkY.js → blob-CszMu60N.js} +1 -1
  14. package/dist/assets/{cell-editor-CM-K1_wq.js → cell-editor-BLpOlLnM.js} +2 -2
  15. package/dist/assets/cell-link-Cj0gGaMV.js +1 -0
  16. package/dist/assets/{cells-BypTg035.js → cells-SvbLY10y.js} +1 -1
  17. package/dist/assets/{chat-display-BRLNOoTF.js → chat-display-D43_g3TX.js} +1 -1
  18. package/dist/assets/{chat-panel-D02Rk9we.js → chat-panel-B_HAJMwx.js} +1 -1
  19. package/dist/assets/{chat-ui-DFN_RwxF.js → chat-ui-BEcYCphu.js} +2 -2
  20. package/dist/assets/{column-preview-85ORMhfB.js → column-preview-CrzaJKk8.js} +1 -1
  21. package/dist/assets/{command-palette-CtyjjdsA.js → command-palette-Bm9yIVcD.js} +1 -1
  22. package/dist/assets/{common-J265II7i.js → common-BJyUxCzv.js} +1 -1
  23. package/dist/assets/{components-Bd3Dpi92.js → components-D1f_Cz4i.js} +1 -1
  24. package/dist/assets/{datasource-Dy4rzzSP.js → datasource-C8KSVfSD.js} +1 -1
  25. package/dist/assets/{dependency-graph-panel-ByGFFT4E.js → dependency-graph-panel-BhmyJbUl.js} +1 -1
  26. package/dist/assets/{documentation-panel-LxuJCY3M.js → documentation-panel-DcVG0Q0d.js} +1 -1
  27. package/dist/assets/{download-BsBJWH_j.js → download-Bx52VRE4.js} +1 -1
  28. package/dist/assets/{edit-page--d6X-OO9.js → edit-page-DUrwOHR-.js} +6 -6
  29. package/dist/assets/{error-entries-0wTSSuK9.js → error-entries-11OuHzUF.js} +1 -1
  30. package/dist/assets/{error-panel-ZNdbE3Fo.js → error-panel-XJy5S25F.js} +1 -1
  31. package/dist/assets/{feedback-button-C6asPmsh.js → feedback-button-B7jh_B4w.js} +1 -1
  32. package/dist/assets/feedback-button-C-eKtxXE.js +1 -0
  33. package/dist/assets/{file-explorer-panel-0fUP_ptL.js → file-explorer-panel-CnVxbF4A.js} +1 -1
  34. package/dist/assets/{file-name-input-B8lzI5iS.js → file-name-input-BJuqjQqH.js} +2 -2
  35. package/dist/assets/floating-outline-8yyZ-MOJ.js +1 -0
  36. package/dist/assets/{gallery-page-ClR8U-km.js → gallery-page-DJvGHePf.js} +1 -1
  37. package/dist/assets/{glide-data-editor-B6QQBQwS.js → glide-data-editor-Cyc8DHZx.js} +1 -1
  38. package/dist/assets/{home-page-CJnqDcT9.js → home-page-tRZJmSU6.js} +1 -1
  39. package/dist/assets/{hooks-CCek4i9f.js → hooks-CxGwybGw.js} +1 -1
  40. package/dist/assets/index-CoxbqhGH.js +30 -0
  41. package/dist/assets/index-u08m0hQE.css +2 -0
  42. package/dist/assets/{layout-66Urh4Zg.js → layout-DvXJxBiL.js} +5 -5
  43. package/dist/assets/{logs-panel-DFhkIcge.js → logs-panel-KtsY7TrR.js} +1 -1
  44. package/dist/assets/{markdown-renderer-mYYQYD2m.js → markdown-renderer-DUgV7_fm.js} +1 -1
  45. package/dist/assets/{name-cell-input-Crcu3i2z.js → name-cell-input-C3-Cj-Ek.js} +1 -1
  46. package/dist/assets/{outline-panel-BFIPK0H_.js → outline-panel-BzyY4JA6.js} +1 -1
  47. package/dist/assets/{packages-panel-B4KtjgkF.js → packages-panel-DxosbWko.js} +1 -1
  48. package/dist/assets/{panel-accordion-state-D6KtwSF8.js → panel-accordion-state-BRbTib2B.js} +1 -1
  49. package/dist/assets/panels-Da3071Q4.js +1 -0
  50. package/dist/assets/{process-output-DDvMd6Jh.js → process-output-CSwx7rvo.js} +1 -1
  51. package/dist/assets/{readonly-code-display-VDqYn2d0.js → readonly-code-display-B9y9423Y.js} +1 -1
  52. package/dist/assets/{readonly-python-code-CZUKLXJ4.js → readonly-python-code-Dgw6trlk.js} +1 -1
  53. package/dist/assets/{reveal-component-BMmDU1uN.js → reveal-component-B9A4wkG-.js} +1 -1
  54. package/dist/assets/{run-page-BmOPPh2Y.js → run-page-C1MQ5b_A.js} +1 -1
  55. package/dist/assets/{runtime-details-state-CipqynUD.js → runtime-details-state-D_hOQL9T.js} +1 -1
  56. package/dist/assets/scratchpad-panel-CBCsPmMW.js +1 -0
  57. package/dist/assets/secrets-panel-ConQpchl.js +1 -0
  58. package/dist/assets/session-panel-CiHCKPlx.js +1 -0
  59. package/dist/assets/snippets-panel-C0VDE_4i.js +1 -0
  60. package/dist/assets/{state-QfGh3m2G.js → state-DrraMmqz.js} +1 -1
  61. package/dist/assets/{state-BLGvzMB6.js → state-DvU2AiFx.js} +22 -22
  62. package/dist/assets/tracing-C5d51Nmi.js +1 -0
  63. package/dist/assets/{tracing-panel-CsHuzRGP.js → tracing-panel-B89R8dWE.js} +2 -2
  64. package/dist/assets/{tree-actions-BQw_utcj.js → tree-actions-Cph8FVzh.js} +1 -1
  65. package/dist/assets/{useAddCell-_b8InOZC.js → useAddCell-VPkB72pn.js} +1 -1
  66. package/dist/assets/{useCellActionButton-BbgMzBdk.js → useCellActionButton--zI3GFnt.js} +1 -1
  67. package/dist/assets/{useDeleteCell-DhoFbEkU.js → useDeleteCell-CjewKeSr.js} +1 -1
  68. package/dist/assets/{useDependencyPanelTab-COTULhoH.js → useDependencyPanelTab-Bt_ka-wl.js} +1 -1
  69. package/dist/assets/{useNotebookActions-xnA_l3nY.js → useNotebookActions-DV2S6KaG.js} +1 -1
  70. package/dist/assets/{useRunCells-VBCe6CLn.js → useRunCells-BupCZpzk.js} +1 -1
  71. package/dist/assets/{useSplitCell-AvSpVAmq.js → useSplitCell-CaLIR0TS.js} +1 -1
  72. package/dist/index.html +21 -21
  73. package/package.json +1 -1
  74. package/src/__tests__/module-entry.test.ts +32 -0
  75. package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
  76. package/src/components/ai/ai-model-dropdown.tsx +11 -6
  77. package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
  78. package/src/components/chat/acp/blocks.tsx +1 -0
  79. package/src/components/chat/acp/session-tabs.tsx +8 -3
  80. package/src/components/data-table/TableTopBar.tsx +2 -0
  81. package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
  82. package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
  83. package/src/components/data-table/data-table.tsx +6 -0
  84. package/src/components/data-table/range-focus/atoms.ts +4 -6
  85. package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
  86. package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
  87. package/src/components/data-table/renderers.tsx +16 -7
  88. package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
  89. package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
  90. package/src/components/datasources/datasources.tsx +1 -0
  91. package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
  92. package/src/components/editor/cell/collapse.tsx +5 -3
  93. package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
  94. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
  95. package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
  96. package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
  97. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
  98. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
  99. package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
  100. package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
  101. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
  102. package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
  103. package/src/components/editor/file-tree/file-explorer.tsx +35 -3
  104. package/src/components/editor/file-tree/file-name-input.tsx +1 -0
  105. package/src/components/editor/header/status.tsx +28 -5
  106. package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
  107. package/src/components/editor/links/cell-link.tsx +14 -5
  108. package/src/components/editor/navigation/focus-utils.ts +4 -4
  109. package/src/components/editor/notebook-cell.tsx +11 -2
  110. package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
  111. package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
  112. package/src/components/editor/package-alert.tsx +8 -3
  113. package/src/components/scratchpad/scratchpad.test.tsx +95 -0
  114. package/src/components/scratchpad/scratchpad.tsx +14 -1
  115. package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
  116. package/src/components/tracing/tracing.tsx +43 -39
  117. package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
  118. package/src/components/ui/draggable-popover.tsx +24 -2
  119. package/src/core/attachments/state.ts +2 -2
  120. package/src/core/document/__tests__/codemirror.test.ts +3 -1
  121. package/src/core/document/__tests__/hooks.test.tsx +7 -7
  122. package/src/core/document/__tests__/model.test.ts +44 -11
  123. package/src/core/document/__tests__/scenario-fixture.ts +2 -1
  124. package/src/core/document/__tests__/test-notebook.ts +19 -9
  125. package/src/core/document/draft.ts +1 -1
  126. package/src/core/document/index.ts +3 -10
  127. package/src/core/document/model.ts +14 -11
  128. package/src/core/document/state.ts +1 -1
  129. package/src/core/document/sync.ts +24 -18
  130. package/src/core/host/AGENTS.md +3 -7
  131. package/src/core/host/__tests__/fixtures.ts +30 -0
  132. package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
  133. package/src/core/host/__tests__/wasm.test.ts +8 -0
  134. package/src/core/host/backend.ts +4 -0
  135. package/src/core/host/index.ts +14 -0
  136. package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
  137. package/src/core/host/static.ts +2 -0
  138. package/src/core/host/wasm.ts +10 -1
  139. package/src/core/wasm/bridge.ts +1 -1
  140. package/src/core/websocket/__tests__/backend.test.ts +111 -22
  141. package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
  142. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
  143. package/src/core/websocket/backend.ts +43 -5
  144. package/src/core/websocket/connection-state.ts +1 -1
  145. package/src/core/websocket/state.ts +3 -3
  146. package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
  147. package/src/core/websocket/types.ts +2 -2
  148. package/src/core/websocket/useNotebookReplica.ts +69 -0
  149. package/src/core/websocket/useNotebookSession.tsx +6 -6
  150. package/src/plugins/impl/chat/chat-ui.tsx +2 -1
  151. package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
  152. package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
  153. package/src/plugins/impl/data-frames/panel.tsx +43 -16
  154. package/dist/assets/cell-link-CAjuFcLK.js +0 -1
  155. package/dist/assets/feedback-button-Bt89tGG2.js +0 -1
  156. package/dist/assets/floating-outline-CRpmOv32.js +0 -1
  157. package/dist/assets/index-B5pWkt7a.js +0 -30
  158. package/dist/assets/index-BnhSnwvB.css +0 -2
  159. package/dist/assets/panels-DiwGKPzK.js +0 -1
  160. package/dist/assets/scratchpad-panel-Drr_kuwb.js +0 -1
  161. package/dist/assets/secrets-panel-D-W1PL76.js +0 -1
  162. package/dist/assets/session-panel-W7JOcog_.js +0 -1
  163. package/dist/assets/snippets-panel-cUPEdm66.js +0 -1
  164. package/dist/assets/tracing-BNFynaTu.js +0 -1
  165. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
  166. package/src/core/websocket/useNotebookClient.ts +0 -89
@@ -0,0 +1,146 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import { useState } from "react";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { cellId } from "@/__tests__/branded";
7
+ import { HTMLCellId, SCRATCH_CELL_ID } from "@/core/cells/ids";
8
+ import { goToCellLine } from "@/core/codemirror/go-to-definition/utils";
9
+ import { CellLink, CellLinkTraceback } from "../cell-link";
10
+
11
+ const showCellIfHidden = vi.hoisted(() => vi.fn());
12
+ const targetId = cellId("target");
13
+
14
+ vi.mock("@/core/cells/cells", () => ({
15
+ useCellActions: () => ({ showCellIfHidden }),
16
+ useCellIds: () => ({ inOrderIds: [targetId] }),
17
+ useCellNames: () => ({ [targetId]: "Target" }),
18
+ }));
19
+ vi.mock("@/core/saving/filename", () => ({ useFilename: () => "notebook.py" }));
20
+ vi.mock("@/core/codemirror/go-to-definition/utils", () => ({
21
+ goToCellLine: vi.fn(),
22
+ }));
23
+
24
+ function flushNavigation() {
25
+ act(() => vi.advanceTimersToNextFrame());
26
+ }
27
+
28
+ describe("CellLink navigation", () => {
29
+ beforeEach(() => {
30
+ vi.useFakeTimers();
31
+ vi.clearAllMocks();
32
+ showCellIfHidden.mockReset();
33
+ });
34
+ afterEach(() => vi.useRealTimers());
35
+
36
+ it("focuses, scrolls, and highlights the target on keyboard activation", () => {
37
+ render(
38
+ <>
39
+ <CellLink cellId={targetId} />
40
+ <div
41
+ id={HTMLCellId.create(targetId)}
42
+ tabIndex={-1}
43
+ data-testid="target"
44
+ />
45
+ </>,
46
+ );
47
+ const target = screen.getByTestId("target");
48
+ target.scrollIntoView = vi.fn();
49
+ const focus = vi.spyOn(target, "focus");
50
+ const link = screen.getByRole("link");
51
+ expect(link).toHaveAttribute("href", `#${target.id}`);
52
+ link.focus();
53
+ // Native anchors emit a click with detail=0 when activated with Enter.
54
+ expect(fireEvent.click(link, { detail: 0 })).toBe(false);
55
+ flushNavigation();
56
+
57
+ expect(showCellIfHidden).toHaveBeenCalledExactlyOnceWith({
58
+ cellId: targetId,
59
+ });
60
+ expect(target).toHaveFocus();
61
+ expect(focus).toHaveBeenCalledWith({ preventScroll: true });
62
+ expect(target.scrollIntoView).toHaveBeenCalledExactlyOnceWith({
63
+ behavior: "smooth",
64
+ block: "center",
65
+ });
66
+ expect(target).toHaveClass("focus-outline");
67
+ });
68
+
69
+ it("reveals a hidden destination before moving focus", () => {
70
+ function HiddenCell() {
71
+ const [visible, setVisible] = useState(false);
72
+ showCellIfHidden.mockImplementation(() => setVisible(true));
73
+ return (
74
+ <>
75
+ <CellLink cellId={targetId} skipScroll={true} />
76
+ {visible && (
77
+ <div
78
+ id={HTMLCellId.create(targetId)}
79
+ tabIndex={-1}
80
+ data-testid="target"
81
+ />
82
+ )}
83
+ </>
84
+ );
85
+ }
86
+ render(<HiddenCell />);
87
+ expect(screen.queryByTestId("target")).not.toBeInTheDocument();
88
+ fireEvent.click(screen.getByRole("link"));
89
+ flushNavigation();
90
+ expect(screen.getByTestId("target")).toHaveFocus();
91
+ });
92
+
93
+ it.each([
94
+ { metaKey: true },
95
+ { ctrlKey: true },
96
+ { shiftKey: true },
97
+ { altKey: true },
98
+ { button: 1 },
99
+ ])("preserves browser activation for %o", (modifiers) => {
100
+ render(<CellLink cellId={targetId} />);
101
+ const link = screen.getByRole("link");
102
+ link.focus();
103
+ expect(fireEvent.click(link, modifiers)).toBe(true);
104
+ flushNavigation();
105
+ expect(showCellIfHidden).not.toHaveBeenCalled();
106
+ expect(link).toHaveFocus();
107
+ });
108
+
109
+ it("lets traceback line navigation choose the final focus", () => {
110
+ render(
111
+ <>
112
+ <CellLinkTraceback cellId={targetId} lineNumber={7} />
113
+ <div
114
+ id={HTMLCellId.create(targetId)}
115
+ tabIndex={-1}
116
+ data-testid="target"
117
+ >
118
+ <textarea aria-label="Editor" />
119
+ </div>
120
+ </>,
121
+ );
122
+ const target = screen.getByTestId("target");
123
+ target.scrollIntoView = vi.fn();
124
+ const editor = screen.getByRole("textbox", { name: "Editor" });
125
+ vi.mocked(goToCellLine).mockImplementation(() => {
126
+ expect(target).toHaveFocus();
127
+ editor.focus();
128
+ return true;
129
+ });
130
+ fireEvent.click(screen.getByRole("link"));
131
+ flushNavigation();
132
+ expect(goToCellLine).toHaveBeenCalledExactlyOnceWith(targetId, 7);
133
+ expect(editor).toHaveFocus();
134
+ expect(target.scrollIntoView).not.toHaveBeenCalled();
135
+ });
136
+
137
+ it("keeps scratch references non-navigable", () => {
138
+ render(
139
+ <CellLink cellId={SCRATCH_CELL_ID} formatCellName={() => "scratch"} />,
140
+ );
141
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
142
+ fireEvent.click(screen.getByText("scratch"));
143
+ flushNavigation();
144
+ expect(showCellIfHidden).not.toHaveBeenCalled();
145
+ });
146
+ });
@@ -7,7 +7,9 @@ import { displayCellName } from "@/core/cells/names";
7
7
  import { goToCellLine } from "@/core/codemirror/go-to-definition/utils";
8
8
  import { useFilename } from "@/core/saving/filename";
9
9
  import { cn } from "@/utils/cn";
10
+ import { Events } from "@/utils/events";
10
11
  import { Logger } from "../../../utils/Logger";
12
+ import { focusCell } from "../navigation/focus-utils";
11
13
 
12
14
  interface Props {
13
15
  cellId: CellId;
@@ -29,14 +31,20 @@ export const CellLink = (props: Props): JSX.Element => {
29
31
  const formatName = formatCellName ?? ((name: string) => name);
30
32
 
31
33
  return (
32
- <div
34
+ // Reveal hidden cells before scrolling and focusing the destination.
35
+ // react-doctor-disable-next-line react-doctor/no-prevent-default
36
+ <a
37
+ href={
38
+ cellId === SCRATCH_CELL_ID ? undefined : `#${HTMLCellId.create(cellId)}`
39
+ }
33
40
  className={cn(
34
- "inline-block cursor-pointer text-link hover:underline",
41
+ "inline-block cursor-pointer text-left text-link hover:underline focus-visible:outline focus-visible:outline-ring",
35
42
  className,
36
43
  )}
37
- role="link"
38
- tabIndex={-1}
39
44
  onClick={(e) => {
45
+ if (e.defaultPrevented || e.button !== 0 || Events.hasModifier(e)) {
46
+ return;
47
+ }
40
48
  // Scratch causes a crash since scratch is not registered like a
41
49
  // normal cell.
42
50
  if (cellId === SCRATCH_CELL_ID) {
@@ -48,13 +56,14 @@ export const CellLink = (props: Props): JSX.Element => {
48
56
  requestAnimationFrame(() => {
49
57
  const succeeded = scrollAndHighlightCell(cellId, variant, skipScroll);
50
58
  if (succeeded) {
59
+ focusCell(cellId, { preventScroll: true });
51
60
  onClick?.();
52
61
  }
53
62
  });
54
63
  }}
55
64
  >
56
65
  {formatName(displayCellName(cellName, cellIndex))}
57
- </div>
66
+ </a>
58
67
  );
59
68
  };
60
69
 
@@ -19,10 +19,10 @@ export function focusCellEditor(
19
19
  }
20
20
  }
21
21
 
22
- export function focusCell(cellId: CellId): void {
22
+ export function focusCell(cellId: CellId, options?: FocusOptions): void {
23
23
  const element = document.getElementById(HTMLCellId.create(cellId));
24
24
  if (element) {
25
- tryFocus(element);
25
+ tryFocus(element, options);
26
26
  } else {
27
27
  Logger.warn(`[CellFocusManager] focusCell: element not found: ${cellId}`);
28
28
  }
@@ -48,9 +48,9 @@ export function isAnyCellFocused(): boolean {
48
48
  );
49
49
  }
50
50
 
51
- export function tryFocus(dom: HTMLElement) {
51
+ export function tryFocus(dom: HTMLElement, options?: FocusOptions) {
52
52
  try {
53
- dom.focus();
53
+ dom.focus(options);
54
54
  } catch {
55
55
  Logger.warn("[CellFocusManager] element may not be focusable", dom);
56
56
  }
@@ -525,13 +525,22 @@ const EditableCellComponent = ({
525
525
  isMarkdownCodeHidden &&
526
526
  isEmptyMarkdownContent &&
527
527
  !needsRun && (
528
+ // Preserve pointer double-click editing; keyboard and assistive clicks activate directly.
529
+ // react-doctor-disable-next-line react-doctor/prefer-tag-over-role
528
530
  <div
531
+ // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
529
532
  role="button"
530
- aria-label="Double-click to edit markdown"
533
+ aria-label="Edit markdown"
531
534
  className="relative cursor-pointer px-3 py-2"
532
535
  onDoubleClick={showHiddenCodeIfMarkdown}
536
+ onClick={(e) => {
537
+ if (e.detail === 0) {
538
+ showHiddenCodeIfMarkdown();
539
+ }
540
+ }}
533
541
  onKeyDown={(e) => {
534
- if (e.key === "Enter") {
542
+ if (e.key === "Enter" || e.key === " ") {
543
+ e.preventDefault();
535
544
  showHiddenCodeIfMarkdown();
536
545
  }
537
546
  }}
@@ -248,21 +248,22 @@ export const replaceTracebackFilenames = (domNode: DOMNode) => {
248
248
  <Tooltip content={tooltipContent}>
249
249
  <button
250
250
  type="button"
251
+ aria-label={
252
+ debuggerEnabled ? "Toggle breakpoint" : "Insert breakpoint"
253
+ }
251
254
  className="ml-1 p-1 rounded-sm hover:bg-muted transition-colors inline"
255
+ onClick={() => {
256
+ if (debuggerEnabled && !isStaticNotebook()) {
257
+ toggleBreakpoint(info.cellId, info.lineNumber);
258
+ return;
259
+ }
260
+ const view = getCellEditorView(info.cellId);
261
+ if (view) {
262
+ insertDebuggerAtLine(view, info.lineNumber);
263
+ }
264
+ }}
252
265
  >
253
- <BugPlayIcon
254
- onClick={() => {
255
- if (debuggerEnabled && !isStaticNotebook()) {
256
- toggleBreakpoint(info.cellId, info.lineNumber);
257
- return;
258
- }
259
- const view = getCellEditorView(info.cellId);
260
- if (view) {
261
- insertDebuggerAtLine(view, info.lineNumber);
262
- }
263
- }}
264
- className="h-3 w-3"
265
- />
266
+ <BugPlayIcon className="h-3 w-3" />
266
267
  </button>
267
268
  </Tooltip>
268
269
  )}
@@ -272,7 +273,8 @@ export const replaceTracebackFilenames = (domNode: DOMNode) => {
272
273
  }
273
274
  if (info?.kind === "file") {
274
275
  return (
275
- <div
276
+ <button
277
+ type="button"
276
278
  className="inline-block cursor-pointer text-destructive hover:underline"
277
279
  onClick={() => {
278
280
  getRequestClient().openFile({
@@ -282,7 +284,7 @@ export const replaceTracebackFilenames = (domNode: DOMNode) => {
282
284
  }}
283
285
  >
284
286
  <span className="nb">"{info.filePath}"</span>
285
- </div>
287
+ </button>
286
288
  );
287
289
  }
288
290
  };
@@ -1,11 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { render } from "@testing-library/react";
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
4
  import { Provider } from "jotai";
5
5
  import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
6
6
  import { MockRequestClient } from "@/__mocks__/requests";
7
7
  import { Tracebacks } from "@/__mocks__/tracebacks";
8
8
  import { cellId } from "@/__tests__/branded";
9
+ import { toggleBreakpoint } from "@/core/codemirror/cells/debugger-state";
9
10
  import { TooltipProvider } from "@/components/ui/tooltip";
10
11
  import { initialModeAtom } from "@/core/mode";
11
12
  import { requestClientAtom } from "@/core/network/requests";
@@ -19,7 +20,32 @@ import {
19
20
 
20
21
  const cid = cellId("1");
21
22
 
23
+ vi.mock("@/core/config/feature-flag", async (importOriginal) => ({
24
+ ...(await importOriginal<typeof import("@/core/config/feature-flag")>()),
25
+ getFeatureFlag: (flag: string) => flag === "debugger",
26
+ }));
27
+ vi.mock("@/core/codemirror/cells/debugger-state", async (importOriginal) => ({
28
+ ...(await importOriginal<
29
+ typeof import("@/core/codemirror/cells/debugger-state")
30
+ >()),
31
+ toggleBreakpoint: vi.fn(),
32
+ }));
33
+
22
34
  describe("traceback component", () => {
35
+ test("activates the breakpoint from the named button itself", () => {
36
+ render(
37
+ <Provider store={store}>
38
+ <TooltipProvider>
39
+ <MarimoTracebackOutput traceback={Tracebacks.raw} cellId={cid} />
40
+ </TooltipProvider>
41
+ </Provider>,
42
+ );
43
+ const buttons = screen.getAllByRole("button", {
44
+ name: "Toggle breakpoint",
45
+ });
46
+ fireEvent.click(buttons[0]);
47
+ expect(toggleBreakpoint).toHaveBeenCalledOnce();
48
+ });
23
49
  beforeEach(() => {
24
50
  vi.resetAllMocks();
25
51
  store.set(initialModeAtom, "edit");
@@ -55,6 +55,7 @@ import {
55
55
  DropdownMenu,
56
56
  DropdownMenuCheckboxItem,
57
57
  DropdownMenuContent,
58
+ DropdownMenuItem,
58
59
  DropdownMenuTrigger,
59
60
  } from "../ui/dropdown-menu";
60
61
  import { ExternalLink } from "../ui/links";
@@ -592,14 +593,18 @@ const ExtrasSelector: React.FC<ExtrasSelectorProps> = ({
592
593
  <div className="p-2 bg-popover border-b border-border">
593
594
  <div className="flex flex-wrap gap-1 p-1 min-h-[24px]">
594
595
  {selectedExtras.map((extra) => (
595
- <span
596
+ <DropdownMenuItem
596
597
  key={extra}
598
+ aria-label={`Remove ${extra}`}
597
599
  className="inline-flex items-center gap-1 px-1 py-0.5 text-sm font-mono border border-muted-foreground/30 hover:border-muted-foreground/60 rounded-sm cursor-pointer group transition-colors"
598
- onClick={() => handleExtraToggle(extra, false)}
600
+ onSelect={(e) => {
601
+ e.preventDefault();
602
+ handleExtraToggle(extra, false);
603
+ }}
599
604
  >
600
605
  {extra}
601
606
  <XIcon className="w-3 h-3 opacity-60 group-hover:opacity-100" />
602
- </span>
607
+ </DropdownMenuItem>
603
608
  ))}
604
609
  </div>
605
610
  </div>
@@ -0,0 +1,95 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { EditorView } from "@codemirror/view";
4
+ import { act, fireEvent, render, screen } from "@testing-library/react";
5
+ import { createStore, Provider } from "jotai";
6
+ import { type ComponentProps, useEffect, useRef } from "react";
7
+ import { expect, it, vi } from "vitest";
8
+ import type { CellEditor } from "@/components/editor/cell/code/cell-editor";
9
+ import { TooltipProvider } from "@/components/ui/tooltip";
10
+ import { SCRATCH_CELL_ID } from "@/core/cells/ids";
11
+ import { ScratchPad } from "./scratchpad";
12
+ import {
13
+ historyVisibleAtom,
14
+ scratchpadHistoryAtom,
15
+ } from "./scratchpad-history";
16
+
17
+ const state = vi.hoisted(() => ({
18
+ editor: null as EditorView | null,
19
+ updateCellCode: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("@/core/cells/cells", () => ({
23
+ useNotebook: () => ({ cellRuntime: {}, cellData: {} }),
24
+ useCellActions: () => ({
25
+ createNewCell: vi.fn(),
26
+ updateCellCode: state.updateCellCode,
27
+ }),
28
+ }));
29
+ vi.mock("@/core/cells/focus", () => ({ useLastFocusedCellId: () => null }));
30
+ vi.mock("@/core/network/requests", () => ({
31
+ useRequestClient: () => ({ sendRunScratchpad: vi.fn() }),
32
+ }));
33
+ vi.mock("@/components/editor/chrome/panels/panel-context", () => ({
34
+ usePanelOrientation: () => "vertical",
35
+ usePanelSection: () => "sidebar",
36
+ }));
37
+ vi.mock("@/components/editor/Output", () => ({ OutputArea: () => null }));
38
+ vi.mock("@/components/editor/output/console/ConsoleOutput", () => ({
39
+ ConsoleOutput: () => null,
40
+ }));
41
+ vi.mock("@/plugins/impl/code/LazyAnyLanguageCodeMirror", () => ({
42
+ LazyAnyLanguageCodeMirror: () => null,
43
+ }));
44
+ vi.mock("@/components/shortcuts/renderShortcut", () => ({
45
+ renderShortcut: () => "",
46
+ }));
47
+ vi.mock("@/components/editor/cell/code/cell-editor", () => ({
48
+ CellEditor: ({ setEditorView }: ComponentProps<typeof CellEditor>) => {
49
+ const parent = useRef<HTMLDivElement>(null);
50
+ const setView = useRef(setEditorView);
51
+ useEffect(() => {
52
+ const view = new EditorView({
53
+ parent: parent.current ?? undefined,
54
+ extensions: [
55
+ EditorView.contentAttributes.of({
56
+ "aria-label": "Scratchpad editor",
57
+ }),
58
+ ],
59
+ });
60
+ state.editor = view;
61
+ setView.current(view);
62
+ return () => {
63
+ view.destroy();
64
+ state.editor = null;
65
+ };
66
+ }, []);
67
+ return <div ref={parent} />;
68
+ },
69
+ }));
70
+
71
+ it("focuses the editor after recalling history", () => {
72
+ const store = createStore();
73
+ store.set(scratchpadHistoryAtom, ["print(42)"]);
74
+ store.set(historyVisibleAtom, true);
75
+ render(
76
+ <Provider store={store}>
77
+ <TooltipProvider>
78
+ <ScratchPad />
79
+ </TooltipProvider>
80
+ </Provider>,
81
+ );
82
+ const recall = screen.getByRole("button", { name: "Recall code" });
83
+ act(() => recall.focus());
84
+ fireEvent.click(recall, { detail: 0 });
85
+ expect(recall).not.toBeInTheDocument();
86
+ expect(
87
+ screen.getByRole("textbox", { name: "Scratchpad editor" }),
88
+ ).toHaveFocus();
89
+ expect(state.editor?.state.doc.toString()).toBe("print(42)");
90
+ expect(state.updateCellCode).toHaveBeenCalledExactlyOnceWith({
91
+ cellId: SCRATCH_CELL_ID,
92
+ code: "print(42)",
93
+ formattingChange: false,
94
+ });
95
+ });
@@ -123,6 +123,7 @@ export const ScratchPad: React.FC = () => {
123
123
  insert: item,
124
124
  },
125
125
  });
126
+ ev.focus();
126
127
  }
127
128
  });
128
129
 
@@ -136,11 +137,23 @@ export const ScratchPad: React.FC = () => {
136
137
  <div className="absolute inset-0 z-100 bg-background p-3 border-none overflow-auto">
137
138
  <div className="overflow-auto flex flex-col gap-3">
138
139
  {history.map((item) => (
140
+ // The Recall code button provides keyboard access without making the code preview a button.
141
+ // react-doctor-disable-next-line react-doctor/click-events-have-key-events, react-doctor/no-static-element-interactions
139
142
  <div
140
143
  key={item}
141
- className="border rounded-md hover:shadow-sm cursor-pointer hover:border-input overflow-hidden"
144
+ className="relative border rounded-md hover:shadow-sm cursor-pointer hover:border-input overflow-hidden"
142
145
  onClick={() => handleSelectHistoryItem(item)}
143
146
  >
147
+ <button
148
+ type="button"
149
+ className="sr-only focus:not-sr-only focus:absolute focus:right-1 focus:top-1 focus:z-10 focus:bg-background focus:px-2 focus:py-1 focus:outline focus:outline-ring"
150
+ onClick={(e) => {
151
+ e.stopPropagation();
152
+ handleSelectHistoryItem(item);
153
+ }}
154
+ >
155
+ Recall code
156
+ </button>
144
157
  <Suspense>
145
158
  <LazyAnyLanguageCodeMirror
146
159
  language="python"
@@ -0,0 +1,85 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { TooltipProvider } from "@/components/ui/tooltip";
7
+ import { type Run, type RunId, runsAtom } from "@/core/cells/runs";
8
+ import { Tracing } from "./tracing";
9
+
10
+ vi.mock("react-vega", () => ({ useVegaEmbed: () => undefined }));
11
+ vi.mock("vega-lite", () => ({ compile: () => ({ spec: {} }) }));
12
+ vi.mock("@/components/editor/chrome/panels/panel-context", () => ({
13
+ usePanelSection: () => "sidebar",
14
+ }));
15
+
16
+ const run: Run = {
17
+ runId: "run-1" as RunId,
18
+ runStartTime: 1_704_067_200,
19
+ cellRuns: new Map(),
20
+ };
21
+
22
+ describe("trace disclosure keyboard focus", () => {
23
+ it.each([false, true])(
24
+ "keeps the header focused across toggles (inline=%s)",
25
+ (inline) => {
26
+ const store = createStore();
27
+ store.set(runsAtom, {
28
+ runIds: [run.runId],
29
+ runMap: new Map([[run.runId, run]]),
30
+ });
31
+ render(
32
+ <Provider store={store}>
33
+ <TooltipProvider>
34
+ <Tracing />
35
+ </TooltipProvider>
36
+ </Provider>,
37
+ );
38
+ if (inline) {
39
+ fireEvent.click(screen.getByRole("checkbox", { name: "Inline chart" }));
40
+ }
41
+ const header = screen.getByRole("button", { name: /^Run -/ });
42
+ act(() => header.focus());
43
+ for (const expanded of [false, true, false]) {
44
+ // Native buttons emit a click with detail=0 for keyboard activation.
45
+ fireEvent.click(header, { detail: 0 });
46
+ expect(screen.getByRole("button", { name: /^Run -/ })).toBe(header);
47
+ expect(header).toHaveFocus();
48
+ expect(header).toHaveAttribute("aria-expanded", String(expanded));
49
+ }
50
+ },
51
+ );
52
+
53
+ it("does not steal focus when a newer run expands automatically", () => {
54
+ const store = createStore();
55
+ store.set(runsAtom, {
56
+ runIds: [run.runId],
57
+ runMap: new Map([[run.runId, run]]),
58
+ });
59
+ render(
60
+ <Provider store={store}>
61
+ <TooltipProvider>
62
+ <Tracing />
63
+ </TooltipProvider>
64
+ </Provider>,
65
+ );
66
+ const header = screen.getByRole("button", { name: /^Run -/ });
67
+ act(() => header.focus());
68
+ const newer: Run = {
69
+ ...run,
70
+ runId: "run-2" as RunId,
71
+ runStartTime: run.runStartTime + 1,
72
+ };
73
+ act(() => {
74
+ store.set(runsAtom, {
75
+ runIds: [newer.runId, run.runId],
76
+ runMap: new Map([
77
+ [run.runId, run],
78
+ [newer.runId, newer],
79
+ ]),
80
+ });
81
+ });
82
+ expect(header).toHaveFocus();
83
+ expect(header).toHaveAttribute("aria-expanded", "false");
84
+ });
85
+ });