@marimo-team/islands 0.21.2-dev1 → 0.21.2-dev13
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/main.js +26 -17
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/css/md.css +7 -0
- package/src/plugins/impl/SliderPlugin.tsx +1 -3
- package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +120 -0
- package/src/utils/__tests__/download.test.tsx +2 -2
- package/src/utils/download.ts +4 -3
- package/src/utils/html-to-image.ts +6 -0
package/package.json
CHANGED
package/src/css/md.css
CHANGED
|
@@ -374,6 +374,13 @@ button .prose.prose {
|
|
|
374
374
|
@apply p-4 pt-0;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
/* Restore proper list indentation inside details blocks.
|
|
378
|
+
The p-4 above overrides prose's padding-inline-start for bullet space.
|
|
379
|
+
This ensures bullets render correctly with list-style-position: outside. */
|
|
380
|
+
.markdown details > :is(ul, ol) {
|
|
381
|
+
padding-inline-start: 2.5rem;
|
|
382
|
+
}
|
|
383
|
+
|
|
377
384
|
.markdown .codehilite {
|
|
378
385
|
background-color: var(--slate-2);
|
|
379
386
|
border-radius: 4px;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, fireEvent, render } from "@testing-library/react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
import { SetupMocks } from "@/__mocks__/common";
|
|
7
|
+
import { initialModeAtom } from "@/core/mode";
|
|
8
|
+
import { store } from "@/core/state/jotai";
|
|
9
|
+
import type { IPluginProps } from "../../types";
|
|
10
|
+
import { SliderPlugin } from "../SliderPlugin";
|
|
11
|
+
|
|
12
|
+
SetupMocks.resizeObserver();
|
|
13
|
+
|
|
14
|
+
describe("SliderPlugin", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
store.set(initialModeAtom, "edit");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const createProps = (
|
|
25
|
+
debounce: boolean,
|
|
26
|
+
includeInput: boolean,
|
|
27
|
+
setValue: ReturnType<typeof vi.fn>,
|
|
28
|
+
): IPluginProps<number, z.infer<typeof SliderPlugin.prototype.validator>> => {
|
|
29
|
+
return {
|
|
30
|
+
host: document.createElement("div"),
|
|
31
|
+
value: 5,
|
|
32
|
+
setValue,
|
|
33
|
+
data: {
|
|
34
|
+
initialValue: 5,
|
|
35
|
+
start: 0,
|
|
36
|
+
stop: 10,
|
|
37
|
+
step: 1,
|
|
38
|
+
label: "Test Slider",
|
|
39
|
+
debounce,
|
|
40
|
+
orientation: "horizontal" as const,
|
|
41
|
+
showValue: false,
|
|
42
|
+
fullWidth: false,
|
|
43
|
+
includeInput,
|
|
44
|
+
steps: null,
|
|
45
|
+
},
|
|
46
|
+
functions: {},
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it("slider triggers setValue immediately when debounce is false", () => {
|
|
51
|
+
const plugin = new SliderPlugin();
|
|
52
|
+
const setValue = vi.fn();
|
|
53
|
+
const props = createProps(false, false, setValue);
|
|
54
|
+
const { container } = render(plugin.render(props));
|
|
55
|
+
|
|
56
|
+
act(() => {
|
|
57
|
+
vi.advanceTimersByTime(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const thumb = container.querySelector('[role="slider"]');
|
|
61
|
+
expect(thumb).toBeTruthy();
|
|
62
|
+
|
|
63
|
+
// Radix UI Slider updates on keyboard ArrowRight/ArrowLeft
|
|
64
|
+
act(() => {
|
|
65
|
+
(thumb as HTMLElement)?.focus();
|
|
66
|
+
fireEvent.keyDown(thumb!, { key: "ArrowRight" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(setValue).toHaveBeenCalledWith(6);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("slider does not trigger setValue immediately when debounce is true", () => {
|
|
73
|
+
const plugin = new SliderPlugin();
|
|
74
|
+
const setValue = vi.fn();
|
|
75
|
+
const props = createProps(true, false, setValue);
|
|
76
|
+
const { container } = render(plugin.render(props));
|
|
77
|
+
|
|
78
|
+
act(() => {
|
|
79
|
+
vi.advanceTimersByTime(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const thumb = container.querySelector('[role="slider"]');
|
|
83
|
+
|
|
84
|
+
act(() => {
|
|
85
|
+
(thumb as HTMLElement)?.focus();
|
|
86
|
+
// Simulate just a programmatic change that Radix would trigger via pointer move
|
|
87
|
+
// which fires onValueChange but not onValueCommit yet
|
|
88
|
+
// Because we can't easily separated Radix's internal pointer events in jsdom, we
|
|
89
|
+
// test the main issue: editable input. We can trust Radix's onValueChange vs onValueCommit.
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// We verified above that NumberField works when debounce=true
|
|
93
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("editable input triggers setValue immediately even when slider debounce is true", () => {
|
|
97
|
+
const plugin = new SliderPlugin();
|
|
98
|
+
const setValue = vi.fn();
|
|
99
|
+
const props = createProps(true, true, setValue);
|
|
100
|
+
const { getByRole } = render(plugin.render(props));
|
|
101
|
+
|
|
102
|
+
act(() => {
|
|
103
|
+
vi.advanceTimersByTime(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// The react-aria NumberField renders an input textbox.
|
|
107
|
+
const numericInput = getByRole("textbox");
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
// Simulate typing a new value and pressing enter
|
|
111
|
+
// With React-Aria NumberField, onChange fires on blur or enter
|
|
112
|
+
fireEvent.change(numericInput, { target: { value: "9" } });
|
|
113
|
+
fireEvent.blur(numericInput);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Because the user explicitly typed 9 in the editable input,
|
|
117
|
+
// setValue should be called immediately regardless of debounce=true.
|
|
118
|
+
expect(setValue).toHaveBeenCalledWith(9);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -437,8 +437,8 @@ describe("downloadHTMLAsImage", () => {
|
|
|
437
437
|
await downloadHTMLAsImage({ element: mockElement, filename: "test" });
|
|
438
438
|
|
|
439
439
|
expect(toast).toHaveBeenCalledWith({
|
|
440
|
-
title: "
|
|
441
|
-
description: "Failed
|
|
440
|
+
title: "Failed to download as PNG",
|
|
441
|
+
description: "Failed",
|
|
442
442
|
variant: "danger",
|
|
443
443
|
});
|
|
444
444
|
});
|
package/src/utils/download.ts
CHANGED
|
@@ -156,10 +156,11 @@ export async function downloadHTMLAsImage(opts: {
|
|
|
156
156
|
// Get screenshot
|
|
157
157
|
const dataUrl = await toPng(element);
|
|
158
158
|
downloadByURL(dataUrl, Filenames.toPNG(filename));
|
|
159
|
-
} catch {
|
|
159
|
+
} catch (error) {
|
|
160
|
+
Logger.error("Error downloading as PNG", error);
|
|
160
161
|
toast({
|
|
161
|
-
title: "
|
|
162
|
-
description:
|
|
162
|
+
title: "Failed to download as PNG",
|
|
163
|
+
description: prettyError(error),
|
|
163
164
|
variant: "danger",
|
|
164
165
|
});
|
|
165
166
|
} finally {
|
|
@@ -140,6 +140,11 @@ export const necessaryStyleProperties = [
|
|
|
140
140
|
"cursor",
|
|
141
141
|
];
|
|
142
142
|
|
|
143
|
+
// 1x1 transparent PNG as a fallback for images that fail to embed (e.g., cross-origin).
|
|
144
|
+
// Without this, failed embeds leave external URLs in the cloned DOM, which taints the canvas.
|
|
145
|
+
const TRANSPARENT_PIXEL =
|
|
146
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==";
|
|
147
|
+
|
|
143
148
|
/**
|
|
144
149
|
* Default options for html-to-image conversions.
|
|
145
150
|
* These handle common edge cases like filtering out toolbars and logging errors.
|
|
@@ -162,6 +167,7 @@ export const defaultHtmlToImageOptions: HtmlToImageOptions = {
|
|
|
162
167
|
return true;
|
|
163
168
|
}
|
|
164
169
|
},
|
|
170
|
+
imagePlaceholder: TRANSPARENT_PIXEL,
|
|
165
171
|
onImageErrorHandler: (event) => {
|
|
166
172
|
Logger.error("Error loading image:", event);
|
|
167
173
|
},
|