@marimo-team/islands 0.23.10-dev3 → 0.23.10-dev30

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 (165) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CyV83R2m.js → ConnectedDataExplorerComponent-DmBropAy.js} +31 -31
  2. package/dist/{ErrorBoundary-rULOrC_p.js → ErrorBoundary-DpbaKVv7.js} +1 -1
  3. package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-DNmoSiWL.js} +20 -20
  4. package/dist/assets/__vite-browser-external-eshhtsgZ.js +1 -0
  5. package/dist/assets/worker-CC0Oul9k.js +73 -0
  6. package/dist/{chat-ui-C1tL1pML.js → chat-ui-D6oraHT2.js} +76 -76
  7. package/dist/{check-DTbrK0zt.js → check-BCaJeT-J.js} +1 -1
  8. package/dist/{code-visibility-DfnO0DcH.js → code-visibility-wR7WSQ4c.js} +2166 -1292
  9. package/dist/{copy-BuQpJEzp.js → copy-UqRYxiOg.js} +33 -33
  10. package/dist/dist-7QfXoMdB.js +5 -0
  11. package/dist/{dist-DgnE8F-r.js → dist-A2846XWO.js} +1 -1
  12. package/dist/dist-BEXXyZig.js +5 -0
  13. package/dist/{dist-B3pZ0Ab6.js → dist-BR_gyG9L.js} +3 -3
  14. package/dist/{dist-CcXxepx6.js → dist-BSAt6RhH.js} +27 -27
  15. package/dist/{dist-Bde4a2kU.js → dist-BY018Paw.js} +8 -8
  16. package/dist/dist-BYj57OV4.js +5 -0
  17. package/dist/{dist-CUCNs1ja.js → dist-BaoDKvdy.js} +2 -2
  18. package/dist/{dist-Cy1WxgBD.js → dist-Bf7SHuNp.js} +5 -5
  19. package/dist/{dist-Bz_sYWbr.js → dist-Bk75fBZA.js} +2 -2
  20. package/dist/dist-BlSvQzNr.js +5 -0
  21. package/dist/{dist-C5VC_yzu.js → dist-BzEzfugY.js} +1 -1
  22. package/dist/dist-CCBlxAgS.js +8 -0
  23. package/dist/dist-CIDTVIUf.js +5 -0
  24. package/dist/{dist-CLUtPrdy.js → dist-CIYBwstr.js} +1 -1
  25. package/dist/{dist-BotSqB48.js → dist-C_Y3oV3C.js} +12 -12
  26. package/dist/{dist-BTfv03uy.js → dist-CcWX6tmx.js} +2 -2
  27. package/dist/{dist-BhM8gdSO.js → dist-CoXAujgg.js} +4 -4
  28. package/dist/{dist-4j4c7bjm.js → dist-CpxNdDkw.js} +3 -3
  29. package/dist/dist-CqQyhAM8.js +8 -0
  30. package/dist/dist-CwRu2Xzh.js +5 -0
  31. package/dist/{dist-BcuoonNH.js → dist-CxJDU6Bh.js} +9 -9
  32. package/dist/{dist-DxvORzUR.js → dist-D-W5ny5a.js} +8 -8
  33. package/dist/dist-D8CDTVgf.js +6 -0
  34. package/dist/dist-D8DNB0nO.js +8 -0
  35. package/dist/dist-DL6N_q-A.js +5 -0
  36. package/dist/{dist-BbbIBDiQ.js → dist-DMjWuVs8.js} +1 -1
  37. package/dist/dist-DOFbNV_b.js +8 -0
  38. package/dist/dist-DPrYzMY0.js +6 -0
  39. package/dist/{dist-h2c8sZvT.js → dist-DZORgqKY.js} +1 -1
  40. package/dist/{dist-B3P2fFpz.js → dist-DZo4nSS0.js} +14 -14
  41. package/dist/{dist-D4CewLk6.js → dist-Dax--nl9.js} +1 -1
  42. package/dist/{dist-DRfcqpxJ.js → dist-DgGbNavJ.js} +2 -2
  43. package/dist/{dist-C1BYNeCR.js → dist-Dk6PV_d3.js} +10 -10
  44. package/dist/{dist-fQ0ViXGs.js → dist-Dv_Y15yk.js} +107 -107
  45. package/dist/{dist-Bfwsv11D.js → dist-DyyjKEYf.js} +2 -2
  46. package/dist/{dist-p2qyWijU.js → dist-GZXUmt0b.js} +2 -2
  47. package/dist/{dist-CLJWPTX2.js → dist-LTU8Hdvn.js} +3 -3
  48. package/dist/{dist-DqAWR3CS.js → dist-M9Vag9Y0.js} +20 -20
  49. package/dist/{dist-DNdhYsgW.js → dist-U4F-tbMs.js} +79 -62
  50. package/dist/{dist-RqXTaiir.js → dist-abid3KgM.js} +11 -11
  51. package/dist/dist-cdmMjgsn.js +5 -0
  52. package/dist/dist-hT4QzYX-.js +1247 -0
  53. package/dist/{dist-luvabDEB.js → dist-t9Kf7xqC.js} +2 -2
  54. package/dist/{error-banner-5bz0L9hS.js → error-banner-Cc0I3C9e.js} +1 -1
  55. package/dist/esm-BaH2eg5-.js +1171 -0
  56. package/dist/{esm-Duie8iU-.js → esm-ga2Bf3O2.js} +43 -43
  57. package/dist/{extends-BgdxCfYu.js → extends-D_hDsj6R.js} +4 -4
  58. package/dist/{formats-DHxc-FdY.js → formats-C4wO47tk.js} +1 -1
  59. package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-Qhu8oCX-.js} +12 -12
  60. package/dist/{html-to-image-CNa5ok96.js → html-to-image-UEH5lFDZ.js} +2318 -2275
  61. package/dist/{input-_2sjvfne.js → input-CMYy4hzj.js} +187 -185
  62. package/dist/{label-LWtdw5i8.js → label-CC4ytI1X.js} +1 -1
  63. package/dist/main.js +6941 -6913
  64. package/dist/{mermaid-lXOw5Py9.js → mermaid-zuLgJ8J8.js} +4 -4
  65. package/dist/{process-output-DKr4f1di.js → process-output-CyMLTogj.js} +3 -3
  66. package/dist/{reveal-component-UdMnCK5U.js → reveal-component-BjnkUAZ9.js} +697 -619
  67. package/dist/{spec-B96zNUEA.js → spec-X7FwLJni.js} +4 -4
  68. package/dist/{strings-Bu3vlb6W.js → strings-J57tzLr3.js} +47 -46
  69. package/dist/style.css +1 -1
  70. package/dist/{toDate-x-WRDCH7.js → toDate-d8RCRrRd.js} +2 -2
  71. package/dist/{tooltip-C5FYOpQc.js → tooltip-DpcyNkQ2.js} +2 -2
  72. package/dist/{types-CVvp1fKr.js → types-ChtMFmZ2.js} +1 -1
  73. package/dist/{useAsyncData-iRgKDT5s.js → useAsyncData-PonK__yh.js} +1 -1
  74. package/dist/{useDateFormatter-BA4FCquG.js → useDateFormatter-QB-3MpYr.js} +2 -2
  75. package/dist/{useDeepCompareMemoize-CkQ57VS2.js → useDeepCompareMemoize-D3NGWke6.js} +1 -1
  76. package/dist/{useLifecycle-BBO9PIph.js → useLifecycle-00mO3OSS.js} +2 -2
  77. package/dist/{useTheme-DHIrRQOe.js → useTheme-DEhDzATN.js} +1 -1
  78. package/dist/{vega-component-Dq-SH463.js → vega-component-9h1ACS78.js} +8 -8
  79. package/dist/{zod-CoBiJ5v4.js → zod-aLSua2NL.js} +24 -23
  80. package/package.json +3 -3
  81. package/src/components/data-table/TableBottomBar.tsx +1 -15
  82. package/src/components/data-table/TableTopBar.tsx +8 -13
  83. package/src/components/data-table/__tests__/TableBottomBar.test.tsx +6 -12
  84. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +227 -0
  85. package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
  86. package/src/components/data-table/column-visibility-dropdown.tsx +204 -0
  87. package/src/components/data-table/data-table.tsx +1 -1
  88. package/src/components/data-table/filter-by-values-picker.tsx +39 -17
  89. package/src/components/data-table/filter-pills.tsx +1 -1
  90. package/src/components/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
  91. package/src/components/data-table/hover-tooltip/content.ts +44 -0
  92. package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
  93. package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
  94. package/src/components/data-table/renderers.tsx +27 -43
  95. package/src/components/datasources/__tests__/filter-empty.test.ts +183 -0
  96. package/src/components/datasources/datasources.tsx +92 -3
  97. package/src/components/editor/cell/cell-context-menu.tsx +15 -2
  98. package/src/components/editor/cell/code/language-toggle.tsx +7 -1
  99. package/src/components/editor/chrome/wrapper/app-chrome.tsx +97 -52
  100. package/src/components/editor/chrome/wrapper/lazy-panels.ts +91 -0
  101. package/src/components/editor/chrome/wrapper/sidebar.tsx +2 -0
  102. package/src/components/editor/documentation.css +35 -0
  103. package/src/components/editor/file-tree/file-explorer.tsx +8 -18
  104. package/src/components/editor/file-tree/tree-actions.tsx +46 -1
  105. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +20 -0
  106. package/src/components/editor/renderers/slides-layout/types.ts +1 -0
  107. package/src/components/slides/__tests__/minimap-actions.test.tsx +166 -0
  108. package/src/components/slides/__tests__/reveal-component.test.ts +425 -0
  109. package/src/components/slides/minimap.tsx +127 -10
  110. package/src/components/slides/reveal-component.tsx +287 -61
  111. package/src/components/slides/slide-cell-view.tsx +26 -2
  112. package/src/components/slides/slide-form.tsx +26 -4
  113. package/src/components/storage/__tests__/storage-inspector.test.ts +53 -0
  114. package/src/components/storage/storage-inspector.tsx +68 -48
  115. package/src/components/ui/__tests__/use-toast.test.ts +75 -0
  116. package/src/components/ui/combobox.tsx +51 -32
  117. package/src/components/ui/reorderable-list.tsx +13 -0
  118. package/src/components/ui/select-core/__tests__/use-select-list.test.ts +294 -0
  119. package/src/components/ui/select-core/__tests__/utils.test.ts +222 -0
  120. package/src/components/ui/select-core/index.ts +16 -0
  121. package/src/components/ui/select-core/option-row.tsx +33 -0
  122. package/src/components/ui/select-core/render-slot.ts +20 -0
  123. package/src/components/ui/select-core/select-list.tsx +248 -0
  124. package/src/components/ui/select-core/types.ts +44 -0
  125. package/src/components/ui/select-core/use-select-list.ts +347 -0
  126. package/src/components/ui/select-core/utils.ts +121 -0
  127. package/src/components/ui/use-toast.ts +33 -13
  128. package/src/core/cells/__tests__/__snapshots__/cells.test.ts.snap +0 -28
  129. package/src/core/cells/__tests__/cell.test.ts +29 -2
  130. package/src/core/cells/cell.ts +5 -1
  131. package/src/core/codemirror/go-to-definition/commands.ts +4 -3
  132. package/src/core/codemirror/language/languages/python.ts +2 -0
  133. package/src/core/codemirror/language/languages/sql/utils.ts +3 -1
  134. package/src/core/codemirror/lsp/__tests__/markdown-renderer.test.ts +41 -0
  135. package/src/core/codemirror/lsp/markdown-renderer.ts +59 -0
  136. package/src/core/datasets/data-source-connections.ts +2 -0
  137. package/src/core/network/__tests__/requests-static.test.ts +30 -0
  138. package/src/core/network/requests-static.ts +14 -10
  139. package/src/core/wasm/worker/bootstrap.ts +12 -4
  140. package/src/plugins/impl/MultiselectPlugin.tsx +19 -142
  141. package/src/plugins/impl/SearchableSelect.tsx +16 -97
  142. package/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +5 -2
  143. package/src/plugins/impl/__tests__/MultiSelectPlugin.test.ts +1 -1
  144. package/src/plugins/layout/DownloadPlugin.tsx +1 -1
  145. package/src/utils/lazy.ts +6 -1
  146. package/dist/assets/__vite-browser-external-Ci2ZQfXU.js +0 -1
  147. package/dist/assets/worker-ip3AI_sN.js +0 -73
  148. package/dist/dist-0Fif7jnk.js +0 -5
  149. package/dist/dist-B5h_9sHB.js +0 -6
  150. package/dist/dist-B9M6R5ye.js +0 -5
  151. package/dist/dist-BCt3tnck.js +0 -8
  152. package/dist/dist-BUIJwMwn.js +0 -8
  153. package/dist/dist-BpquMd3k.js +0 -5
  154. package/dist/dist-BzJsqYfz.js +0 -5
  155. package/dist/dist-CA5ELXAf.js +0 -6
  156. package/dist/dist-CLBRs6Uv.js +0 -5
  157. package/dist/dist-CStVCMbq.js +0 -5
  158. package/dist/dist-CZRIEY3Y.js +0 -8
  159. package/dist/dist-CuUHbFD0.js +0 -5
  160. package/dist/dist-DV7Iabxb.js +0 -8
  161. package/dist/dist-DhHh0jLg.js +0 -1247
  162. package/dist/dist-DuEeHMvL.js +0 -5
  163. package/dist/esm-BfhQmZjp.js +0 -1171
  164. package/src/plugins/impl/multiselectFilterFn.tsx +0 -22
  165. /package/src/components/{data-table → ui}/value-chips.tsx +0 -0
@@ -0,0 +1,425 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import { cellId } from "@/__tests__/branded";
6
+ import type { CellId } from "@/core/cells/ids";
7
+ import type { RuntimeCell } from "@/core/cells/types";
8
+ import type { SlideConfig } from "../../editor/renderers/slides-layout/types";
9
+ import {
10
+ deckSlideType,
11
+ parkedRendersSource,
12
+ shouldShowCode,
13
+ useParkedPreview,
14
+ } from "../reveal-component";
15
+
16
+ const A = cellId("a");
17
+ const B = cellId("b");
18
+
19
+ const cells = (
20
+ ...entries: Array<[CellId, SlideConfig]>
21
+ ): ReadonlyMap<CellId, SlideConfig> => new Map(entries);
22
+
23
+ // The hook only reads `cell.id`, so a minimal stub is enough. Cast is confined
24
+ // to this test helper (see `@/__tests__/branded` for the same rationale).
25
+ const cell = (id: CellId): RuntimeCell => ({ id }) as RuntimeCell;
26
+
27
+ describe("shouldShowCode", () => {
28
+ it("is off when the code toggle is unavailable, regardless of config/override", () => {
29
+ expect(
30
+ shouldShowCode({
31
+ cells: cells([A, { showCode: true }]),
32
+ cellId: A,
33
+ showCodeOverrides: new Set([A]),
34
+ codeToggleEnabled: false,
35
+ }),
36
+ ).toBe(false);
37
+ });
38
+
39
+ it("is off when there is no active cell", () => {
40
+ expect(
41
+ shouldShowCode({
42
+ cells: cells([A, { showCode: true }]),
43
+ cellId: undefined,
44
+ showCodeOverrides: new Set(),
45
+ codeToggleEnabled: true,
46
+ }),
47
+ ).toBe(false);
48
+ });
49
+
50
+ it("follows the persisted config when there is no override", () => {
51
+ expect(
52
+ shouldShowCode({
53
+ cells: cells([A, { showCode: true }]),
54
+ cellId: A,
55
+ showCodeOverrides: new Set(),
56
+ codeToggleEnabled: true,
57
+ }),
58
+ ).toBe(true);
59
+ // Missing config entry defaults to off.
60
+ expect(
61
+ shouldShowCode({
62
+ cells: cells(),
63
+ cellId: A,
64
+ showCodeOverrides: new Set(),
65
+ codeToggleEnabled: true,
66
+ }),
67
+ ).toBe(false);
68
+ });
69
+
70
+ it("shows code when either the config or the override is set (logical OR)", () => {
71
+ // Peek: config unset, override present -> shown.
72
+ expect(
73
+ shouldShowCode({
74
+ cells: cells(),
75
+ cellId: A,
76
+ showCodeOverrides: new Set([A]),
77
+ codeToggleEnabled: true,
78
+ }),
79
+ ).toBe(true);
80
+ // Configured + override present -> still shown; the override never hides.
81
+ expect(
82
+ shouldShowCode({
83
+ cells: cells([A, { showCode: true }]),
84
+ cellId: A,
85
+ showCodeOverrides: new Set([A]),
86
+ codeToggleEnabled: true,
87
+ }),
88
+ ).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("deckSlideType", () => {
93
+ const NONE_IDS: ReadonlySet<CellId> = new Set();
94
+
95
+ it("uses the configured type for a normal cell", () => {
96
+ expect(
97
+ deckSlideType({
98
+ cell: cell(A),
99
+ noOutputIds: NONE_IDS,
100
+ heldEditCellId: null,
101
+ slideConfigs: cells([A, { type: "fragment" }]),
102
+ }),
103
+ ).toBe("fragment");
104
+ });
105
+
106
+ it("defaults to a top-level slide when no type is configured", () => {
107
+ expect(
108
+ deckSlideType({
109
+ cell: cell(A),
110
+ noOutputIds: NONE_IDS,
111
+ heldEditCellId: null,
112
+ slideConfigs: cells(),
113
+ }),
114
+ ).toBe("slide");
115
+ });
116
+
117
+ it("drops output-less cells from the deck", () => {
118
+ expect(
119
+ deckSlideType({
120
+ cell: cell(A),
121
+ noOutputIds: new Set([A]),
122
+ heldEditCellId: null,
123
+ // Even an explicit type is overridden by the skip.
124
+ slideConfigs: cells([A, { type: "sub-slide" }]),
125
+ }),
126
+ ).toBe("skip");
127
+ });
128
+
129
+ it("drops the held-edit cell so it isn't mounted twice", () => {
130
+ expect(
131
+ deckSlideType({
132
+ cell: cell(A),
133
+ noOutputIds: NONE_IDS,
134
+ heldEditCellId: A,
135
+ slideConfigs: cells([A, { type: "slide" }]),
136
+ }),
137
+ ).toBe("skip");
138
+ });
139
+ });
140
+
141
+ describe("parkedRendersSource", () => {
142
+ it("follows `showCode` for a cell that has output", () => {
143
+ expect(
144
+ parkedRendersSource({
145
+ isNoOutputPreview: false,
146
+ isEditable: false,
147
+ showCode: true,
148
+ }),
149
+ ).toBe(true);
150
+ expect(
151
+ parkedRendersSource({
152
+ isNoOutputPreview: false,
153
+ isEditable: true,
154
+ showCode: false,
155
+ }),
156
+ ).toBe(false);
157
+ });
158
+
159
+ it("falls back to source for an editable no-output cell even when showCode is off", () => {
160
+ expect(
161
+ parkedRendersSource({
162
+ isNoOutputPreview: true,
163
+ isEditable: true,
164
+ showCode: false,
165
+ }),
166
+ ).toBe(true);
167
+ });
168
+
169
+ it("renders output for a read-only no-output cell with showCode off", () => {
170
+ expect(
171
+ parkedRendersSource({
172
+ isNoOutputPreview: true,
173
+ isEditable: false,
174
+ showCode: false,
175
+ }),
176
+ ).toBe(false);
177
+ });
178
+ });
179
+
180
+ describe("useParkedPreview", () => {
181
+ const NO_CONFIG = cells();
182
+ const NONE = new Set<CellId>();
183
+
184
+ it("is inert for a rendered cell with output", () => {
185
+ const { result } = renderHook(() =>
186
+ useParkedPreview({
187
+ activeCell: cell(A),
188
+ slideConfigs: NO_CONFIG,
189
+ noOutputIds: NONE,
190
+ }),
191
+ );
192
+ expect(result.current).toEqual({
193
+ parkedPreviewCell: null,
194
+ isHeldEdit: false,
195
+ isNoOutputPreview: false,
196
+ heldEditCellId: null,
197
+ heldShowsCode: true,
198
+ toggleHeldShowsCode: expect.any(Function),
199
+ });
200
+ });
201
+
202
+ it("is inert when there is no active cell", () => {
203
+ const { result } = renderHook(() =>
204
+ useParkedPreview({
205
+ activeCell: undefined,
206
+ slideConfigs: NO_CONFIG,
207
+ noOutputIds: NONE,
208
+ }),
209
+ );
210
+ expect(result.current).toEqual({
211
+ parkedPreviewCell: null,
212
+ isHeldEdit: false,
213
+ isNoOutputPreview: false,
214
+ heldEditCellId: null,
215
+ heldShowsCode: true,
216
+ toggleHeldShowsCode: expect.any(Function),
217
+ });
218
+ });
219
+
220
+ it("parks a skipped cell without flagging it as a no-output preview", () => {
221
+ const { result } = renderHook(() =>
222
+ useParkedPreview({
223
+ activeCell: cell(A),
224
+ slideConfigs: cells([A, { type: "skip" }]),
225
+ noOutputIds: NONE,
226
+ }),
227
+ );
228
+ expect(result.current).toEqual({
229
+ parkedPreviewCell: cell(A),
230
+ isHeldEdit: false,
231
+ isNoOutputPreview: false,
232
+ heldEditCellId: null,
233
+ heldShowsCode: true,
234
+ toggleHeldShowsCode: expect.any(Function),
235
+ });
236
+ });
237
+
238
+ it("parks an output-less cell as a no-output preview", () => {
239
+ const { result } = renderHook(() =>
240
+ useParkedPreview({
241
+ activeCell: cell(A),
242
+ slideConfigs: NO_CONFIG,
243
+ noOutputIds: new Set([A]),
244
+ }),
245
+ );
246
+ expect(result.current).toEqual({
247
+ parkedPreviewCell: cell(A),
248
+ isHeldEdit: false,
249
+ isNoOutputPreview: true,
250
+ heldEditCellId: null,
251
+ heldShowsCode: true,
252
+ toggleHeldShowsCode: expect.any(Function),
253
+ });
254
+ });
255
+
256
+ it("holds the cell in the overlay once it gains output", () => {
257
+ const { result, rerender } = renderHook(
258
+ (props: Parameters<typeof useParkedPreview>[0]) =>
259
+ useParkedPreview(props),
260
+ {
261
+ initialProps: {
262
+ activeCell: cell(A),
263
+ slideConfigs: NO_CONFIG,
264
+ noOutputIds: new Set([A]),
265
+ },
266
+ },
267
+ );
268
+ expect(result.current.isNoOutputPreview).toBe(true);
269
+ expect(result.current.isHeldEdit).toBe(false);
270
+
271
+ // Same cell, now with output: keep it parked so the editor isn't remounted.
272
+ rerender({
273
+ activeCell: cell(A),
274
+ slideConfigs: NO_CONFIG,
275
+ noOutputIds: NONE,
276
+ });
277
+ expect(result.current).toEqual({
278
+ parkedPreviewCell: cell(A),
279
+ isHeldEdit: true,
280
+ isNoOutputPreview: false,
281
+ heldEditCellId: A,
282
+ heldShowsCode: true,
283
+ toggleHeldShowsCode: expect.any(Function),
284
+ });
285
+ });
286
+
287
+ it("releases the hold when a different cell becomes active", () => {
288
+ const { result, rerender } = renderHook(
289
+ (props: Parameters<typeof useParkedPreview>[0]) =>
290
+ useParkedPreview(props),
291
+ {
292
+ initialProps: {
293
+ activeCell: cell(A),
294
+ slideConfigs: NO_CONFIG,
295
+ noOutputIds: new Set([A]),
296
+ },
297
+ },
298
+ );
299
+ // Arm the hold, then let A gain output (held).
300
+ rerender({
301
+ activeCell: cell(A),
302
+ slideConfigs: NO_CONFIG,
303
+ noOutputIds: NONE,
304
+ });
305
+ expect(result.current.isHeldEdit).toBe(true);
306
+
307
+ // Navigate to a rendered B: the hold on A is released.
308
+ rerender({
309
+ activeCell: cell(B),
310
+ slideConfigs: NO_CONFIG,
311
+ noOutputIds: NONE,
312
+ });
313
+ expect(result.current).toEqual({
314
+ parkedPreviewCell: null,
315
+ isHeldEdit: false,
316
+ isNoOutputPreview: false,
317
+ heldEditCellId: null,
318
+ heldShowsCode: true,
319
+ toggleHeldShowsCode: expect.any(Function),
320
+ });
321
+ });
322
+
323
+ it("releases a skipped cell into the deck as soon as it is un-skipped", () => {
324
+ const { result, rerender } = renderHook(
325
+ (props: Parameters<typeof useParkedPreview>[0]) =>
326
+ useParkedPreview(props),
327
+ {
328
+ initialProps: {
329
+ activeCell: cell(A),
330
+ slideConfigs: cells([A, { type: "skip" }]),
331
+ noOutputIds: NONE,
332
+ },
333
+ },
334
+ );
335
+ expect(result.current.parkedPreviewCell).toEqual(cell(A));
336
+
337
+ // Un-skip the still-active cell: it has output, so it must rejoin the deck
338
+ // immediately rather than staying held in the overlay until navigation.
339
+ rerender({
340
+ activeCell: cell(A),
341
+ slideConfigs: NO_CONFIG,
342
+ noOutputIds: NONE,
343
+ });
344
+ expect(result.current).toEqual({
345
+ parkedPreviewCell: null,
346
+ isHeldEdit: false,
347
+ isNoOutputPreview: false,
348
+ heldEditCellId: null,
349
+ heldShowsCode: true,
350
+ toggleHeldShowsCode: expect.any(Function),
351
+ });
352
+ });
353
+
354
+ it("shows the held editor by default and toggles it off without navigating", () => {
355
+ const { result, rerender } = renderHook(
356
+ (props: Parameters<typeof useParkedPreview>[0]) =>
357
+ useParkedPreview(props),
358
+ {
359
+ initialProps: {
360
+ activeCell: cell(A),
361
+ slideConfigs: NO_CONFIG,
362
+ noOutputIds: new Set([A]),
363
+ },
364
+ },
365
+ );
366
+ // A gains output: held, with its editor shown by default.
367
+ rerender({
368
+ activeCell: cell(A),
369
+ slideConfigs: NO_CONFIG,
370
+ noOutputIds: NONE,
371
+ });
372
+ expect(result.current.isHeldEdit).toBe(true);
373
+ expect(result.current.heldShowsCode).toBe(true);
374
+
375
+ // The `C` toggle hides the editor in place, no navigation required.
376
+ act(() => {
377
+ result.current.toggleHeldShowsCode();
378
+ });
379
+ expect(result.current.heldShowsCode).toBe(false);
380
+
381
+ // Toggling again brings it back.
382
+ act(() => {
383
+ result.current.toggleHeldShowsCode();
384
+ });
385
+ expect(result.current.heldShowsCode).toBe(true);
386
+ });
387
+
388
+ it("resets held code visibility when a new cell takes the held slot", () => {
389
+ const { result, rerender } = renderHook(
390
+ (props: Parameters<typeof useParkedPreview>[0]) =>
391
+ useParkedPreview(props),
392
+ {
393
+ initialProps: {
394
+ activeCell: cell(A),
395
+ slideConfigs: NO_CONFIG,
396
+ noOutputIds: new Set([A]),
397
+ },
398
+ },
399
+ );
400
+ rerender({
401
+ activeCell: cell(A),
402
+ slideConfigs: NO_CONFIG,
403
+ noOutputIds: NONE,
404
+ });
405
+ act(() => {
406
+ result.current.toggleHeldShowsCode();
407
+ });
408
+ expect(result.current.heldShowsCode).toBe(false);
409
+
410
+ // Move to a fresh output-less cell B, then let it gain output (held).
411
+ rerender({
412
+ activeCell: cell(B),
413
+ slideConfigs: NO_CONFIG,
414
+ noOutputIds: new Set([B]),
415
+ });
416
+ rerender({
417
+ activeCell: cell(B),
418
+ slideConfigs: NO_CONFIG,
419
+ noOutputIds: NONE,
420
+ });
421
+ expect(result.current.heldEditCellId).toBe(B);
422
+ // The new cell starts with its editor visible again.
423
+ expect(result.current.heldShowsCode).toBe(true);
424
+ });
425
+ });
@@ -1,5 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import { useDeleteCellCallback } from "@/components/editor/cell/useDeleteCell";
3
4
  import { useCellActions, useCellIds } from "@/core/cells/cells";
4
5
  import type { CellId } from "@/core/cells/ids";
5
6
  import type { CellColumnId } from "@/utils/id-tree";
@@ -31,9 +32,17 @@ import {
31
32
  } from "@dnd-kit/sortable";
32
33
  import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
33
34
  import { cn } from "@/utils/cn";
35
+ import { Events } from "@/utils/events";
34
36
  import { Slide } from "./slide";
35
- import { InfoIcon, type LucideIcon } from "lucide-react";
37
+ import { InfoIcon, type LucideIcon, PlusIcon, Trash2Icon } from "lucide-react";
36
38
  import { Tooltip } from "@/components/ui/tooltip";
39
+ import {
40
+ ContextMenu,
41
+ ContextMenuContent,
42
+ ContextMenuItem,
43
+ ContextMenuSeparator,
44
+ ContextMenuTrigger,
45
+ } from "@/components/ui/context-menu";
37
46
  import { Logger } from "@/utils/Logger";
38
47
  import { SLIDE_TYPE_OPTIONS_BY_VALUE } from "./slide-form";
39
48
 
@@ -71,7 +80,7 @@ interface SlideThumbnailCardProps extends React.HTMLAttributes<HTMLDivElement> {
71
80
  ref?: React.Ref<HTMLDivElement>;
72
81
  }
73
82
 
74
- interface SlideThumbnailRowProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
83
+ interface SlideThumbnailRowProps extends React.HTMLAttributes<HTMLDivElement> {
75
84
  cell: SlideCell;
76
85
  dimensions: ThumbnailDimensions;
77
86
  isActiveSlide?: boolean;
@@ -80,7 +89,10 @@ interface SlideThumbnailRowProps extends React.ButtonHTMLAttributes<HTMLButtonEl
80
89
  isVisible?: boolean;
81
90
  isNoOutput?: boolean;
82
91
  slideType?: SlideType;
83
- ref?: React.Ref<HTMLButtonElement>;
92
+ onInsertAbove?: () => void;
93
+ onInsertBelow?: () => void;
94
+ onDelete?: () => void;
95
+ ref?: React.Ref<HTMLDivElement>;
84
96
  }
85
97
 
86
98
  interface SlidesMinimapProps {
@@ -216,7 +228,8 @@ export const SlidesMinimap = ({
216
228
  onSlideClick,
217
229
  }: SlidesMinimapProps) => {
218
230
  const cellIds = useCellIds();
219
- const { moveCellToIndex } = useCellActions();
231
+ const { moveCellToIndex, createNewCell } = useCellActions();
232
+ const deleteCell = useDeleteCellCallback();
220
233
  const containerRef = useRef<HTMLDivElement>(null);
221
234
  const visibleIds = useVisibleCellIds(containerRef);
222
235
  const [activeId, setActiveId] = useState<CellId | null>(null);
@@ -225,6 +238,10 @@ export const SlidesMinimap = ({
225
238
  );
226
239
  const dimensions = computeThumbnailDimensions(thumbnailWidth);
227
240
 
241
+ const insertCell = (cellId: CellId, before: boolean) => {
242
+ createNewCell({ cellId, before, code: "", autoFocus: false });
243
+ };
244
+
228
245
  useEffect(() => {
229
246
  if (!activeCellId || !containerRef.current) {
230
247
  return;
@@ -310,6 +327,11 @@ export const SlidesMinimap = ({
310
327
  slideTypes,
311
328
  skippedIds,
312
329
  })}
330
+ onInsertAbove={
331
+ index === 0 ? () => insertCell(cell.id, true) : undefined
332
+ }
333
+ onInsertBelow={() => insertCell(cell.id, false)}
334
+ onDelete={() => deleteCell({ cellId: cell.id })}
313
335
  onClick={() => onSlideClick(index)}
314
336
  />
315
337
  ))}
@@ -353,6 +375,11 @@ export const SlidesMinimap = ({
353
375
  ? dropTarget.position
354
376
  : null
355
377
  }
378
+ onInsertAbove={
379
+ index === 0 ? () => insertCell(cell.id, true) : undefined
380
+ }
381
+ onInsertBelow={() => insertCell(cell.id, false)}
382
+ onDelete={() => deleteCell({ cellId: cell.id })}
356
383
  onClick={() => onSlideClick(index)}
357
384
  />
358
385
  ))}
@@ -399,6 +426,9 @@ interface SortableSlideThumbnailProps {
399
426
  isVisible?: boolean;
400
427
  isNoOutput?: boolean;
401
428
  slideType?: SlideType;
429
+ onInsertAbove?: () => void;
430
+ onInsertBelow?: () => void;
431
+ onDelete?: () => void;
402
432
  onClick?: () => void;
403
433
  }
404
434
 
@@ -411,6 +441,9 @@ const SortableSlideThumbnail = ({
411
441
  isVisible,
412
442
  isNoOutput,
413
443
  slideType,
444
+ onInsertAbove,
445
+ onInsertBelow,
446
+ onDelete,
414
447
  onClick,
415
448
  }: SortableSlideThumbnailProps) => {
416
449
  const { attributes, listeners, setNodeRef } = useSortable({
@@ -428,6 +461,9 @@ const SortableSlideThumbnail = ({
428
461
  isVisible={isVisible}
429
462
  isNoOutput={isNoOutput}
430
463
  slideType={slideType}
464
+ onInsertAbove={onInsertAbove}
465
+ onInsertBelow={onInsertBelow}
466
+ onDelete={onDelete}
431
467
  onClick={onClick}
432
468
  {...attributes}
433
469
  {...listeners}
@@ -446,6 +482,9 @@ const SlideThumbnailRow = ({
446
482
  isVisible,
447
483
  isNoOutput,
448
484
  slideType,
485
+ onInsertAbove,
486
+ onInsertBelow,
487
+ onDelete,
449
488
  onClick,
450
489
  ref,
451
490
  ...props
@@ -456,10 +495,23 @@ const SlideThumbnailRow = ({
456
495
  ...style,
457
496
  };
458
497
 
459
- return (
460
- <button
498
+ // Space is ignored as Reveal.js listens for Space on `document` to advance
499
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
500
+ if (event.target === event.currentTarget && event.key === "Enter") {
501
+ event.preventDefault();
502
+ event.stopPropagation();
503
+ event.currentTarget.click();
504
+ }
505
+ };
506
+
507
+ const row = (
508
+ <div
461
509
  ref={ref}
462
- type="button"
510
+ // A real <button> can't host the nested insert <button>s (invalid HTML),
511
+ // so we keep button semantics via role + keyboard handling below.
512
+ // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
513
+ role="button"
514
+ tabIndex={0}
463
515
  data-cell-id={cell.id}
464
516
  className={cn(
465
517
  "relative shrink-0 appearance-none text-left p-0 bg-transparent outline-none",
@@ -467,6 +519,7 @@ const SlideThumbnailRow = ({
467
519
  )}
468
520
  style={rowStyle}
469
521
  onClick={onClick}
522
+ onKeyDown={handleKeyDown}
470
523
  {...props}
471
524
  >
472
525
  {dropIndicator && (
@@ -479,6 +532,9 @@ const SlideThumbnailRow = ({
479
532
  )}
480
533
  />
481
534
  )}
535
+ {onInsertAbove && (
536
+ <InsertCellLine position="above" onInsert={onInsertAbove} />
537
+ )}
482
538
  <SlideThumbnailCard
483
539
  cell={cell}
484
540
  dimensions={dimensions}
@@ -488,7 +544,68 @@ const SlideThumbnailRow = ({
488
544
  isNoOutput={isNoOutput}
489
545
  slideType={slideType}
490
546
  />
491
- </button>
547
+ {onInsertBelow && (
548
+ <InsertCellLine position="below" onInsert={onInsertBelow} />
549
+ )}
550
+ </div>
551
+ );
552
+
553
+ if (!onInsertBelow && !onDelete) {
554
+ return row;
555
+ }
556
+
557
+ return (
558
+ <ContextMenu>
559
+ <ContextMenuTrigger asChild={true}>{row}</ContextMenuTrigger>
560
+ <ContextMenuContent>
561
+ {onInsertBelow && (
562
+ <ContextMenuItem onSelect={onInsertBelow}>
563
+ <PlusIcon className="mr-2 h-3.5 w-3.5" />
564
+ Add cell
565
+ </ContextMenuItem>
566
+ )}
567
+ <ContextMenuSeparator />
568
+ {onDelete && (
569
+ <ContextMenuItem variant="danger" onSelect={onDelete}>
570
+ <Trash2Icon className="mr-2 h-3.5 w-3.5" />
571
+ Delete cell
572
+ </ContextMenuItem>
573
+ )}
574
+ </ContextMenuContent>
575
+ </ContextMenu>
576
+ );
577
+ };
578
+
579
+ const InsertCellLine = ({
580
+ position,
581
+ onInsert,
582
+ }: {
583
+ position: "above" | "below";
584
+ onInsert: () => void;
585
+ }) => {
586
+ return (
587
+ <Tooltip content="Add Python cell">
588
+ <button
589
+ type="button"
590
+ aria-label="Add Python cell"
591
+ data-testid="minimap-insert-cell"
592
+ className={cn(
593
+ "absolute left-0 right-0 z-30 flex h-3 items-center justify-center",
594
+ "opacity-0 transition-opacity hover:opacity-80 focus-visible:opacity-100 focus:outline-none",
595
+ position === "below"
596
+ ? "bottom-0 translate-y-1/2"
597
+ : "top-0 -translate-y-1/2",
598
+ )}
599
+ // Stop the pointer event from reaching the row's drag sensor / click.
600
+ onPointerDown={Events.stopPropagation()}
601
+ onClick={Events.stopPropagation(onInsert)}
602
+ >
603
+ <span className="absolute left-2 right-2 h-px rounded-full bg-blue-500" />
604
+ <span className="relative flex h-3 w-3 items-center justify-center rounded-full bg-blue-500 text-background shadow-xs">
605
+ <PlusIcon className="h-2 w-2" strokeWidth={3} />
606
+ </span>
607
+ </button>
608
+ </Tooltip>
492
609
  );
493
610
  };
494
611
 
@@ -529,10 +646,10 @@ const SlideThumbnailCard = ({
529
646
  <div
530
647
  ref={ref}
531
648
  className={cn(
532
- "border-2 shrink-0 rounded-md relative select-none bg-background cursor-pointer active:cursor-grabbing overflow-hidden",
649
+ "border-2 shrink-0 rounded-md relative select-none bg-background cursor-pointer active:cursor-grabbing overflow-hidden transition-colors",
533
650
  isActiveSlide || isActiveDragSource || isOverlay
534
651
  ? "border-blue-500"
535
- : "border-border",
652
+ : "border-border hover:border-blue-500/50",
536
653
  isActiveDragSource && !isOverlay && "opacity-35",
537
654
  isOverlay && "opacity-95 shadow-lg",
538
655
  className,