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
@@ -57,11 +57,73 @@ var BlurDeferral = class {
57
57
  }
58
58
  };
59
59
 
60
+ // src/utils/declared_value.ts
61
+ function parseDeclared(raw, parse, fallback) {
62
+ try {
63
+ return parse(raw);
64
+ } catch {
65
+ return fallback;
66
+ }
67
+ }
68
+ function validSelector(element, raw, fallback) {
69
+ if (raw.length === 0) return fallback;
70
+ return parseDeclared(
71
+ raw,
72
+ (selector) => {
73
+ element.matches(selector);
74
+ return selector;
75
+ },
76
+ fallback
77
+ );
78
+ }
79
+
80
+ // src/utils/frame_coalescer.ts
81
+ var FrameCoalescer = class {
82
+ #frame = null;
83
+ /**
84
+ * Runs `run` on the next frame, unless a frame is already pending — the first
85
+ * request of a burst wins and the rest are dropped. The pending frame is
86
+ * released before `run`, so `run` may request the next one.
87
+ */
88
+ schedule(run) {
89
+ if (this.#frame !== null) return;
90
+ this.#frame = requestAnimationFrame(() => {
91
+ this.#frame = null;
92
+ run();
93
+ });
94
+ }
95
+ /**
96
+ * Drops the pending frame, and reaches the platform only when there is one.
97
+ *
98
+ * There is no handle value that stands for "nothing pending":
99
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
100
+ * arrives as a large positive number that the same allocator can hand out, and
101
+ * an idle cancel would then drop a frame belonging to someone else.
102
+ */
103
+ cancel() {
104
+ if (this.#frame === null) return;
105
+ cancelAnimationFrame(this.#frame);
106
+ this.#frame = null;
107
+ }
108
+ };
109
+
60
110
  // src/utils/reduced_motion.ts
61
111
  function prefersReducedMotion() {
62
112
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
63
113
  }
64
114
 
115
+ // src/utils/scroll_source.ts
116
+ function resolveScrollContainer(selector) {
117
+ const match = selector ? document.querySelector(selector) : null;
118
+ return match instanceof HTMLElement ? match : null;
119
+ }
120
+ function resolveScrollSource(selector) {
121
+ return resolveScrollContainer(selector) ?? window;
122
+ }
123
+ function scrollOffset(source) {
124
+ return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
125
+ }
126
+
65
127
  // src/utils/before_cache_reset.ts
66
128
  var BeforeCacheReset = class _BeforeCacheReset {
67
129
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -134,8 +196,8 @@ var ScrollVisibilityController = class extends Controller {
134
196
  };
135
197
  static actions = ["toTop"];
136
198
  static events = ["change"];
137
- /** Pending rAF id that coalesces scroll bursts into one measurement. */
138
- #rafId = null;
199
+ /** Coalesces scroll bursts into one measurement per frame. */
200
+ #frames = new FrameCoalescer();
139
201
  /** Previous scroll position, for `direction` mode delta detection. */
140
202
  #lastScrollY = 0;
141
203
  /** Current visibility, tracked to dispatch `change` only on real transitions. */
@@ -171,16 +233,10 @@ var ScrollVisibilityController = class extends Controller {
171
233
  #pendingHide = new BlurDeferral(() => {
172
234
  if (this.#connected) this.#evaluate();
173
235
  });
174
- #onScroll = () => {
175
- if (this.#rafId !== null) return;
176
- this.#rafId = requestAnimationFrame(() => {
177
- this.#rafId = null;
178
- this.#evaluate();
179
- });
180
- };
236
+ #onScroll = () => this.#frames.schedule(() => this.#evaluate());
181
237
  connect() {
182
- this.#scrollSource = this.#resolveScrollSource();
183
- this.#lastScrollY = this.#scrollY();
238
+ this.#scrollSource = resolveScrollSource(this.#rootSelector);
239
+ this.#lastScrollY = scrollOffset(this.#scrollSource);
184
240
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
185
241
  this.#evaluate(false);
186
242
  this.#connected = true;
@@ -188,10 +244,7 @@ var ScrollVisibilityController = class extends Controller {
188
244
  disconnect() {
189
245
  this.#connected = false;
190
246
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
191
- if (this.#rafId !== null) {
192
- cancelAnimationFrame(this.#rafId);
193
- this.#rafId = null;
194
- }
247
+ this.#frames.cancel();
195
248
  this.#pendingHide.releaseAll();
196
249
  this.#tabindex.returnAll();
197
250
  this.#visible = null;
@@ -223,11 +276,11 @@ var ScrollVisibilityController = class extends Controller {
223
276
  }
224
277
  /** Validates `root` once so connect never parses a selector that throws. */
225
278
  rootValueChanged() {
226
- this.#rootSelector = this.#validSelector(this.rootValue);
279
+ this.#rootSelector = validSelector(this.element, this.rootValue, "");
227
280
  }
228
281
  /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
229
282
  focusSelectorValueChanged() {
230
- this.#focusSelector = this.#validSelector(this.focusSelectorValue);
283
+ this.#focusSelector = validSelector(this.element, this.focusSelectorValue, "");
231
284
  }
232
285
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
233
286
  toTop() {
@@ -250,7 +303,7 @@ var ScrollVisibilityController = class extends Controller {
250
303
  * @stimeoRenderRoot
251
304
  */
252
305
  #evaluate(notify = true) {
253
- const y = this.#scrollY();
306
+ const y = scrollOffset(this.#scrollSource);
254
307
  let nextVisible;
255
308
  if (this.modeValue === "direction") {
256
309
  if (y <= this.#offset) {
@@ -280,13 +333,6 @@ var ScrollVisibilityController = class extends Controller {
280
333
  if (notify) this.dispatch("change", { detail: { visible: next } });
281
334
  }
282
335
  /** Resolves the scroll source from `root` (falling back to the window). */
283
- #resolveScrollSource() {
284
- if (this.#rootSelector) {
285
- const root = document.querySelector(this.#rootSelector);
286
- if (root) return root;
287
- }
288
- return window;
289
- }
290
336
  /**
291
337
  * The focus owner inside the target, or `null` when focus is elsewhere.
292
338
  *
@@ -299,23 +345,6 @@ var ScrollVisibilityController = class extends Controller {
299
345
  if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
300
346
  return null;
301
347
  }
302
- /** Returns `declared` when it parses as a selector, and `""` when it does not. */
303
- #validSelector(declared) {
304
- if (declared.length > 0) {
305
- try {
306
- this.element.matches(declared);
307
- return declared;
308
- } catch {
309
- }
310
- }
311
- return "";
312
- }
313
- #scrollY() {
314
- if (this.#scrollSource === window) {
315
- return window.scrollY ?? window.pageYOffset ?? 0;
316
- }
317
- return this.#scrollSource.scrollTop;
318
- }
319
348
  };
320
349
 
321
350
  export { ScrollVisibilityController };
@@ -2,14 +2,60 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/scrollspy_controller.ts
4
4
 
5
- // src/utils/intersection_watcher.ts
6
- function queryRoot(selector) {
7
- if (!selector) return null;
5
+ // src/utils/declared_value.ts
6
+ function parseDeclared(raw, parse, fallback) {
8
7
  try {
9
- return document.querySelector(selector);
8
+ return parse(raw);
10
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
+
25
+ // src/utils/frame_coalescer.ts
26
+ var FrameCoalescer = class {
27
+ #frame = null;
28
+ /**
29
+ * Runs `run` on the next frame, unless a frame is already pending — the first
30
+ * request of a burst wins and the rest are dropped. The pending frame is
31
+ * released before `run`, so `run` may request the next one.
32
+ */
33
+ schedule(run) {
34
+ if (this.#frame !== null) return;
35
+ this.#frame = requestAnimationFrame(() => {
36
+ this.#frame = null;
37
+ run();
38
+ });
39
+ }
40
+ /**
41
+ * Drops the pending frame, and reaches the platform only when there is one.
42
+ *
43
+ * There is no handle value that stands for "nothing pending":
44
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
45
+ * arrives as a large positive number that the same allocator can hand out, and
46
+ * an idle cancel would then drop a frame belonging to someone else.
47
+ */
48
+ cancel() {
49
+ if (this.#frame === null) return;
50
+ cancelAnimationFrame(this.#frame);
51
+ this.#frame = null;
11
52
  }
12
- return null;
53
+ };
54
+
55
+ // src/utils/intersection_watcher.ts
56
+ function queryRoot(selector) {
57
+ if (!selector) return null;
58
+ return parseDeclared(selector, (raw) => document.querySelector(raw), null);
13
59
  }
14
60
  var IntersectionWatcher = class {
15
61
  #onEntries;
@@ -112,6 +158,15 @@ function prefersReducedMotion() {
112
158
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
113
159
  }
114
160
 
161
+ // src/utils/scroll_source.ts
162
+ function resolveScrollContainer(selector) {
163
+ const match = selector ? document.querySelector(selector) : null;
164
+ return match instanceof HTMLElement ? match : null;
165
+ }
166
+ function scrollOffset(source) {
167
+ return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
168
+ }
169
+
115
170
  // src/controllers/scrollspy_controller.ts
116
171
  var ANCHOR_ATTRIBUTES = ["href", "data-href"];
117
172
  var ScrollspyController = class extends Controller {
@@ -158,8 +213,10 @@ var ScrollspyController = class extends Controller {
158
213
  * rather than from whatever the selector resolves to now.
159
214
  */
160
215
  #scrollSource = null;
161
- /** Pending re-evaluation frame; coalesces a scroll burst into one measurement. */
162
- #frame = null;
216
+ /** Coalesces a scroll burst into one measurement per frame. */
217
+ #frames = new FrameCoalescer();
218
+ /** The validated `rootSelector`, or `""` when the declaration cannot be parsed. */
219
+ #rootSelector = "";
163
220
  /** Watches the link targets' anchor attributes for an in-place morph rewrite. */
164
221
  #anchorObserver = null;
165
222
  /** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
@@ -176,10 +233,7 @@ var ScrollspyController = class extends Controller {
176
233
  this.#anchorObserver?.disconnect();
177
234
  this.#anchorObserver = null;
178
235
  this.#detachScrollListener();
179
- if (this.#frame !== null) {
180
- cancelAnimationFrame(this.#frame);
181
- this.#frame = null;
182
- }
236
+ this.#frames.cancel();
183
237
  this.#intersectionStates.clear();
184
238
  this.#activeSectionId = "";
185
239
  this.#rootElement = null;
@@ -196,6 +250,7 @@ var ScrollspyController = class extends Controller {
196
250
  this.#initializeObserver();
197
251
  }
198
252
  rootSelectorValueChanged() {
253
+ this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
199
254
  if (!this.#isConnected) return;
200
255
  this.#initializeObserver();
201
256
  }
@@ -239,7 +294,7 @@ var ScrollspyController = class extends Controller {
239
294
  * only rewrites attributes, so Stimulus fires no target callback: without
240
295
  * this, a link re-pointed from `#intro` to `#faq` would keep the controller
241
296
  * observing `#intro` for the rest of the page's life. The filter is exactly
242
- * {@link ANCHOR_ATTRIBUTES}; guarding on `MutationObserver` keeps the
297
+ * `href` and `data-href`; guarding on `MutationObserver` keeps the
243
298
  * controller usable where the API is absent, matching `IntersectionWatcher`'s
244
299
  * own support guard.
245
300
  */
@@ -289,10 +344,10 @@ var ScrollspyController = class extends Controller {
289
344
  const offset = this.#offset;
290
345
  if (rootElement) {
291
346
  const containerRect = rootElement.getBoundingClientRect();
292
- const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - offset;
347
+ const scrollPosition = scrollOffset(rootElement) + (targetRect.top - containerRect.top) - offset;
293
348
  rootElement.scrollTo({ top: scrollPosition, behavior });
294
349
  } else {
295
- const scrollPosition = window.scrollY + targetRect.top - offset;
350
+ const scrollPosition = scrollOffset(window) + targetRect.top - offset;
296
351
  window.scrollTo({ top: scrollPosition, behavior });
297
352
  }
298
353
  if (this.focusSectionValue) this.#focusSection(targetElement);
@@ -327,19 +382,14 @@ var ScrollspyController = class extends Controller {
327
382
  /**
328
383
  * Resolves `rootSelector` to a scrollable element.
329
384
  *
330
- * @returns The container, or `null` meaning "spy the viewport" when the value
331
- * is empty, matches nothing, matches a non-HTML element (an SVG node is not a
332
- * scroll container), or is not a valid selector — a typo in a data attribute
333
- * must degrade to viewport spying, not leave the controller inert.
385
+ * @returns The container, or `null` meaning "spy the viewport" when the
386
+ * declaration is empty, matches nothing, or matches a non-HTML element. A typo
387
+ * reads as empty: the declaration is validated once when it changes, so a
388
+ * selector that cannot be parsed degrades to viewport spying rather than
389
+ * leaving the controller inert.
334
390
  */
335
391
  #queryRootElement() {
336
- if (!this.rootSelectorValue) return null;
337
- try {
338
- const root = document.querySelector(this.rootSelectorValue);
339
- return root instanceof HTMLElement ? root : null;
340
- } catch {
341
- return null;
342
- }
392
+ return resolveScrollContainer(this.#rootSelector);
343
393
  }
344
394
  /**
345
395
  * Re-evaluates once per frame while the reader scrolls.
@@ -354,13 +404,7 @@ var ScrollspyController = class extends Controller {
354
404
  * end in {@link #evaluateActiveSection} — which measures section rects and the
355
405
  * root's top edge at that instant — they converge on the same answer.
356
406
  */
357
- #onScroll = () => {
358
- if (this.#frame !== null) return;
359
- this.#frame = requestAnimationFrame(() => {
360
- this.#frame = null;
361
- this.#evaluateActiveSection();
362
- });
363
- };
407
+ #onScroll = () => this.#frames.schedule(() => this.#evaluateActiveSection());
364
408
  /**
365
409
  * Points the `scroll` listener at whatever the reader actually scrolls: the
366
410
  * resolved root, or the window when the viewport is spied.
@@ -1,5 +1,69 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/smart_sticky_header_controller.ts
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
+
25
+ // src/utils/frame_coalescer.ts
26
+ var FrameCoalescer = class {
27
+ #frame = null;
28
+ /**
29
+ * Runs `run` on the next frame, unless a frame is already pending — the first
30
+ * request of a burst wins and the rest are dropped. The pending frame is
31
+ * released before `run`, so `run` may request the next one.
32
+ */
33
+ schedule(run) {
34
+ if (this.#frame !== null) return;
35
+ this.#frame = requestAnimationFrame(() => {
36
+ this.#frame = null;
37
+ run();
38
+ });
39
+ }
40
+ /**
41
+ * Drops the pending frame, and reaches the platform only when there is one.
42
+ *
43
+ * There is no handle value that stands for "nothing pending":
44
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
45
+ * arrives as a large positive number that the same allocator can hand out, and
46
+ * an idle cancel would then drop a frame belonging to someone else.
47
+ */
48
+ cancel() {
49
+ if (this.#frame === null) return;
50
+ cancelAnimationFrame(this.#frame);
51
+ this.#frame = null;
52
+ }
53
+ };
54
+
55
+ // src/utils/scroll_source.ts
56
+ function resolveScrollContainer(selector) {
57
+ const match = selector ? document.querySelector(selector) : null;
58
+ return match instanceof HTMLElement ? match : null;
59
+ }
60
+ function resolveScrollSource(selector) {
61
+ return resolveScrollContainer(selector) ?? window;
62
+ }
63
+ function scrollOffset(source) {
64
+ return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
65
+ }
66
+
3
67
  // src/controllers/smart_sticky_header_controller.ts
4
68
  var DEFAULT_OFFSET = 80;
5
69
  var SmartStickyHeaderController = class extends Controller {
@@ -10,7 +74,8 @@ var SmartStickyHeaderController = class extends Controller {
10
74
  };
11
75
  static events = ["change"];
12
76
  #connected = false;
13
- #frame = null;
77
+ /** Coalesces scroll bursts into one measurement per frame. */
78
+ #frames = new FrameCoalescer();
14
79
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
15
80
  #scrollerEl = window;
16
81
  /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
@@ -27,13 +92,7 @@ var SmartStickyHeaderController = class extends Controller {
27
92
  get #offset() {
28
93
  return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
29
94
  }
30
- #onScroll = () => {
31
- if (this.#frame !== null) return;
32
- this.#frame = requestAnimationFrame(() => {
33
- this.#frame = null;
34
- this.#measure();
35
- });
36
- };
95
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
37
96
  /**
38
97
  * Focus inside a hidden header must reveal it (WCAG 2.4.7 / 2.4.11); the
39
98
  * hold while focus *stays* inside is the `#apply` hide invariant.
@@ -41,7 +100,7 @@ var SmartStickyHeaderController = class extends Controller {
41
100
  #onFocusin = () => this.#apply(false);
42
101
  /** Validates `containerSelector` once so connect never parses a selector that throws. */
43
102
  containerSelectorValueChanged() {
44
- this.#containerSelector = this.#validSelector(this.containerSelectorValue);
103
+ this.#containerSelector = validSelector(this.element, this.containerSelectorValue, "");
45
104
  }
46
105
  /** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
47
106
  offsetValueChanged() {
@@ -49,8 +108,8 @@ var SmartStickyHeaderController = class extends Controller {
49
108
  }
50
109
  connect() {
51
110
  this.#hidden = null;
52
- this.#scrollerEl = this.#resolveScroller();
53
- this.#lastY = this.#scrollY;
111
+ this.#scrollerEl = resolveScrollSource(this.#containerSelector);
112
+ this.#lastY = scrollOffset(this.#scrollerEl);
54
113
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
55
114
  this.element.addEventListener("focusin", this.#onFocusin);
56
115
  this.#apply(false, false);
@@ -60,34 +119,10 @@ var SmartStickyHeaderController = class extends Controller {
60
119
  this.#connected = false;
61
120
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
62
121
  this.element.removeEventListener("focusin", this.#onFocusin);
63
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
64
- this.#frame = null;
65
- }
66
- /** Resolves the scroll source: the `containerSelector` match, else the window. */
67
- #resolveScroller() {
68
- if (this.#containerSelector) {
69
- const container = document.querySelector(this.#containerSelector);
70
- if (container) return container;
71
- }
72
- return window;
73
- }
74
- /** Returns `declared` when it parses as a selector, and `""` when it does not. */
75
- #validSelector(declared) {
76
- if (declared.length > 0) {
77
- try {
78
- this.element.matches(declared);
79
- return declared;
80
- } catch {
81
- }
82
- }
83
- return "";
84
- }
85
- get #scrollY() {
86
- const scroller = this.#scrollerEl;
87
- return scroller === window ? window.scrollY : scroller.scrollTop;
122
+ this.#frames.cancel();
88
123
  }
89
124
  #measure() {
90
- const y = this.#scrollY;
125
+ const y = scrollOffset(this.#scrollerEl);
91
126
  if (y <= this.#offset) {
92
127
  this.#lastY = y;
93
128
  this.#apply(false);
@@ -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
 
@@ -2,6 +2,15 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/sticky_observer_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
+
5
14
  // src/utils/intersection_watcher.ts
6
15
  function isBeforeRootStart(entry) {
7
16
  const rect = entry.boundingClientRect;
@@ -11,11 +20,7 @@ function isBeforeRootStart(entry) {
11
20
  }
12
21
  function queryRoot(selector) {
13
22
  if (!selector) return null;
14
- try {
15
- return document.querySelector(selector);
16
- } catch {
17
- }
18
- return null;
23
+ return parseDeclared(selector, (raw) => document.querySelector(raw), null);
19
24
  }
20
25
  var IntersectionWatcher = class {
21
26
  #onEntries;
@@ -138,6 +138,30 @@ var DetachGate = class _DetachGate {
138
138
  }
139
139
  };
140
140
 
141
+ // src/utils/listener_set.ts
142
+ var ListenerSet = class {
143
+ /** The generation every `add` joins until the next `dispose()`. */
144
+ #abort = new AbortController();
145
+ /**
146
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
147
+ *
148
+ * The set supplies the signal, so `options` carries everything else the DOM
149
+ * accepts — `capture` included, which has to match at release time and no
150
+ * longer has a second place to drift from.
151
+ */
152
+ add(target, type, handler, options) {
153
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
154
+ }
155
+ /**
156
+ * Releases every listener of the open generation, synchronously, and opens the
157
+ * next one. Idempotent, and safe before anything has been added.
158
+ */
159
+ dispose() {
160
+ this.#abort.abort();
161
+ this.#abort = new AbortController();
162
+ }
163
+ };
164
+
141
165
  // src/utils/safe_timeout.ts
142
166
  var TimerRegistry = class {
143
167
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -206,6 +230,7 @@ var SubmitOnceController = class extends Controller {
206
230
  static events = ["start", "end", "reconcile"];
207
231
  #timers = new SafeTimeout();
208
232
  #gate = new DetachGate();
233
+ #listeners = new ListenerSet();
209
234
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
210
235
  #sessions = /* @__PURE__ */ new Map();
211
236
  #disabled = new AttributeLease("disabled");
@@ -230,14 +255,12 @@ var SubmitOnceController = class extends Controller {
230
255
  connect() {
231
256
  this.#gate.cancel();
232
257
  this.#beforeCache.activate();
233
- this.element.addEventListener("submit", this.#onNativeSubmit, true);
234
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
235
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
258
+ this.#listeners.add(this.element, "submit", this.#onNativeSubmit, { capture: true });
259
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
260
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
236
261
  }
237
262
  disconnect() {
238
- this.element.removeEventListener("submit", this.#onNativeSubmit, true);
239
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
240
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
263
+ this.#listeners.dispose();
241
264
  this.#beforeCache.deactivate();
242
265
  this.#gate.disconnected(this, () => this.#teardown());
243
266
  }