@marimo-team/islands 0.22.6-dev1 → 0.22.6-dev11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.22.6-dev1",
3
+ "version": "0.22.6-dev11",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -19,6 +19,7 @@ import { useForm } from "react-hook-form";
19
19
  import type z from "zod";
20
20
  import { Button } from "@/components/ui/button";
21
21
  import { Checkbox } from "@/components/ui/checkbox";
22
+ import { acceptCompletionOnEnterAtom } from "@/core/codemirror/completion/accept-on-enter-atom";
22
23
  import {
23
24
  Form,
24
25
  FormControl,
@@ -118,6 +119,9 @@ const LOCALE_SYSTEM_VALUE = "__system__";
118
119
 
119
120
  export const UserConfigForm: React.FC = () => {
120
121
  const [config, setConfig] = useUserConfig();
122
+ const [acceptOnEnter, setAcceptOnEnter] = useAtom(
123
+ acceptCompletionOnEnterAtom,
124
+ );
121
125
  const formElement = useRef<HTMLFormElement>(null);
122
126
  const setKeyboardShortcutsOpen = useSetAtom(keyboardShortcutsAtom);
123
127
  const [activeCategory, setActiveCategory] = useAtom(
@@ -446,6 +450,27 @@ export const UserConfigForm: React.FC = () => {
446
450
  </div>
447
451
  )}
448
452
  />
453
+ <div className="flex flex-col space-y-1">
454
+ <FormItem className={formItemClasses}>
455
+ <FormLabel className="font-normal">
456
+ Accept suggestion on Enter
457
+ </FormLabel>
458
+ <FormControl>
459
+ <Checkbox
460
+ data-testid="accept-completion-on-enter-checkbox"
461
+ checked={acceptOnEnter}
462
+ onCheckedChange={(checked) =>
463
+ setAcceptOnEnter(Boolean(checked))
464
+ }
465
+ />
466
+ </FormControl>
467
+ </FormItem>
468
+ <FormDescription>
469
+ When unchecked, pressing Enter inserts a new line instead of
470
+ accepting an autocomplete suggestion. Use Tab to accept
471
+ suggestions.
472
+ </FormDescription>
473
+ </div>
449
474
  <FormField
450
475
  control={form.control}
451
476
  name="completion.signature_hint_on_typing"
@@ -16,6 +16,7 @@ import { Events } from "@/utils/events";
16
16
  import { Tooltip } from "@/components/ui/tooltip";
17
17
  import { assertNever } from "@/utils/assertNever";
18
18
  import { asRemoteURL, useRuntimeManager } from "@/core/runtime/config";
19
+ import { API } from "@/core/network/api";
19
20
 
20
21
  type AgentTab = "claude" | "codex" | "opencode";
21
22
 
@@ -54,7 +55,9 @@ const SKILL_INSTALL = "npx skills add marimo-team/marimo-pair";
54
55
  function useAuthToken(): string | null {
55
56
  const [token, setToken] = useState<string | null>(null);
56
57
  useEffect(() => {
57
- fetch(asRemoteURL("/auth/token").href, { credentials: "include" })
58
+ fetch(asRemoteURL("/auth/token").href, {
59
+ headers: API.headers(),
60
+ })
58
61
  .then((res) =>
59
62
  res.ok ? (res.json() as Promise<{ token: string | null }>) : null,
60
63
  )
@@ -13,6 +13,7 @@ import { useCellActions } from "@/core/cells/cells";
13
13
  import { usePendingDeleteService } from "@/core/cells/pending-delete-service";
14
14
  import type { CellData, CellRuntimeState } from "@/core/cells/types";
15
15
  import { setupCodeMirror } from "@/core/codemirror/cm";
16
+ import { acceptCompletionOnEnterAtom } from "@/core/codemirror/completion/accept-on-enter-atom";
16
17
  import {
17
18
  getInitialLanguageAdapter,
18
19
  languageAdapterState,
@@ -146,6 +147,7 @@ const CellEditorInternal = ({
146
147
  });
147
148
 
148
149
  const autoInstantiate = useAtomValue(autoInstantiateAtom);
150
+ const acceptCompletionOnEnter = useAtomValue(acceptCompletionOnEnterAtom);
149
151
  const afterToggleMarkdown = useEvent(() => {
150
152
  maybeAddMarimoImport({
151
153
  autoInstantiate,
@@ -212,6 +214,7 @@ const CellEditorInternal = ({
212
214
  },
213
215
  },
214
216
  completionConfig: userConfig.completion,
217
+ acceptCompletionOnEnter,
215
218
  keymapConfig: userConfig.keymap,
216
219
  lspConfig: userConfig.language_servers,
217
220
  theme,
@@ -261,6 +264,7 @@ const CellEditorInternal = ({
261
264
  return extensions;
262
265
  }, [
263
266
  cellId,
267
+ acceptCompletionOnEnter,
264
268
  userConfig.keymap,
265
269
  userConfig.completion,
266
270
  userConfig.language_servers,
@@ -76,6 +76,21 @@ function buildPackageSpecifier(name: string, extras: string[]): string {
76
76
  return `${name}[${extras.join(",")}]`;
77
77
  }
78
78
 
79
+ const SourceBadge: React.FC<{ source?: "kernel" | "server" }> = ({
80
+ source,
81
+ }) => {
82
+ if (source !== "server") {
83
+ return null;
84
+ }
85
+ return (
86
+ <Tooltip content="Installing into the server environment, not the notebook kernel">
87
+ <span className="ml-2 text-xs font-normal border border-current rounded px-1 py-0.5 opacity-60">
88
+ server
89
+ </span>
90
+ </Tooltip>
91
+ );
92
+ };
93
+
79
94
  export const PackageAlert: React.FC = () => {
80
95
  const { packageAlert, packageLogs } = useAlerts();
81
96
  const { clearPackageAlert } = useAlertActions();
@@ -105,6 +120,7 @@ export const PackageAlert: React.FC = () => {
105
120
  <span className="font-bold text-lg flex items-center mb-2">
106
121
  <PackageXIcon className="w-5 h-5 inline-block mr-2" />
107
122
  Missing packages
123
+ <SourceBadge source={packageAlert.source} />
108
124
  </span>
109
125
  <Button
110
126
  variant="text"
@@ -217,6 +233,7 @@ export const PackageAlert: React.FC = () => {
217
233
  <span className="font-bold text-lg flex items-center mb-2">
218
234
  {titleIcon}
219
235
  {title}
236
+ <SourceBadge source={packageAlert.source} />
220
237
  </span>
221
238
  <Button
222
239
  variant="text"
@@ -19,6 +19,7 @@ export interface InstallingPackageAlert {
19
19
  packages: PackageInstallationStatus;
20
20
  logs?: { [key: string]: string } | null;
21
21
  log_status?: "append" | "start" | "done" | null;
22
+ source?: "kernel" | "server";
22
23
  }
23
24
 
24
25
  export interface StartupLogsAlert {
@@ -72,6 +72,7 @@ export function resourceExtension(opts: {
72
72
  keymap.of([
73
73
  {
74
74
  key: "Tab",
75
+ preventDefault: true,
75
76
  run: (view: EditorView) => {
76
77
  return acceptCompletion(view);
77
78
  },
@@ -73,6 +73,7 @@ export interface CodeMirrorSetupOpts {
73
73
  cellId: CellId;
74
74
  showPlaceholder: boolean;
75
75
  enableAI: boolean;
76
+ acceptCompletionOnEnter?: boolean;
76
77
  cellActions: CodemirrorCellActions;
77
78
  completionConfig: CompletionConfig;
78
79
  keymapConfig: KeymapConfig;
@@ -175,6 +176,7 @@ export const basicBundle = (opts: CodeMirrorSetupOpts): Extension[] => {
175
176
  theme,
176
177
  hotkeys,
177
178
  completionConfig,
179
+ acceptCompletionOnEnter,
178
180
  cellId,
179
181
  lspConfig,
180
182
  diagnosticsConfig,
@@ -207,7 +209,7 @@ export const basicBundle = (opts: CodeMirrorSetupOpts): Extension[] => {
207
209
  foldGutter(),
208
210
  stringsAutoCloseBraces(),
209
211
  closeBrackets(),
210
- completionKeymap(),
212
+ completionKeymap(acceptCompletionOnEnter),
211
213
  // to avoid clash with charDeleteBackward keymap
212
214
  Prec.high(keymap.of(closeBracketsKeymap)),
213
215
  bracketMatching(),
@@ -1,8 +1,21 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { completionKeymap as defaultCompletionKeymap } from "@codemirror/autocomplete";
4
+ import { EditorState } from "@codemirror/state";
5
+ import { keymap } from "@codemirror/view";
4
6
  import { describe, expect, it } from "vitest";
5
- import { filterCompletionBindings } from "../keymap";
7
+ import { completionKeymap, filterCompletionBindings } from "../keymap";
8
+
9
+ function hasEnterBinding(acceptOnEnter: boolean): boolean {
10
+ const state = EditorState.create({
11
+ extensions: [completionKeymap(acceptOnEnter)],
12
+ });
13
+
14
+ return state
15
+ .facet(keymap)
16
+ .flat()
17
+ .some((binding) => binding.key === "Enter");
18
+ }
6
19
 
7
20
  describe("completionKeymap", () => {
8
21
  it("upstream includes the macOS-only completion bindings we care about", () => {
@@ -21,4 +34,30 @@ describe("completionKeymap", () => {
21
34
  expect(filtered.some((binding) => binding.key === "Escape")).toBe(false);
22
35
  expect(filtered.some((binding) => binding.mac === "Alt-i")).toBe(true);
23
36
  });
37
+
38
+ it("includes Enter by default", () => {
39
+ const filtered = filterCompletionBindings(defaultCompletionKeymap);
40
+ expect(filtered.some((binding) => binding.key === "Enter")).toBe(true);
41
+ });
42
+
43
+ it("removes Enter when passed a keysToRemove set containing Enter", () => {
44
+ const keysToRemove = new Set<string | undefined>([
45
+ "Escape",
46
+ "Alt-`",
47
+ "Enter",
48
+ ]);
49
+ const filtered = filterCompletionBindings(
50
+ defaultCompletionKeymap,
51
+ keysToRemove,
52
+ );
53
+ expect(filtered.some((binding) => binding.key === "Enter")).toBe(false);
54
+ });
55
+
56
+ it("completionKeymap includes Enter when acceptOnEnter is true", () => {
57
+ expect(hasEnterBinding(true)).toBe(true);
58
+ });
59
+
60
+ it("completionKeymap removes Enter when acceptOnEnter is false", () => {
61
+ expect(hasEnterBinding(false)).toBe(false);
62
+ });
24
63
  });
@@ -0,0 +1,10 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { atomWithStorage } from "jotai/utils";
3
+ import { jotaiJsonStorage } from "@/utils/storage/jotai";
4
+ // Default: true (Enter accepts suggestion, matching VS Code default)
5
+ export const acceptCompletionOnEnterAtom = atomWithStorage<boolean>(
6
+ "marimo:accept-completion-on-enter",
7
+ true,
8
+ jotaiJsonStorage,
9
+ { getOnInit: true },
10
+ );
@@ -22,20 +22,27 @@ const KEYS_TO_REMOVE = new Set<string | undefined>([
22
22
  "Alt-`",
23
23
  ]);
24
24
 
25
- function hasRemovedKeybinding(binding: KeyBinding): boolean {
26
- return [binding.key, binding.mac, binding.linux, binding.win].some((key) =>
27
- KEYS_TO_REMOVE.has(key),
28
- );
29
- }
30
-
31
25
  export function filterCompletionBindings(
32
26
  bindings: readonly KeyBinding[],
27
+ keysToRemove: Set<string | undefined> = KEYS_TO_REMOVE,
33
28
  ): readonly KeyBinding[] {
34
- return bindings.filter((binding) => !hasRemovedKeybinding(binding));
29
+ return bindings.filter(
30
+ (binding) =>
31
+ ![binding.key, binding.mac, binding.linux, binding.win].some((key) =>
32
+ keysToRemove.has(key),
33
+ ),
34
+ );
35
35
  }
36
36
 
37
- export function completionKeymap(): Extension {
38
- const withoutKeysToRemove = filterCompletionBindings(defaultCompletionKeymap);
37
+ export function completionKeymap(acceptOnEnter = true): Extension {
38
+ const keysToRemove = new Set(KEYS_TO_REMOVE);
39
+ if (!acceptOnEnter) {
40
+ keysToRemove.add("Enter");
41
+ }
42
+ const withoutKeysToRemove = filterCompletionBindings(
43
+ defaultCompletionKeymap,
44
+ keysToRemove,
45
+ );
39
46
 
40
47
  return Prec.highest(
41
48
  keymap.of([
@@ -19,6 +19,7 @@ import {
19
19
  extractPoints,
20
20
  extractSunburstPoints,
21
21
  extractTreemapPoints,
22
+ hasAreaTrace,
22
23
  hasPureLineTrace,
23
24
  lineSelectionButtons,
24
25
  type ModeBarButton,
@@ -113,7 +114,8 @@ export const PlotlyComponent = memo(
113
114
 
114
115
  const configMemo = useDeepCompareMemoize(config);
115
116
  const plotlyConfig = useMemo((): Partial<Plotly.Config> => {
116
- const hasPureLine = hasPureLineTrace(figure.data);
117
+ const hasLineOrAreaTrace =
118
+ hasPureLineTrace(figure.data) || hasAreaTrace(figure.data);
117
119
  const defaultButtons: ModeBarButton[] = [
118
120
  // Custom button to reset the state
119
121
  {
@@ -130,7 +132,7 @@ export const PlotlyComponent = memo(
130
132
  click: handleResetWithClear,
131
133
  },
132
134
  ];
133
- if (hasPureLine) {
135
+ if (hasLineOrAreaTrace) {
134
136
  defaultButtons.push(...lineSelectionButtons(handleSetDragmode));
135
137
  }
136
138
 
@@ -111,4 +111,54 @@ describe("PlotlyPlugin", () => {
111
111
  range: undefined,
112
112
  });
113
113
  });
114
+
115
+ it("clicking a violin element triggers onClick", async () => {
116
+ const setValue = vi.fn<Setter<unknown>>();
117
+
118
+ render(
119
+ <Suspense fallback={null}>
120
+ <PlotlyComponent
121
+ figure={{
122
+ data: [{ type: "violin" }],
123
+ layout: {},
124
+ frames: null,
125
+ }}
126
+ value={undefined}
127
+ setValue={setValue}
128
+ host={document.createElement("div")}
129
+ config={{}}
130
+ />
131
+ </Suspense>,
132
+ );
133
+
134
+ await waitFor(() => {
135
+ expect(capturedPlotProps).not.toBeNull();
136
+ });
137
+
138
+ act(() => {
139
+ capturedPlotProps?.onClick?.({
140
+ points: [
141
+ {
142
+ data: { type: "violin" },
143
+ x: "Group A",
144
+ y: 3,
145
+ pointIndex: 0,
146
+ pointNumber: 0,
147
+ curveNumber: 0,
148
+ },
149
+ ],
150
+ });
151
+ });
152
+
153
+ expect(setValue).toHaveBeenCalledTimes(1);
154
+ const updater = setValue.mock.calls[0][0] as (value: unknown) => unknown;
155
+ expect(updater({})).toEqual({
156
+ selections: [],
157
+ points: [
158
+ { x: "Group A", y: 3, curveNumber: 0, pointNumber: 0, pointIndex: 0 },
159
+ ],
160
+ indices: [0],
161
+ range: undefined,
162
+ });
163
+ });
114
164
  });
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
5
5
  import {
6
6
  extractIndices,
7
7
  extractPoints,
8
+ hasAreaTrace,
8
9
  hasPureLineTrace,
9
10
  lineSelectionButtons,
10
11
  type ModeBarButton,
@@ -101,6 +102,14 @@ describe("shouldHandleClickSelection", () => {
101
102
  expect(shouldHandleClickSelection([heatmapPoint])).toBe(true);
102
103
  });
103
104
 
105
+ it("accepts violin clicks", () => {
106
+ const violinPoint = createPlotDatum({
107
+ data: { type: "violin" },
108
+ });
109
+
110
+ expect(shouldHandleClickSelection([violinPoint])).toBe(true);
111
+ });
112
+
104
113
  it("accepts histogram clicks", () => {
105
114
  const histogramPoint = createPlotDatum({
106
115
  data: { type: "histogram" },
@@ -219,3 +228,67 @@ describe("extractPoints", () => {
219
228
  ]);
220
229
  });
221
230
  });
231
+
232
+ describe("hasAreaTrace", () => {
233
+ it("detects scatter trace with tozeroy fill", () => {
234
+ expect(
235
+ hasAreaTrace([createTrace({ type: "scatter", fill: "tozeroy" })]),
236
+ ).toBe(true);
237
+ });
238
+
239
+ it("detects scatter trace with tonexty fill", () => {
240
+ expect(
241
+ hasAreaTrace([createTrace({ type: "scatter", fill: "tonexty" })]),
242
+ ).toBe(true);
243
+ });
244
+
245
+ it("detects scatter trace with stackgroup (px.area pattern)", () => {
246
+ expect(
247
+ hasAreaTrace([
248
+ createTrace({ type: "scatter", mode: "lines", stackgroup: "one" }),
249
+ ]),
250
+ ).toBe(true);
251
+ });
252
+
253
+ it("detects area traces with mode=none (fill-only, no visible line)", () => {
254
+ expect(
255
+ hasAreaTrace([
256
+ createTrace({ type: "scatter", fill: "tozeroy", mode: "none" }),
257
+ ]),
258
+ ).toBe(true);
259
+ });
260
+
261
+ it("ignores scatter traces with no fill and no stackgroup", () => {
262
+ expect(
263
+ hasAreaTrace([
264
+ createTrace({ type: "scatter", mode: "lines" }),
265
+ createTrace({ type: "scatter", mode: "markers" }),
266
+ ]),
267
+ ).toBe(false);
268
+ });
269
+
270
+ it("ignores scatter traces with fill=none", () => {
271
+ expect(hasAreaTrace([createTrace({ type: "scatter", fill: "none" })])).toBe(
272
+ false,
273
+ );
274
+ });
275
+
276
+ it("ignores scatter traces with fill=empty string", () => {
277
+ expect(
278
+ hasAreaTrace([createTrace({ type: "scatter", fill: "" as "none" })]),
279
+ ).toBe(false);
280
+ });
281
+
282
+ it("ignores non-scatter traces", () => {
283
+ expect(
284
+ hasAreaTrace([
285
+ createTrace({ type: "bar" }),
286
+ createTrace({ type: "heatmap" }),
287
+ ]),
288
+ ).toBe(false);
289
+ });
290
+
291
+ it("returns false for undefined data", () => {
292
+ expect(hasAreaTrace(undefined)).toBe(false);
293
+ });
294
+ });
@@ -8,11 +8,15 @@ import {
8
8
  computeLayoutUpdate,
9
9
  computeOmitKeys,
10
10
  createInitialLayout,
11
+ hasCompatibleTraces,
11
12
  } from "../usePlotlyLayout";
12
13
 
13
- function createFigure(layoutOverrides: Partial<Plotly.Layout> = {}): Figure {
14
+ function createFigure(
15
+ layoutOverrides: Partial<Plotly.Layout> = {},
16
+ data: Plotly.Data[] = [],
17
+ ): Figure {
14
18
  return {
15
- data: [],
19
+ data,
16
20
  layout: { ...layoutOverrides } as Plotly.Layout,
17
21
  frames: null,
18
22
  };
@@ -35,9 +39,46 @@ describe("createInitialLayout", () => {
35
39
  });
36
40
  });
37
41
 
42
+ describe("hasCompatibleTraces", () => {
43
+ it("returns true for same trace types", () => {
44
+ const a = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
45
+ const b = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
46
+ expect(hasCompatibleTraces(a, b)).toBe(true);
47
+ });
48
+
49
+ it("returns true for default scatter types (undefined type)", () => {
50
+ const a = createFigure({}, [{} as Plotly.Data]);
51
+ const b = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
52
+ expect(hasCompatibleTraces(a, b)).toBe(true);
53
+ });
54
+
55
+ it("returns false for different trace types", () => {
56
+ const a = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
57
+ const b = createFigure({}, [{ type: "histogram" } as Plotly.Data]);
58
+ expect(hasCompatibleTraces(a, b)).toBe(false);
59
+ });
60
+
61
+ it("returns false for different number of traces", () => {
62
+ const a = createFigure({}, [{ type: "scatter" } as Plotly.Data]);
63
+ const b = createFigure({}, [
64
+ { type: "scatter" } as Plotly.Data,
65
+ { type: "scatter" } as Plotly.Data,
66
+ ]);
67
+ expect(hasCompatibleTraces(a, b)).toBe(false);
68
+ });
69
+
70
+ it("returns true for empty data arrays", () => {
71
+ const a = createFigure({}, []);
72
+ const b = createFigure({}, []);
73
+ expect(hasCompatibleTraces(a, b)).toBe(true);
74
+ });
75
+ });
76
+
38
77
  describe("computeLayoutOnFigureChange", () => {
39
- it("preserves only dragmode/xaxis/yaxis from previous layout (#7964)", () => {
40
- const nextFigure = createFigure({ title: { text: "New" } });
78
+ it("preserves only dragmode/xaxis/yaxis from previous layout for compatible traces (#7964)", () => {
79
+ const scatterData = [{ type: "scatter" } as Plotly.Data];
80
+ const prevFigure = createFigure({}, scatterData);
81
+ const nextFigure = createFigure({ title: { text: "New" } }, scatterData);
41
82
  const prevLayout: Partial<Plotly.Layout> = {
42
83
  dragmode: "zoom",
43
84
  xaxis: { range: [0, 10] },
@@ -46,7 +87,11 @@ describe("computeLayoutOnFigureChange", () => {
46
87
  annotations: [{ text: "Old", x: 0, y: 0 }],
47
88
  };
48
89
 
49
- const result = computeLayoutOnFigureChange(nextFigure, prevLayout);
90
+ const result = computeLayoutOnFigureChange(
91
+ nextFigure,
92
+ prevFigure,
93
+ prevLayout,
94
+ );
50
95
 
51
96
  // Preserved from prev
52
97
  expect(result.dragmode).toBe("zoom");
@@ -59,15 +104,68 @@ describe("computeLayoutOnFigureChange", () => {
59
104
  expect(result.annotations).toBeUndefined();
60
105
  });
61
106
 
107
+ it("resets axis settings when trace types change (#5898)", () => {
108
+ const prevFigure = createFigure({}, [{ type: "histogram" } as Plotly.Data]);
109
+ const nextFigure = createFigure({ title: { text: "Bar" } }, [
110
+ { type: "bar" } as Plotly.Data,
111
+ ]);
112
+ const prevLayout: Partial<Plotly.Layout> = {
113
+ dragmode: "zoom",
114
+ xaxis: { range: [-3, 3] },
115
+ yaxis: { range: [0, 200] },
116
+ };
117
+
118
+ const result = computeLayoutOnFigureChange(
119
+ nextFigure,
120
+ prevFigure,
121
+ prevLayout,
122
+ );
123
+
124
+ // Dragmode is still preserved
125
+ expect(result.dragmode).toBe("zoom");
126
+ // Axis settings are NOT preserved — they come from the new figure's layout
127
+ expect(result.xaxis).toBeUndefined();
128
+ expect(result.yaxis).toBeUndefined();
129
+ // New figure's layout is applied
130
+ expect(result.title).toEqual({ text: "Bar" });
131
+ });
132
+
133
+ it("preserves nextFigure dragmode when prevLayout has no dragmode", () => {
134
+ const prevFigure = createFigure({}, [{ type: "histogram" } as Plotly.Data]);
135
+ const nextFigure = createFigure({ dragmode: "lasso" }, [
136
+ { type: "bar" } as Plotly.Data,
137
+ ]);
138
+ const prevLayout: Partial<Plotly.Layout> = {
139
+ xaxis: { range: [0, 10] },
140
+ };
141
+
142
+ const result = computeLayoutOnFigureChange(
143
+ nextFigure,
144
+ prevFigure,
145
+ prevLayout,
146
+ );
147
+
148
+ // nextFigure.layout.dragmode should be preserved via base, not overwritten
149
+ expect(result.dragmode).toBe("lasso");
150
+ // Axis settings are NOT preserved for incompatible traces
151
+ expect(result.xaxis).toBeUndefined();
152
+ expect(result.yaxis).toBeUndefined();
153
+ });
154
+
62
155
  it("uses shapes from new figure, not previous layout", () => {
63
156
  const nextFigure = createFigure({
64
157
  shapes: [{ type: "circle", x0: 0, x1: 1, y0: 0, y1: 1 }],
65
158
  });
159
+ const prevFigure = createFigure({});
66
160
  const prevLayout: Partial<Plotly.Layout> = {
67
161
  shapes: [{ type: "rect", x0: 0, x1: 1, y0: 0, y1: 1 }],
68
162
  };
69
163
 
70
- const result = computeLayoutOnFigureChange(nextFigure, prevLayout);
164
+ const result = computeLayoutOnFigureChange(
165
+ nextFigure,
166
+ prevFigure,
167
+ prevLayout,
168
+ );
71
169
 
72
170
  expect(result.shapes).toHaveLength(1);
73
171
  expect(result.shapes?.[0].type).toBe("circle");
@@ -141,13 +141,44 @@ export function hasPureLineTrace(
141
141
  }
142
142
 
143
143
  return data.some((trace) => {
144
- const traceType = (trace as { type?: unknown }).type;
144
+ const t = trace as Record<string, unknown>;
145
145
  const isScatterLike =
146
- traceType === undefined || LINE_CLICK_TRACE_TYPES.has(String(traceType));
146
+ t.type === undefined || LINE_CLICK_TRACE_TYPES.has(String(t.type));
147
147
  if (!isScatterLike) {
148
148
  return false;
149
149
  }
150
- return isPureLineMode((trace as { mode?: unknown }).mode);
150
+ return isPureLineMode(t.mode);
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Return true when any scatter/scattergl trace has a non-empty fill or a
156
+ * stackgroup, i.e. it is an area chart.
157
+ *
158
+ * Area traces built with `mode="none"` have no visible line or markers, so
159
+ * `hasPureLineTrace` returns false for them even though they need select/lasso
160
+ * buttons just as much as `mode="lines"` area charts. This function covers
161
+ * that gap and is OR-ed with `hasPureLineTrace` in the config builder.
162
+ */
163
+ export function hasAreaTrace(
164
+ data: readonly Plotly.Data[] | undefined,
165
+ ): boolean {
166
+ if (!data) {
167
+ return false;
168
+ }
169
+
170
+ return data.some((trace) => {
171
+ const t = trace as Record<string, unknown>;
172
+ // Only scatter/scattergl can be area traces.
173
+ if (t.type !== undefined && !LINE_CLICK_TRACE_TYPES.has(String(t.type))) {
174
+ return false;
175
+ }
176
+ // A trace is an area trace when fill is a non-empty string other than
177
+ // "none", OR it belongs to a stackgroup (px.area always sets stackgroup).
178
+ return (
179
+ (typeof t.fill === "string" && t.fill !== "" && t.fill !== "none") ||
180
+ t.stackgroup != null
181
+ );
151
182
  });
152
183
  }
153
184
 
@@ -228,6 +259,7 @@ export function shouldHandleClickSelection(
228
259
  type === "heatmap" ||
229
260
  type === "histogram" ||
230
261
  type === "waterfall" ||
262
+ type === "violin" ||
231
263
  isLinePoint(point)
232
264
  );
233
265
  });