@marimo-team/islands 0.23.12-dev5 → 0.23.12-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/{chat-ui-BElU7iES.js → chat-ui-BEOvjkmJ.js} +2 -2
- package/dist/{code-visibility-C4oGgzI1.js → code-visibility-B9yvB9rV.js} +12 -12
- package/dist/{formats-CGj29bgR.js → formats-d6MhLuQ9.js} +1 -1
- package/dist/{html-to-image-Pd4oj3-L.js → html-to-image-Di0mtt6O.js} +58 -58
- package/dist/main.js +1562 -1557
- package/dist/{process-output-nrhrehth.js → process-output-BLd4KuwX.js} +1 -1
- package/dist/{reveal-component-BnYITWzo.js → reveal-component-D6wEWbxH.js} +3 -3
- package/dist/style.css +2 -2
- package/dist/{vega-component-CKPImOhx.js → vega-component-Pk6lyc_a.js} +1 -1
- package/package.json +3 -3
- package/src/components/ai/display-helpers.tsx +5 -5
- package/src/components/app-config/ai-config.tsx +5 -5
- package/src/components/app-config/mcp-config.tsx +3 -3
- package/src/components/chat/acp/agent-panel.tsx +3 -3
- package/src/components/chat/acp/blocks.tsx +36 -38
- package/src/components/chat/acp/common.tsx +12 -16
- package/src/components/chat/acp/scroll-to-bottom-button.tsx +1 -1
- package/src/components/chat/acp/session-tabs.tsx +2 -2
- package/src/components/chat/chat-history-popover.tsx +1 -1
- package/src/components/data-table/columns.tsx +2 -2
- package/src/components/data-table/filter-pill-editor.tsx +1 -1
- package/src/components/dependency-graph/minimap-content.tsx +1 -1
- package/src/components/editor/RecoveryButton.tsx +1 -1
- package/src/components/editor/actions/pair-with-agent-modal.tsx +2 -2
- package/src/components/editor/ai/ai-completion-editor.tsx +1 -1
- package/src/components/editor/cell/CreateCellButton.tsx +1 -1
- package/src/components/editor/chrome/panels/empty-state.tsx +1 -1
- package/src/components/editor/chrome/panels/outline/floating-outline.tsx +1 -1
- package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +1 -1
- package/src/components/editor/columns/cell-column.tsx +1 -1
- package/src/components/editor/columns/sortable-column.tsx +2 -2
- package/src/components/editor/output/MarimoErrorOutput.tsx +1 -1
- package/src/components/editor/output/TextOutput.tsx +2 -2
- package/src/components/slides/minimap.tsx +2 -2
- package/src/components/slides/reveal-component.tsx +1 -1
- package/src/components/ui/alert.tsx +1 -1
- package/src/components/ui/command.tsx +2 -2
- package/src/components/ui/reorderable-list.tsx +1 -1
- package/src/components/ui/table.tsx +2 -5
- package/src/core/codemirror/language/languages/sql/renderers.tsx +60 -68
- package/src/plugins/impl/FileBrowserPlugin.tsx +165 -74
- package/src/plugins/impl/MatrixPlugin.tsx +2 -2
- package/src/plugins/impl/TabsPlugin.tsx +1 -1
- package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +314 -0
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +1 -1
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +155 -98
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +154 -1
- package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +10 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { type LucideIcon, CornerLeftUp } from "lucide-react";
|
|
4
|
-
import { type JSX, useEffect, useState } from "react";
|
|
4
|
+
import { type JSX, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import {
|
|
7
7
|
FILE_ICON as FILE_TYPE_ICONS,
|
|
@@ -129,6 +129,7 @@ interface FileBrowserProps extends Data, PluginFunctions {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
interface CheckboxOrIconProps {
|
|
132
|
+
name: string;
|
|
132
133
|
isSelected: boolean;
|
|
133
134
|
canSelect: boolean;
|
|
134
135
|
Icon: LucideIcon;
|
|
@@ -136,6 +137,7 @@ interface CheckboxOrIconProps {
|
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
function CheckboxOrIcon({
|
|
140
|
+
name,
|
|
139
141
|
isSelected,
|
|
140
142
|
canSelect,
|
|
141
143
|
Icon,
|
|
@@ -146,17 +148,21 @@ function CheckboxOrIcon({
|
|
|
146
148
|
<>
|
|
147
149
|
<Checkbox
|
|
148
150
|
checked={isSelected}
|
|
151
|
+
aria-label={`Select ${name}`}
|
|
152
|
+
tabIndex={-1}
|
|
149
153
|
onClick={(e) => {
|
|
150
154
|
onSelect();
|
|
151
155
|
e.stopPropagation();
|
|
152
156
|
}}
|
|
153
|
-
className={cn({
|
|
157
|
+
className={cn({
|
|
158
|
+
"hidden group-hover:flex group-focus:flex": !isSelected,
|
|
159
|
+
})}
|
|
154
160
|
/>
|
|
155
161
|
<Icon
|
|
156
162
|
size={16}
|
|
157
163
|
className={cn("mr-2", {
|
|
158
164
|
hidden: isSelected,
|
|
159
|
-
"group-hover:hidden": !isSelected,
|
|
165
|
+
"group-hover:hidden group-focus:hidden": !isSelected,
|
|
160
166
|
})}
|
|
161
167
|
/>
|
|
162
168
|
</>
|
|
@@ -165,6 +171,18 @@ function CheckboxOrIcon({
|
|
|
165
171
|
return <Icon size={16} className="mr-2" />;
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
interface RowModel {
|
|
175
|
+
key: string;
|
|
176
|
+
name: string;
|
|
177
|
+
Icon: LucideIcon;
|
|
178
|
+
isSelected: boolean;
|
|
179
|
+
canSelect: boolean;
|
|
180
|
+
/** Enter and mouse-click action. */
|
|
181
|
+
onPrimary: () => void;
|
|
182
|
+
/** Space action; null when the row has nothing to toggle. */
|
|
183
|
+
onToggleSelect: (() => void) | null;
|
|
184
|
+
}
|
|
185
|
+
|
|
168
186
|
/**
|
|
169
187
|
* File browser component.
|
|
170
188
|
*
|
|
@@ -184,6 +202,13 @@ export const FileBrowser = ({
|
|
|
184
202
|
const [path, setPath] = useInternalStateWithSync(initialPath);
|
|
185
203
|
const [isUpdatingPath, setIsUpdatingPath] = useState(false);
|
|
186
204
|
const [showLoadingOverlay, setShowLoadingOverlay] = useState(false);
|
|
205
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
206
|
+
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
|
|
207
|
+
const gridRef = useRef<HTMLTableElement | null>(null);
|
|
208
|
+
// Set when navigation is triggered from within the grid, so focus can follow
|
|
209
|
+
// to the parent row after the new listing renders instead of falling to the
|
|
210
|
+
// body when the previously focused row unmounts.
|
|
211
|
+
const refocusParentRef = useRef(false);
|
|
187
212
|
|
|
188
213
|
// HACK: use the random-id of the host element to force a re-render
|
|
189
214
|
// when the random-id changes, this means the cell was re-rendered
|
|
@@ -210,6 +235,22 @@ export const FileBrowser = ({
|
|
|
210
235
|
};
|
|
211
236
|
}, [isPending]);
|
|
212
237
|
|
|
238
|
+
// Reset the roving tabindex whenever the listing reloads (a new path or a
|
|
239
|
+
// same-path refresh) so activeIndex never points past the current rows.
|
|
240
|
+
const listingKey = `${path}::${randomId}`;
|
|
241
|
+
const [prevListingKey, setPrevListingKey] = useState(listingKey);
|
|
242
|
+
if (prevListingKey !== listingKey) {
|
|
243
|
+
setPrevListingKey(listingKey);
|
|
244
|
+
setActiveIndex(0);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
useLayoutEffect(() => {
|
|
248
|
+
if (refocusParentRef.current) {
|
|
249
|
+
refocusParentRef.current = false;
|
|
250
|
+
rowRefs.current[0]?.focus();
|
|
251
|
+
}
|
|
252
|
+
}, [listingKey]);
|
|
253
|
+
|
|
213
254
|
const files = data?.files ?? [];
|
|
214
255
|
const selectedPaths = new Set(value.map((x) => x.path));
|
|
215
256
|
const canSelectDirectories =
|
|
@@ -269,6 +310,8 @@ export const FileBrowser = ({
|
|
|
269
310
|
return;
|
|
270
311
|
}
|
|
271
312
|
|
|
313
|
+
refocusParentRef.current =
|
|
314
|
+
gridRef.current?.contains(document.activeElement) ?? false;
|
|
272
315
|
setPath(newPath);
|
|
273
316
|
setIsUpdatingPath(false);
|
|
274
317
|
}
|
|
@@ -333,78 +376,90 @@ export const FileBrowser = ({
|
|
|
333
376
|
setValue([...value, ...filesInView]);
|
|
334
377
|
}
|
|
335
378
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
);
|
|
379
|
+
const rowModels: RowModel[] = [
|
|
380
|
+
{
|
|
381
|
+
key: "parent",
|
|
382
|
+
name: PARENT_DIRECTORY,
|
|
383
|
+
Icon: CornerLeftUp,
|
|
384
|
+
isSelected: false,
|
|
385
|
+
canSelect: false,
|
|
386
|
+
onPrimary: () => setNewPath(PARENT_DIRECTORY),
|
|
387
|
+
onToggleSelect: null,
|
|
388
|
+
},
|
|
389
|
+
...files.map((file): RowModel => {
|
|
390
|
+
let filePath = file.path;
|
|
391
|
+
if (filePath.startsWith("//")) {
|
|
392
|
+
filePath = filePath.slice(1) as FilePath;
|
|
393
|
+
}
|
|
352
394
|
|
|
353
|
-
|
|
354
|
-
|
|
395
|
+
const canSelect =
|
|
396
|
+
(canSelectDirectories && file.is_directory) ||
|
|
397
|
+
(canSelectFiles && !file.is_directory);
|
|
398
|
+
const isSelected = selectedPaths.has(filePath);
|
|
399
|
+
const fileType: FileType = file.is_directory
|
|
400
|
+
? "directory"
|
|
401
|
+
: guessFileType(file.name);
|
|
402
|
+
|
|
403
|
+
const toggle = () =>
|
|
404
|
+
handleSelection({
|
|
405
|
+
path: filePath,
|
|
406
|
+
name: file.name,
|
|
407
|
+
isDirectory: file.is_directory,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
key: file.id,
|
|
412
|
+
name: file.name,
|
|
413
|
+
Icon: FILE_TYPE_ICONS[fileType],
|
|
414
|
+
isSelected,
|
|
415
|
+
canSelect,
|
|
416
|
+
onPrimary: file.is_directory
|
|
417
|
+
? () => setNewPath(filePath)
|
|
418
|
+
: canSelect
|
|
419
|
+
? toggle
|
|
420
|
+
: () => {},
|
|
421
|
+
onToggleSelect: canSelect ? toggle : null,
|
|
422
|
+
};
|
|
423
|
+
}),
|
|
424
|
+
];
|
|
355
425
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
426
|
+
function focusRow(index: number) {
|
|
427
|
+
setActiveIndex(index);
|
|
428
|
+
rowRefs.current[index]?.focus();
|
|
429
|
+
}
|
|
359
430
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
(canSelectDirectories && file.is_directory) ||
|
|
393
|
-
(canSelectFiles && !file.is_directory)
|
|
394
|
-
}
|
|
395
|
-
Icon={Icon}
|
|
396
|
-
onSelect={() =>
|
|
397
|
-
handleSelection({
|
|
398
|
-
path: filePath,
|
|
399
|
-
name: file.name,
|
|
400
|
-
isDirectory: file.is_directory,
|
|
401
|
-
})
|
|
402
|
-
}
|
|
403
|
-
/>
|
|
404
|
-
</TableCell>
|
|
405
|
-
<TableCell>{file.name}</TableCell>
|
|
406
|
-
</TableRow>,
|
|
407
|
-
);
|
|
431
|
+
function handleRowKeyDown(
|
|
432
|
+
e: React.KeyboardEvent<HTMLTableRowElement>,
|
|
433
|
+
index: number,
|
|
434
|
+
) {
|
|
435
|
+
const lastIndex = rowModels.length - 1;
|
|
436
|
+
switch (e.key) {
|
|
437
|
+
case "ArrowDown":
|
|
438
|
+
e.preventDefault();
|
|
439
|
+
focusRow(Math.min(index + 1, lastIndex));
|
|
440
|
+
break;
|
|
441
|
+
case "ArrowUp":
|
|
442
|
+
e.preventDefault();
|
|
443
|
+
focusRow(Math.max(index - 1, 0));
|
|
444
|
+
break;
|
|
445
|
+
case "Home":
|
|
446
|
+
e.preventDefault();
|
|
447
|
+
focusRow(0);
|
|
448
|
+
break;
|
|
449
|
+
case "End":
|
|
450
|
+
e.preventDefault();
|
|
451
|
+
focusRow(lastIndex);
|
|
452
|
+
break;
|
|
453
|
+
case "Enter":
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
rowModels[index].onPrimary();
|
|
456
|
+
break;
|
|
457
|
+
// Space is select-only; preventDefault stops the list from scrolling.
|
|
458
|
+
case " ":
|
|
459
|
+
e.preventDefault();
|
|
460
|
+
rowModels[index].onToggleSelect?.();
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
408
463
|
}
|
|
409
464
|
|
|
410
465
|
// Get list of parent directories.
|
|
@@ -490,8 +545,44 @@ export const FileBrowser = ({
|
|
|
490
545
|
<span>Listing files...</span>
|
|
491
546
|
</div>
|
|
492
547
|
)}
|
|
493
|
-
<Table
|
|
494
|
-
|
|
548
|
+
<Table
|
|
549
|
+
ref={gridRef}
|
|
550
|
+
className="cursor-pointer table-fixed"
|
|
551
|
+
role="grid"
|
|
552
|
+
aria-label="File browser"
|
|
553
|
+
aria-multiselectable={multiple}
|
|
554
|
+
>
|
|
555
|
+
<TableBody>
|
|
556
|
+
{rowModels.map((row, index) => (
|
|
557
|
+
<TableRow
|
|
558
|
+
key={row.key}
|
|
559
|
+
role="row"
|
|
560
|
+
ref={(el) => {
|
|
561
|
+
rowRefs.current[index] = el;
|
|
562
|
+
}}
|
|
563
|
+
tabIndex={index === activeIndex ? 0 : -1}
|
|
564
|
+
onFocus={() => setActiveIndex(index)}
|
|
565
|
+
onKeyDown={(e) => handleRowKeyDown(e, index)}
|
|
566
|
+
className={cn(
|
|
567
|
+
"hover:bg-accent group select-none focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset",
|
|
568
|
+
{ "bg-primary/25 hover:bg-primary/35": row.isSelected },
|
|
569
|
+
)}
|
|
570
|
+
aria-selected={row.canSelect ? row.isSelected : undefined}
|
|
571
|
+
onClick={row.onPrimary}
|
|
572
|
+
>
|
|
573
|
+
<TableCell role="gridcell" className="w-[50px] pl-4">
|
|
574
|
+
<CheckboxOrIcon
|
|
575
|
+
name={row.name}
|
|
576
|
+
isSelected={row.isSelected}
|
|
577
|
+
canSelect={row.canSelect}
|
|
578
|
+
Icon={row.Icon}
|
|
579
|
+
onSelect={() => row.onToggleSelect?.()}
|
|
580
|
+
/>
|
|
581
|
+
</TableCell>
|
|
582
|
+
<TableCell role="gridcell">{row.name}</TableCell>
|
|
583
|
+
</TableRow>
|
|
584
|
+
))}
|
|
585
|
+
</TableBody>
|
|
495
586
|
</Table>
|
|
496
587
|
</div>
|
|
497
588
|
<div className="mt-4">
|
|
@@ -244,10 +244,10 @@ const MatrixComponent = ({
|
|
|
244
244
|
<td
|
|
245
245
|
key={j}
|
|
246
246
|
className={cn(
|
|
247
|
-
"relative text-center min-w-14 h-8 px-2 transition-colors touch-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-
|
|
247
|
+
"relative text-center min-w-14 h-8 px-2 transition-colors touch-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden",
|
|
248
248
|
isDisabled
|
|
249
249
|
? "cursor-default text-muted-foreground"
|
|
250
|
-
: "cursor-ew-resize text-
|
|
250
|
+
: "cursor-ew-resize text-link hover:bg-accent",
|
|
251
251
|
isActive && "bg-accent",
|
|
252
252
|
j === 0 && "bracket-l",
|
|
253
253
|
j === numCols - 1 && "bracket-r",
|
|
@@ -90,7 +90,7 @@ const TabComponent = ({
|
|
|
90
90
|
className={cn(
|
|
91
91
|
"scrollbar-thin",
|
|
92
92
|
isVertical
|
|
93
|
-
? "flex flex-col items-stretch justify-start h-auto max-h-none shrink-0 min-w-
|
|
93
|
+
? "flex flex-col items-stretch justify-start h-auto max-h-none shrink-0 min-w-40 overflow-y-auto"
|
|
94
94
|
: "max-w-full overflow-x-auto justify-start",
|
|
95
95
|
)}
|
|
96
96
|
>
|
|
@@ -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
|
+
}
|
|
@@ -292,7 +292,7 @@ export class MatplotlibRenderer {
|
|
|
292
292
|
// Configure container
|
|
293
293
|
container.tabIndex = -1;
|
|
294
294
|
container.role = "application";
|
|
295
|
-
container.className = "relative inline-block select-none outline-
|
|
295
|
+
container.className = "relative inline-block select-none outline-hidden";
|
|
296
296
|
|
|
297
297
|
// Create canvas
|
|
298
298
|
const canvas = document.createElement("canvas");
|