@marimo-team/islands 0.25.3-dev129 → 0.25.3-dev134
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/{ConnectedDataExplorerComponent-q0C1F7Sn.js → ConnectedDataExplorerComponent-BAiSVaHj.js} +3 -1
- package/dist/{ErrorBoundary-BwvkPkyC.js → ErrorBoundary-CImYa86e.js} +1 -1
- package/dist/{chat-ui-C0nJ84Wf.js → chat-ui-DqcauBr6.js} +21 -20
- package/dist/{code-visibility-BI1HUhjs.js → code-visibility-BZoWT5f1.js} +2 -2
- package/dist/{common-CMm0Ot4s.js → common-CGmIoGK3.js} +4383 -4327
- package/dist/{data-grid-overlay-editor-DMLTMI_S.js → data-grid-overlay-editor-CtOna148.js} +1 -1
- package/dist/{error-entries-BL2ZBgVo.js → error-entries-CmFDQ9CA.js} +109 -105
- package/dist/{feedback-button-BrpQxIbi.js → feedback-button-7ZbDdjfz.js} +14 -14
- package/dist/{glide-data-editor-DBsCdnOC.js → glide-data-editor-D72zmCan.js} +3 -3
- package/dist/{globals-BWb8HdpC.js → globals-DE2S2io9.js} +1 -1
- package/dist/{html-to-image-D9gz793p.js → html-to-image-CfgsWObF.js} +19 -19
- package/dist/main.js +5504 -5477
- package/dist/{number-overlay-editor-CUARNHHp.js → number-overlay-editor-Br0lPnFE.js} +1 -1
- package/dist/{process-output-761ZoDH1.js → process-output-B64ZYWuI.js} +3 -3
- package/dist/{reveal-component-CJjh6lrq.js → reveal-component-DnbPU885.js} +12 -12
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/module-entry.test.ts +32 -0
- package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
- package/src/components/ai/ai-model-dropdown.tsx +11 -6
- package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
- package/src/components/chat/acp/blocks.tsx +1 -0
- package/src/components/chat/acp/session-tabs.tsx +8 -3
- package/src/components/data-table/TableTopBar.tsx +2 -0
- package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
- package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
- package/src/components/data-table/data-table.tsx +6 -0
- package/src/components/data-table/range-focus/atoms.ts +4 -6
- package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
- package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
- package/src/components/data-table/renderers.tsx +16 -7
- package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
- package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
- package/src/components/datasources/datasources.tsx +1 -0
- package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
- package/src/components/editor/cell/collapse.tsx +5 -3
- package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
- package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
- package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
- package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
- package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
- package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
- package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
- package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
- package/src/components/editor/file-tree/file-explorer.tsx +35 -3
- package/src/components/editor/file-tree/file-name-input.tsx +1 -0
- package/src/components/editor/header/status.tsx +28 -5
- package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
- package/src/components/editor/links/cell-link.tsx +14 -5
- package/src/components/editor/navigation/focus-utils.ts +4 -4
- package/src/components/editor/notebook-cell.tsx +11 -2
- package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
- package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
- package/src/components/editor/package-alert.tsx +8 -3
- package/src/components/scratchpad/scratchpad.test.tsx +95 -0
- package/src/components/scratchpad/scratchpad.tsx +14 -1
- package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
- package/src/components/tracing/tracing.tsx +43 -39
- package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
- package/src/components/ui/draggable-popover.tsx +24 -2
- package/src/core/attachments/state.ts +2 -2
- package/src/core/document/__tests__/codemirror.test.ts +3 -1
- package/src/core/document/__tests__/hooks.test.tsx +7 -7
- package/src/core/document/__tests__/model.test.ts +44 -11
- package/src/core/document/__tests__/scenario-fixture.ts +2 -1
- package/src/core/document/__tests__/test-notebook.ts +19 -9
- package/src/core/document/draft.ts +1 -1
- package/src/core/document/index.ts +3 -10
- package/src/core/document/model.ts +14 -11
- package/src/core/document/state.ts +1 -1
- package/src/core/document/sync.ts +24 -18
- package/src/core/host/AGENTS.md +3 -7
- package/src/core/host/__tests__/fixtures.ts +30 -0
- package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
- package/src/core/host/__tests__/wasm.test.ts +8 -0
- package/src/core/host/backend.ts +4 -0
- package/src/core/host/index.ts +14 -0
- package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
- package/src/core/host/static.ts +2 -0
- package/src/core/host/wasm.ts +10 -1
- package/src/core/wasm/bridge.ts +1 -1
- package/src/core/websocket/__tests__/backend.test.ts +111 -22
- package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
- package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
- package/src/core/websocket/backend.ts +43 -5
- package/src/core/websocket/connection-state.ts +1 -1
- package/src/core/websocket/state.ts +3 -3
- package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
- package/src/core/websocket/types.ts +2 -2
- package/src/core/websocket/useNotebookReplica.ts +69 -0
- package/src/core/websocket/useNotebookSession.tsx +6 -6
- package/src/plugins/impl/chat/chat-ui.tsx +2 -1
- package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
- package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
- package/src/plugins/impl/data-frames/panel.tsx +43 -16
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
- package/src/core/websocket/useNotebookClient.ts +0 -89
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { moduleEntry } from "../../lint/module-entry.js";
|
|
5
|
+
|
|
6
|
+
/** Whether the rule reports importing `source` from `file`. */
|
|
7
|
+
function reports(file: string, source: string): boolean {
|
|
8
|
+
let reported = false;
|
|
9
|
+
const visitors = moduleEntry.createOnce({
|
|
10
|
+
filename: `/repo/frontend/src/${file}`,
|
|
11
|
+
report: () => {
|
|
12
|
+
reported = true;
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
visitors.ImportDeclaration({ source: { value: source } });
|
|
16
|
+
return reported;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
it.each([
|
|
20
|
+
["core/websocket/state.ts", "@/core/host", false],
|
|
21
|
+
["core/websocket/state.ts", "../host", false],
|
|
22
|
+
["core/websocket/state.ts", "@/core/document", false],
|
|
23
|
+
["core/host/replica.ts", "./resources/document", false],
|
|
24
|
+
["core/host/replica.ts", "@/core/host/backend", false],
|
|
25
|
+
["core/websocket/__tests__/a.test.ts", "../../host/replica", false],
|
|
26
|
+
["core/websocket/fixtures.ts", "../host/__tests__/fixtures", false],
|
|
27
|
+
["core/websocket/state.ts", "@/core/host/replica", true],
|
|
28
|
+
["core/websocket/state.ts", "../host/resources/document", true],
|
|
29
|
+
["core/websocket/state.ts", "../document/hooks", true],
|
|
30
|
+
])("from %s, importing %s reports %s", (file, source, expected) => {
|
|
31
|
+
expect(reports(file, source)).toBe(expected);
|
|
32
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { createStore, Provider } from "jotai";
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
|
+
import { getKnownModelMaps } from "@/core/ai/model-registry";
|
|
8
|
+
import { AIModelDropdown } from "../ai-model-dropdown";
|
|
9
|
+
import * as aiUtils from "../ai-utils";
|
|
10
|
+
|
|
11
|
+
vi.mock("@/core/ai/config", () => ({
|
|
12
|
+
useAIConfigActions: () => ({ saveModelChange: vi.fn() }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("@/components/app-config/state", () => ({
|
|
15
|
+
useOpenSettingsToTab: () => ({ handleClick: vi.fn() }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
it.each(["Enter", " "])(
|
|
19
|
+
"selects a model with %j while ArrowRight retains information access",
|
|
20
|
+
async (key) => {
|
|
21
|
+
const knownModel = [...getKnownModelMaps().modelMap.values()].find(
|
|
22
|
+
(model) => model.provider === "openai",
|
|
23
|
+
);
|
|
24
|
+
if (!knownModel) {
|
|
25
|
+
throw new Error("Expected a known OpenAI model");
|
|
26
|
+
}
|
|
27
|
+
const model = { ...knownModel, capabilities: [] };
|
|
28
|
+
vi.spyOn(aiUtils, "listModelsForAiSettings").mockReturnValue([
|
|
29
|
+
[model.provider, [model]],
|
|
30
|
+
]);
|
|
31
|
+
const onSelect = vi.fn();
|
|
32
|
+
render(
|
|
33
|
+
<Provider store={createStore()}>
|
|
34
|
+
<TooltipProvider>
|
|
35
|
+
<AIModelDropdown
|
|
36
|
+
forRole="chat"
|
|
37
|
+
placeholder="Choose model"
|
|
38
|
+
onSelect={onSelect}
|
|
39
|
+
/>
|
|
40
|
+
</TooltipProvider>
|
|
41
|
+
</Provider>,
|
|
42
|
+
);
|
|
43
|
+
fireEvent.keyDown(screen.getByRole("button", { name: "Choose model" }), {
|
|
44
|
+
key: "ArrowDown",
|
|
45
|
+
});
|
|
46
|
+
fireEvent.keyDown(
|
|
47
|
+
await screen.findByRole("menuitem", { name: /OpenAI$/ }),
|
|
48
|
+
{ key: "ArrowRight" },
|
|
49
|
+
);
|
|
50
|
+
const option = await screen.findByRole("menuitem", {
|
|
51
|
+
name: (name) => name.endsWith(model.name),
|
|
52
|
+
});
|
|
53
|
+
fireEvent.keyDown(option, { key: "ArrowRight" });
|
|
54
|
+
expect(
|
|
55
|
+
await screen.findByRole("heading", { name: model.name }),
|
|
56
|
+
).toBeInTheDocument();
|
|
57
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
58
|
+
fireEvent.keyDown(option, { key });
|
|
59
|
+
expect(onSelect).toHaveBeenCalledExactlyOnceWith(
|
|
60
|
+
`${model.provider}/${model.model}`,
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
);
|
|
@@ -276,13 +276,18 @@ const ProviderDropdownContent = ({
|
|
|
276
276
|
const qualifiedModelId: QualifiedModelId = `${provider}/${model.model}`;
|
|
277
277
|
return (
|
|
278
278
|
<DropdownMenuSub key={qualifiedModelId}>
|
|
279
|
-
<DropdownMenuSubTrigger
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
<DropdownMenuSubTrigger
|
|
280
|
+
showChevron={false}
|
|
281
|
+
className="py-2"
|
|
282
|
+
onClick={() => onSelect(qualifiedModelId)}
|
|
283
|
+
onKeyDown={(e) => {
|
|
284
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
285
|
+
e.preventDefault();
|
|
283
286
|
onSelect(qualifiedModelId);
|
|
284
|
-
}
|
|
285
|
-
|
|
287
|
+
}
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
<div className="flex items-center gap-2 w-full cursor-pointer">
|
|
286
291
|
<AiModelDropdownItem model={model} provider={provider} />
|
|
287
292
|
</div>
|
|
288
293
|
</DropdownMenuSubTrigger>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import { createStore, Provider } from "jotai";
|
|
5
|
+
import { expect, it, vi } from "vitest";
|
|
6
|
+
import { SessionTabs } from "../session-tabs";
|
|
7
|
+
import { addSession, agentSessionStateAtom } from "../state";
|
|
8
|
+
|
|
9
|
+
vi.mock("../agent-selector", () => ({ AgentSelector: () => null }));
|
|
10
|
+
|
|
11
|
+
it("keeps session selection and close as separate named buttons", () => {
|
|
12
|
+
const first = addSession(
|
|
13
|
+
{ sessions: [], activeTabId: null },
|
|
14
|
+
{ agentId: "claude", firstMessage: "First" },
|
|
15
|
+
).sessions[0];
|
|
16
|
+
const second = addSession(
|
|
17
|
+
{ sessions: [], activeTabId: null },
|
|
18
|
+
{ agentId: "codex", firstMessage: "Second" },
|
|
19
|
+
).sessions[0];
|
|
20
|
+
const store = createStore();
|
|
21
|
+
store.set(agentSessionStateAtom, {
|
|
22
|
+
sessions: [first, second],
|
|
23
|
+
activeTabId: first.tabId,
|
|
24
|
+
});
|
|
25
|
+
render(
|
|
26
|
+
<Provider store={store}>
|
|
27
|
+
<SessionTabs />
|
|
28
|
+
</Provider>,
|
|
29
|
+
);
|
|
30
|
+
const select = screen.getByRole("button", {
|
|
31
|
+
name: /Second$/,
|
|
32
|
+
pressed: false,
|
|
33
|
+
});
|
|
34
|
+
select.focus();
|
|
35
|
+
expect(select).toHaveFocus();
|
|
36
|
+
fireEvent.click(select);
|
|
37
|
+
expect(select).toHaveAttribute("aria-pressed", "true");
|
|
38
|
+
fireEvent.click(screen.getByRole("button", { name: "Close First" }));
|
|
39
|
+
expect(store.get(agentSessionStateAtom).activeTabId).toBe(second.tabId);
|
|
40
|
+
expect(
|
|
41
|
+
store.get(agentSessionStateAtom).sessions.map((session) => session.title),
|
|
42
|
+
).toEqual(["Second"]);
|
|
43
|
+
});
|
|
@@ -337,6 +337,7 @@ export const PlansBlock = (props: { data: PlanNotificationEvent[] }) => {
|
|
|
337
337
|
>
|
|
338
338
|
<input
|
|
339
339
|
type="checkbox"
|
|
340
|
+
aria-label={item.content}
|
|
340
341
|
checked={item.status === "completed"}
|
|
341
342
|
readOnly={true}
|
|
342
343
|
className="accent-primary h-4 w-4 rounded border border-muted-foreground/30"
|
|
@@ -32,17 +32,22 @@ const SessionTab: React.FC<SessionTabProps> = memo(
|
|
|
32
32
|
"flex items-center gap-1 px-2 py-1 text-xs border-r border-border bg-muted/30 hover:bg-muted/50 cursor-pointer min-w-0",
|
|
33
33
|
isActive && "bg-background border-b-0 relative z-1",
|
|
34
34
|
)}
|
|
35
|
-
onClick={() => onSelect(session.tabId)}
|
|
36
35
|
>
|
|
37
|
-
<
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
aria-pressed={isActive}
|
|
39
|
+
onClick={() => onSelect(session.tabId)}
|
|
40
|
+
className="flex items-center gap-1 min-w-0 flex-1 text-left focus-visible:outline focus-visible:outline-ring"
|
|
41
|
+
>
|
|
38
42
|
<span className="text-muted-foreground text-[10px] font-medium">
|
|
39
43
|
<AiProviderIcon provider={session.agentId} className="h-3 w-3" />
|
|
40
44
|
</span>
|
|
41
45
|
<span className="truncate" title={session.title}>
|
|
42
46
|
{session.title}
|
|
43
47
|
</span>
|
|
44
|
-
</
|
|
48
|
+
</button>
|
|
45
49
|
<Button
|
|
50
|
+
aria-label={`Close ${session.title}`}
|
|
46
51
|
variant="ghost"
|
|
47
52
|
size="sm"
|
|
48
53
|
className="h-4 w-4 p-0 hover:bg-destructive/20 hover:text-destructive shrink-0"
|
|
@@ -76,6 +76,7 @@ export const TableTopBar = <TData,>({
|
|
|
76
76
|
<input
|
|
77
77
|
ref={inputRef}
|
|
78
78
|
type="text"
|
|
79
|
+
aria-label="Search table"
|
|
79
80
|
className="h-6 border-none bg-transparent focus:outline-hidden text-sm w-full min-w-0"
|
|
80
81
|
value={internalValue}
|
|
81
82
|
onKeyDown={(e) => {
|
|
@@ -90,6 +91,7 @@ export const TableTopBar = <TData,>({
|
|
|
90
91
|
{reloading && <Spinner size="small" />}
|
|
91
92
|
{internalValue && (
|
|
92
93
|
<Button
|
|
94
|
+
aria-label="Clear table search"
|
|
93
95
|
variant="text"
|
|
94
96
|
size="xs"
|
|
95
97
|
className="h-5 w-5 p-0 shrink-0"
|
|
@@ -260,6 +260,7 @@ describe("DataTable", () => {
|
|
|
260
260
|
);
|
|
261
261
|
|
|
262
262
|
const cells = within(screen.getAllByRole("row")[1]).getAllByRole("cell");
|
|
263
|
+
cells[1].scrollIntoView = vi.fn();
|
|
263
264
|
// Start a pending hover-show on cell A, then focus cell B before it fires.
|
|
264
265
|
fireEvent.mouseOver(cells[0], { buttons: 0 });
|
|
265
266
|
fireEvent.focus(cells[1]);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
|
+
import { copyToClipboard } from "@/utils/copy";
|
|
8
|
+
import type { CellSelectionState } from "../cell-selection/types";
|
|
9
|
+
import { DataTable } from "../data-table";
|
|
10
|
+
|
|
11
|
+
vi.mock("@/utils/copy", () => ({ copyToClipboard: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
function SelectableTable({ multiple = false }: { multiple?: boolean }) {
|
|
14
|
+
const [selection, setSelection] = useState<CellSelectionState>([]);
|
|
15
|
+
return (
|
|
16
|
+
<TooltipProvider>
|
|
17
|
+
<DataTable
|
|
18
|
+
data={[{ name: "Alice" }, { name: "Bob" }]}
|
|
19
|
+
columns={[{ accessorKey: "name", header: "Name" }]}
|
|
20
|
+
totalRows={2}
|
|
21
|
+
totalColumns={1}
|
|
22
|
+
pagination={false}
|
|
23
|
+
selection={multiple ? "multi-cell" : "single-cell"}
|
|
24
|
+
cellSelection={selection}
|
|
25
|
+
onCellSelectionChange={setSelection}
|
|
26
|
+
/>
|
|
27
|
+
</TooltipProvider>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("table keyboard access", () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("includes the initial focused cell when extending a keyboard range", () => {
|
|
38
|
+
render(<SelectableTable />);
|
|
39
|
+
const [alice, bob] = screen.getAllByRole("gridcell");
|
|
40
|
+
act(() => alice.focus());
|
|
41
|
+
fireEvent.keyDown(alice, { key: "ArrowDown", shiftKey: true });
|
|
42
|
+
fireEvent.keyDown(bob, { key: "c", ctrlKey: true });
|
|
43
|
+
expect(copyToClipboard).toHaveBeenLastCalledWith("Alice\nBob", undefined);
|
|
44
|
+
fireEvent.keyDown(bob, { key: "ArrowUp", shiftKey: true });
|
|
45
|
+
fireEvent.keyDown(alice, { key: "c", ctrlKey: true });
|
|
46
|
+
expect(copyToClipboard).toHaveBeenLastCalledWith("Alice", undefined);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each(["Enter", " "])(
|
|
50
|
+
"copies the activated cell with %s, including after an older selection",
|
|
51
|
+
(key) => {
|
|
52
|
+
render(<SelectableTable />);
|
|
53
|
+
const [alice, bob] = screen.getAllByRole("gridcell");
|
|
54
|
+
for (const [cell, value] of [
|
|
55
|
+
[alice, "Alice"],
|
|
56
|
+
[bob, "Bob"],
|
|
57
|
+
] as const) {
|
|
58
|
+
act(() => cell.focus());
|
|
59
|
+
fireEvent.keyDown(cell, { key });
|
|
60
|
+
fireEvent.keyDown(cell, { key: "c", ctrlKey: true });
|
|
61
|
+
expect(copyToClipboard).toHaveBeenLastCalledWith(value, undefined);
|
|
62
|
+
expect(cell).toHaveAttribute("aria-selected", "true");
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
it("moves focus and activation together inside a shadow root", () => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
const host = document.createElement("div");
|
|
70
|
+
document.body.append(host);
|
|
71
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
72
|
+
const container = document.createElement("div");
|
|
73
|
+
shadow.append(container);
|
|
74
|
+
const view = render(<SelectableTable />, { container });
|
|
75
|
+
try {
|
|
76
|
+
const [alice, bob] = within(container).getAllByRole("gridcell");
|
|
77
|
+
alice.scrollIntoView = vi.fn();
|
|
78
|
+
bob.scrollIntoView = vi.fn();
|
|
79
|
+
act(() => alice.focus());
|
|
80
|
+
fireEvent.keyDown(alice, { key: "ArrowDown" });
|
|
81
|
+
act(() => vi.runAllTimers());
|
|
82
|
+
expect(shadow.activeElement).toBe(bob);
|
|
83
|
+
fireEvent.keyDown(bob, { key: " " });
|
|
84
|
+
expect(bob).toHaveAttribute("aria-selected", "true");
|
|
85
|
+
expect(alice).toHaveAttribute("aria-selected", "false");
|
|
86
|
+
} finally {
|
|
87
|
+
view.unmount();
|
|
88
|
+
host.remove();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not move focus away from a nested control after navigation", () => {
|
|
93
|
+
vi.useFakeTimers();
|
|
94
|
+
render(<SelectableTable />);
|
|
95
|
+
const [alice, bob] = screen.getAllByRole("gridcell");
|
|
96
|
+
alice.scrollIntoView = vi.fn();
|
|
97
|
+
bob.scrollIntoView = vi.fn();
|
|
98
|
+
const button = document.createElement("button");
|
|
99
|
+
alice.append(button);
|
|
100
|
+
act(() => alice.focus());
|
|
101
|
+
fireEvent.keyDown(alice, { key: "ArrowDown" });
|
|
102
|
+
act(() => {
|
|
103
|
+
button.focus();
|
|
104
|
+
vi.runAllTimers();
|
|
105
|
+
});
|
|
106
|
+
expect(button).toHaveFocus();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("ignores stale scheduled focus when moving between cells", () => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
render(<SelectableTable />);
|
|
112
|
+
const [alice, bob] = screen.getAllByRole("gridcell");
|
|
113
|
+
alice.scrollIntoView = vi.fn();
|
|
114
|
+
bob.scrollIntoView = vi.fn();
|
|
115
|
+
act(() => {
|
|
116
|
+
alice.focus();
|
|
117
|
+
bob.focus();
|
|
118
|
+
vi.runAllTimers();
|
|
119
|
+
});
|
|
120
|
+
expect(bob).toHaveFocus();
|
|
121
|
+
expect(alice.scrollIntoView).not.toHaveBeenCalled();
|
|
122
|
+
expect(bob.scrollIntoView).toHaveBeenCalledOnce();
|
|
123
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
it.each([false, true])(
|
|
126
|
+
"selects focused cells without a preceding click (multiple=%s)",
|
|
127
|
+
(multiple) => {
|
|
128
|
+
render(<SelectableTable multiple={multiple} />);
|
|
129
|
+
const [alice, bob] = screen.getAllByRole("gridcell");
|
|
130
|
+
alice.focus();
|
|
131
|
+
fireEvent.keyDown(alice, { key: "Enter" });
|
|
132
|
+
expect(alice).toHaveAttribute("aria-selected", "true");
|
|
133
|
+
bob.focus();
|
|
134
|
+
fireEvent.keyDown(bob, { key: " " });
|
|
135
|
+
expect(bob).toHaveAttribute("aria-selected", "true");
|
|
136
|
+
expect(alice).toHaveAttribute("aria-selected", String(multiple));
|
|
137
|
+
fireEvent.keyDown(bob, { key: " " });
|
|
138
|
+
expect(bob).toHaveAttribute("aria-selected", "false");
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("does not intercept keys from embedded buttons", () => {
|
|
143
|
+
const select = vi.fn();
|
|
144
|
+
render(
|
|
145
|
+
<TooltipProvider>
|
|
146
|
+
<DataTable
|
|
147
|
+
data={[{ name: "Alice" }]}
|
|
148
|
+
columns={[
|
|
149
|
+
{
|
|
150
|
+
accessorKey: "name",
|
|
151
|
+
cell: () => <button type="button">Open person</button>,
|
|
152
|
+
},
|
|
153
|
+
]}
|
|
154
|
+
totalRows={1}
|
|
155
|
+
totalColumns={1}
|
|
156
|
+
pagination={false}
|
|
157
|
+
selection="single-cell"
|
|
158
|
+
onCellSelectionChange={select}
|
|
159
|
+
/>
|
|
160
|
+
</TooltipProvider>,
|
|
161
|
+
);
|
|
162
|
+
const button = screen.getByRole("button", { name: "Open person" });
|
|
163
|
+
for (const key of ["Enter", " ", "ArrowDown"]) {
|
|
164
|
+
expect(fireEvent.keyDown(button, { key })).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
expect(select).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("names search controls and clears a query", () => {
|
|
170
|
+
render(
|
|
171
|
+
<TooltipProvider>
|
|
172
|
+
<DataTable
|
|
173
|
+
data={[{ name: "Alice" }]}
|
|
174
|
+
columns={[{ accessorKey: "name" }]}
|
|
175
|
+
totalRows={1}
|
|
176
|
+
totalColumns={1}
|
|
177
|
+
pagination={false}
|
|
178
|
+
showSearch={true}
|
|
179
|
+
onSearchQueryChange={vi.fn()}
|
|
180
|
+
/>
|
|
181
|
+
</TooltipProvider>,
|
|
182
|
+
);
|
|
183
|
+
const search = screen.getByRole("textbox", { name: "Search table" });
|
|
184
|
+
fireEvent.change(search, { target: { value: "Alice" } });
|
|
185
|
+
fireEvent.click(screen.getByRole("button", { name: "Clear table search" }));
|
|
186
|
+
expect(search).toHaveValue("");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -402,6 +402,12 @@ const DataTableInternal = <TData,>({
|
|
|
402
402
|
</Banner>
|
|
403
403
|
)}
|
|
404
404
|
<Table
|
|
405
|
+
role={table.options.enableCellSelection ? "grid" : undefined}
|
|
406
|
+
aria-multiselectable={
|
|
407
|
+
table.options.enableCellSelection
|
|
408
|
+
? table.options.enableMultiCellSelection
|
|
409
|
+
: undefined
|
|
410
|
+
}
|
|
405
411
|
className={cn(
|
|
406
412
|
"relative",
|
|
407
413
|
columns.length <= AUTO_WIDTH_MAX_COLUMNS ? "w-auto" : "w-full",
|
|
@@ -244,15 +244,13 @@ const {
|
|
|
244
244
|
cellId: nextCell.id,
|
|
245
245
|
};
|
|
246
246
|
|
|
247
|
-
if (isShiftKey
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
state.selectedStartCell,
|
|
251
|
-
newCell,
|
|
252
|
-
);
|
|
247
|
+
if (isShiftKey) {
|
|
248
|
+
const anchor = state.selectedStartCell ?? currentCell;
|
|
249
|
+
const cellsInRange = getCellsBetween(table, anchor, newCell);
|
|
253
250
|
return {
|
|
254
251
|
...state,
|
|
255
252
|
selectedCells: new Set(cellsInRange),
|
|
253
|
+
selectedStartCell: anchor,
|
|
256
254
|
focusedCell: newCell,
|
|
257
255
|
};
|
|
258
256
|
} else {
|
|
@@ -45,45 +45,71 @@ export const useCellRangeSelection = <TData>({
|
|
|
45
45
|
},
|
|
46
46
|
);
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
break;
|
|
55
|
-
case "ArrowDown":
|
|
56
|
-
e.preventDefault();
|
|
57
|
-
navigate(e, "down");
|
|
58
|
-
break;
|
|
59
|
-
case "ArrowUp":
|
|
60
|
-
e.preventDefault();
|
|
61
|
-
navigate(e, "up");
|
|
62
|
-
break;
|
|
63
|
-
case "ArrowLeft":
|
|
64
|
-
e.preventDefault();
|
|
65
|
-
navigate(e, "left");
|
|
66
|
-
break;
|
|
67
|
-
case "ArrowRight":
|
|
68
|
-
e.preventDefault();
|
|
69
|
-
navigate(e, "right");
|
|
70
|
-
break;
|
|
71
|
-
case "Enter":
|
|
72
|
-
e.preventDefault();
|
|
73
|
-
actions.toggleCurrentRowSelection(table);
|
|
74
|
-
break;
|
|
75
|
-
case "Escape":
|
|
76
|
-
actions.clearSelection();
|
|
77
|
-
break;
|
|
78
|
-
case "a":
|
|
79
|
-
if (e.metaKey || e.ctrlKey) {
|
|
80
|
-
e.preventDefault();
|
|
81
|
-
actions.selectAllCells(table);
|
|
82
|
-
}
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
48
|
+
const handleCellFocus = useEvent((cell: Cell<TData, unknown>) => {
|
|
49
|
+
actions.setFocusedCell({
|
|
50
|
+
cellId: cell.id,
|
|
51
|
+
rowId: cell.row.id,
|
|
52
|
+
columnId: cell.column.id,
|
|
53
|
+
});
|
|
85
54
|
});
|
|
86
55
|
|
|
56
|
+
const handleCellsKeyDown = useEvent(
|
|
57
|
+
(e: React.KeyboardEvent<HTMLElement>, cell: Cell<TData, unknown>) => {
|
|
58
|
+
if (e.defaultPrevented || isInteractiveTarget(e)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
switch (e.key) {
|
|
62
|
+
case "c":
|
|
63
|
+
if (Events.isMetaOrCtrl(e)) {
|
|
64
|
+
handleCopy();
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
case "ArrowDown":
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
navigate(e, "down");
|
|
70
|
+
break;
|
|
71
|
+
case "ArrowUp":
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
navigate(e, "up");
|
|
74
|
+
break;
|
|
75
|
+
case "ArrowLeft":
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
navigate(e, "left");
|
|
78
|
+
break;
|
|
79
|
+
case "ArrowRight":
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
navigate(e, "right");
|
|
82
|
+
break;
|
|
83
|
+
case "Enter":
|
|
84
|
+
case " ":
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
updateSelection(
|
|
87
|
+
{
|
|
88
|
+
cellId: cell.id,
|
|
89
|
+
rowId: cell.row.id,
|
|
90
|
+
columnId: cell.column.id,
|
|
91
|
+
},
|
|
92
|
+
false,
|
|
93
|
+
);
|
|
94
|
+
if (table.options.enableCellSelection) {
|
|
95
|
+
cell.toggleSelected?.();
|
|
96
|
+
} else if (cell.row.getCanSelect()) {
|
|
97
|
+
cell.row.toggleSelected();
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
case "Escape":
|
|
101
|
+
actions.clearSelection();
|
|
102
|
+
break;
|
|
103
|
+
case "a":
|
|
104
|
+
if (e.metaKey || e.ctrlKey) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
actions.selectAllCells(table);
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
87
113
|
const handleCellMouseDown = useEvent(
|
|
88
114
|
(e: React.MouseEvent, cell: Cell<TData, unknown>) => {
|
|
89
115
|
// Right-clicks will trigger context menu, so avoid updating selected cells
|
|
@@ -122,6 +148,7 @@ export const useCellRangeSelection = <TData>({
|
|
|
122
148
|
handleCellMouseOver,
|
|
123
149
|
handleCopy,
|
|
124
150
|
handleCellsKeyDown,
|
|
151
|
+
handleCellFocus,
|
|
125
152
|
updateSelection,
|
|
126
153
|
clearSelection: actions.clearSelection,
|
|
127
154
|
};
|
|
@@ -148,7 +175,7 @@ const CONTENT_WRAPPER_MARIMO_TAGS: ReadonlySet<string> = new Set([
|
|
|
148
175
|
* shadow host (e.g. `<marimo-lazy>`), hiding any genuinely interactive
|
|
149
176
|
* descendants rendered inside the shadow tree.
|
|
150
177
|
*/
|
|
151
|
-
export function isInteractiveTarget(e: React.
|
|
178
|
+
export function isInteractiveTarget(e: React.SyntheticEvent): boolean {
|
|
152
179
|
const path: readonly EventTarget[] =
|
|
153
180
|
typeof e.nativeEvent?.composedPath === "function"
|
|
154
181
|
? e.nativeEvent.composedPath()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { atom } from "jotai";
|
|
3
|
+
import { atom, useStore } from "jotai";
|
|
4
4
|
import { useJotaiEffect } from "@/core/state/jotai";
|
|
5
5
|
import { Logger } from "@/utils/Logger";
|
|
6
6
|
import { cellSelectionStateAtom } from "./atoms";
|
|
@@ -15,14 +15,21 @@ const focusedCellAtom = atom((get) => get(cellSelectionStateAtom).focusedCell);
|
|
|
15
15
|
export function useScrollIntoViewOnFocus(
|
|
16
16
|
root: React.RefObject<HTMLElement | null>,
|
|
17
17
|
) {
|
|
18
|
+
const store = useStore();
|
|
18
19
|
useJotaiEffect(focusedCellAtom, (focusedCell) => {
|
|
19
20
|
if (!focusedCell?.cellId) {
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const scroll = () => {
|
|
25
|
+
if (
|
|
26
|
+
!root.current ||
|
|
27
|
+
store.get(focusedCellAtom)?.cellId !== focusedCell.cellId
|
|
28
|
+
) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
24
31
|
// Check if this cell contains the CellRangeSelectionIndicator with our cellId
|
|
25
|
-
const indicator = root.current?.querySelector(
|
|
32
|
+
const indicator = root.current?.querySelector<HTMLElement>(
|
|
26
33
|
`[data-cell-id="${focusedCell.cellId}"]`,
|
|
27
34
|
);
|
|
28
35
|
if (!indicator) {
|
|
@@ -33,6 +40,18 @@ export function useScrollIntoViewOnFocus(
|
|
|
33
40
|
return;
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
const focusRoot = root.current.getRootNode();
|
|
44
|
+
const activeElement =
|
|
45
|
+
focusRoot instanceof ShadowRoot || focusRoot instanceof Document
|
|
46
|
+
? focusRoot.activeElement
|
|
47
|
+
: null;
|
|
48
|
+
if (
|
|
49
|
+
root.current.contains(activeElement) &&
|
|
50
|
+
activeElement?.matches("td[data-cell-id]")
|
|
51
|
+
) {
|
|
52
|
+
indicator.focus({ preventScroll: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
indicator.scrollIntoView({
|
|
37
56
|
behavior: "smooth",
|
|
38
57
|
block: "nearest",
|