@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.
Files changed (99) hide show
  1. package/dist/{ConnectedDataExplorerComponent-q0C1F7Sn.js → ConnectedDataExplorerComponent-BAiSVaHj.js} +3 -1
  2. package/dist/{ErrorBoundary-BwvkPkyC.js → ErrorBoundary-CImYa86e.js} +1 -1
  3. package/dist/{chat-ui-C0nJ84Wf.js → chat-ui-DqcauBr6.js} +21 -20
  4. package/dist/{code-visibility-BI1HUhjs.js → code-visibility-BZoWT5f1.js} +2 -2
  5. package/dist/{common-CMm0Ot4s.js → common-CGmIoGK3.js} +4383 -4327
  6. package/dist/{data-grid-overlay-editor-DMLTMI_S.js → data-grid-overlay-editor-CtOna148.js} +1 -1
  7. package/dist/{error-entries-BL2ZBgVo.js → error-entries-CmFDQ9CA.js} +109 -105
  8. package/dist/{feedback-button-BrpQxIbi.js → feedback-button-7ZbDdjfz.js} +14 -14
  9. package/dist/{glide-data-editor-DBsCdnOC.js → glide-data-editor-D72zmCan.js} +3 -3
  10. package/dist/{globals-BWb8HdpC.js → globals-DE2S2io9.js} +1 -1
  11. package/dist/{html-to-image-D9gz793p.js → html-to-image-CfgsWObF.js} +19 -19
  12. package/dist/main.js +5504 -5477
  13. package/dist/{number-overlay-editor-CUARNHHp.js → number-overlay-editor-Br0lPnFE.js} +1 -1
  14. package/dist/{process-output-761ZoDH1.js → process-output-B64ZYWuI.js} +3 -3
  15. package/dist/{reveal-component-CJjh6lrq.js → reveal-component-DnbPU885.js} +12 -12
  16. package/dist/style.css +1 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/module-entry.test.ts +32 -0
  19. package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
  20. package/src/components/ai/ai-model-dropdown.tsx +11 -6
  21. package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
  22. package/src/components/chat/acp/blocks.tsx +1 -0
  23. package/src/components/chat/acp/session-tabs.tsx +8 -3
  24. package/src/components/data-table/TableTopBar.tsx +2 -0
  25. package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
  26. package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
  27. package/src/components/data-table/data-table.tsx +6 -0
  28. package/src/components/data-table/range-focus/atoms.ts +4 -6
  29. package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
  30. package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
  31. package/src/components/data-table/renderers.tsx +16 -7
  32. package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
  33. package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
  34. package/src/components/datasources/datasources.tsx +1 -0
  35. package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
  36. package/src/components/editor/cell/collapse.tsx +5 -3
  37. package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
  38. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
  39. package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
  40. package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
  41. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
  42. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
  43. package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
  44. package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
  45. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
  46. package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
  47. package/src/components/editor/file-tree/file-explorer.tsx +35 -3
  48. package/src/components/editor/file-tree/file-name-input.tsx +1 -0
  49. package/src/components/editor/header/status.tsx +28 -5
  50. package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
  51. package/src/components/editor/links/cell-link.tsx +14 -5
  52. package/src/components/editor/navigation/focus-utils.ts +4 -4
  53. package/src/components/editor/notebook-cell.tsx +11 -2
  54. package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
  55. package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
  56. package/src/components/editor/package-alert.tsx +8 -3
  57. package/src/components/scratchpad/scratchpad.test.tsx +95 -0
  58. package/src/components/scratchpad/scratchpad.tsx +14 -1
  59. package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
  60. package/src/components/tracing/tracing.tsx +43 -39
  61. package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
  62. package/src/components/ui/draggable-popover.tsx +24 -2
  63. package/src/core/attachments/state.ts +2 -2
  64. package/src/core/document/__tests__/codemirror.test.ts +3 -1
  65. package/src/core/document/__tests__/hooks.test.tsx +7 -7
  66. package/src/core/document/__tests__/model.test.ts +44 -11
  67. package/src/core/document/__tests__/scenario-fixture.ts +2 -1
  68. package/src/core/document/__tests__/test-notebook.ts +19 -9
  69. package/src/core/document/draft.ts +1 -1
  70. package/src/core/document/index.ts +3 -10
  71. package/src/core/document/model.ts +14 -11
  72. package/src/core/document/state.ts +1 -1
  73. package/src/core/document/sync.ts +24 -18
  74. package/src/core/host/AGENTS.md +3 -7
  75. package/src/core/host/__tests__/fixtures.ts +30 -0
  76. package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
  77. package/src/core/host/__tests__/wasm.test.ts +8 -0
  78. package/src/core/host/backend.ts +4 -0
  79. package/src/core/host/index.ts +14 -0
  80. package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
  81. package/src/core/host/static.ts +2 -0
  82. package/src/core/host/wasm.ts +10 -1
  83. package/src/core/wasm/bridge.ts +1 -1
  84. package/src/core/websocket/__tests__/backend.test.ts +111 -22
  85. package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
  86. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
  87. package/src/core/websocket/backend.ts +43 -5
  88. package/src/core/websocket/connection-state.ts +1 -1
  89. package/src/core/websocket/state.ts +3 -3
  90. package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
  91. package/src/core/websocket/types.ts +2 -2
  92. package/src/core/websocket/useNotebookReplica.ts +69 -0
  93. package/src/core/websocket/useNotebookSession.tsx +6 -6
  94. package/src/plugins/impl/chat/chat-ui.tsx +2 -1
  95. package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
  96. package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
  97. package/src/plugins/impl/data-frames/panel.tsx +43 -16
  98. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
  99. package/src/core/websocket/useNotebookClient.ts +0 -89
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev129",
3
+ "version": "0.25.3-dev134",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -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 showChevron={false} className="py-2">
280
- <div
281
- className="flex items-center gap-2 w-full cursor-pointer"
282
- onClick={() => {
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
- <div className="flex items-center gap-1 min-w-0 flex-1">
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
- </div>
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 && state.selectedStartCell) {
248
- const cellsInRange = getCellsBetween(
249
- table,
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 handleCellsKeyDown = useEvent((e: React.KeyboardEvent<HTMLElement>) => {
49
- switch (e.key) {
50
- case "c":
51
- if (Events.isMetaOrCtrl(e)) {
52
- handleCopy();
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.MouseEvent): boolean {
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",