@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
@@ -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
  >
@@ -27,10 +27,12 @@ function mountPackageAlert({
27
27
  configuredManager,
28
28
  sandboxBackend,
29
29
  source = "kernel",
30
+ packages = ["polars"],
30
31
  }: {
31
32
  configuredManager: PackageManagerName;
32
33
  sandboxBackend: "uv" | "pixi" | null;
33
34
  source?: "kernel" | "server";
35
+ packages?: string[];
34
36
  }) {
35
37
  const store = createStore();
36
38
  const client = MockRequestClient.create();
@@ -55,7 +57,7 @@ function mountPackageAlert({
55
57
  packageAlert: {
56
58
  id: "missing-package",
57
59
  kind: "missing",
58
- packages: ["polars"],
60
+ packages,
59
61
  isolated: true,
60
62
  source,
61
63
  },
@@ -97,6 +99,25 @@ it("keeps the configured package manager selectable outside a sandbox", () => {
97
99
  expect(managerSelect).not.toBeDisabled();
98
100
  });
99
101
 
102
+ it("removes an extra absent from available choices through a keyboard menu item", async () => {
103
+ const client = mountPackageAlert({
104
+ configuredManager: "uv",
105
+ sandboxBackend: null,
106
+ packages: ["polars[dev]"],
107
+ });
108
+ fireEvent.keyDown(screen.getByRole("button", { name: "dev" }), {
109
+ key: "ArrowDown",
110
+ });
111
+ const remove = await screen.findByRole("menuitem", { name: "Remove dev" });
112
+ fireEvent.keyDown(remove, { key: "Enter" });
113
+ fireEvent.click(screen.getByTestId("install-packages-button"));
114
+ expect(client.sendInstallMissingPackages).toHaveBeenCalledExactlyOnceWith({
115
+ manager: "uv",
116
+ source: "kernel",
117
+ versions: { polars: "" },
118
+ });
119
+ });
120
+
100
121
  it("installs a selected version in a Pixi sandbox", () => {
101
122
  const client = mountPackageAlert({
102
123
  configuredManager: "uv",
@@ -67,10 +67,12 @@ export const CollapsedCellBanner: React.FC<{
67
67
  const states = getDescendantsStatus(notebook, cellId);
68
68
 
69
69
  return (
70
- <div
70
+ <button
71
+ type="button"
72
+ aria-label={`Expand ${count} collapsed ${count === 1 ? "cell" : "cells"}`}
71
73
  onClick={onClick}
72
74
  className={cn(
73
- "flex items-center justify-between w-[calc(100%-2rem)] h-9 bg-muted rounded-b mx-4 opacity-80 hover:opacity-100 cursor-pointer",
75
+ "flex items-center justify-between w-[calc(100%-2rem)] h-9 bg-muted rounded-b mx-4 opacity-80 hover:opacity-100 cursor-pointer focus-visible:outline focus-visible:outline-ring",
74
76
  )}
75
77
  >
76
78
  <Toolbar
@@ -102,7 +104,7 @@ export const CollapsedCellBanner: React.FC<{
102
104
  </>
103
105
  }
104
106
  />
105
- </div>
107
+ </button>
106
108
  );
107
109
  });
108
110
  CollapsedCellBanner.displayName = "CollapsedCellBanner";
@@ -0,0 +1,84 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { OutlineItem } from "@/core/cells/outline";
6
+ import { MiniMap, OutlineList } from "../floating-outline";
7
+ import { scrollToOutlineItem } from "../useActiveOutline";
8
+
9
+ vi.mock("../useActiveOutline", () => ({ scrollToOutlineItem: vi.fn() }));
10
+
11
+ const items: OutlineItem[] = [
12
+ { name: "First", level: 1, by: { id: "first" } },
13
+ { name: "Second", html: "<em>Second</em>", level: 2, by: { id: "second" } },
14
+ ];
15
+
16
+ describe("outline keyboard navigation", () => {
17
+ it.each([
18
+ { Component: MiniMap, role: "button" },
19
+ { Component: OutlineList, role: "link" },
20
+ ])(
21
+ "keeps the focused $role when other headings change",
22
+ ({ Component, role }) => {
23
+ const duplicate: OutlineItem = {
24
+ name: "Repeated second",
25
+ level: 2,
26
+ by: { id: "second" },
27
+ };
28
+ const initial = [...items, duplicate];
29
+ const props = {
30
+ activeHeaderId: undefined,
31
+ activeOccurrences: undefined,
32
+ };
33
+ const { rerender } = render(<Component {...props} items={initial} />);
34
+ const focused = screen.getByRole(role, { name: "Repeated second" });
35
+ focused.focus();
36
+
37
+ for (const by of [{ id: "inserted" }, { path: "second" }]) {
38
+ const inserted: OutlineItem = { name: "Inserted", level: 1, by };
39
+ rerender(<Component {...props} items={[inserted, ...initial]} />);
40
+ expect(screen.getByRole(role, { name: "Repeated second" })).toBe(
41
+ focused,
42
+ );
43
+ expect(focused).toHaveFocus();
44
+ }
45
+
46
+ rerender(
47
+ <Component {...props} items={[items[1], duplicate, items[0]]} />,
48
+ );
49
+ expect(screen.getByRole(role, { name: "Repeated second" })).toBe(focused);
50
+ expect(focused).toHaveFocus();
51
+ fireEvent.click(focused);
52
+ expect(scrollToOutlineItem).toHaveBeenLastCalledWith(duplicate, 1);
53
+ },
54
+ );
55
+
56
+ it("activates plain and rich headings with Enter", () => {
57
+ render(
58
+ <OutlineList
59
+ items={items}
60
+ activeHeaderId={undefined}
61
+ activeOccurrences={undefined}
62
+ />,
63
+ );
64
+ items.forEach((item) => {
65
+ const link = screen.getByRole("link", { name: item.name });
66
+ expect(link).toHaveAttribute("tabindex", "0");
67
+ fireEvent.keyDown(link, { key: "Enter" });
68
+ expect(scrollToOutlineItem).toHaveBeenLastCalledWith(item, 0);
69
+ });
70
+ });
71
+
72
+ it("names each minimap button", () => {
73
+ render(
74
+ <MiniMap
75
+ items={items}
76
+ activeHeaderId={undefined}
77
+ activeOccurrences={undefined}
78
+ />,
79
+ );
80
+ const button = screen.getByRole("button", { name: "Second" });
81
+ fireEvent.click(button);
82
+ expect(scrollToOutlineItem).toHaveBeenLastCalledWith(items[1], 0);
83
+ });
84
+ });
@@ -17,6 +17,8 @@ export const FloatingOutline: React.FC = () => {
17
17
  findOutlineElements(items),
18
18
  );
19
19
  const [isHovered, setIsHovered] = React.useState(false);
20
+ const [isFocused, setIsFocused] = React.useState(false);
21
+ const isVisible = isHovered || isFocused;
20
22
 
21
23
  // Hide if < 2 items
22
24
  // It's kinda useless to have an outline with only one item
@@ -29,6 +31,12 @@ export const FloatingOutline: React.FC = () => {
29
31
  <div
30
32
  onMouseEnter={() => setIsHovered(true)}
31
33
  onMouseLeave={() => setIsHovered(false)}
34
+ onFocus={() => setIsFocused(true)}
35
+ onBlur={(e) => {
36
+ if (!e.currentTarget.contains(e.relatedTarget)) {
37
+ setIsFocused(false);
38
+ }
39
+ }}
32
40
  className={cn(
33
41
  "fixed top-[25vh] right-8 z-10000 print:hidden",
34
42
  // Hide on small screens
@@ -36,9 +44,10 @@ export const FloatingOutline: React.FC = () => {
36
44
  )}
37
45
  >
38
46
  <OutlineList
47
+ inert={!isVisible}
39
48
  className={cn(
40
49
  "-top-4 max-h-[70vh] bg-background rounded-lg shadow-lg absolute overflow-auto transition-all duration-300 w-[300px] border",
41
- isHovered ? "left-[-280px] opacity-100" : "left-[300px] opacity-0",
50
+ isVisible ? "left-[-280px] opacity-100" : "left-[300px] opacity-0",
42
51
  )}
43
52
  items={items}
44
53
  activeHeaderId={activeHeaderId}
@@ -62,17 +71,20 @@ export const MiniMap: React.FC<{
62
71
  const seen = new Map<string, number>();
63
72
  return (
64
73
  <div className="flex flex-col gap-4 items-end max-h-[70vh] overflow-hidden">
65
- {items.map((item, idx) => {
74
+ {items.map((item) => {
66
75
  const identifier = "id" in item.by ? item.by.id : item.by.path;
76
+ const selector = JSON.stringify(item.by);
67
77
  // Keep track of how many times we've seen this selector
68
- const occurrences = seen.get(identifier) ?? 0;
69
- seen.set(identifier, occurrences + 1);
78
+ const occurrences = seen.get(selector) ?? 0;
79
+ seen.set(selector, occurrences + 1);
70
80
 
71
81
  return (
72
- <div
73
- key={`${identifier}-${idx}`}
82
+ <button
83
+ type="button"
84
+ aria-label={item.name}
85
+ key={`${selector}-${occurrences}`}
74
86
  className={cn(
75
- "h-[2px] bg-muted-foreground/60",
87
+ "h-[2px] bg-muted-foreground/60 focus-visible:outline focus-visible:outline-ring",
76
88
  item.level === 1 && "w-5",
77
89
  item.level === 2 && "w-4",
78
90
  item.level === 3 && "w-3",
@@ -91,24 +103,31 @@ export const MiniMap: React.FC<{
91
103
 
92
104
  export const OutlineList: React.FC<{
93
105
  className?: string;
106
+ inert?: boolean;
94
107
  items: OutlineItem[];
95
108
  activeHeaderId: string | undefined;
96
109
  activeOccurrences: number | undefined;
97
- }> = ({ items, activeHeaderId, activeOccurrences, className }) => {
110
+ }> = ({ items, activeHeaderId, activeOccurrences, className, inert }) => {
98
111
  // Map of selector to its occurrences
99
112
  const seen = new Map<string, number>();
100
113
  return (
101
- <div className={cn("flex flex-col overflow-auto py-4 pl-2", className)}>
102
- {items.map((item, idx) => {
114
+ <div
115
+ inert={inert}
116
+ className={cn("flex flex-col overflow-auto py-4 pl-2", className)}
117
+ >
118
+ {items.map((item) => {
103
119
  const identifier = "id" in item.by ? item.by.id : item.by.path;
120
+ const selector = JSON.stringify(item.by);
104
121
  // Keep track of how many times we've seen this selector
105
- const occurrences = seen.get(identifier) ?? 0;
106
- seen.set(identifier, occurrences + 1);
122
+ const occurrences = seen.get(selector) ?? 0;
123
+ seen.set(selector, occurrences + 1);
107
124
 
108
- const key = `${identifier}-${idx}`;
125
+ const key = `${selector}-${occurrences}`;
109
126
  const sharedProps = {
127
+ role: "link",
128
+ tabIndex: 0,
110
129
  className: cn(
111
- "px-2 py-1 cursor-pointer hover:bg-accent/50 hover:text-accent-foreground rounded-l",
130
+ "px-2 py-1 cursor-pointer hover:bg-accent/50 hover:text-accent-foreground rounded-l focus-visible:outline focus-visible:outline-ring",
112
131
  item.level === 1 && "font-semibold",
113
132
  item.level === 2 && "ml-3",
114
133
  item.level === 3 && "ml-6",
@@ -118,6 +137,12 @@ export const OutlineList: React.FC<{
118
137
  "text-accent-foreground",
119
138
  ),
120
139
  onClick: () => scrollToOutlineItem(item, occurrences),
140
+ onKeyDown: (e: React.KeyboardEvent) => {
141
+ if (e.target === e.currentTarget && e.key === "Enter") {
142
+ e.preventDefault();
143
+ scrollToOutlineItem(item, occurrences);
144
+ }
145
+ },
121
146
  };
122
147
 
123
148
  if (item.html) {
@@ -113,9 +113,10 @@ export function findOutlineElements(items: OutlineItem[]) {
113
113
  return items
114
114
  .map((item) => {
115
115
  const identifier = "id" in item.by ? item.by.id : item.by.path;
116
+ const selector = JSON.stringify(item.by);
116
117
  // Keep track of how many times we've seen this selector
117
- const occurrences = seen.get(identifier) ?? 0;
118
- seen.set(identifier, occurrences + 1);
118
+ const occurrences = seen.get(selector) ?? 0;
119
+ seen.set(selector, occurrences + 1);
119
120
 
120
121
  const el = findOutlineItem(item, occurrences);
121
122
  if (!el) {
@@ -66,6 +66,7 @@ const SecretsPanel: React.FC = () => {
66
66
  <div className="flex justify-start h-8 border-b">
67
67
  <button
68
68
  type="button"
69
+ aria-label="Add environment variable"
69
70
  className="border-r px-2 m-0 h-full hover:bg-accent hover:text-accent-foreground"
70
71
  onClick={() =>
71
72
  openModal(
@@ -71,7 +71,11 @@ const SnippetsPanel: React.FC = () => {
71
71
  rootClassName="flex-1 border-r"
72
72
  />
73
73
  <ContributeSnippetButton>
74
- <button className="float-right px-2 m-0 h-full hover:bg-accent hover:text-accent-foreground">
74
+ <button
75
+ type="button"
76
+ aria-label="Contribute snippet"
77
+ className="float-right px-2 m-0 h-full hover:bg-accent hover:text-accent-foreground"
78
+ >
75
79
  <PlusIcon className="h-4 w-4" />
76
80
  </button>
77
81
  </ContributeSnippetButton>
@@ -16,14 +16,14 @@ import { TooltipProvider } from "@/components/ui/tooltip";
16
16
  import { toast } from "@/components/ui/use-toast";
17
17
  import type { Attachment } from "@/core/attachments/state";
18
18
  import { pairConfigAtom } from "@/core/config/pair";
19
- import { createNotebookClient } from "@/core/host/notebook-client";
19
+ import { createNotebookReplica } from "@/core/host/replica";
20
20
  import {
21
21
  type StableSessionId,
22
22
  stableSessionIdAtom,
23
23
  } from "@/core/kernel/session";
24
24
  import { API } from "@/core/network/api";
25
25
  import { connectionAtom } from "@/core/network/connection";
26
- import { notebookClientAtom } from "@/core/websocket/state";
26
+ import { notebookReplicaAtom } from "@/core/websocket/state";
27
27
  import { ConnectionState } from "@/core/websocket/types";
28
28
  import { Deferred } from "@/utils/Deferred";
29
29
  import { AttachmentStatus } from "../footer-items/attachment-status";
@@ -45,8 +45,8 @@ function renderStatus(attachments: Attachment[] = [AGENT], preview = true) {
45
45
  const store = createStore();
46
46
  store.set(connectionAtom, { state: ConnectionState.OPEN });
47
47
  store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
48
- store.set(notebookClientAtom, {
49
- ...createNotebookClient(store).client,
48
+ store.set(notebookReplicaAtom, {
49
+ ...createNotebookReplica(store),
50
50
  connectionAtom: atom({ state: ConnectionState.OPEN }),
51
51
  attachmentsAtom,
52
52
  });
@@ -0,0 +1,61 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { TooltipProvider } from "@/components/ui/tooltip";
7
+ import { userConfigAtom } from "@/core/config/config";
8
+ import { defaultUserConfig } from "@/core/config/config-schema";
9
+ import { getDefaultHotkey } from "@/core/hotkeys/hotkeys";
10
+ import {
11
+ KeyboardShortcuts,
12
+ keyboardShortcutsAtom,
13
+ } from "../keyboard-shortcuts";
14
+
15
+ const saveUserConfig = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
16
+ vi.mock("@/core/network/requests", () => ({
17
+ useRequestClient: () => ({ saveUserConfig }),
18
+ }));
19
+
20
+ describe("shortcut recorder keyboard navigation", () => {
21
+ beforeEach(() => vi.clearAllMocks());
22
+
23
+ it("allows Tab navigation and Reset without saving a shortcut", async () => {
24
+ const store = createStore();
25
+ const config = defaultUserConfig();
26
+ store.set(userConfigAtom, {
27
+ ...config,
28
+ keymap: { ...config.keymap, overrides: { "cell.run": "Ctrl-9" } },
29
+ });
30
+ store.set(keyboardShortcutsAtom, true);
31
+ render(
32
+ <Provider store={store}>
33
+ <TooltipProvider>
34
+ <KeyboardShortcuts />
35
+ </TooltipProvider>
36
+ </Provider>,
37
+ );
38
+ const edit = screen.getByRole("button", {
39
+ name: `Edit shortcut: ${getDefaultHotkey("cell.run").name}`,
40
+ });
41
+ act(() => edit.focus());
42
+ fireEvent.click(edit, { detail: 0 });
43
+ const recorder = screen.getByPlaceholderText(
44
+ getDefaultHotkey("cell.run").name,
45
+ );
46
+ expect(recorder).toHaveFocus();
47
+ expect(fireEvent.keyDown(recorder, { key: "Tab" })).toBe(true);
48
+ expect(fireEvent.keyDown(recorder, { key: "Tab", shiftKey: true })).toBe(
49
+ true,
50
+ );
51
+ expect(recorder).toBeInTheDocument();
52
+ expect(saveUserConfig).not.toHaveBeenCalled();
53
+ const reset = screen.getByRole("button", { name: /^Reset to default:/ });
54
+ act(() => reset.focus());
55
+ expect(reset).toHaveFocus();
56
+ await act(async () => fireEvent.click(reset, { detail: 0 }));
57
+ expect(saveUserConfig).toHaveBeenCalledExactlyOnceWith({
58
+ config: { keymap: { ...config.keymap, overrides: {} } },
59
+ });
60
+ });
61
+ });
@@ -134,6 +134,9 @@ export const KeyboardShortcuts: React.FC = () => {
134
134
  defaultValue={newShortcut.join("+")}
135
135
  placeholder={hotkey.name}
136
136
  onKeyDown={(e) => {
137
+ if (e.key === "Tab" && !e.metaKey && !e.ctrlKey && !e.altKey) {
138
+ return;
139
+ }
137
140
  e.preventDefault();
138
141
  const next: string[] = [];
139
142
 
@@ -189,6 +192,7 @@ export const KeyboardShortcuts: React.FC = () => {
189
192
  autoFocus={true}
190
193
  endAdornment={
191
194
  <Button
195
+ aria-label="Cancel shortcut recording"
192
196
  variant="text"
193
197
  size="xs"
194
198
  className="mb-0"
@@ -206,13 +210,14 @@ export const KeyboardShortcuts: React.FC = () => {
206
210
  Press a key combination
207
211
  </span>
208
212
  {defaultHotkey.key !== hotkey.key && (
209
- <span
213
+ <button
214
+ type="button"
210
215
  className="text-xs cursor-pointer text-primary"
211
216
  onClick={handleResetShortcut}
212
217
  >
213
218
  Reset to default:{" "}
214
219
  <span className="font-mono">{defaultHotkey.key}</span>
215
- </span>
220
+ </button>
216
221
  )}
217
222
  </div>
218
223
  </div>
@@ -228,13 +233,17 @@ export const KeyboardShortcuts: React.FC = () => {
228
233
  className="grid grid-cols-[auto_2fr_3fr] gap-2 items-center"
229
234
  >
230
235
  {hotkeys.isEditable(action) ? (
231
- <EditIcon
232
- className="cursor-pointer opacity-60 hover:opacity-100 text-muted-foreground w-3 h-3"
236
+ <button
237
+ type="button"
238
+ aria-label={`Edit shortcut: ${hotkey.name}`}
239
+ className="cursor-pointer opacity-60 hover:opacity-100 text-muted-foreground w-3 h-3 focus-visible:outline focus-visible:outline-ring"
233
240
  onClick={() => {
234
241
  setNewShortcut([]);
235
242
  setEditingShortcut(action);
236
243
  }}
237
- />
244
+ >
245
+ <EditIcon className="w-3 h-3" />
246
+ </button>
238
247
  ) : (
239
248
  <div className="w-3 h-3" />
240
249
  )}
@@ -25,7 +25,7 @@ import {
25
25
  } from "@/core/cells/error-report";
26
26
  import { pairConfigAtom } from "@/core/config/pair";
27
27
  import { notebookStateFromSession } from "@/core/cells/session";
28
- import { createNotebookClient } from "@/core/host/notebook-client";
28
+ import { createNotebookReplica } from "@/core/host/replica";
29
29
  import type { MarimoError, OutputMessage } from "@/core/kernel/messages";
30
30
  import {
31
31
  type StableSessionId,
@@ -33,7 +33,7 @@ import {
33
33
  } from "@/core/kernel/session";
34
34
  import { API } from "@/core/network/api";
35
35
  import { connectionAtom } from "@/core/network/connection";
36
- import { notebookClientAtom } from "@/core/websocket/state";
36
+ import { notebookReplicaAtom } from "@/core/websocket/state";
37
37
  import { ConnectionState } from "@/core/websocket/types";
38
38
  import { Deferred } from "@/utils/Deferred";
39
39
  import { HTTPError } from "@/utils/errors";
@@ -97,8 +97,8 @@ function renderButton({
97
97
  const store = createStore();
98
98
  store.set(connectionAtom, { state: ConnectionState.OPEN });
99
99
  store.set(pairConfigAtom, { ...PAIR_CONFIG, hostEnabled: preview });
100
- store.set(notebookClientAtom, {
101
- ...createNotebookClient(store).client,
100
+ store.set(notebookReplicaAtom, {
101
+ ...createNotebookReplica(store),
102
102
  connectionAtom: atom({ state: ConnectionState.OPEN }),
103
103
  attachmentsAtom,
104
104
  });
@@ -17,6 +17,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
17
17
  import { requestClientAtom } from "@/core/network/requests";
18
18
  import type { FileInfo, FileSearchResponse } from "@/core/network/types";
19
19
  import { Deferred } from "@/utils/Deferred";
20
+ import { openNotebook } from "@/utils/links";
20
21
  import type { FilePath } from "@/utils/paths";
21
22
  import { TreeDndProvider } from "../dnd-wrapper";
22
23
  import { FileExplorer } from "../file-explorer";
@@ -29,6 +30,11 @@ vi.mock("../file-viewer", () => ({
29
30
  FileViewer: ({ file }: { file: FileInfo }) => <div>Preview: {file.name}</div>,
30
31
  }));
31
32
 
33
+ vi.mock("@/utils/links", async (importOriginal) => ({
34
+ ...(await importOriginal<typeof import("@/utils/links")>()),
35
+ openNotebook: vi.fn(),
36
+ }));
37
+
32
38
  function file(name: string, isDirectory = false): FileInfo {
33
39
  return {
34
40
  id: name,
@@ -91,6 +97,47 @@ beforeEach(() => {
91
97
  });
92
98
 
93
99
  describe("file browser navigation", () => {
100
+ it("tabs into the notebook action, returns to its row, and exits the tree", async () => {
101
+ client.sendListFiles.mockResolvedValue({
102
+ root: "/workspace",
103
+ files: [
104
+ { ...file("notebook.py"), isMarimoFile: true },
105
+ file("notes.txt"),
106
+ ],
107
+ });
108
+ render(
109
+ <>
110
+ <FileExplorer height={300} />
111
+ <button type="button">After tree</button>
112
+ </>,
113
+ { wrapper },
114
+ );
115
+ const row = await screen.findByRole("treeitem", { name: "notebook.py" });
116
+ act(() => row.focus());
117
+ fireEvent.keyDown(row, { key: "Tab" });
118
+ const open = screen.getByRole("button", {
119
+ name: "Open notebook notebook.py",
120
+ });
121
+ expect(open).toHaveFocus();
122
+ fireEvent.keyDown(open, { key: "Tab", shiftKey: true });
123
+ expect(row).toHaveFocus();
124
+ fireEvent.keyDown(row, { key: "Tab" });
125
+ for (const key of ["Enter", " "]) {
126
+ expect(fireEvent.keyDown(open, { key })).toBe(true);
127
+ expect(
128
+ screen.queryByText("Preview: notebook.py"),
129
+ ).not.toBeInTheDocument();
130
+ }
131
+ fireEvent.click(open);
132
+ expect(openNotebook).toHaveBeenCalledExactlyOnceWith("notebook.py");
133
+ fireEvent.keyDown(open, { key: "Tab" });
134
+ expect(screen.getByRole("button", { name: "After tree" })).toHaveFocus();
135
+ const plain = screen.getByRole("treeitem", { name: "notes.txt" });
136
+ act(() => plain.focus());
137
+ fireEvent.keyDown(plain, { key: "Tab" });
138
+ expect(screen.getByRole("button", { name: "After tree" })).toHaveFocus();
139
+ });
140
+
94
141
  it("debounces the first character, subsequent edits, and searches after clearing", async () => {
95
142
  render(<FileExplorer height={300} />, { wrapper });
96
143
  const input = await screen.findByRole("textbox", {