@marimo-team/islands 0.23.10-dev5 → 0.23.10-dev8
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.
- package/dist/{any-language-editor-DfdpyDv_.js → any-language-editor-CiES2a2h.js} +2 -2
- package/dist/{chat-ui-1rLrACXD.js → chat-ui-K84W5ecY.js} +5 -5
- package/dist/{code-visibility-Dek9JNl9.js → code-visibility-ChnpTxqU.js} +1036 -882
- package/dist/{copy-BuQpJEzp.js → copy-5jQ_kGE1.js} +32 -32
- package/dist/{esm-BfhQmZjp.js → esm-CCuYCd3R.js} +1 -1
- package/dist/{extends-BgdxCfYu.js → extends-CkydH1Q5.js} +1 -1
- package/dist/{glide-data-editor-BOmK9ETQ.js → glide-data-editor-CgIxTzAP.js} +1 -1
- package/dist/{html-to-image-CnBdHeLh.js → html-to-image-B93KtAVY.js} +3 -3
- package/dist/main.js +10 -10
- package/dist/{process-output-vFgqkyUT.js → process-output-BOXi9fnS.js} +1 -1
- package/dist/{reveal-component-C8AmnVfj.js → reveal-component-OrljHylW.js} +2 -2
- package/package.json +1 -1
- package/src/components/data-table/__tests__/data-table.test.tsx +154 -12
- package/src/components/data-table/hover-tooltip/__tests__/content.test.ts +60 -0
- package/src/components/data-table/hover-tooltip/content.ts +44 -0
- package/src/components/data-table/hover-tooltip/hover-tooltip.tsx +55 -0
- package/src/components/data-table/hover-tooltip/use-table-hover-tooltip.ts +159 -0
- package/src/components/data-table/renderers.tsx +27 -43
- package/src/components/ui/__tests__/use-toast.test.ts +75 -0
- package/src/components/ui/use-toast.ts +33 -13
- package/src/core/network/__tests__/requests-static.test.ts +30 -0
- package/src/core/network/requests-static.ts +14 -10
- package/src/plugins/layout/DownloadPlugin.tsx +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import {
|
|
3
|
+
TooltipContent,
|
|
4
|
+
TooltipPortal,
|
|
5
|
+
TooltipRoot,
|
|
6
|
+
TooltipTrigger,
|
|
7
|
+
} from "@/components/ui/tooltip";
|
|
8
|
+
import type { HoverTooltipState } from "./use-table-hover-tooltip";
|
|
9
|
+
|
|
10
|
+
interface HoverTooltipProps {
|
|
11
|
+
state: HoverTooltipState | null;
|
|
12
|
+
contentId: string;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A single radix tooltip whose anchor is repositioned to the hovered cell.
|
|
18
|
+
* Rendering one instance per table (instead of one per cell) keeps the cost
|
|
19
|
+
* constant regardless of how many cells are on screen.
|
|
20
|
+
*/
|
|
21
|
+
export const HoverTooltip = ({
|
|
22
|
+
state,
|
|
23
|
+
contentId,
|
|
24
|
+
onClose,
|
|
25
|
+
}: HoverTooltipProps) => {
|
|
26
|
+
return (
|
|
27
|
+
<TooltipRoot
|
|
28
|
+
open={state != null}
|
|
29
|
+
onOpenChange={(open) => {
|
|
30
|
+
if (!open) {
|
|
31
|
+
onClose();
|
|
32
|
+
}
|
|
33
|
+
}}
|
|
34
|
+
delayDuration={0}
|
|
35
|
+
disableHoverableContent={true}
|
|
36
|
+
>
|
|
37
|
+
<TooltipTrigger asChild={true}>
|
|
38
|
+
<div
|
|
39
|
+
aria-hidden={true}
|
|
40
|
+
style={{
|
|
41
|
+
position: "fixed",
|
|
42
|
+
top: state?.rect.top ?? 0,
|
|
43
|
+
left: state?.rect.left ?? 0,
|
|
44
|
+
width: state?.rect.width ?? 0,
|
|
45
|
+
height: state?.rect.height ?? 0,
|
|
46
|
+
pointerEvents: "none",
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
</TooltipTrigger>
|
|
50
|
+
<TooltipPortal>
|
|
51
|
+
<TooltipContent id={contentId}>{state?.content}</TooltipContent>
|
|
52
|
+
</TooltipPortal>
|
|
53
|
+
</TooltipRoot>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type { Cell, RowData, Table } from "@tanstack/react-table";
|
|
3
|
+
import {
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import useEvent from "react-use-event-hook";
|
|
12
|
+
import { computeCellTooltipContent } from "./content";
|
|
13
|
+
|
|
14
|
+
// Matches the default TooltipProvider delay (MarimoApp.tsx) for visual parity
|
|
15
|
+
// with the rest of the app's tooltips.
|
|
16
|
+
const TOOLTIP_DELAY_MS = 400;
|
|
17
|
+
|
|
18
|
+
export interface HoverTooltipState {
|
|
19
|
+
rect: { top: number; left: number; width: number; height: number };
|
|
20
|
+
content: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useTableHoverTooltip<TData extends RowData>({
|
|
24
|
+
table,
|
|
25
|
+
}: {
|
|
26
|
+
table: Table<TData>;
|
|
27
|
+
}) {
|
|
28
|
+
const hoverTemplate = table.getState().cellHoverTemplate || null;
|
|
29
|
+
const [tooltipState, setTooltipState] = useState<HoverTooltipState | null>(
|
|
30
|
+
null,
|
|
31
|
+
);
|
|
32
|
+
const timer = useRef<number | null>(null);
|
|
33
|
+
|
|
34
|
+
// Stable id linking the focused/hovered cell to the tooltip content for
|
|
35
|
+
// assistive tech (the radix trigger is an aria-hidden phantom anchor).
|
|
36
|
+
const tooltipContentId = useId();
|
|
37
|
+
const anchorCell = useRef<HTMLElement | null>(null);
|
|
38
|
+
// Focus fires for pointer interactions too; track pointer state so
|
|
39
|
+
// click/drag-select focus doesn't show a tooltip (keyboard focus still does).
|
|
40
|
+
const pointerDown = useRef(false);
|
|
41
|
+
|
|
42
|
+
const clearTimer = () => {
|
|
43
|
+
if (timer.current != null) {
|
|
44
|
+
clearTimeout(timer.current);
|
|
45
|
+
timer.current = null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const hideTooltip = useEvent(() => {
|
|
50
|
+
clearTimer();
|
|
51
|
+
setTooltipState(null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const showFor = (target: HTMLElement, content: ReactNode) => {
|
|
55
|
+
anchorCell.current = target;
|
|
56
|
+
const r = target.getBoundingClientRect();
|
|
57
|
+
setTooltipState({
|
|
58
|
+
rect: { top: r.top, left: r.left, width: r.width, height: r.height },
|
|
59
|
+
content,
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Point the real cell at the tooltip content while it is shown. Done in a
|
|
64
|
+
// layout effect (after commit) so React's re-render from `setTooltipState`
|
|
65
|
+
// can't clobber an imperatively set attribute; cleanup unlinks the previous
|
|
66
|
+
// cell.
|
|
67
|
+
useLayoutEffect(() => {
|
|
68
|
+
if (!tooltipState) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const cell = anchorCell.current;
|
|
72
|
+
cell?.setAttribute("aria-describedby", tooltipContentId);
|
|
73
|
+
return () => cell?.removeAttribute("aria-describedby");
|
|
74
|
+
}, [tooltipState, tooltipContentId]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const onDown = () => {
|
|
78
|
+
pointerDown.current = true;
|
|
79
|
+
};
|
|
80
|
+
const onUp = () => {
|
|
81
|
+
pointerDown.current = false;
|
|
82
|
+
};
|
|
83
|
+
window.addEventListener("mousedown", onDown, { capture: true });
|
|
84
|
+
window.addEventListener("mouseup", onUp, { capture: true });
|
|
85
|
+
return () => {
|
|
86
|
+
window.removeEventListener("mousedown", onDown, { capture: true });
|
|
87
|
+
window.removeEventListener("mouseup", onUp, { capture: true });
|
|
88
|
+
};
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const handleCellMouseOver = useEvent(
|
|
92
|
+
(e: React.MouseEvent, cell: Cell<TData, unknown>) => {
|
|
93
|
+
// Suppress while a mouse button is held (range-select drag).
|
|
94
|
+
if (e.buttons !== 0) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const target = e.currentTarget as HTMLElement;
|
|
98
|
+
const content = computeCellTooltipContent(cell, hoverTemplate);
|
|
99
|
+
if (content == null || content === "") {
|
|
100
|
+
hideTooltip();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
clearTimer();
|
|
104
|
+
timer.current = window.setTimeout(
|
|
105
|
+
() => showFor(target, content),
|
|
106
|
+
TOOLTIP_DELAY_MS,
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const handleCellMouseLeave = useEvent(() => hideTooltip());
|
|
112
|
+
|
|
113
|
+
// Keyboard parity: cells are tabIndex=0, native `title` showed on focus too.
|
|
114
|
+
const handleCellFocus = useEvent(
|
|
115
|
+
(e: React.FocusEvent, cell: Cell<TData, unknown>) => {
|
|
116
|
+
// Cancel any pending hover-show so a stale timer can't overwrite the
|
|
117
|
+
// focus-triggered tooltip after the delay.
|
|
118
|
+
clearTimer();
|
|
119
|
+
// Focus also fires for click/drag-select; only keyboard focus (no pointer
|
|
120
|
+
// held) should show the tooltip, mirroring the hover drag suppression.
|
|
121
|
+
if (pointerDown.current) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const content = computeCellTooltipContent(cell, hoverTemplate);
|
|
125
|
+
if (content == null || content === "") {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
showFor(e.currentTarget as HTMLElement, content);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const handleCellBlur = useEvent(() => hideTooltip());
|
|
133
|
+
|
|
134
|
+
// The anchor rect is captured at hover time, so any scroll or resize leaves
|
|
135
|
+
// it stale; hide instead of tracking. Capture catches scrolls inside the
|
|
136
|
+
// table's own container too (scroll events don't bubble but do fire in
|
|
137
|
+
// capture).
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const opts = { passive: true, capture: true } as const;
|
|
140
|
+
window.addEventListener("scroll", hideTooltip, opts);
|
|
141
|
+
window.addEventListener("resize", hideTooltip);
|
|
142
|
+
return () => {
|
|
143
|
+
window.removeEventListener("scroll", hideTooltip, { capture: true });
|
|
144
|
+
window.removeEventListener("resize", hideTooltip);
|
|
145
|
+
};
|
|
146
|
+
}, [hideTooltip]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => clearTimer, []);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
tooltipState,
|
|
152
|
+
tooltipContentId,
|
|
153
|
+
hideTooltip,
|
|
154
|
+
handleCellMouseOver,
|
|
155
|
+
handleCellMouseLeave,
|
|
156
|
+
handleCellFocus,
|
|
157
|
+
handleCellBlur,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -24,11 +24,12 @@ import { cn } from "@/utils/cn";
|
|
|
24
24
|
import { getCellDomProps } from "./cell-utils";
|
|
25
25
|
import { COLUMN_WRAPPING_STYLES } from "./column-wrapping/feature";
|
|
26
26
|
import { DataTableContextMenu } from "./context-menu";
|
|
27
|
+
import { HoverTooltip } from "./hover-tooltip/hover-tooltip";
|
|
28
|
+
import { useTableHoverTooltip } from "./hover-tooltip/use-table-hover-tooltip";
|
|
27
29
|
import { CellRangeSelectionIndicator } from "./range-focus/cell-selection-indicator";
|
|
28
30
|
import { useCellRangeSelection } from "./range-focus/use-cell-range-selection";
|
|
29
31
|
import { useScrollIntoViewOnFocus } from "./range-focus/use-scroll-into-view";
|
|
30
32
|
import { AUTO_WIDTH_MAX_COLUMNS, TABLE_ROW_HEIGHT_PX } from "./types";
|
|
31
|
-
import { stringifyUnknownValue } from "./utils";
|
|
32
33
|
|
|
33
34
|
export function renderTableHeader<TData>(
|
|
34
35
|
table: Table<TData>,
|
|
@@ -135,24 +136,7 @@ export const DataTableBody = <TData,>({
|
|
|
135
136
|
contextMenuCell.current = cell;
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
template: string,
|
|
140
|
-
cells: Cell<TData, unknown>[],
|
|
141
|
-
): string {
|
|
142
|
-
const variableRegex = /{{(\w+)}}/g;
|
|
143
|
-
// Map column id -> stringified value
|
|
144
|
-
const idToValue = new Map<string, string>();
|
|
145
|
-
for (const c of cells) {
|
|
146
|
-
const v = c.getValue();
|
|
147
|
-
// Prefer empty string for nulls to keep tooltip clean
|
|
148
|
-
const s = stringifyUnknownValue({ value: v, nullAsEmptyString: true });
|
|
149
|
-
idToValue.set(c.column.id, s);
|
|
150
|
-
}
|
|
151
|
-
return template.replaceAll(variableRegex, (_substr, varName: string) => {
|
|
152
|
-
const val = idToValue.get(varName);
|
|
153
|
-
return val === undefined ? `{{${varName}}}` : val;
|
|
154
|
-
});
|
|
155
|
-
}
|
|
139
|
+
const hoverTooltip = useTableHoverTooltip({ table });
|
|
156
140
|
|
|
157
141
|
const renderCells = (cells: Cell<TData, unknown>[]) => {
|
|
158
142
|
return cells.map((cell) => {
|
|
@@ -163,7 +147,6 @@ export const DataTableBody = <TData,>({
|
|
|
163
147
|
pinningstyle,
|
|
164
148
|
);
|
|
165
149
|
|
|
166
|
-
const title = cell.getHoverTitle?.() ?? undefined;
|
|
167
150
|
return (
|
|
168
151
|
<TableCell
|
|
169
152
|
tabIndex={0}
|
|
@@ -178,10 +161,18 @@ export const DataTableBody = <TData,>({
|
|
|
178
161
|
className,
|
|
179
162
|
)}
|
|
180
163
|
style={style}
|
|
181
|
-
|
|
182
|
-
|
|
164
|
+
onMouseDown={(e) => {
|
|
165
|
+
handleCellMouseDown(e, cell);
|
|
166
|
+
hoverTooltip.hideTooltip();
|
|
167
|
+
}}
|
|
183
168
|
onMouseUp={handleCellMouseUp}
|
|
184
|
-
onMouseOver={(e) =>
|
|
169
|
+
onMouseOver={(e) => {
|
|
170
|
+
handleCellMouseOver(e, cell);
|
|
171
|
+
hoverTooltip.handleCellMouseOver(e, cell);
|
|
172
|
+
}}
|
|
173
|
+
onMouseLeave={hoverTooltip.handleCellMouseLeave}
|
|
174
|
+
onFocus={(e) => hoverTooltip.handleCellFocus(e, cell)}
|
|
175
|
+
onBlur={hoverTooltip.handleCellBlur}
|
|
185
176
|
onContextMenu={() => handleContextMenu(cell)}
|
|
186
177
|
>
|
|
187
178
|
<CellRangeSelectionIndicator cellId={cell.id} />
|
|
@@ -200,8 +191,6 @@ export const DataTableBody = <TData,>({
|
|
|
200
191
|
}
|
|
201
192
|
};
|
|
202
193
|
|
|
203
|
-
const hoverTemplate = table.getState().cellHoverTemplate || null;
|
|
204
|
-
|
|
205
194
|
const renderRow = (row: Row<TData>) => {
|
|
206
195
|
// Only find the row index if the row viewer panel is open
|
|
207
196
|
const rowIndex = rowViewerPanelOpen
|
|
@@ -209,22 +198,10 @@ export const DataTableBody = <TData,>({
|
|
|
209
198
|
: undefined;
|
|
210
199
|
const isRowViewedInPanel = rowViewerPanelOpen && viewedRowIdx === rowIndex;
|
|
211
200
|
|
|
212
|
-
// Compute hover title once per row using all visible cells
|
|
213
|
-
let rowTitle: string | undefined;
|
|
214
|
-
if (hoverTemplate) {
|
|
215
|
-
const visibleCells = row.getVisibleCells?.() ?? [
|
|
216
|
-
...row.getLeftVisibleCells(),
|
|
217
|
-
...row.getCenterVisibleCells(),
|
|
218
|
-
...row.getRightVisibleCells(),
|
|
219
|
-
];
|
|
220
|
-
rowTitle = applyHoverTemplate(hoverTemplate, visibleCells);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
201
|
return (
|
|
224
202
|
<TableRow
|
|
225
203
|
key={row.id}
|
|
226
204
|
data-state={row.getIsSelected() && "selected"}
|
|
227
|
-
title={rowTitle}
|
|
228
205
|
// These classes ensure that empty rows (nulls) still render
|
|
229
206
|
className={cn(
|
|
230
207
|
"border-t h-6",
|
|
@@ -296,12 +273,19 @@ export const DataTableBody = <TData,>({
|
|
|
296
273
|
);
|
|
297
274
|
|
|
298
275
|
return (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
276
|
+
<>
|
|
277
|
+
<DataTableContextMenu
|
|
278
|
+
tableBody={tableBody}
|
|
279
|
+
contextMenuRef={contextMenuCell}
|
|
280
|
+
tableRef={tableRef}
|
|
281
|
+
copyAllCells={handleCopyAllCells}
|
|
282
|
+
/>
|
|
283
|
+
<HoverTooltip
|
|
284
|
+
state={hoverTooltip.tooltipState}
|
|
285
|
+
contentId={hoverTooltip.tooltipContentId}
|
|
286
|
+
onClose={hoverTooltip.hideTooltip}
|
|
287
|
+
/>
|
|
288
|
+
</>
|
|
305
289
|
);
|
|
306
290
|
};
|
|
307
291
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { toast, useToast } from "@/components/ui/use-toast";
|
|
5
|
+
|
|
6
|
+
describe("toast once", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
const { result } = renderHook(() => useToast());
|
|
13
|
+
act(() => {
|
|
14
|
+
result.current.dismiss();
|
|
15
|
+
vi.runAllTimers();
|
|
16
|
+
});
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("shows a once-toast a single time per session, even after removal", () => {
|
|
21
|
+
const { result } = renderHook(() => useToast());
|
|
22
|
+
|
|
23
|
+
act(() => {
|
|
24
|
+
toast({ id: "static-notebook", once: true, title: "Static notebook" });
|
|
25
|
+
});
|
|
26
|
+
expect(result.current.toasts).toHaveLength(1);
|
|
27
|
+
|
|
28
|
+
act(() => {
|
|
29
|
+
result.current.dismiss("static-notebook");
|
|
30
|
+
vi.advanceTimersByTime(10_000);
|
|
31
|
+
});
|
|
32
|
+
expect(result.current.toasts).toHaveLength(0);
|
|
33
|
+
|
|
34
|
+
act(() => {
|
|
35
|
+
toast({ id: "static-notebook", once: true, title: "Static notebook" });
|
|
36
|
+
});
|
|
37
|
+
expect(result.current.toasts).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not suppress normal (non-once) toasts", () => {
|
|
41
|
+
const { result } = renderHook(() => useToast());
|
|
42
|
+
|
|
43
|
+
act(() => {
|
|
44
|
+
toast({ id: "normal", title: "First" });
|
|
45
|
+
});
|
|
46
|
+
act(() => {
|
|
47
|
+
result.current.dismiss("normal");
|
|
48
|
+
vi.advanceTimersByTime(10_000);
|
|
49
|
+
});
|
|
50
|
+
expect(result.current.toasts).toHaveLength(0);
|
|
51
|
+
|
|
52
|
+
act(() => {
|
|
53
|
+
toast({ id: "normal", title: "First" });
|
|
54
|
+
});
|
|
55
|
+
expect(result.current.toasts).toHaveLength(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not dedupe a once-toast without a stable id", () => {
|
|
59
|
+
const { result } = renderHook(() => useToast());
|
|
60
|
+
|
|
61
|
+
act(() => {
|
|
62
|
+
toast({ once: true, title: "No id" });
|
|
63
|
+
});
|
|
64
|
+
act(() => {
|
|
65
|
+
result.current.dismiss();
|
|
66
|
+
vi.advanceTimersByTime(10_000);
|
|
67
|
+
});
|
|
68
|
+
expect(result.current.toasts).toHaveLength(0);
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
toast({ once: true, title: "No id" });
|
|
72
|
+
});
|
|
73
|
+
expect(result.current.toasts).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -58,6 +58,10 @@ interface State {
|
|
|
58
58
|
|
|
59
59
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
60
60
|
|
|
61
|
+
// Keys of toasts requested with `once: true`, suppressed after first show
|
|
62
|
+
// for the lifetime of the page (i.e. one static-preview session).
|
|
63
|
+
const shownOnceKeys = new Set<string>();
|
|
64
|
+
|
|
61
65
|
const addToRemoveQueue = (toastId: string) => {
|
|
62
66
|
if (toastTimeouts.has(toastId)) {
|
|
63
67
|
return;
|
|
@@ -159,9 +163,18 @@ function dispatch(action: Action) {
|
|
|
159
163
|
|
|
160
164
|
type Toast = Omit<ToasterToast, "id">;
|
|
161
165
|
|
|
162
|
-
function toast({
|
|
166
|
+
function toast({
|
|
167
|
+
id,
|
|
168
|
+
once,
|
|
169
|
+
...props
|
|
170
|
+
}: Toast & { id?: string; once?: boolean }) {
|
|
163
171
|
const toastId = id || genId();
|
|
164
172
|
|
|
173
|
+
// `once` dedupe requires a caller-provided stable `id`. A generated id is
|
|
174
|
+
// unique per call, so it could never match (and would grow the set without
|
|
175
|
+
// bound); gate on `id` so `once` without one is simply a no-op.
|
|
176
|
+
const dedupeOnce = once === true && id !== undefined;
|
|
177
|
+
|
|
165
178
|
const update = (props: Toast) =>
|
|
166
179
|
dispatch({
|
|
167
180
|
type: "UPDATE_TOAST",
|
|
@@ -183,19 +196,26 @@ function toast({ id, ...props }: Toast & { id?: string }) {
|
|
|
183
196
|
},
|
|
184
197
|
});
|
|
185
198
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
const suppressed = dedupeOnce && shownOnceKeys.has(toastId);
|
|
200
|
+
if (dedupeOnce) {
|
|
201
|
+
shownOnceKeys.add(toastId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!suppressed) {
|
|
205
|
+
dispatch({
|
|
206
|
+
type: "ADD_TOAST",
|
|
207
|
+
toast: {
|
|
208
|
+
...props,
|
|
209
|
+
id: toastId,
|
|
210
|
+
open: true,
|
|
211
|
+
onOpenChange: (open) => {
|
|
212
|
+
if (!open) {
|
|
213
|
+
dismiss();
|
|
214
|
+
}
|
|
215
|
+
},
|
|
196
216
|
},
|
|
197
|
-
}
|
|
198
|
-
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
199
219
|
|
|
200
220
|
return {
|
|
201
221
|
id: toastId,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { toast } from "@/components/ui/use-toast";
|
|
4
|
+
import { createStaticRequests } from "../requests-static";
|
|
5
|
+
|
|
6
|
+
vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
describe("createStaticRequests static-notebook toast", () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.mocked(toast).mockClear();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("requests a once-toast with a stable id on component value updates", async () => {
|
|
14
|
+
const requests = createStaticRequests();
|
|
15
|
+
await requests.sendComponentValues({} as never);
|
|
16
|
+
|
|
17
|
+
expect(toast).toHaveBeenCalledWith(
|
|
18
|
+
expect.objectContaining({ id: "static-notebook", once: true }),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses the same once-toast for function requests", async () => {
|
|
23
|
+
const requests = createStaticRequests();
|
|
24
|
+
await requests.sendFunctionRequest({} as never);
|
|
25
|
+
|
|
26
|
+
expect(toast).toHaveBeenCalledWith(
|
|
27
|
+
expect.objectContaining({ id: "static-notebook", once: true }),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -3,6 +3,18 @@ import { toast } from "@/components/ui/use-toast";
|
|
|
3
3
|
import { Logger } from "@/utils/Logger";
|
|
4
4
|
import type { EditRequests, RunRequests } from "./types";
|
|
5
5
|
|
|
6
|
+
const STATIC_NOTEBOOK_TOAST_ID = "static-notebook";
|
|
7
|
+
|
|
8
|
+
function showStaticNotebookToast() {
|
|
9
|
+
toast({
|
|
10
|
+
id: STATIC_NOTEBOOK_TOAST_ID,
|
|
11
|
+
once: true,
|
|
12
|
+
title: "Static notebook",
|
|
13
|
+
description:
|
|
14
|
+
"This notebook is not connected to a kernel. Any interactive elements will not work.",
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export function createStaticRequests(): EditRequests & RunRequests {
|
|
7
19
|
const throwNotInEditMode = () => {
|
|
8
20
|
throw new Error("Unreachable. Expected to be in run mode");
|
|
@@ -10,11 +22,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
10
22
|
|
|
11
23
|
return {
|
|
12
24
|
sendComponentValues: async () => {
|
|
13
|
-
|
|
14
|
-
title: "Static notebook",
|
|
15
|
-
description:
|
|
16
|
-
"This notebook is not connected to a kernel. Any interactive elements will not work.",
|
|
17
|
-
});
|
|
25
|
+
showStaticNotebookToast();
|
|
18
26
|
Logger.log("Updating UI elements is not supported in static mode");
|
|
19
27
|
return null;
|
|
20
28
|
},
|
|
@@ -27,11 +35,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
27
35
|
return null;
|
|
28
36
|
},
|
|
29
37
|
sendFunctionRequest: async () => {
|
|
30
|
-
|
|
31
|
-
title: "Static notebook",
|
|
32
|
-
description:
|
|
33
|
-
"This notebook is not connected to a kernel. Any interactive elements will not work.",
|
|
34
|
-
});
|
|
38
|
+
showStaticNotebookToast();
|
|
35
39
|
Logger.log("Function requests are not supported in static mode");
|
|
36
40
|
return null;
|
|
37
41
|
},
|