@marimo-team/islands 0.23.12-dev7 → 0.23.12-dev9
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/{code-visibility-43gCeXKe.js → code-visibility-w2yZTVwB.js} +1 -1
- package/dist/main.js +1311 -1319
- package/dist/{reveal-component-BQHpjptH.js → reveal-component-CuqTvwmg.js} +1 -1
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +4 -4
- package/src/components/home/components.tsx +4 -4
- package/src/components/icons/github.tsx +21 -0
- package/src/components/icons/youtube.tsx +21 -0
- package/src/components/storage/components.tsx +3 -7
- package/src/plugins/impl/FileBrowserPlugin.tsx +165 -74
- package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +314 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { SetupMocks } from "@/__mocks__/common";
|
|
6
|
+
import { initialModeAtom } from "@/core/mode";
|
|
7
|
+
import { store } from "@/core/state/jotai";
|
|
8
|
+
import type { IPluginProps } from "../../types";
|
|
9
|
+
import { FileBrowserPlugin } from "../FileBrowserPlugin";
|
|
10
|
+
|
|
11
|
+
interface MockFile {
|
|
12
|
+
id: string;
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
is_directory: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FILES: MockFile[] = [
|
|
19
|
+
{ id: "1", path: "/home/user/docs", name: "docs", is_directory: true },
|
|
20
|
+
{ id: "2", path: "/home/user/a.txt", name: "a.txt", is_directory: false },
|
|
21
|
+
{ id: "3", path: "/home/user/b.txt", name: "b.txt", is_directory: false },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
type Value = MockFile[];
|
|
25
|
+
|
|
26
|
+
function mockListDirectory(files: MockFile[]) {
|
|
27
|
+
return vi.fn().mockResolvedValue({
|
|
28
|
+
files,
|
|
29
|
+
total_count: files.length,
|
|
30
|
+
is_truncated: false,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeProps(
|
|
35
|
+
overrides: {
|
|
36
|
+
selectionMode?: string;
|
|
37
|
+
multiple?: boolean;
|
|
38
|
+
value?: Value;
|
|
39
|
+
setValue?: (v: Value) => void;
|
|
40
|
+
files?: MockFile[];
|
|
41
|
+
list_directory?: ReturnType<typeof vi.fn>;
|
|
42
|
+
host?: HTMLElement;
|
|
43
|
+
} = {},
|
|
44
|
+
): IPluginProps<Value, Record<string, unknown>> {
|
|
45
|
+
const files = overrides.files ?? FILES;
|
|
46
|
+
const list_directory = overrides.list_directory ?? mockListDirectory(files);
|
|
47
|
+
return {
|
|
48
|
+
data: {
|
|
49
|
+
initialPath: "/home/user",
|
|
50
|
+
filetypes: [],
|
|
51
|
+
selectionMode: overrides.selectionMode ?? "all",
|
|
52
|
+
multiple: overrides.multiple ?? true,
|
|
53
|
+
label: null,
|
|
54
|
+
restrictNavigation: false,
|
|
55
|
+
},
|
|
56
|
+
value: overrides.value ?? [],
|
|
57
|
+
setValue: overrides.setValue ?? vi.fn(),
|
|
58
|
+
host: overrides.host ?? document.createElement("div"),
|
|
59
|
+
functions: {
|
|
60
|
+
list_directory,
|
|
61
|
+
},
|
|
62
|
+
} as unknown as IPluginProps<Value, Record<string, unknown>>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderBrowser(overrides: Parameters<typeof makeProps>[0] = {}) {
|
|
66
|
+
const listDirectory =
|
|
67
|
+
overrides.list_directory ?? mockListDirectory(overrides.files ?? FILES);
|
|
68
|
+
const result = render(
|
|
69
|
+
FileBrowserPlugin.render(
|
|
70
|
+
makeProps({ ...overrides, list_directory: listDirectory }) as Parameters<
|
|
71
|
+
typeof FileBrowserPlugin.render
|
|
72
|
+
>[0],
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
return { ...result, listDirectory };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeAll(() => {
|
|
79
|
+
SetupMocks.resizeObserver();
|
|
80
|
+
store.set(initialModeAtom, "edit");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("FileBrowserPlugin keyboard accessibility", () => {
|
|
84
|
+
it("renders a row per file plus the parent row", async () => {
|
|
85
|
+
renderBrowser();
|
|
86
|
+
expect(await screen.findByText("docs")).toBeInTheDocument();
|
|
87
|
+
// parent "..", docs, a.txt, b.txt
|
|
88
|
+
expect(screen.getAllByRole("row")).toHaveLength(4);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("marks the list as a multiselectable grid", async () => {
|
|
92
|
+
renderBrowser({ multiple: true });
|
|
93
|
+
await screen.findByText("docs");
|
|
94
|
+
const grid = screen.getByRole("grid");
|
|
95
|
+
expect(grid).toHaveAttribute("aria-multiselectable", "true");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not select a non-selectable file on click (mode=directory)", async () => {
|
|
99
|
+
const setValue = vi.fn();
|
|
100
|
+
renderBrowser({ selectionMode: "directory", setValue });
|
|
101
|
+
const fileCell = await screen.findByText("a.txt");
|
|
102
|
+
fireEvent.click(fileCell.closest('[role="row"]')!);
|
|
103
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("labels each selectable checkbox with the file name", async () => {
|
|
107
|
+
renderBrowser({ selectionMode: "all" });
|
|
108
|
+
await screen.findByText("docs");
|
|
109
|
+
expect(
|
|
110
|
+
screen.getByRole("checkbox", { name: "Select a.txt" }),
|
|
111
|
+
).toBeInTheDocument();
|
|
112
|
+
expect(
|
|
113
|
+
screen.getByRole("checkbox", { name: "Select docs" }),
|
|
114
|
+
).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("keeps checkboxes out of the tab order", async () => {
|
|
118
|
+
renderBrowser({ selectionMode: "all" });
|
|
119
|
+
await screen.findByText("docs");
|
|
120
|
+
expect(
|
|
121
|
+
screen.getByRole("checkbox", { name: "Select a.txt" }),
|
|
122
|
+
).toHaveAttribute("tabindex", "-1");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("places exactly one row in the tab order", async () => {
|
|
126
|
+
renderBrowser();
|
|
127
|
+
await screen.findByText("docs");
|
|
128
|
+
const rows = screen.getAllByRole("row");
|
|
129
|
+
const tabbable = rows.filter((r) => r.getAttribute("tabindex") === "0");
|
|
130
|
+
expect(tabbable).toHaveLength(1);
|
|
131
|
+
// the parent row is first and starts active
|
|
132
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resets the active row to the parent row after navigating", async () => {
|
|
136
|
+
renderBrowser({ selectionMode: "all" });
|
|
137
|
+
const docs = await screen.findByText("docs");
|
|
138
|
+
const docsRow = docs.closest('[role="row"]')!;
|
|
139
|
+
fireEvent.keyDown(docsRow, { key: "ArrowDown" }); // move active off the parent
|
|
140
|
+
fireEvent.click(docs); // navigate into "docs"
|
|
141
|
+
await screen.findByText("docs"); // listing reloads (mock returns same files)
|
|
142
|
+
const rows = screen.getAllByRole("row");
|
|
143
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
144
|
+
// the focused row must match the only tabbable row
|
|
145
|
+
expect(rows[0]).toHaveFocus();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("resets the active row when the listing refreshes in place", async () => {
|
|
149
|
+
// The cell's random-id changes when the cell re-renders, refetching the
|
|
150
|
+
// same path. A shrinking listing must not leave activeIndex out of bounds.
|
|
151
|
+
const parent = document.createElement("div");
|
|
152
|
+
parent.setAttribute("random-id", "a");
|
|
153
|
+
const host = document.createElement("div");
|
|
154
|
+
parent.append(host);
|
|
155
|
+
document.body.append(parent);
|
|
156
|
+
|
|
157
|
+
const list_directory = vi
|
|
158
|
+
.fn()
|
|
159
|
+
.mockResolvedValueOnce({
|
|
160
|
+
files: FILES,
|
|
161
|
+
total_count: FILES.length,
|
|
162
|
+
is_truncated: false,
|
|
163
|
+
})
|
|
164
|
+
.mockResolvedValue({
|
|
165
|
+
files: [FILES[0]],
|
|
166
|
+
total_count: 1,
|
|
167
|
+
is_truncated: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const props = () =>
|
|
171
|
+
FileBrowserPlugin.render(
|
|
172
|
+
makeProps({ host, list_directory }) as Parameters<
|
|
173
|
+
typeof FileBrowserPlugin.render
|
|
174
|
+
>[0],
|
|
175
|
+
);
|
|
176
|
+
const { rerender } = render(props());
|
|
177
|
+
await screen.findByText("a.txt");
|
|
178
|
+
|
|
179
|
+
// Move the active row to the last row, then trigger a same-path refresh.
|
|
180
|
+
fireEvent.keyDown(screen.getAllByRole("row")[0], { key: "End" });
|
|
181
|
+
expect(screen.getAllByRole("row").at(-1)).toHaveAttribute("tabindex", "0");
|
|
182
|
+
|
|
183
|
+
parent.setAttribute("random-id", "b");
|
|
184
|
+
rerender(props());
|
|
185
|
+
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
// parent ".." plus the single remaining file
|
|
188
|
+
expect(screen.getAllByRole("row")).toHaveLength(2),
|
|
189
|
+
);
|
|
190
|
+
const rows = screen.getAllByRole("row");
|
|
191
|
+
const tabbable = rows.filter((r) => r.getAttribute("tabindex") === "0");
|
|
192
|
+
expect(tabbable).toHaveLength(1);
|
|
193
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("syncs the tabbable row to whichever row gains focus", async () => {
|
|
197
|
+
renderBrowser();
|
|
198
|
+
await screen.findByText("docs");
|
|
199
|
+
const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
|
|
200
|
+
expect(rows[0]).toHaveAttribute("tabindex", "0");
|
|
201
|
+
|
|
202
|
+
fireEvent.focus(rows[2]); // e.g. focus from a mouse click, not arrow keys
|
|
203
|
+
expect(rows[2]).toHaveAttribute("tabindex", "0");
|
|
204
|
+
expect(rows[0]).toHaveAttribute("tabindex", "-1");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("moves focus to the parent row after navigating from within the grid", async () => {
|
|
208
|
+
// Navigating into a directory unmounts the focused row; focus must follow
|
|
209
|
+
// to the parent row instead of falling back to the document body.
|
|
210
|
+
const list_directory = vi
|
|
211
|
+
.fn()
|
|
212
|
+
.mockResolvedValueOnce({
|
|
213
|
+
files: FILES,
|
|
214
|
+
total_count: FILES.length,
|
|
215
|
+
is_truncated: false,
|
|
216
|
+
})
|
|
217
|
+
.mockResolvedValue({
|
|
218
|
+
files: [
|
|
219
|
+
{
|
|
220
|
+
id: "99",
|
|
221
|
+
path: "/home/user/docs/inner.txt",
|
|
222
|
+
name: "inner.txt",
|
|
223
|
+
is_directory: false,
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
total_count: 1,
|
|
227
|
+
is_truncated: false,
|
|
228
|
+
});
|
|
229
|
+
renderBrowser({ selectionMode: "all", list_directory });
|
|
230
|
+
await screen.findByText("docs");
|
|
231
|
+
|
|
232
|
+
const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
|
|
233
|
+
fireEvent.keyDown(rows[0], { key: "ArrowDown" }); // focus the docs row
|
|
234
|
+
expect(rows[1]).toHaveFocus();
|
|
235
|
+
fireEvent.keyDown(rows[1], { key: "Enter" }); // navigate into docs
|
|
236
|
+
|
|
237
|
+
await screen.findByText("inner.txt");
|
|
238
|
+
expect(screen.getAllByRole("row")[0]).toHaveFocus();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("moves focus with arrows and clamps at the ends", async () => {
|
|
242
|
+
renderBrowser();
|
|
243
|
+
await screen.findByText("docs");
|
|
244
|
+
const rows = screen.getAllByRole("row"); // [.., docs, a.txt, b.txt]
|
|
245
|
+
|
|
246
|
+
fireEvent.keyDown(rows[0], { key: "ArrowDown" });
|
|
247
|
+
expect(rows[1]).toHaveFocus();
|
|
248
|
+
expect(rows[1]).toHaveAttribute("tabindex", "0");
|
|
249
|
+
|
|
250
|
+
fireEvent.keyDown(rows[1], { key: "ArrowUp" });
|
|
251
|
+
expect(rows[0]).toHaveFocus();
|
|
252
|
+
|
|
253
|
+
fireEvent.keyDown(rows[0], { key: "ArrowUp" }); // clamp at top
|
|
254
|
+
expect(rows[0]).toHaveFocus();
|
|
255
|
+
|
|
256
|
+
fireEvent.keyDown(rows[0], { key: "End" });
|
|
257
|
+
expect(rows[3]).toHaveFocus();
|
|
258
|
+
|
|
259
|
+
fireEvent.keyDown(rows[3], { key: "ArrowDown" }); // clamp at bottom
|
|
260
|
+
expect(rows[3]).toHaveFocus();
|
|
261
|
+
|
|
262
|
+
fireEvent.keyDown(rows[3], { key: "Home" });
|
|
263
|
+
expect(rows[0]).toHaveFocus();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("Enter navigates into a directory", async () => {
|
|
267
|
+
const setValue = vi.fn();
|
|
268
|
+
const { listDirectory } = renderBrowser({
|
|
269
|
+
selectionMode: "all",
|
|
270
|
+
setValue,
|
|
271
|
+
});
|
|
272
|
+
await screen.findByText("docs");
|
|
273
|
+
fireEvent.keyDown(rowFor("docs"), { key: "Enter" });
|
|
274
|
+
await waitFor(() =>
|
|
275
|
+
expect(listDirectory).toHaveBeenCalledWith({ path: "/home/user/docs" }),
|
|
276
|
+
);
|
|
277
|
+
// navigation does not mutate value
|
|
278
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("Enter toggles selection on a selectable file", async () => {
|
|
282
|
+
const setValue = vi.fn();
|
|
283
|
+
renderBrowser({ selectionMode: "all", multiple: true, setValue });
|
|
284
|
+
await screen.findByText("a.txt");
|
|
285
|
+
fireEvent.keyDown(rowFor("a.txt"), { key: "Enter" });
|
|
286
|
+
expect(setValue).toHaveBeenCalledWith([
|
|
287
|
+
expect.objectContaining({ path: "/home/user/a.txt" }),
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("Space toggles selection and never navigates", async () => {
|
|
292
|
+
const setValue = vi.fn();
|
|
293
|
+
renderBrowser({ selectionMode: "all", multiple: true, setValue });
|
|
294
|
+
await screen.findByText("docs");
|
|
295
|
+
// Space on a selectable directory selects it (does not navigate)
|
|
296
|
+
fireEvent.keyDown(rowFor("docs"), { key: " " });
|
|
297
|
+
expect(setValue).toHaveBeenCalledWith([
|
|
298
|
+
expect.objectContaining({ path: "/home/user/docs" }),
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("Space on the parent row is a no-op", async () => {
|
|
303
|
+
const setValue = vi.fn();
|
|
304
|
+
renderBrowser({ selectionMode: "all", setValue });
|
|
305
|
+
await screen.findByText("docs");
|
|
306
|
+
const parentRow = screen.getAllByRole("row")[0];
|
|
307
|
+
fireEvent.keyDown(parentRow, { key: " " });
|
|
308
|
+
expect(setValue).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
function rowFor(name: string): HTMLElement {
|
|
313
|
+
return screen.getByText(name).closest('[role="row"]') as HTMLElement;
|
|
314
|
+
}
|