stimeo-ui 0.14.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  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 +20 -3
  8. data/dist/controllers/checkbox_controller.js +27 -2
  9. data/dist/controllers/conditional_fields_controller.js +29 -6
  10. data/dist/controllers/currency_input_controller.js +16 -9
  11. data/dist/controllers/date_range_picker_controller.js +12 -3
  12. data/dist/controllers/dirty_form_controller.js +32 -12
  13. data/dist/controllers/flash_controller.js +145 -43
  14. data/dist/controllers/frame_loading_controller.js +29 -6
  15. data/dist/controllers/highlight_controller.js +63 -23
  16. data/dist/controllers/local_time_controller.js +10 -5
  17. data/dist/controllers/masonry_controller.js +31 -13
  18. data/dist/controllers/optimistic_controller.js +28 -4
  19. data/dist/controllers/overflow_indicator_controller.js +31 -8
  20. data/dist/controllers/overflow_menu_controller.js +29 -3
  21. data/dist/controllers/read_more_controller.js +31 -5
  22. data/dist/controllers/reading_progress_controller.js +65 -19
  23. data/dist/controllers/relative_time_controller.js +10 -5
  24. data/dist/controllers/scroll_area_controller.js +70 -26
  25. data/dist/controllers/scroll_restore_controller.js +37 -16
  26. data/dist/controllers/scroll_visibility_controller.js +49 -29
  27. data/dist/controllers/scrollspy_controller.js +65 -26
  28. data/dist/controllers/smart_sticky_header_controller.js +49 -25
  29. data/dist/controllers/stick_to_bottom_controller.js +29 -3
  30. data/dist/controllers/submit_once_controller.js +29 -6
  31. data/dist/controllers/textarea_autosize_controller.js +29 -3
  32. data/dist/controllers/toast_controller.js +136 -73
  33. data/dist/index.js +473 -435
  34. data/lib/stimeo/ui/version.rb +1 -1
  35. metadata +2 -2
@@ -2,11 +2,6 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/highlight_controller.ts
4
4
 
5
- // src/utils/reduced_motion.ts
6
- function prefersReducedMotion() {
7
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
- }
9
-
10
5
  // src/utils/safe_timeout.ts
11
6
  var TimerRegistry = class {
12
7
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -60,6 +55,54 @@ var SafeTimeout = class extends TimerRegistry {
60
55
  }
61
56
  };
62
57
 
58
+ // src/utils/keyed_timers.ts
59
+ var KeyedTimers = class {
60
+ #timers = new SafeTimeout();
61
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
62
+ #pending = /* @__PURE__ */ new Map();
63
+ /**
64
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
65
+ * pending. The entry is dropped before the callback runs, so the callback sees
66
+ * the key unarmed and may arm it again for the next round.
67
+ */
68
+ set(key, callback, delay) {
69
+ this.clear(key);
70
+ const id = this.#timers.set(() => {
71
+ this.#pending.delete(key);
72
+ callback();
73
+ }, delay);
74
+ this.#pending.set(key, id);
75
+ }
76
+ /**
77
+ * Cancels `key`'s pending timer, if it has one.
78
+ *
79
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
80
+ * the registry ignores an id it does not own — the unarmed case needs no
81
+ * branch of its own, and no other key's timer can be reached from here.
82
+ */
83
+ clear(key) {
84
+ this.#timers.clear(this.#pending.get(key) ?? -1);
85
+ this.#pending.delete(key);
86
+ }
87
+ /**
88
+ * Cancels every pending timer and forgets every key. Call this from a
89
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
90
+ */
91
+ clearAll() {
92
+ this.#timers.clearAll();
93
+ this.#pending.clear();
94
+ }
95
+ /** Whether `key` has a timer pending. */
96
+ has(key) {
97
+ return this.#pending.has(key);
98
+ }
99
+ };
100
+
101
+ // src/utils/reduced_motion.ts
102
+ function prefersReducedMotion() {
103
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
104
+ }
105
+
63
106
  // src/controllers/highlight_controller.ts
64
107
  var hookOwners = /* @__PURE__ */ new WeakMap();
65
108
  var HighlightController = class extends Controller {
@@ -68,14 +111,11 @@ var HighlightController = class extends Controller {
68
111
  observe: { type: Boolean, default: false }
69
112
  };
70
113
  static events = ["start", "end"];
71
- #timeouts = new SafeTimeout();
72
114
  /**
73
- * The removal timer this connection has outstanding for an element. Held weakly so
74
- * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
75
- * so a cleared id can never be matched against a recycled one. Which connection owns
76
- * an element's hook is answered by the shared owner registry above.
115
+ * The removal timer this connection has outstanding for each element. Which
116
+ * connection owns an element's hook is answered by the shared owner registry above.
77
117
  */
78
- #pending = /* @__PURE__ */ new WeakMap();
118
+ #removal = new KeyedTimers();
79
119
  #observer = null;
80
120
  connect() {
81
121
  this.#clearArrivedHook(this.element);
@@ -92,8 +132,7 @@ var HighlightController = class extends Controller {
92
132
  disconnect() {
93
133
  this.#observer?.disconnect();
94
134
  this.#observer = null;
95
- this.#timeouts.clearAll();
96
- this.#pending = /* @__PURE__ */ new WeakMap();
135
+ this.#removal.clearAll();
97
136
  }
98
137
  /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
99
138
  #clearArrivedHook(el) {
@@ -114,13 +153,15 @@ var HighlightController = class extends Controller {
114
153
  this.#releasePending(el);
115
154
  el.setAttribute("data-highlight", "true");
116
155
  this.dispatch("start", { target: el, detail: { element: el } });
117
- const id = this.#timeouts.set(() => {
118
- this.#pending.delete(el);
119
- hookOwners.delete(el);
120
- el.removeAttribute("data-highlight");
121
- this.dispatch("end", { target: el, detail: { element: el } });
122
- }, this.durationValue);
123
- this.#pending.set(el, id);
156
+ this.#removal.set(
157
+ el,
158
+ () => {
159
+ hookOwners.delete(el);
160
+ el.removeAttribute("data-highlight");
161
+ this.dispatch("end", { target: el, detail: { element: el } });
162
+ },
163
+ this.durationValue
164
+ );
124
165
  hookOwners.set(el, this);
125
166
  }
126
167
  /**
@@ -133,10 +174,9 @@ var HighlightController = class extends Controller {
133
174
  if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
134
175
  this.#cancelPending(el);
135
176
  }
136
- /** Releases `el`'s pending removal timer, if it has one. */
177
+ /** Releases `el`'s pending removal timer, if it has one, and this connection's claim. */
137
178
  #cancelPending(el) {
138
- this.#timeouts.clear(this.#pending.get(el) ?? -1);
139
- this.#pending.delete(el);
179
+ this.#removal.clear(el);
140
180
  hookOwners.delete(el);
141
181
  }
142
182
  };
@@ -31,6 +31,11 @@ function construct(factory, locale, options, fallbackLocale) {
31
31
  }
32
32
  }
33
33
 
34
+ // src/utils/locale.ts
35
+ function resolveLocale(element, declared = "") {
36
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
37
+ }
38
+
34
39
  // src/utils/microtask_coalescer.ts
35
40
  var MicrotaskCoalescer = class {
36
41
  #run;
@@ -190,13 +195,13 @@ var LocalTimeController = class extends Controller {
190
195
  };
191
196
  if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
192
197
  if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
193
- const formatter = intlFormatter(Intl.DateTimeFormat, this.#locale, options);
198
+ const formatter = intlFormatter(
199
+ Intl.DateTimeFormat,
200
+ resolveLocale(this.element, this.localeValue),
201
+ options
202
+ );
194
203
  return formatter === null ? null : formatter.format(date);
195
204
  }
196
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
197
- get #locale() {
198
- return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
199
- }
200
205
  };
201
206
 
202
207
  export { LocalTimeController };
@@ -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);
@@ -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
  /**
@@ -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
  });
@@ -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;
@@ -64,10 +64,15 @@ var LayoutObserver = class {
64
64
  #resizeObserverFactory;
65
65
  #resizeObserver = null;
66
66
  #observingViewport = false;
67
+ #loadContainer = null;
67
68
  /** Stable bound handler so add/removeEventListener target the same reference. */
68
69
  #handleViewportResize = () => {
69
70
  this.#callback();
70
71
  };
72
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
73
+ #handleDescendantLoad = () => {
74
+ this.#callback();
75
+ };
71
76
  constructor(callback, options = {}) {
72
77
  this.#callback = callback;
73
78
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -103,14 +108,35 @@ var LayoutObserver = class {
103
108
  window.removeEventListener("resize", this.#handleViewportResize);
104
109
  }
105
110
  /**
106
- * Releases every observation: disconnects the {@link ResizeObserver} and
107
- * removes the viewport listener. Safe to call multiple times. Call this from a
108
- * 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()`.
109
134
  */
110
135
  disconnect() {
111
136
  this.#resizeObserver?.disconnect();
112
137
  this.#resizeObserver = null;
113
138
  this.unobserveViewport();
139
+ this.unobserveDescendantLoads();
114
140
  }
115
141
  };
116
142
 
@@ -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();
@@ -31,16 +31,51 @@ var BeforeCacheReset = class _BeforeCacheReset {
31
31
  }
32
32
  };
33
33
 
34
+ // src/utils/frame_coalescer.ts
35
+ var FrameCoalescer = class {
36
+ #frame = null;
37
+ /**
38
+ * Runs `run` on the next frame, unless a frame is already pending — the first
39
+ * request of a burst wins and the rest are dropped. The pending frame is
40
+ * released before `run`, so `run` may request the next one.
41
+ */
42
+ schedule(run) {
43
+ if (this.#frame !== null) return;
44
+ this.#frame = requestAnimationFrame(() => {
45
+ this.#frame = null;
46
+ run();
47
+ });
48
+ }
49
+ /**
50
+ * Drops the pending frame, and reaches the platform only when there is one.
51
+ *
52
+ * There is no handle value that stands for "nothing pending":
53
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
54
+ * arrives as a large positive number that the same allocator can hand out, and
55
+ * an idle cancel would then drop a frame belonging to someone else.
56
+ */
57
+ cancel() {
58
+ if (this.#frame === null) return;
59
+ cancelAnimationFrame(this.#frame);
60
+ this.#frame = null;
61
+ }
62
+ };
63
+
34
64
  // src/utils/layout_observer.ts
35
65
  var LayoutObserver = class {
36
66
  #callback;
37
67
  #resizeObserverFactory;
38
68
  #resizeObserver = null;
39
69
  #observingViewport = false;
70
+ #loadContainer = null;
40
71
  /** Stable bound handler so add/removeEventListener target the same reference. */
41
72
  #handleViewportResize = () => {
42
73
  this.#callback();
43
74
  };
75
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
76
+ #handleDescendantLoad = () => {
77
+ this.#callback();
78
+ };
44
79
  constructor(callback, options = {}) {
45
80
  this.#callback = callback;
46
81
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -76,14 +111,35 @@ var LayoutObserver = class {
76
111
  window.removeEventListener("resize", this.#handleViewportResize);
77
112
  }
78
113
  /**
79
- * Releases every observation: disconnects the {@link ResizeObserver} and
80
- * removes the viewport listener. Safe to call multiple times. Call this from a
81
- * controller's `disconnect()`.
114
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
115
+ * frame settling changes the box it sits in, and it measures as zero high until
116
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
117
+ * on the container itself and nothing the caller spells.
118
+ *
119
+ * **One container at a time.** A further call moves the observation, so a widget
120
+ * whose content element is swapped at runtime releases the element it let go by
121
+ * naming the new one — there is no second place for the release to drift from.
122
+ */
123
+ observeDescendantLoads(container) {
124
+ this.unobserveDescendantLoads();
125
+ this.#loadContainer = container;
126
+ container.addEventListener("load", this.#handleDescendantLoad, true);
127
+ }
128
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
129
+ unobserveDescendantLoads() {
130
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
131
+ this.#loadContainer = null;
132
+ }
133
+ /**
134
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
135
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
136
+ * this from a controller's `disconnect()`.
82
137
  */
83
138
  disconnect() {
84
139
  this.#resizeObserver?.disconnect();
85
140
  this.#resizeObserver = null;
86
141
  this.unobserveViewport();
142
+ this.unobserveDescendantLoads();
87
143
  }
88
144
  };
89
145
 
@@ -148,18 +204,13 @@ var ReadingProgressController = class extends Controller {
148
204
  /** The article's own box and the viewport: either changes the span. */
149
205
  #layout = new LayoutObserver(() => this.#onScroll());
150
206
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
151
- #frame = null;
207
+ /** Coalesces scroll bursts, and the connect baseline, into one frame. */
208
+ #frames = new FrameCoalescer();
152
209
  /** Last published progress, so `change`/`complete` fire only on movement. */
153
210
  #progress = -1;
154
211
  /** False until the connect frame has run: `complete` needs real reading. */
155
212
  #baselined = false;
156
- #onScroll = () => {
157
- if (this.#frame !== null) return;
158
- this.#frame = requestAnimationFrame(() => {
159
- this.#frame = null;
160
- this.#measure();
161
- });
162
- };
213
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
163
214
  connect() {
164
215
  this.#progress = -1;
165
216
  this.#baselined = false;
@@ -168,8 +219,7 @@ var ReadingProgressController = class extends Controller {
168
219
  this.#layout.observeViewport();
169
220
  this.#beforeCache.activate();
170
221
  this.#measure();
171
- this.#frame = requestAnimationFrame(() => {
172
- this.#frame = null;
222
+ this.#frames.schedule(() => {
173
223
  this.#measure();
174
224
  this.#baselined = true;
175
225
  });
@@ -178,7 +228,7 @@ var ReadingProgressController = class extends Controller {
178
228
  window.removeEventListener("scroll", this.#onScroll, { capture: true });
179
229
  this.#layout.disconnect();
180
230
  this.#beforeCache.deactivate();
181
- this.#cancelFrame();
231
+ this.#frames.cancel();
182
232
  this.#lease.returnAll();
183
233
  }
184
234
  /**
@@ -189,14 +239,10 @@ var ReadingProgressController = class extends Controller {
189
239
  * value that has already been handed back.
190
240
  */
191
241
  #rewindForCache() {
192
- this.#cancelFrame();
242
+ this.#frames.cancel();
193
243
  this.#lease.returnAll();
194
244
  this.#progress = -1;
195
245
  }
196
- #cancelFrame() {
197
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
198
- this.#frame = null;
199
- }
200
246
  /** Computes and publishes the progress; emits on movement only. */
201
247
  #measure() {
202
248
  const rect = this.element.getBoundingClientRect();