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
@@ -60,6 +60,11 @@ function construct(factory, locale, options, fallbackLocale) {
60
60
  }
61
61
  }
62
62
 
63
+ // src/utils/locale.ts
64
+ function resolveLocale(element, declared = "") {
65
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
66
+ }
67
+
63
68
  // src/utils/microtask_coalescer.ts
64
69
  var MicrotaskCoalescer = class {
65
70
  #run;
@@ -265,11 +270,11 @@ var RelativeTimeController = class extends Controller {
265
270
  * when the runtime rejects that locale.
266
271
  */
267
272
  get #formatter() {
268
- return intlFormatter(Intl.RelativeTimeFormat, this.#locale, RELATIVE_OPTIONS);
269
- }
270
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
271
- get #locale() {
272
- return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
273
+ return intlFormatter(
274
+ Intl.RelativeTimeFormat,
275
+ resolveLocale(this.element, this.localeValue),
276
+ RELATIVE_OPTIONS
277
+ );
273
278
  }
274
279
  };
275
280
 
@@ -150,16 +150,51 @@ function hasTabStop(root) {
150
150
  return firstTabStop(root) !== null;
151
151
  }
152
152
 
153
+ // src/utils/frame_coalescer.ts
154
+ var FrameCoalescer = class {
155
+ #frame = null;
156
+ /**
157
+ * Runs `run` on the next frame, unless a frame is already pending — the first
158
+ * request of a burst wins and the rest are dropped. The pending frame is
159
+ * released before `run`, so `run` may request the next one.
160
+ */
161
+ schedule(run) {
162
+ if (this.#frame !== null) return;
163
+ this.#frame = requestAnimationFrame(() => {
164
+ this.#frame = null;
165
+ run();
166
+ });
167
+ }
168
+ /**
169
+ * Drops the pending frame, and reaches the platform only when there is one.
170
+ *
171
+ * There is no handle value that stands for "nothing pending":
172
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
173
+ * arrives as a large positive number that the same allocator can hand out, and
174
+ * an idle cancel would then drop a frame belonging to someone else.
175
+ */
176
+ cancel() {
177
+ if (this.#frame === null) return;
178
+ cancelAnimationFrame(this.#frame);
179
+ this.#frame = null;
180
+ }
181
+ };
182
+
153
183
  // src/utils/layout_observer.ts
154
184
  var LayoutObserver = class {
155
185
  #callback;
156
186
  #resizeObserverFactory;
157
187
  #resizeObserver = null;
158
188
  #observingViewport = false;
189
+ #loadContainer = null;
159
190
  /** Stable bound handler so add/removeEventListener target the same reference. */
160
191
  #handleViewportResize = () => {
161
192
  this.#callback();
162
193
  };
194
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
195
+ #handleDescendantLoad = () => {
196
+ this.#callback();
197
+ };
163
198
  constructor(callback, options = {}) {
164
199
  this.#callback = callback;
165
200
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -195,14 +230,35 @@ var LayoutObserver = class {
195
230
  window.removeEventListener("resize", this.#handleViewportResize);
196
231
  }
197
232
  /**
198
- * Releases every observation: disconnects the {@link ResizeObserver} and
199
- * removes the viewport listener. Safe to call multiple times. Call this from a
200
- * controller's `disconnect()`.
233
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
234
+ * frame settling changes the box it sits in, and it measures as zero high until
235
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
236
+ * on the container itself and nothing the caller spells.
237
+ *
238
+ * **One container at a time.** A further call moves the observation, so a widget
239
+ * whose content element is swapped at runtime releases the element it let go by
240
+ * naming the new one — there is no second place for the release to drift from.
241
+ */
242
+ observeDescendantLoads(container) {
243
+ this.unobserveDescendantLoads();
244
+ this.#loadContainer = container;
245
+ container.addEventListener("load", this.#handleDescendantLoad, true);
246
+ }
247
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
248
+ unobserveDescendantLoads() {
249
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
250
+ this.#loadContainer = null;
251
+ }
252
+ /**
253
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
254
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
255
+ * this from a controller's `disconnect()`.
201
256
  */
202
257
  disconnect() {
203
258
  this.#resizeObserver?.disconnect();
204
259
  this.#resizeObserver = null;
205
260
  this.unobserveViewport();
261
+ this.unobserveDescendantLoads();
206
262
  }
207
263
  };
208
264
 
@@ -359,20 +415,14 @@ var ScrollAreaController = class extends Controller {
359
415
  #observedNameIds = /* @__PURE__ */ new Set();
360
416
  #observedNameSources = [];
361
417
  #fonts = null;
362
- #scrollFrame = null;
418
+ /** Coalesces scroll bursts into one position sync per frame. */
419
+ #frames = new FrameCoalescer();
363
420
  #overflowing = false;
364
421
  #lastEdge = null;
365
422
  #ownedHostMutations = /* @__PURE__ */ new Map();
366
- #onScroll = () => {
367
- if (this.#scrollFrame !== null) return;
368
- this.#scrollFrame = requestAnimationFrame(() => {
369
- this.#scrollFrame = null;
370
- const viewport = this.#viewport;
371
- if (viewport) this.#syncPosition(viewport, this.#overflowing);
372
- });
373
- };
374
- #onLoad = () => {
375
- this.#refresh();
423
+ #onScroll = (event) => {
424
+ const viewport = event.currentTarget;
425
+ this.#frames.schedule(() => this.#syncPosition(viewport, this.#overflowing));
376
426
  };
377
427
  #onFontsSettled = () => {
378
428
  this.#refresh();
@@ -387,7 +437,7 @@ var ScrollAreaController = class extends Controller {
387
437
  disconnect() {
388
438
  this.#beforeCache.deactivate();
389
439
  this.#rebind.cancel();
390
- this.#cancelScrollFrame();
440
+ this.#frames.cancel();
391
441
  if (this.#viewport) this.#unbindViewport(this.#viewport);
392
442
  this.#layout.disconnect();
393
443
  this.#unbindFonts();
@@ -420,7 +470,7 @@ var ScrollAreaController = class extends Controller {
420
470
  return;
421
471
  }
422
472
  next.addEventListener("scroll", this.#onScroll, { passive: true });
423
- next.addEventListener("load", this.#onLoad, true);
473
+ this.#layout.observeDescendantLoads(next);
424
474
  this.#layout.observe(next);
425
475
  this.#bindContentObserver(next);
426
476
  this.#refresh();
@@ -455,9 +505,9 @@ var ScrollAreaController = class extends Controller {
455
505
  }
456
506
  /** Releases every resource and borrowed attribute owned by one former viewport. */
457
507
  #unbindViewport(viewport) {
458
- this.#cancelScrollFrame();
508
+ this.#frames.cancel();
459
509
  viewport.removeEventListener("scroll", this.#onScroll);
460
- viewport.removeEventListener("load", this.#onLoad, true);
510
+ this.#layout.unobserveDescendantLoads();
461
511
  this.#layout.unobserve(viewport);
462
512
  this.#content?.disconnect();
463
513
  this.#content = null;
@@ -472,7 +522,7 @@ var ScrollAreaController = class extends Controller {
472
522
  #refresh() {
473
523
  const viewport = this.#viewport;
474
524
  if (!viewport) return;
475
- this.#cancelScrollFrame();
525
+ this.#frames.cancel();
476
526
  this.#syncNameSources(viewport);
477
527
  this.#overflowing = this.#syncOverflow(viewport);
478
528
  this.#syncKeyboardReach(viewport, this.#overflowing);
@@ -680,16 +730,10 @@ var ScrollAreaController = class extends Controller {
680
730
  this.#fonts?.removeEventListener("loadingerror", this.#onFontsSettled);
681
731
  this.#fonts = null;
682
732
  }
683
- /** Cancels a pending scroll frame. */
684
- #cancelScrollFrame() {
685
- if (this.#scrollFrame === null) return;
686
- cancelAnimationFrame(this.#scrollFrame);
687
- this.#scrollFrame = null;
688
- }
689
733
  /** Suspends live resources and returns all derived state before snapshotting. */
690
734
  #rewindForCache() {
691
735
  this.#rebind.cancel();
692
- this.#cancelScrollFrame();
736
+ this.#frames.cancel();
693
737
  if (this.#viewport) this.#unbindViewport(this.#viewport);
694
738
  this.#layout.disconnect();
695
739
  this.#unbindFonts();
@@ -1,5 +1,37 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/scroll_restore_controller.ts
4
+
5
+ // src/utils/frame_coalescer.ts
6
+ var FrameCoalescer = class {
7
+ #frame = null;
8
+ /**
9
+ * Runs `run` on the next frame, unless a frame is already pending — the first
10
+ * request of a burst wins and the rest are dropped. The pending frame is
11
+ * released before `run`, so `run` may request the next one.
12
+ */
13
+ schedule(run) {
14
+ if (this.#frame !== null) return;
15
+ this.#frame = requestAnimationFrame(() => {
16
+ this.#frame = null;
17
+ run();
18
+ });
19
+ }
20
+ /**
21
+ * Drops the pending frame, and reaches the platform only when there is one.
22
+ *
23
+ * There is no handle value that stands for "nothing pending":
24
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
25
+ * arrives as a large positive number that the same allocator can hand out, and
26
+ * an idle cancel would then drop a frame belonging to someone else.
27
+ */
28
+ cancel() {
29
+ if (this.#frame === null) return;
30
+ cancelAnimationFrame(this.#frame);
31
+ this.#frame = null;
32
+ }
33
+ };
34
+
3
35
  // src/controllers/scroll_restore_controller.ts
4
36
  function parseStored(raw) {
5
37
  if (raw === null) return null;
@@ -17,8 +49,8 @@ var ScrollRestoreController = class extends Controller {
17
49
  key: { type: String, default: "" },
18
50
  axis: { type: String, default: "vertical" }
19
51
  };
20
- /** Pending rAF id that coalesces scroll bursts into one save. */
21
- #rafId = null;
52
+ /** Coalesces scroll bursts into one save per frame. */
53
+ #frames = new FrameCoalescer();
22
54
  /** Resolved storage key; empty disables persistence (no key and no id). */
23
55
  #storageKey = "";
24
56
  /** Last offset captured while the element was live; persisted as-is on teardown. */
@@ -46,11 +78,7 @@ var ScrollRestoreController = class extends Controller {
46
78
  #connected = false;
47
79
  #onScroll = () => {
48
80
  this.#capture();
49
- if (this.#rafId !== null) return;
50
- this.#rafId = requestAnimationFrame(() => {
51
- this.#rafId = null;
52
- this.#persist();
53
- });
81
+ this.#frames.schedule(() => this.#persist());
54
82
  };
55
83
  connect() {
56
84
  this.#connected = true;
@@ -61,7 +89,7 @@ var ScrollRestoreController = class extends Controller {
61
89
  disconnect() {
62
90
  this.#connected = false;
63
91
  this.element.removeEventListener("scroll", this.#onScroll);
64
- this.#cancelFrame();
92
+ this.#frames.cancel();
65
93
  this.#persist();
66
94
  }
67
95
  /** Re-derives the namespace when application code or a Turbo morph moves `key`. */
@@ -75,7 +103,7 @@ var ScrollRestoreController = class extends Controller {
75
103
  /** Rebuilds the persistence state around the Values as they now read. */
76
104
  #resync() {
77
105
  if (!this.#connected) return;
78
- this.#cancelFrame();
106
+ this.#frames.cancel();
79
107
  this.#storageKey = this.#resolveKey();
80
108
  this.#echoTop = null;
81
109
  this.#echoLeft = null;
@@ -84,13 +112,6 @@ var ScrollRestoreController = class extends Controller {
84
112
  this.#capturedLeft = false;
85
113
  if (this.#storageKey) this.#restore();
86
114
  }
87
- /** Drops the pending coalesced save, if one is queued. */
88
- #cancelFrame() {
89
- if (this.#rafId !== null) {
90
- cancelAnimationFrame(this.#rafId);
91
- this.#rafId = null;
92
- }
93
- }
94
115
  /**
95
116
  * Records the live scroll offset for the configured axis.
96
117
  *
@@ -77,11 +77,53 @@ function validSelector(element, raw, fallback) {
77
77
  );
78
78
  }
79
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
+
80
110
  // src/utils/reduced_motion.ts
81
111
  function prefersReducedMotion() {
82
112
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
83
113
  }
84
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
+
85
127
  // src/utils/before_cache_reset.ts
86
128
  var BeforeCacheReset = class _BeforeCacheReset {
87
129
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -154,8 +196,8 @@ var ScrollVisibilityController = class extends Controller {
154
196
  };
155
197
  static actions = ["toTop"];
156
198
  static events = ["change"];
157
- /** Pending rAF id that coalesces scroll bursts into one measurement. */
158
- #rafId = null;
199
+ /** Coalesces scroll bursts into one measurement per frame. */
200
+ #frames = new FrameCoalescer();
159
201
  /** Previous scroll position, for `direction` mode delta detection. */
160
202
  #lastScrollY = 0;
161
203
  /** Current visibility, tracked to dispatch `change` only on real transitions. */
@@ -191,16 +233,10 @@ var ScrollVisibilityController = class extends Controller {
191
233
  #pendingHide = new BlurDeferral(() => {
192
234
  if (this.#connected) this.#evaluate();
193
235
  });
194
- #onScroll = () => {
195
- if (this.#rafId !== null) return;
196
- this.#rafId = requestAnimationFrame(() => {
197
- this.#rafId = null;
198
- this.#evaluate();
199
- });
200
- };
236
+ #onScroll = () => this.#frames.schedule(() => this.#evaluate());
201
237
  connect() {
202
- this.#scrollSource = this.#resolveScrollSource();
203
- this.#lastScrollY = this.#scrollY();
238
+ this.#scrollSource = resolveScrollSource(this.#rootSelector);
239
+ this.#lastScrollY = scrollOffset(this.#scrollSource);
204
240
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
205
241
  this.#evaluate(false);
206
242
  this.#connected = true;
@@ -208,10 +244,7 @@ var ScrollVisibilityController = class extends Controller {
208
244
  disconnect() {
209
245
  this.#connected = false;
210
246
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
211
- if (this.#rafId !== null) {
212
- cancelAnimationFrame(this.#rafId);
213
- this.#rafId = null;
214
- }
247
+ this.#frames.cancel();
215
248
  this.#pendingHide.releaseAll();
216
249
  this.#tabindex.returnAll();
217
250
  this.#visible = null;
@@ -270,7 +303,7 @@ var ScrollVisibilityController = class extends Controller {
270
303
  * @stimeoRenderRoot
271
304
  */
272
305
  #evaluate(notify = true) {
273
- const y = this.#scrollY();
306
+ const y = scrollOffset(this.#scrollSource);
274
307
  let nextVisible;
275
308
  if (this.modeValue === "direction") {
276
309
  if (y <= this.#offset) {
@@ -300,13 +333,6 @@ var ScrollVisibilityController = class extends Controller {
300
333
  if (notify) this.dispatch("change", { detail: { visible: next } });
301
334
  }
302
335
  /** Resolves the scroll source from `root` (falling back to the window). */
303
- #resolveScrollSource() {
304
- if (this.#rootSelector) {
305
- const root = document.querySelector(this.#rootSelector);
306
- if (root) return root;
307
- }
308
- return window;
309
- }
310
336
  /**
311
337
  * The focus owner inside the target, or `null` when focus is elsewhere.
312
338
  *
@@ -319,12 +345,6 @@ var ScrollVisibilityController = class extends Controller {
319
345
  if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
320
346
  return null;
321
347
  }
322
- #scrollY() {
323
- if (this.#scrollSource === window) {
324
- return window.scrollY ?? window.pageYOffset ?? 0;
325
- }
326
- return this.#scrollSource.scrollTop;
327
- }
328
348
  };
329
349
 
330
350
  export { ScrollVisibilityController };
@@ -10,6 +10,47 @@ function parseDeclared(raw, parse, fallback) {
10
10
  return fallback;
11
11
  }
12
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
+ };
13
54
 
14
55
  // src/utils/intersection_watcher.ts
15
56
  function queryRoot(selector) {
@@ -117,6 +158,15 @@ function prefersReducedMotion() {
117
158
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
118
159
  }
119
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
+
120
170
  // src/controllers/scrollspy_controller.ts
121
171
  var ANCHOR_ATTRIBUTES = ["href", "data-href"];
122
172
  var ScrollspyController = class extends Controller {
@@ -163,8 +213,10 @@ var ScrollspyController = class extends Controller {
163
213
  * rather than from whatever the selector resolves to now.
164
214
  */
165
215
  #scrollSource = null;
166
- /** Pending re-evaluation frame; coalesces a scroll burst into one measurement. */
167
- #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 = "";
168
220
  /** Watches the link targets' anchor attributes for an in-place morph rewrite. */
169
221
  #anchorObserver = null;
170
222
  /** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
@@ -181,10 +233,7 @@ var ScrollspyController = class extends Controller {
181
233
  this.#anchorObserver?.disconnect();
182
234
  this.#anchorObserver = null;
183
235
  this.#detachScrollListener();
184
- if (this.#frame !== null) {
185
- cancelAnimationFrame(this.#frame);
186
- this.#frame = null;
187
- }
236
+ this.#frames.cancel();
188
237
  this.#intersectionStates.clear();
189
238
  this.#activeSectionId = "";
190
239
  this.#rootElement = null;
@@ -201,6 +250,7 @@ var ScrollspyController = class extends Controller {
201
250
  this.#initializeObserver();
202
251
  }
203
252
  rootSelectorValueChanged() {
253
+ this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
204
254
  if (!this.#isConnected) return;
205
255
  this.#initializeObserver();
206
256
  }
@@ -294,10 +344,10 @@ var ScrollspyController = class extends Controller {
294
344
  const offset = this.#offset;
295
345
  if (rootElement) {
296
346
  const containerRect = rootElement.getBoundingClientRect();
297
- const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - offset;
347
+ const scrollPosition = scrollOffset(rootElement) + (targetRect.top - containerRect.top) - offset;
298
348
  rootElement.scrollTo({ top: scrollPosition, behavior });
299
349
  } else {
300
- const scrollPosition = window.scrollY + targetRect.top - offset;
350
+ const scrollPosition = scrollOffset(window) + targetRect.top - offset;
301
351
  window.scrollTo({ top: scrollPosition, behavior });
302
352
  }
303
353
  if (this.focusSectionValue) this.#focusSection(targetElement);
@@ -332,19 +382,14 @@ var ScrollspyController = class extends Controller {
332
382
  /**
333
383
  * Resolves `rootSelector` to a scrollable element.
334
384
  *
335
- * @returns The container, or `null` meaning "spy the viewport" when the value
336
- * is empty, matches nothing, matches a non-HTML element (an SVG node is not a
337
- * scroll container), or is not a valid selector — a typo in a data attribute
338
- * 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.
339
390
  */
340
391
  #queryRootElement() {
341
- if (!this.rootSelectorValue) return null;
342
- try {
343
- const root = document.querySelector(this.rootSelectorValue);
344
- return root instanceof HTMLElement ? root : null;
345
- } catch {
346
- return null;
347
- }
392
+ return resolveScrollContainer(this.#rootSelector);
348
393
  }
349
394
  /**
350
395
  * Re-evaluates once per frame while the reader scrolls.
@@ -359,13 +404,7 @@ var ScrollspyController = class extends Controller {
359
404
  * end in {@link #evaluateActiveSection} — which measures section rects and the
360
405
  * root's top edge at that instant — they converge on the same answer.
361
406
  */
362
- #onScroll = () => {
363
- if (this.#frame !== null) return;
364
- this.#frame = requestAnimationFrame(() => {
365
- this.#frame = null;
366
- this.#evaluateActiveSection();
367
- });
368
- };
407
+ #onScroll = () => this.#frames.schedule(() => this.#evaluateActiveSection());
369
408
  /**
370
409
  * Points the `scroll` listener at whatever the reader actually scrolls: the
371
410
  * resolved root, or the window when the viewport is spied.
@@ -22,6 +22,48 @@ function validSelector(element, raw, fallback) {
22
22
  );
23
23
  }
24
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
+
25
67
  // src/controllers/smart_sticky_header_controller.ts
26
68
  var DEFAULT_OFFSET = 80;
27
69
  var SmartStickyHeaderController = class extends Controller {
@@ -32,7 +74,8 @@ var SmartStickyHeaderController = class extends Controller {
32
74
  };
33
75
  static events = ["change"];
34
76
  #connected = false;
35
- #frame = null;
77
+ /** Coalesces scroll bursts into one measurement per frame. */
78
+ #frames = new FrameCoalescer();
36
79
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
37
80
  #scrollerEl = window;
38
81
  /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
@@ -49,13 +92,7 @@ var SmartStickyHeaderController = class extends Controller {
49
92
  get #offset() {
50
93
  return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
51
94
  }
52
- #onScroll = () => {
53
- if (this.#frame !== null) return;
54
- this.#frame = requestAnimationFrame(() => {
55
- this.#frame = null;
56
- this.#measure();
57
- });
58
- };
95
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
59
96
  /**
60
97
  * Focus inside a hidden header must reveal it (WCAG 2.4.7 / 2.4.11); the
61
98
  * hold while focus *stays* inside is the `#apply` hide invariant.
@@ -71,8 +108,8 @@ var SmartStickyHeaderController = class extends Controller {
71
108
  }
72
109
  connect() {
73
110
  this.#hidden = null;
74
- this.#scrollerEl = this.#resolveScroller();
75
- this.#lastY = this.#scrollY;
111
+ this.#scrollerEl = resolveScrollSource(this.#containerSelector);
112
+ this.#lastY = scrollOffset(this.#scrollerEl);
76
113
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
77
114
  this.element.addEventListener("focusin", this.#onFocusin);
78
115
  this.#apply(false, false);
@@ -82,23 +119,10 @@ var SmartStickyHeaderController = class extends Controller {
82
119
  this.#connected = false;
83
120
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
84
121
  this.element.removeEventListener("focusin", this.#onFocusin);
85
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
86
- this.#frame = null;
87
- }
88
- /** Resolves the scroll source: the `containerSelector` match, else the window. */
89
- #resolveScroller() {
90
- if (this.#containerSelector) {
91
- const container = document.querySelector(this.#containerSelector);
92
- if (container) return container;
93
- }
94
- return window;
95
- }
96
- get #scrollY() {
97
- const scroller = this.#scrollerEl;
98
- return scroller === window ? window.scrollY : scroller.scrollTop;
122
+ this.#frames.cancel();
99
123
  }
100
124
  #measure() {
101
- const y = this.#scrollY;
125
+ const y = scrollOffset(this.#scrollerEl);
102
126
  if (y <= this.#offset) {
103
127
  this.#lastY = y;
104
128
  this.#apply(false);