@marimo-team/frontend 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 (166) hide show
  1. package/dist/assets/{CellStatus-6xDEj3Hs.js → CellStatus-DC6FFQOg.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-DCcLAktu.js → ConnectedDataExplorerComponent-BV1mVpaE.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-98Xo04DS.js → ErrorBoundary-Ce-Ujlwj.js} +2 -2
  4. package/dist/assets/{JsonOutput-V47aiTIK.js → JsonOutput-CfEBzaqW.js} +4 -4
  5. package/dist/assets/{MarimoErrorOutput-DeWZNBgL.js → MarimoErrorOutput-Bh6o9zle.js} +1 -1
  6. package/dist/assets/{RenderHTML-Bjvcs70-.js → RenderHTML-COGZsXQV.js} +1 -1
  7. package/dist/assets/{RunButton-CnvvXD9d.js → RunButton-DQ51plUv.js} +1 -1
  8. package/dist/assets/{add-cell-with-ai-BbuqzITi.js → add-cell-with-ai-D7gtGUsg.js} +1 -1
  9. package/dist/assets/{add-connection-dialog-DIC7owvZ.js → add-connection-dialog-CpMCc97S.js} +1 -1
  10. package/dist/assets/{agent-panel-D8hO2P1x.js → agent-panel-BSFYqJ9B.js} +2 -2
  11. package/dist/assets/{ai-model-dropdown-DgmiG_5j.js → ai-model-dropdown-D575Vid8.js} +3 -3
  12. package/dist/assets/{app-config-button-UZHv1Iim.js → app-config-button-Cfcg_5Bz.js} +1 -1
  13. package/dist/assets/{blob-BPknzzkY.js → blob-CszMu60N.js} +1 -1
  14. package/dist/assets/{cell-editor-CM-K1_wq.js → cell-editor-BLpOlLnM.js} +2 -2
  15. package/dist/assets/cell-link-Cj0gGaMV.js +1 -0
  16. package/dist/assets/{cells-BypTg035.js → cells-SvbLY10y.js} +1 -1
  17. package/dist/assets/{chat-display-BRLNOoTF.js → chat-display-D43_g3TX.js} +1 -1
  18. package/dist/assets/{chat-panel-D02Rk9we.js → chat-panel-B_HAJMwx.js} +1 -1
  19. package/dist/assets/{chat-ui-DFN_RwxF.js → chat-ui-BEcYCphu.js} +2 -2
  20. package/dist/assets/{column-preview-85ORMhfB.js → column-preview-CrzaJKk8.js} +1 -1
  21. package/dist/assets/{command-palette-CtyjjdsA.js → command-palette-Bm9yIVcD.js} +1 -1
  22. package/dist/assets/{common-J265II7i.js → common-BJyUxCzv.js} +1 -1
  23. package/dist/assets/{components-Bd3Dpi92.js → components-D1f_Cz4i.js} +1 -1
  24. package/dist/assets/{datasource-Dy4rzzSP.js → datasource-C8KSVfSD.js} +1 -1
  25. package/dist/assets/{dependency-graph-panel-ByGFFT4E.js → dependency-graph-panel-BhmyJbUl.js} +1 -1
  26. package/dist/assets/{documentation-panel-LxuJCY3M.js → documentation-panel-DcVG0Q0d.js} +1 -1
  27. package/dist/assets/{download-BsBJWH_j.js → download-Bx52VRE4.js} +1 -1
  28. package/dist/assets/{edit-page--d6X-OO9.js → edit-page-DUrwOHR-.js} +6 -6
  29. package/dist/assets/{error-entries-0wTSSuK9.js → error-entries-11OuHzUF.js} +1 -1
  30. package/dist/assets/{error-panel-ZNdbE3Fo.js → error-panel-XJy5S25F.js} +1 -1
  31. package/dist/assets/{feedback-button-C6asPmsh.js → feedback-button-B7jh_B4w.js} +1 -1
  32. package/dist/assets/feedback-button-C-eKtxXE.js +1 -0
  33. package/dist/assets/{file-explorer-panel-0fUP_ptL.js → file-explorer-panel-CnVxbF4A.js} +1 -1
  34. package/dist/assets/{file-name-input-B8lzI5iS.js → file-name-input-BJuqjQqH.js} +2 -2
  35. package/dist/assets/floating-outline-8yyZ-MOJ.js +1 -0
  36. package/dist/assets/{gallery-page-ClR8U-km.js → gallery-page-DJvGHePf.js} +1 -1
  37. package/dist/assets/{glide-data-editor-B6QQBQwS.js → glide-data-editor-Cyc8DHZx.js} +1 -1
  38. package/dist/assets/{home-page-CJnqDcT9.js → home-page-tRZJmSU6.js} +1 -1
  39. package/dist/assets/{hooks-CCek4i9f.js → hooks-CxGwybGw.js} +1 -1
  40. package/dist/assets/index-CoxbqhGH.js +30 -0
  41. package/dist/assets/index-u08m0hQE.css +2 -0
  42. package/dist/assets/{layout-66Urh4Zg.js → layout-DvXJxBiL.js} +5 -5
  43. package/dist/assets/{logs-panel-DFhkIcge.js → logs-panel-KtsY7TrR.js} +1 -1
  44. package/dist/assets/{markdown-renderer-mYYQYD2m.js → markdown-renderer-DUgV7_fm.js} +1 -1
  45. package/dist/assets/{name-cell-input-Crcu3i2z.js → name-cell-input-C3-Cj-Ek.js} +1 -1
  46. package/dist/assets/{outline-panel-BFIPK0H_.js → outline-panel-BzyY4JA6.js} +1 -1
  47. package/dist/assets/{packages-panel-B4KtjgkF.js → packages-panel-DxosbWko.js} +1 -1
  48. package/dist/assets/{panel-accordion-state-D6KtwSF8.js → panel-accordion-state-BRbTib2B.js} +1 -1
  49. package/dist/assets/panels-Da3071Q4.js +1 -0
  50. package/dist/assets/{process-output-DDvMd6Jh.js → process-output-CSwx7rvo.js} +1 -1
  51. package/dist/assets/{readonly-code-display-VDqYn2d0.js → readonly-code-display-B9y9423Y.js} +1 -1
  52. package/dist/assets/{readonly-python-code-CZUKLXJ4.js → readonly-python-code-Dgw6trlk.js} +1 -1
  53. package/dist/assets/{reveal-component-BMmDU1uN.js → reveal-component-B9A4wkG-.js} +1 -1
  54. package/dist/assets/{run-page-BmOPPh2Y.js → run-page-C1MQ5b_A.js} +1 -1
  55. package/dist/assets/{runtime-details-state-CipqynUD.js → runtime-details-state-D_hOQL9T.js} +1 -1
  56. package/dist/assets/scratchpad-panel-CBCsPmMW.js +1 -0
  57. package/dist/assets/secrets-panel-ConQpchl.js +1 -0
  58. package/dist/assets/session-panel-CiHCKPlx.js +1 -0
  59. package/dist/assets/snippets-panel-C0VDE_4i.js +1 -0
  60. package/dist/assets/{state-QfGh3m2G.js → state-DrraMmqz.js} +1 -1
  61. package/dist/assets/{state-BLGvzMB6.js → state-DvU2AiFx.js} +22 -22
  62. package/dist/assets/tracing-C5d51Nmi.js +1 -0
  63. package/dist/assets/{tracing-panel-CsHuzRGP.js → tracing-panel-B89R8dWE.js} +2 -2
  64. package/dist/assets/{tree-actions-BQw_utcj.js → tree-actions-Cph8FVzh.js} +1 -1
  65. package/dist/assets/{useAddCell-_b8InOZC.js → useAddCell-VPkB72pn.js} +1 -1
  66. package/dist/assets/{useCellActionButton-BbgMzBdk.js → useCellActionButton--zI3GFnt.js} +1 -1
  67. package/dist/assets/{useDeleteCell-DhoFbEkU.js → useDeleteCell-CjewKeSr.js} +1 -1
  68. package/dist/assets/{useDependencyPanelTab-COTULhoH.js → useDependencyPanelTab-Bt_ka-wl.js} +1 -1
  69. package/dist/assets/{useNotebookActions-xnA_l3nY.js → useNotebookActions-DV2S6KaG.js} +1 -1
  70. package/dist/assets/{useRunCells-VBCe6CLn.js → useRunCells-BupCZpzk.js} +1 -1
  71. package/dist/assets/{useSplitCell-AvSpVAmq.js → useSplitCell-CaLIR0TS.js} +1 -1
  72. package/dist/index.html +21 -21
  73. package/package.json +1 -1
  74. package/src/__tests__/module-entry.test.ts +32 -0
  75. package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
  76. package/src/components/ai/ai-model-dropdown.tsx +11 -6
  77. package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
  78. package/src/components/chat/acp/blocks.tsx +1 -0
  79. package/src/components/chat/acp/session-tabs.tsx +8 -3
  80. package/src/components/data-table/TableTopBar.tsx +2 -0
  81. package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
  82. package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
  83. package/src/components/data-table/data-table.tsx +6 -0
  84. package/src/components/data-table/range-focus/atoms.ts +4 -6
  85. package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
  86. package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
  87. package/src/components/data-table/renderers.tsx +16 -7
  88. package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
  89. package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
  90. package/src/components/datasources/datasources.tsx +1 -0
  91. package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
  92. package/src/components/editor/cell/collapse.tsx +5 -3
  93. package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
  94. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
  95. package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
  96. package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
  97. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
  98. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
  99. package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
  100. package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
  101. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
  102. package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
  103. package/src/components/editor/file-tree/file-explorer.tsx +35 -3
  104. package/src/components/editor/file-tree/file-name-input.tsx +1 -0
  105. package/src/components/editor/header/status.tsx +28 -5
  106. package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
  107. package/src/components/editor/links/cell-link.tsx +14 -5
  108. package/src/components/editor/navigation/focus-utils.ts +4 -4
  109. package/src/components/editor/notebook-cell.tsx +11 -2
  110. package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
  111. package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
  112. package/src/components/editor/package-alert.tsx +8 -3
  113. package/src/components/scratchpad/scratchpad.test.tsx +95 -0
  114. package/src/components/scratchpad/scratchpad.tsx +14 -1
  115. package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
  116. package/src/components/tracing/tracing.tsx +43 -39
  117. package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
  118. package/src/components/ui/draggable-popover.tsx +24 -2
  119. package/src/core/attachments/state.ts +2 -2
  120. package/src/core/document/__tests__/codemirror.test.ts +3 -1
  121. package/src/core/document/__tests__/hooks.test.tsx +7 -7
  122. package/src/core/document/__tests__/model.test.ts +44 -11
  123. package/src/core/document/__tests__/scenario-fixture.ts +2 -1
  124. package/src/core/document/__tests__/test-notebook.ts +19 -9
  125. package/src/core/document/draft.ts +1 -1
  126. package/src/core/document/index.ts +3 -10
  127. package/src/core/document/model.ts +14 -11
  128. package/src/core/document/state.ts +1 -1
  129. package/src/core/document/sync.ts +24 -18
  130. package/src/core/host/AGENTS.md +3 -7
  131. package/src/core/host/__tests__/fixtures.ts +30 -0
  132. package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
  133. package/src/core/host/__tests__/wasm.test.ts +8 -0
  134. package/src/core/host/backend.ts +4 -0
  135. package/src/core/host/index.ts +14 -0
  136. package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
  137. package/src/core/host/static.ts +2 -0
  138. package/src/core/host/wasm.ts +10 -1
  139. package/src/core/wasm/bridge.ts +1 -1
  140. package/src/core/websocket/__tests__/backend.test.ts +111 -22
  141. package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
  142. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
  143. package/src/core/websocket/backend.ts +43 -5
  144. package/src/core/websocket/connection-state.ts +1 -1
  145. package/src/core/websocket/state.ts +3 -3
  146. package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
  147. package/src/core/websocket/types.ts +2 -2
  148. package/src/core/websocket/useNotebookReplica.ts +69 -0
  149. package/src/core/websocket/useNotebookSession.tsx +6 -6
  150. package/src/plugins/impl/chat/chat-ui.tsx +2 -1
  151. package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
  152. package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
  153. package/src/plugins/impl/data-frames/panel.tsx +43 -16
  154. package/dist/assets/cell-link-CAjuFcLK.js +0 -1
  155. package/dist/assets/feedback-button-Bt89tGG2.js +0 -1
  156. package/dist/assets/floating-outline-CRpmOv32.js +0 -1
  157. package/dist/assets/index-B5pWkt7a.js +0 -30
  158. package/dist/assets/index-BnhSnwvB.css +0 -2
  159. package/dist/assets/panels-DiwGKPzK.js +0 -1
  160. package/dist/assets/scratchpad-panel-Drr_kuwb.js +0 -1
  161. package/dist/assets/secrets-panel-D-W1PL76.js +0 -1
  162. package/dist/assets/session-panel-W7JOcog_.js +0 -1
  163. package/dist/assets/snippets-panel-cUPEdm66.js +0 -1
  164. package/dist/assets/tracing-BNFynaTu.js +0 -1
  165. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
  166. package/src/core/websocket/useNotebookClient.ts +0 -89
@@ -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",
@@ -124,6 +124,7 @@ export const DataTableBody = <TData,>({
124
124
  handleCellMouseUp,
125
125
  handleCellMouseOver,
126
126
  handleCellsKeyDown,
127
+ handleCellFocus,
127
128
  handleCopy: handleCopyAllCells,
128
129
  } = useCellRangeSelection({ table });
129
130
 
@@ -147,10 +148,16 @@ export const DataTableBody = <TData,>({
147
148
  return (
148
149
  <TableCell
149
150
  tabIndex={0}
151
+ role={table.options.enableCellSelection ? "gridcell" : undefined}
152
+ aria-selected={
153
+ table.options.enableCellSelection
154
+ ? cell.getIsSelected?.()
155
+ : undefined
156
+ }
150
157
  {...getCellDomProps(cell.id)}
151
158
  key={cell.id}
152
159
  className={cn(
153
- "whitespace-pre truncate outline-hidden border-r border-r-border/75",
160
+ "whitespace-pre truncate outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring border-r border-r-border/75",
154
161
  !fixedWidth && "max-w-[300px]",
155
162
  cell.column.getColumnWrapping &&
156
163
  cell.column.getColumnWrapping?.() === "wrap" &&
@@ -169,7 +176,13 @@ export const DataTableBody = <TData,>({
169
176
  hoverTooltip.handleCellMouseOver(e, cell);
170
177
  }}
171
178
  onMouseLeave={hoverTooltip.handleCellMouseLeave}
172
- onFocus={(e) => hoverTooltip.handleCellFocus(e, cell)}
179
+ onFocus={(e) => {
180
+ if (e.target === e.currentTarget) {
181
+ handleCellFocus(cell);
182
+ }
183
+ hoverTooltip.handleCellFocus(e, cell);
184
+ }}
185
+ onKeyDown={(e) => handleCellsKeyDown(e, cell)}
173
186
  onBlur={hoverTooltip.handleCellBlur}
174
187
  onContextMenu={() => handleContextMenu(cell)}
175
188
  >
@@ -264,11 +277,7 @@ export const DataTableBody = <TData,>({
264
277
  return rows.map((row) => renderRow(row));
265
278
  };
266
279
 
267
- const tableBody = (
268
- <TableBody onKeyDown={handleCellsKeyDown} ref={tableRef}>
269
- {renderRows()}
270
- </TableBody>
271
- );
280
+ const tableBody = <TableBody ref={tableRef}>{renderRows()}</TableBody>;
272
281
 
273
282
  return (
274
283
  <>
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { render, screen } from "@testing-library/react";
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
4
  import { Provider } from "jotai";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import { TooltipProvider } from "@/components/ui/tooltip";
@@ -17,6 +17,29 @@ const renderWithProviders = (component: React.ReactNode) => {
17
17
  };
18
18
 
19
19
  describe("RowExpandedPanel", () => {
20
+ it("preserves Space activation on navigation buttons", async () => {
21
+ const select = vi.fn();
22
+ const navigate = vi.fn();
23
+ renderWithProviders(
24
+ <RowViewerPanel
25
+ rowIdx={0}
26
+ setRowIdx={navigate}
27
+ totalRows={2}
28
+ fieldTypes={[["name", ["string", "string"]]]}
29
+ getRow={async () => ({ rows: [{ name: "Alice" }] })}
30
+ isSelectable={true}
31
+ isRowSelected={false}
32
+ handleRowSelectionChange={select}
33
+ />,
34
+ );
35
+ await screen.findByText("Alice");
36
+ const next = screen.getByRole("button", { name: "Next row" });
37
+ expect(fireEvent.keyDown(next, { key: " " })).toBe(true);
38
+ expect(select).not.toHaveBeenCalled();
39
+ fireEvent.click(next);
40
+ expect(navigate).toHaveBeenCalledExactlyOnceWith(1);
41
+ });
42
+
20
43
  const mockFieldTypes: FieldTypesWithExternalType = [
21
44
  ["name", ["string", "string"]],
22
45
  ["age", ["number", "number"]],
@@ -37,6 +37,7 @@ import type { GetRowResult } from "@/plugins/impl/DataTablePlugin";
37
37
  import { Objects } from "@/utils/objects";
38
38
  import { NAMELESS_COLUMN_PREFIX, renderCellValue } from "../columns";
39
39
  import { prettifyRowCount } from "../pagination";
40
+ import { isInteractiveTarget } from "../range-focus/use-cell-range-selection";
40
41
  import {
41
42
  type FieldTypesWithExternalType,
42
43
  INDEX_COLUMN_NAME,
@@ -102,8 +103,7 @@ export const RowViewerPanel: React.FC<RowViewerPanelProps> = ({
102
103
  }
103
104
 
104
105
  const handleKeyDown = (e: KeyboardEvent) => {
105
- // Don't intercept keys when typing in an input
106
- if (e.target instanceof HTMLInputElement) {
106
+ if (e.defaultPrevented || isInteractiveTarget(e)) {
107
107
  return;
108
108
  }
109
109
  switch (e.key) {
@@ -284,6 +284,7 @@ export const DataSources: React.FC = () => {
284
284
  {hasSearch && (
285
285
  <button
286
286
  type="button"
287
+ aria-label="Clear table search"
287
288
  className="float-right border-b px-2 m-0 h-full hover:bg-accent hover:text-accent-foreground"
288
289
  onClick={() => setSearchValue("")}
289
290
  >