stimeo-ui 0.13.0 → 0.15.0

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -5
  3. data/dist/cable/index.js +60 -14
  4. data/dist/controllers/announcer_controller.js +88 -61
  5. data/dist/controllers/auto_submit_controller.js +40 -5
  6. data/dist/controllers/breadcrumb_controller.js +29 -3
  7. data/dist/controllers/calendar_controller.js +51 -10
  8. data/dist/controllers/carousel_controller.js +8 -6
  9. data/dist/controllers/checkbox_controller.js +27 -2
  10. data/dist/controllers/conditional_fields_controller.js +36 -7
  11. data/dist/controllers/currency_input_controller.js +77 -25
  12. data/dist/controllers/date_range_picker_controller.js +43 -10
  13. data/dist/controllers/direct_upload_controller.js +21 -10
  14. data/dist/controllers/dirty_form_controller.js +32 -12
  15. data/dist/controllers/flash_controller.js +145 -43
  16. data/dist/controllers/form_field_controller.js +7 -1
  17. data/dist/controllers/frame_loading_controller.js +29 -6
  18. data/dist/controllers/highlight_controller.js +63 -23
  19. data/dist/controllers/input_mask_controller.js +33 -12
  20. data/dist/controllers/intersection_controller.js +10 -5
  21. data/dist/controllers/lazy_frame_controller.js +10 -5
  22. data/dist/controllers/local_time_controller.js +43 -11
  23. data/dist/controllers/masonry_controller.js +32 -14
  24. data/dist/controllers/menu_controller.js +10 -2
  25. data/dist/controllers/menubar_controller.js +10 -2
  26. data/dist/controllers/navigation_menu_controller.js +8 -2
  27. data/dist/controllers/optimistic_controller.js +28 -4
  28. data/dist/controllers/otp_controller.js +23 -10
  29. data/dist/controllers/overflow_indicator_controller.js +31 -8
  30. data/dist/controllers/overflow_menu_controller.js +39 -4
  31. data/dist/controllers/pointer_drag_controller.js +10 -3
  32. data/dist/controllers/portal_controller.js +21 -10
  33. data/dist/controllers/radio_group_controller.js +11 -3
  34. data/dist/controllers/read_more_controller.js +31 -5
  35. data/dist/controllers/reading_progress_controller.js +65 -19
  36. data/dist/controllers/relative_time_controller.js +40 -9
  37. data/dist/controllers/reset_before_cache_controller.js +23 -10
  38. data/dist/controllers/resizable_controller.js +7 -2
  39. data/dist/controllers/roving_controller.js +7 -3
  40. data/dist/controllers/scroll_area_controller.js +70 -26
  41. data/dist/controllers/scroll_restore_controller.js +37 -16
  42. data/dist/controllers/scroll_visibility_controller.js +71 -42
  43. data/dist/controllers/scrollspy_controller.js +76 -32
  44. data/dist/controllers/smart_sticky_header_controller.js +72 -37
  45. data/dist/controllers/stick_to_bottom_controller.js +29 -3
  46. data/dist/controllers/sticky_observer_controller.js +10 -5
  47. data/dist/controllers/submit_once_controller.js +29 -6
  48. data/dist/controllers/textarea_autosize_controller.js +29 -3
  49. data/dist/controllers/theme_controller.js +23 -13
  50. data/dist/controllers/toast_controller.js +136 -73
  51. data/dist/controllers/toggle_group_controller.js +11 -3
  52. data/dist/controllers/toolbar_controller.js +7 -3
  53. data/dist/index.js +629 -597
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. metadata +2 -2
@@ -8,10 +8,15 @@ var LayoutObserver = class {
8
8
  #resizeObserverFactory;
9
9
  #resizeObserver = null;
10
10
  #observingViewport = false;
11
+ #loadContainer = null;
11
12
  /** Stable bound handler so add/removeEventListener target the same reference. */
12
13
  #handleViewportResize = () => {
13
14
  this.#callback();
14
15
  };
16
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
+ #handleDescendantLoad = () => {
18
+ this.#callback();
19
+ };
15
20
  constructor(callback, options = {}) {
16
21
  this.#callback = callback;
17
22
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -47,14 +52,35 @@ var LayoutObserver = class {
47
52
  window.removeEventListener("resize", this.#handleViewportResize);
48
53
  }
49
54
  /**
50
- * Releases every observation: disconnects the {@link ResizeObserver} and
51
- * removes the viewport listener. Safe to call multiple times. Call this from a
52
- * controller's `disconnect()`.
55
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
56
+ * frame settling changes the box it sits in, and it measures as zero high until
57
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
58
+ * on the container itself and nothing the caller spells.
59
+ *
60
+ * **One container at a time.** A further call moves the observation, so a widget
61
+ * whose content element is swapped at runtime releases the element it let go by
62
+ * naming the new one — there is no second place for the release to drift from.
63
+ */
64
+ observeDescendantLoads(container) {
65
+ this.unobserveDescendantLoads();
66
+ this.#loadContainer = container;
67
+ container.addEventListener("load", this.#handleDescendantLoad, true);
68
+ }
69
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
70
+ unobserveDescendantLoads() {
71
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
+ this.#loadContainer = null;
73
+ }
74
+ /**
75
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
76
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
77
+ * this from a controller's `disconnect()`.
53
78
  */
54
79
  disconnect() {
55
80
  this.#resizeObserver?.disconnect();
56
81
  this.#resizeObserver = null;
57
82
  this.unobserveViewport();
83
+ this.unobserveDescendantLoads();
58
84
  }
59
85
  };
60
86
 
@@ -127,13 +153,6 @@ var MasonryController = class extends Controller {
127
153
  #mutationObserver = null;
128
154
  /** Last published column count, so `layout` fires only on real changes. */
129
155
  #lastColumns = 0;
130
- /**
131
- * Re-pack when a descendant resource finishes loading. Images/iframes report a
132
- * height of 0 until loaded, which would skew the shortest-column packing if the
133
- * first pass ran before they settled; `load` does not bubble, so this is bound in
134
- * the capture phase to catch every descendant.
135
- */
136
- #onLoad = () => this.#reconcile.schedule();
137
156
  /** Resolves the declared column width once, falling back when it is unreadable. */
138
157
  minColumnWidthValueChanged() {
139
158
  this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
@@ -153,7 +172,7 @@ var MasonryController = class extends Controller {
153
172
  *
154
173
  * The removal is queued rather than immediate because teardown reports every
155
174
  * target as disconnected: doing it here would strip the whole grid just before
156
- * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
175
+ * a Turbo snapshot is taken. The coalescer's `cancel` drops the queue
157
176
  * with the pass, so only a genuine target change reaches it.
158
177
  */
159
178
  itemTargetDisconnected(item) {
@@ -168,18 +187,17 @@ var MasonryController = class extends Controller {
168
187
  this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
169
188
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
170
189
  }
171
- this.element.addEventListener("load", this.#onLoad, true);
190
+ this.#layout.observeDescendantLoads(this.element);
172
191
  this.#relayout();
173
192
  this.#reconcile.activate();
174
193
  }
175
- /** Releases both observers and the load listener so nothing fires after detach. */
194
+ /** Releases every observation so nothing fires after detach. */
176
195
  disconnect() {
177
196
  this.#reconcile.cancel();
178
197
  this.#released.clear();
179
198
  this.#layout.disconnect();
180
199
  this.#mutationObserver?.disconnect();
181
200
  this.#mutationObserver = null;
182
- this.element.removeEventListener("load", this.#onLoad, true);
183
201
  this.#lastColumns = 0;
184
202
  }
185
203
  /**
@@ -98,6 +98,15 @@ var EscapeLayer = class _EscapeLayer {
98
98
  }
99
99
  };
100
100
 
101
+ // src/utils/event_owner.ts
102
+ function ownerIndex(candidates, node) {
103
+ if (!(node instanceof Node)) return -1;
104
+ return candidates.findIndex((candidate) => candidate.contains(node));
105
+ }
106
+ function ownerOf(candidates, node) {
107
+ return candidates[ownerIndex(candidates, node)] ?? null;
108
+ }
109
+
101
110
  // src/utils/safe_timeout.ts
102
111
  var TimerRegistry = class {
103
112
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -338,8 +347,7 @@ var MenuController = class extends Controller {
338
347
  };
339
348
  /** The item target that is, or contains, `node`; `null` when it is neither. */
340
349
  #itemFrom(node) {
341
- if (!(node instanceof Node)) return null;
342
- return this.itemTargets.find((item) => item === node || item.contains(node)) ?? null;
350
+ return ownerOf(this.itemTargets, node);
343
351
  }
344
352
  /**
345
353
  * First look at every click inside the controller, and the only point at which
@@ -103,6 +103,15 @@ var EscapeLayer = class _EscapeLayer {
103
103
  }
104
104
  };
105
105
 
106
+ // src/utils/event_owner.ts
107
+ function ownerIndex(candidates, node) {
108
+ if (!(node instanceof Node)) return -1;
109
+ return candidates.findIndex((candidate) => candidate.contains(node));
110
+ }
111
+ function ownerOf(candidates, node) {
112
+ return candidates[ownerIndex(candidates, node)] ?? null;
113
+ }
114
+
106
115
  // src/utils/roving_tabindex.ts
107
116
  var RovingTabindex = class {
108
117
  /** Returns the current ordered item elements; called on every operation. */
@@ -577,8 +586,7 @@ var MenubarController = class extends Controller {
577
586
  * menu without moving focus.
578
587
  */
579
588
  #dismissOpenMenu() {
580
- const active = document.activeElement;
581
- const menu = this.menuTargets.find((candidate) => candidate.contains(active));
589
+ const menu = ownerOf(this.menuTargets, document.activeElement);
582
590
  if (menu) {
583
591
  const top = this.#topFor(menu);
584
592
  this.#closeMenu(top);
@@ -103,6 +103,12 @@ var EscapeLayer = class _EscapeLayer {
103
103
  }
104
104
  };
105
105
 
106
+ // src/utils/event_owner.ts
107
+ function ownerIndex(candidates, node) {
108
+ if (!(node instanceof Node)) return -1;
109
+ return candidates.findIndex((candidate) => candidate.contains(node));
110
+ }
111
+
106
112
  // src/utils/safe_timeout.ts
107
113
  var TimerRegistry = class {
108
114
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -395,7 +401,7 @@ var NavigationMenuController = class extends Controller {
395
401
  */
396
402
  #onPointerLeave = (event) => {
397
403
  const next = event instanceof MouseEvent ? event.relatedTarget : null;
398
- if (next instanceof Node && this.#hoverElements.some((el) => el.contains(next))) return;
404
+ if (ownerIndex(this.#hoverElements, next) !== -1) return;
399
405
  this.#hoveredTrigger = null;
400
406
  this.#hoverTimers.clearAll();
401
407
  this.#hoverTimers.set(() => this.#closeFromHover(), this.hoverDelayValue);
@@ -465,7 +471,7 @@ var NavigationMenuController = class extends Controller {
465
471
  */
466
472
  get #hoverElements() {
467
473
  const areas = this.hoverAreaTargets;
468
- const covered = (element) => areas.some((area) => area.contains(element));
474
+ const covered = (element) => ownerIndex(areas, element) !== -1;
469
475
  return [
470
476
  ...areas,
471
477
  ...this.triggerTargets.filter((trigger) => !covered(trigger)),
@@ -57,6 +57,30 @@ var DetachGate = class _DetachGate {
57
57
  }
58
58
  };
59
59
 
60
+ // src/utils/listener_set.ts
61
+ var ListenerSet = class {
62
+ /** The generation every `add` joins until the next `dispose()`. */
63
+ #abort = new AbortController();
64
+ /**
65
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
66
+ *
67
+ * The set supplies the signal, so `options` carries everything else the DOM
68
+ * accepts — `capture` included, which has to match at release time and no
69
+ * longer has a second place to drift from.
70
+ */
71
+ add(target, type, handler, options) {
72
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
73
+ }
74
+ /**
75
+ * Releases every listener of the open generation, synchronously, and opens the
76
+ * next one. Idempotent, and safe before anything has been added.
77
+ */
78
+ dispose() {
79
+ this.#abort.abort();
80
+ this.#abort = new AbortController();
81
+ }
82
+ };
83
+
60
84
  // src/controllers/optimistic_controller.ts
61
85
  var HIDDEN_MARKER = "data-optimistic-toggled";
62
86
  var BUSY_MARKER = "data-optimistic-busy";
@@ -73,6 +97,7 @@ var OptimisticController = class extends Controller {
73
97
  /** The submission that owns the applied optimistic state, if any. */
74
98
  #pending = null;
75
99
  #gate = new DetachGate();
100
+ #listeners = new ListenerSet();
76
101
  /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
77
102
  #connected = false;
78
103
  connect() {
@@ -80,13 +105,12 @@ var OptimisticController = class extends Controller {
80
105
  this.#gate.cancel();
81
106
  this.#connected = true;
82
107
  if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
83
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
84
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
108
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
109
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
85
110
  }
86
111
  disconnect() {
87
112
  this.#connected = false;
88
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
89
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
113
+ this.#listeners.dispose();
90
114
  this.#gate.disconnected(this, () => {
91
115
  this.#pending = null;
92
116
  });
@@ -152,6 +152,25 @@ var CompositionTracker = class {
152
152
  };
153
153
  };
154
154
 
155
+ // src/utils/declared_value.ts
156
+ function parseDeclared(raw, parse, fallback) {
157
+ try {
158
+ return parse(raw);
159
+ } catch {
160
+ return fallback;
161
+ }
162
+ }
163
+ function compileRegExp(source, anchor = "none") {
164
+ return parseDeclared(
165
+ source,
166
+ (text) => {
167
+ const bare = new RegExp(text);
168
+ return anchor === "exact" ? new RegExp(`^(?:${text})$`) : bare;
169
+ },
170
+ null
171
+ );
172
+ }
173
+
155
174
  // src/utils/half_width.ts
156
175
  var FULL_WIDTH_SHIFT = 65248;
157
176
  function halfWidthChar(char) {
@@ -201,13 +220,7 @@ var MicrotaskCoalescer = class {
201
220
 
202
221
  // src/controllers/otp_controller.ts
203
222
  var DEFAULT_PATTERN = "[0-9]";
204
- function compilePattern(source) {
205
- try {
206
- return new RegExp(`^${source}$`);
207
- } catch {
208
- return null;
209
- }
210
- }
223
+ var DEFAULT_MATCHER = new RegExp(`^(?:${DEFAULT_PATTERN})$`);
211
224
  function hasModifier(event) {
212
225
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
213
226
  }
@@ -222,7 +235,7 @@ var OtpController = class extends Controller {
222
235
  static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
223
236
  static events = ["change", "complete", "invalid", "reconcile"];
224
237
  /** Validated matcher; the hot path never compiles a raw declaration. */
225
- #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
238
+ #pattern = DEFAULT_MATCHER;
226
239
  /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
227
240
  #patternSource = DEFAULT_PATTERN;
228
241
  /** Public state carried by the last dispatch; keeps a no-op sync silent. */
@@ -295,9 +308,9 @@ var OtpController = class extends Controller {
295
308
  * the new pattern no longer accepts, so the combined value stays interpretable.
296
309
  */
297
310
  patternValueChanged() {
298
- const compiled = compilePattern(this.patternValue);
311
+ const compiled = compileRegExp(this.patternValue, "exact");
299
312
  this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
300
- this.#pattern = compiled ?? new RegExp(`^${DEFAULT_PATTERN}$`);
313
+ this.#pattern = compiled ?? DEFAULT_MATCHER;
301
314
  if (!this.#connected) return;
302
315
  let dropped = false;
303
316
  for (const field of this.fieldTargets) {
@@ -63,10 +63,15 @@ var LayoutObserver = class {
63
63
  #resizeObserverFactory;
64
64
  #resizeObserver = null;
65
65
  #observingViewport = false;
66
+ #loadContainer = null;
66
67
  /** Stable bound handler so add/removeEventListener target the same reference. */
67
68
  #handleViewportResize = () => {
68
69
  this.#callback();
69
70
  };
71
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
72
+ #handleDescendantLoad = () => {
73
+ this.#callback();
74
+ };
70
75
  constructor(callback, options = {}) {
71
76
  this.#callback = callback;
72
77
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -102,14 +107,35 @@ var LayoutObserver = class {
102
107
  window.removeEventListener("resize", this.#handleViewportResize);
103
108
  }
104
109
  /**
105
- * Releases every observation: disconnects the {@link ResizeObserver} and
106
- * removes the viewport listener. Safe to call multiple times. Call this from a
107
- * controller's `disconnect()`.
110
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
111
+ * frame settling changes the box it sits in, and it measures as zero high until
112
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
113
+ * on the container itself and nothing the caller spells.
114
+ *
115
+ * **One container at a time.** A further call moves the observation, so a widget
116
+ * whose content element is swapped at runtime releases the element it let go by
117
+ * naming the new one — there is no second place for the release to drift from.
118
+ */
119
+ observeDescendantLoads(container) {
120
+ this.unobserveDescendantLoads();
121
+ this.#loadContainer = container;
122
+ container.addEventListener("load", this.#handleDescendantLoad, true);
123
+ }
124
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
125
+ unobserveDescendantLoads() {
126
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
127
+ this.#loadContainer = null;
128
+ }
129
+ /**
130
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
131
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
132
+ * this from a controller's `disconnect()`.
108
133
  */
109
134
  disconnect() {
110
135
  this.#resizeObserver?.disconnect();
111
136
  this.#resizeObserver = null;
112
137
  this.unobserveViewport();
138
+ this.unobserveDescendantLoads();
113
139
  }
114
140
  };
115
141
 
@@ -324,7 +350,7 @@ var OverflowIndicatorController = class extends Controller {
324
350
  this.#observedViewport = next;
325
351
  this.#layout.observe(next);
326
352
  this.#layout.observeViewport();
327
- next.addEventListener("load", this.#onContentLoad, true);
353
+ this.#layout.observeDescendantLoads(next);
328
354
  this.#syncContentObservation();
329
355
  if (typeof MutationObserver !== "undefined") {
330
356
  this.#mutationObserver = new MutationObserver(() => {
@@ -362,16 +388,13 @@ var OverflowIndicatorController = class extends Controller {
362
388
  #stopObservingViewport() {
363
389
  this.#mutationObserver?.disconnect();
364
390
  this.#mutationObserver = null;
365
- this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
391
+ this.#layout.unobserveDescendantLoads();
366
392
  if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
367
393
  for (const content of this.#observedContent) this.#layout.unobserve(content);
368
394
  this.#observedContent.clear();
369
395
  this.#observedViewport = null;
370
396
  this.#layout.unobserveViewport();
371
397
  }
372
- #onContentLoad = () => {
373
- if (this.#connected) this.update();
374
- };
375
398
  get #threshold() {
376
399
  const value = this.thresholdValue;
377
400
  return Number.isFinite(value) ? Math.max(0, value) : 1;
@@ -31,6 +31,15 @@ var BeforeCacheReset = class _BeforeCacheReset {
31
31
  }
32
32
  };
33
33
 
34
+ // src/utils/event_owner.ts
35
+ function ownerIndex(candidates, node) {
36
+ if (!(node instanceof Node)) return -1;
37
+ return candidates.findIndex((candidate) => candidate.contains(node));
38
+ }
39
+ function ownerOf(candidates, node) {
40
+ return candidates[ownerIndex(candidates, node)] ?? null;
41
+ }
42
+
34
43
  // src/utils/focus_candidate.ts
35
44
  function inheritsFieldsetDisabled(control) {
36
45
  let fieldset = control.closest("fieldset[disabled]");
@@ -55,10 +64,15 @@ var LayoutObserver = class {
55
64
  #resizeObserverFactory;
56
65
  #resizeObserver = null;
57
66
  #observingViewport = false;
67
+ #loadContainer = null;
58
68
  /** Stable bound handler so add/removeEventListener target the same reference. */
59
69
  #handleViewportResize = () => {
60
70
  this.#callback();
61
71
  };
72
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
73
+ #handleDescendantLoad = () => {
74
+ this.#callback();
75
+ };
62
76
  constructor(callback, options = {}) {
63
77
  this.#callback = callback;
64
78
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -94,14 +108,35 @@ var LayoutObserver = class {
94
108
  window.removeEventListener("resize", this.#handleViewportResize);
95
109
  }
96
110
  /**
97
- * Releases every observation: disconnects the {@link ResizeObserver} and
98
- * removes the viewport listener. Safe to call multiple times. Call this from a
99
- * controller's `disconnect()`.
111
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
112
+ * frame settling changes the box it sits in, and it measures as zero high until
113
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
114
+ * on the container itself and nothing the caller spells.
115
+ *
116
+ * **One container at a time.** A further call moves the observation, so a widget
117
+ * whose content element is swapped at runtime releases the element it let go by
118
+ * naming the new one — there is no second place for the release to drift from.
119
+ */
120
+ observeDescendantLoads(container) {
121
+ this.unobserveDescendantLoads();
122
+ this.#loadContainer = container;
123
+ container.addEventListener("load", this.#handleDescendantLoad, true);
124
+ }
125
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
126
+ unobserveDescendantLoads() {
127
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
128
+ this.#loadContainer = null;
129
+ }
130
+ /**
131
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
132
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
133
+ * this from a controller's `disconnect()`.
100
134
  */
101
135
  disconnect() {
102
136
  this.#resizeObserver?.disconnect();
103
137
  this.#resizeObserver = null;
104
138
  this.unobserveViewport();
139
+ this.unobserveDescendantLoads();
105
140
  }
106
141
  };
107
142
 
@@ -502,7 +537,7 @@ var OverflowMenuController = class extends Controller {
502
537
  }
503
538
  /** The managed item that is, or contains, `el` — focus rescue works on either. */
504
539
  #ownerOf(el) {
505
- return this.#items.find((item) => item === el || item.contains(el)) ?? null;
540
+ return ownerOf(this.#items, el);
506
541
  }
507
542
  /** Whether the item takes up room in the bar; an authored `hidden` one does not. */
508
543
  #rendered(item) {
@@ -63,6 +63,15 @@ var DetachGate = class _DetachGate {
63
63
  }
64
64
  };
65
65
 
66
+ // src/utils/event_owner.ts
67
+ function ownerIndex(candidates, node) {
68
+ if (!(node instanceof Node)) return -1;
69
+ return candidates.findIndex((candidate) => candidate.contains(node));
70
+ }
71
+ function ownerOf(candidates, node) {
72
+ return candidates[ownerIndex(candidates, node)] ?? null;
73
+ }
74
+
66
75
  // src/controllers/pointer_drag_controller.ts
67
76
  var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
68
77
  var PointerDragController = class _PointerDragController extends Controller {
@@ -406,9 +415,7 @@ var PointerDragController = class _PointerDragController extends Controller {
406
415
  }
407
416
  /** Resolves the handle owning an event target (the handle or a descendant). */
408
417
  #handleFor(target) {
409
- const node = target;
410
- if (!node) return null;
411
- return this.#handles().find((handle) => handle === node || handle.contains(node)) ?? null;
418
+ return ownerOf(this.#handles(), target);
412
419
  }
413
420
  #pointerTypeOf(event) {
414
421
  const type = event.pointerType;
@@ -2,6 +2,26 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/portal_controller.ts
4
4
 
5
+ // src/utils/declared_value.ts
6
+ function parseDeclared(raw, parse, fallback) {
7
+ try {
8
+ return parse(raw);
9
+ } catch {
10
+ return fallback;
11
+ }
12
+ }
13
+ function validSelector(element, raw, fallback) {
14
+ if (raw.length === 0) return fallback;
15
+ return parseDeclared(
16
+ raw,
17
+ (selector) => {
18
+ element.matches(selector);
19
+ return selector;
20
+ },
21
+ fallback
22
+ );
23
+ }
24
+
5
25
  // src/utils/detach_gate.ts
6
26
  var DetachGate = class _DetachGate {
7
27
  /** Set while a probe is queued, waiting for a reconnect to cancel it. */
@@ -81,16 +101,7 @@ var PortalController = class extends Controller {
81
101
  * instead, which reads as a mistake and can be traced back to the declaration.
82
102
  */
83
103
  toValueChanged() {
84
- const selector = this.toValue;
85
- if (selector.length > 0) {
86
- try {
87
- this.element.matches(selector);
88
- this.#toSelector = selector;
89
- return;
90
- } catch {
91
- }
92
- }
93
- this.#toSelector = "body";
104
+ this.#toSelector = validSelector(this.element, this.toValue, "body");
94
105
  }
95
106
  connect() {
96
107
  this.#gate.cancel();
@@ -20,6 +20,15 @@ function isReservedArrowChord(event, allow = []) {
20
20
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
21
  }
22
22
 
23
+ // src/utils/event_owner.ts
24
+ function ownerIndex(candidates, node) {
25
+ if (!(node instanceof Node)) return -1;
26
+ return candidates.findIndex((candidate) => candidate.contains(node));
27
+ }
28
+ function ownerOf(candidates, node) {
29
+ return candidates[ownerIndex(candidates, node)] ?? null;
30
+ }
31
+
23
32
  // src/utils/focus_candidate.ts
24
33
  function inheritsFieldsetDisabled(control) {
25
34
  let fieldset = control.closest("fieldset[disabled]");
@@ -479,9 +488,8 @@ var RadioGroupController = class extends Controller {
479
488
  }
480
489
  /** Finds this group's radio containing an event target, excluding nested groups. */
481
490
  #radioForEventTarget(target) {
482
- if (!this.#ownsEventTarget(target)) return void 0;
483
- const node = target;
484
- return this.radioTargets.find((radio) => radio === node || radio.contains(node));
491
+ if (!this.#ownsEventTarget(target)) return null;
492
+ return ownerOf(this.radioTargets, target);
485
493
  }
486
494
  /** Whether the closest Radio Group scope around a target is this instance. */
487
495
  #ownsEventTarget(target) {
@@ -63,10 +63,15 @@ var LayoutObserver = class {
63
63
  #resizeObserverFactory;
64
64
  #resizeObserver = null;
65
65
  #observingViewport = false;
66
+ #loadContainer = null;
66
67
  /** Stable bound handler so add/removeEventListener target the same reference. */
67
68
  #handleViewportResize = () => {
68
69
  this.#callback();
69
70
  };
71
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
72
+ #handleDescendantLoad = () => {
73
+ this.#callback();
74
+ };
70
75
  constructor(callback, options = {}) {
71
76
  this.#callback = callback;
72
77
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -102,14 +107,35 @@ var LayoutObserver = class {
102
107
  window.removeEventListener("resize", this.#handleViewportResize);
103
108
  }
104
109
  /**
105
- * Releases every observation: disconnects the {@link ResizeObserver} and
106
- * removes the viewport listener. Safe to call multiple times. Call this from a
107
- * controller's `disconnect()`.
110
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
111
+ * frame settling changes the box it sits in, and it measures as zero high until
112
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
113
+ * on the container itself and nothing the caller spells.
114
+ *
115
+ * **One container at a time.** A further call moves the observation, so a widget
116
+ * whose content element is swapped at runtime releases the element it let go by
117
+ * naming the new one — there is no second place for the release to drift from.
118
+ */
119
+ observeDescendantLoads(container) {
120
+ this.unobserveDescendantLoads();
121
+ this.#loadContainer = container;
122
+ container.addEventListener("load", this.#handleDescendantLoad, true);
123
+ }
124
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
125
+ unobserveDescendantLoads() {
126
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
127
+ this.#loadContainer = null;
128
+ }
129
+ /**
130
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
131
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
132
+ * this from a controller's `disconnect()`.
108
133
  */
109
134
  disconnect() {
110
135
  this.#resizeObserver?.disconnect();
111
136
  this.#resizeObserver = null;
112
137
  this.unobserveViewport();
138
+ this.unobserveDescendantLoads();
113
139
  }
114
140
  };
115
141
 
@@ -192,7 +218,7 @@ var ReadMoreController = class extends Controller {
192
218
  this.#observedContent = next;
193
219
  this.#layout.observe(next);
194
220
  this.#layout.observeViewport();
195
- next.addEventListener("load", this.#update, true);
221
+ this.#layout.observeDescendantLoads(next);
196
222
  if (typeof MutationObserver !== "undefined") {
197
223
  this.#contentMutationObserver = new MutationObserver(this.#update);
198
224
  this.#contentMutationObserver.observe(next, {
@@ -206,7 +232,7 @@ var ReadMoreController = class extends Controller {
206
232
  this.#deferredHide.releaseAll();
207
233
  if (this.#observedContent) {
208
234
  this.#layout.unobserve(this.#observedContent);
209
- this.#observedContent.removeEventListener("load", this.#update, true);
235
+ this.#layout.unobserveDescendantLoads();
210
236
  }
211
237
  this.#observedContent = null;
212
238
  this.#contentMutationObserver?.disconnect();