@marimo-team/islands 0.23.4-dev9 → 0.23.5-dev22

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.
@@ -1,117 +1,198 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { describe, expect, it } from "vitest";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
4
  import { isInteractiveTarget } from "../use-cell-range-selection";
5
5
 
6
- function createMouseEvent(
7
- target: HTMLElement,
8
- currentTarget: HTMLElement,
9
- ): React.MouseEvent {
10
- return { target, currentTarget } as unknown as React.MouseEvent;
6
+ /**
7
+ * Dispatch a real `mousedown` from `target` (going through real DOM event
8
+ * dispatch so `composedPath()` traverses any open shadow roots) and call
9
+ * `isInteractiveTarget` from inside the listener, where the event's target
10
+ * and composed path are still live.
11
+ */
12
+ function isInteractive(target: Element, cell: Element): boolean {
13
+ let result: boolean | undefined;
14
+ const handler = (event: Event) => {
15
+ const path = event.composedPath();
16
+ result = isInteractiveTarget({
17
+ target: event.target,
18
+ currentTarget: cell,
19
+ nativeEvent: {
20
+ composedPath: () => path,
21
+ },
22
+ } as unknown as React.MouseEvent);
23
+ };
24
+ cell.addEventListener("mousedown", handler);
25
+ target.dispatchEvent(
26
+ new MouseEvent("mousedown", { bubbles: true, composed: true }),
27
+ );
28
+ cell.removeEventListener("mousedown", handler);
29
+ if (result === undefined) {
30
+ throw new Error("mousedown did not bubble to the cell");
31
+ }
32
+ return result;
33
+ }
34
+
35
+ let mounted: HTMLElement[] = [];
36
+
37
+ function makeCell(): HTMLTableCellElement {
38
+ const table = document.createElement("table");
39
+ const tbody = document.createElement("tbody");
40
+ const row = document.createElement("tr");
41
+ const cell = document.createElement("td");
42
+ row.append(cell);
43
+ tbody.append(row);
44
+ table.append(tbody);
45
+ document.body.append(table);
46
+ mounted.push(table);
47
+ return cell;
11
48
  }
12
49
 
50
+ afterEach(() => {
51
+ for (const el of mounted) {
52
+ el.remove();
53
+ }
54
+ mounted = [];
55
+ });
56
+
13
57
  describe("isInteractiveTarget", () => {
14
58
  it("returns false when target is the cell itself", () => {
15
- const cell = document.createElement("td");
16
- expect(isInteractiveTarget(createMouseEvent(cell, cell))).toBe(false);
59
+ const cell = makeCell();
60
+ expect(isInteractive(cell, cell)).toBe(false);
17
61
  });
18
62
 
19
63
  it("returns false when clicking plain text inside a cell", () => {
20
- const cell = document.createElement("td");
64
+ const cell = makeCell();
21
65
  const span = document.createElement("span");
22
66
  cell.append(span);
23
- expect(isInteractiveTarget(createMouseEvent(span, cell))).toBe(false);
67
+ expect(isInteractive(span, cell)).toBe(false);
24
68
  });
25
69
 
26
70
  it.each(["input", "button", "select", "textarea"])(
27
71
  "returns true when clicking a <%s>",
28
72
  (tag) => {
29
- const cell = document.createElement("td");
73
+ const cell = makeCell();
30
74
  const el = document.createElement(tag);
31
75
  cell.append(el);
32
- expect(isInteractiveTarget(createMouseEvent(el, cell))).toBe(true);
76
+ expect(isInteractive(el, cell)).toBe(true);
33
77
  },
34
78
  );
35
79
 
36
80
  it("returns true when clicking an <a> link", () => {
37
- const cell = document.createElement("td");
81
+ const cell = makeCell();
38
82
  const a = document.createElement("a");
39
83
  a.href = "#";
40
84
  cell.append(a);
41
- expect(isInteractiveTarget(createMouseEvent(a, cell))).toBe(true);
85
+ expect(isInteractive(a, cell)).toBe(true);
42
86
  });
43
87
 
44
88
  it("returns true when clicking a <label>", () => {
45
- const cell = document.createElement("td");
89
+ const cell = makeCell();
46
90
  const label = document.createElement("label");
47
91
  cell.append(label);
48
- expect(isInteractiveTarget(createMouseEvent(label, cell))).toBe(true);
49
- });
50
-
51
- it('returns true for element with role="checkbox"', () => {
52
- const cell = document.createElement("td");
53
- const div = document.createElement("div");
54
- div.setAttribute("role", "checkbox");
55
- cell.append(div);
56
- expect(isInteractiveTarget(createMouseEvent(div, cell))).toBe(true);
92
+ expect(isInteractive(label, cell)).toBe(true);
57
93
  });
58
94
 
59
- it('returns true for element with role="button"', () => {
60
- const cell = document.createElement("td");
61
- const div = document.createElement("div");
62
- div.setAttribute("role", "button");
63
- cell.append(div);
64
- expect(isInteractiveTarget(createMouseEvent(div, cell))).toBe(true);
65
- });
95
+ it.each(["checkbox", "button"])(
96
+ 'returns true for element with role="%s"',
97
+ (role) => {
98
+ const cell = makeCell();
99
+ const div = document.createElement("div");
100
+ div.setAttribute("role", role);
101
+ cell.append(div);
102
+ expect(isInteractive(div, cell)).toBe(true);
103
+ },
104
+ );
66
105
 
67
106
  it('returns true for contenteditable="true"', () => {
68
- const cell = document.createElement("td");
107
+ const cell = makeCell();
69
108
  const div = document.createElement("div");
70
109
  div.setAttribute("contenteditable", "true");
71
110
  cell.append(div);
72
- expect(isInteractiveTarget(createMouseEvent(div, cell))).toBe(true);
111
+ expect(isInteractive(div, cell)).toBe(true);
73
112
  });
74
113
 
75
114
  it("returns true when clicking a child nested inside an interactive element", () => {
76
- const cell = document.createElement("td");
115
+ const cell = makeCell();
77
116
  const button = document.createElement("button");
78
117
  const icon = document.createElement("span");
79
118
  button.append(icon);
80
119
  cell.append(button);
81
- expect(isInteractiveTarget(createMouseEvent(icon, cell))).toBe(true);
120
+ expect(isInteractive(icon, cell)).toBe(true);
82
121
  });
83
122
 
84
- it("returns true when clicking inside a marimo-ui-element", () => {
85
- const cell = document.createElement("td");
123
+ it("returns true when clicking inside a marimo-ui-element wrapping a real widget", () => {
124
+ const cell = makeCell();
86
125
  const marimoEl = document.createElement("marimo-ui-element");
87
- const inner = document.createElement("div");
88
- marimoEl.append(inner);
126
+ const widget = document.createElement("marimo-slider");
127
+ marimoEl.append(widget);
89
128
  cell.append(marimoEl);
90
- expect(isInteractiveTarget(createMouseEvent(inner, cell))).toBe(true);
129
+ expect(isInteractive(widget, cell)).toBe(true);
130
+ expect(isInteractive(marimoEl, cell)).toBe(true);
91
131
  });
92
132
 
93
- it("returns true when clicking the marimo-ui-element itself", () => {
94
- const cell = document.createElement("td");
133
+ it.each(["marimo-lazy", "marimo-routes"])(
134
+ "returns false when clicking inside a passive content-wrapper UIElement (%s)",
135
+ (tag) => {
136
+ const cell = makeCell();
137
+ const marimoEl = document.createElement("marimo-ui-element");
138
+ const wrapper = document.createElement(tag);
139
+ const inner = document.createElement("div");
140
+ wrapper.append(inner);
141
+ marimoEl.append(wrapper);
142
+ cell.append(marimoEl);
143
+ expect(isInteractive(inner, cell)).toBe(false);
144
+ expect(isInteractive(wrapper, cell)).toBe(false);
145
+ },
146
+ );
147
+
148
+ it("returns false when clicking plain content rendered through mo.lazy's shadow DOM (#9189)", () => {
149
+ // Reproduces the structure marimo creates for mo.lazy(<plain html>):
150
+ // event.target gets retargeted to <marimo-lazy>, so closest() can't see
151
+ // into the shadow root. composedPath() must be used to confirm there's
152
+ // no genuinely interactive descendant.
153
+ const cell = makeCell();
95
154
  const marimoEl = document.createElement("marimo-ui-element");
155
+ const lazy = document.createElement("marimo-lazy");
156
+ marimoEl.append(lazy);
96
157
  cell.append(marimoEl);
97
- expect(isInteractiveTarget(createMouseEvent(marimoEl, cell))).toBe(true);
158
+
159
+ const shadow = lazy.attachShadow({ mode: "open" });
160
+ const img = document.createElement("img");
161
+ shadow.append(img);
162
+
163
+ expect(isInteractive(img, cell)).toBe(false);
164
+ });
165
+
166
+ it("returns true when clicking an interactive widget rendered inside a content wrapper's shadow DOM", () => {
167
+ // mo.lazy(mo.ui.slider(...)): the slider's <marimo-ui-element> lives
168
+ // inside marimo-lazy's shadow root, so closest() from the retargeted
169
+ // host couldn't see it. composedPath() does.
170
+ const cell = makeCell();
171
+ const outerUi = document.createElement("marimo-ui-element");
172
+ const lazy = document.createElement("marimo-lazy");
173
+ outerUi.append(lazy);
174
+ cell.append(outerUi);
175
+
176
+ const lazyShadow = lazy.attachShadow({ mode: "open" });
177
+ const innerUi = document.createElement("marimo-ui-element");
178
+ const slider = document.createElement("marimo-slider");
179
+ innerUi.append(slider);
180
+ lazyShadow.append(innerUi);
181
+
182
+ const sliderShadow = slider.attachShadow({ mode: "open" });
183
+ const input = document.createElement("input");
184
+ input.type = "range";
185
+ sliderShadow.append(input);
186
+
187
+ expect(isInteractive(input, cell)).toBe(true);
98
188
  });
99
189
 
100
190
  it("returns false when clicking a non-interactive div", () => {
101
- const cell = document.createElement("td");
191
+ const cell = makeCell();
102
192
  const wrapper = document.createElement("div");
103
193
  const text = document.createElement("span");
104
194
  wrapper.append(text);
105
195
  cell.append(wrapper);
106
- expect(isInteractiveTarget(createMouseEvent(text, cell))).toBe(false);
107
- });
108
-
109
- it("returns false when target is a non-Element (e.g. Text node)", () => {
110
- const cell = document.createElement("td");
111
- const textNode = document.createTextNode("hello");
112
- cell.append(textNode);
113
- expect(isInteractiveTarget(createMouseEvent(textNode as never, cell))).toBe(
114
- false,
115
- );
196
+ expect(isInteractive(text, cell)).toBe(false);
116
197
  });
117
198
  });
@@ -130,14 +130,46 @@ export const useCellRangeSelection = <TData>({
130
130
  const INTERACTIVE_SELECTOR =
131
131
  'input, button, select, textarea, a, label, [role="checkbox"], [role="button"], [contenteditable="true"], marimo-ui-element';
132
132
 
133
+ // `<marimo-ui-element>` wraps every stateful UIElement, but content-wrapper
134
+ // UIElements like `mo.lazy` and `mo.routes` are themselves inert. Clicks on
135
+ // their inner content should still allow cell selection.
136
+ // See https://github.com/marimo-team/marimo/issues/9189.
137
+ const CONTENT_WRAPPER_MARIMO_TAGS: ReadonlySet<string> = new Set([
138
+ "marimo-lazy",
139
+ "marimo-routes",
140
+ ]);
141
+
133
142
  /**
134
143
  * Skip cell selection when the click target is inside an interactive element
135
144
  * (e.g. a checkbox or button rendered as rich cell content).
145
+ *
146
+ * Walks `composedPath()` so we can see through Shadow DOM boundaries used by
147
+ * marimo plugins. Without this, `event.target` is retargeted to the outermost
148
+ * shadow host (e.g. `<marimo-lazy>`), hiding any genuinely interactive
149
+ * descendants rendered inside the shadow tree.
136
150
  */
137
151
  export function isInteractiveTarget(e: React.MouseEvent): boolean {
138
- const target = e.target;
139
- if (target === e.currentTarget || !(target instanceof Element)) {
140
- return false;
152
+ const path: readonly EventTarget[] =
153
+ typeof e.nativeEvent?.composedPath === "function"
154
+ ? e.nativeEvent.composedPath()
155
+ : [e.target];
156
+
157
+ for (const node of path) {
158
+ if (node === e.currentTarget) {
159
+ break;
160
+ }
161
+ if (!(node instanceof Element) || !node.matches(INTERACTIVE_SELECTOR)) {
162
+ continue;
163
+ }
164
+ // A `<marimo-ui-element>` directly wrapping a passive content-wrapper is
165
+ // inert; keep walking to find a real interactive ancestor (if any).
166
+ if (node.localName === "marimo-ui-element") {
167
+ const inner = node.firstElementChild;
168
+ if (inner && CONTENT_WRAPPER_MARIMO_TAGS.has(inner.localName)) {
169
+ continue;
170
+ }
171
+ }
172
+ return true;
141
173
  }
142
- return target.closest(INTERACTIVE_SELECTOR) !== null;
174
+ return false;
143
175
  }