stimeo-ui 0.14.0 → 0.16.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -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;
@@ -468,11 +518,15 @@ var ScrollAreaController = class extends Controller {
468
518
  this.#overflowing = false;
469
519
  this.#lastEdge = null;
470
520
  }
471
- /** Runs the full structural and positional measurement pass. */
521
+ /**
522
+ * Runs the full structural and positional measurement pass.
523
+ *
524
+ * @stimeoRenderRoot
525
+ */
472
526
  #refresh() {
473
527
  const viewport = this.#viewport;
474
528
  if (!viewport) return;
475
- this.#cancelScrollFrame();
529
+ this.#frames.cancel();
476
530
  this.#syncNameSources(viewport);
477
531
  this.#overflowing = this.#syncOverflow(viewport);
478
532
  this.#syncKeyboardReach(viewport, this.#overflowing);
@@ -680,16 +734,10 @@ var ScrollAreaController = class extends Controller {
680
734
  this.#fonts?.removeEventListener("loadingerror", this.#onFontsSettled);
681
735
  this.#fonts = null;
682
736
  }
683
- /** Cancels a pending scroll frame. */
684
- #cancelScrollFrame() {
685
- if (this.#scrollFrame === null) return;
686
- cancelAnimationFrame(this.#scrollFrame);
687
- this.#scrollFrame = null;
688
- }
689
737
  /** Suspends live resources and returns all derived state before snapshotting. */
690
738
  #rewindForCache() {
691
739
  this.#rebind.cancel();
692
- this.#cancelScrollFrame();
740
+ this.#frames.cancel();
693
741
  if (this.#viewport) this.#unbindViewport(this.#viewport);
694
742
  this.#layout.disconnect();
695
743
  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) {
@@ -299,14 +332,6 @@ var ScrollVisibilityController = class extends Controller {
299
332
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
300
333
  if (notify) this.dispatch("change", { detail: { visible: next } });
301
334
  }
302
- /** 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
335
  /**
311
336
  * The focus owner inside the target, or `null` when focus is elsewhere.
312
337
  *
@@ -319,12 +344,6 @@ var ScrollVisibilityController = class extends Controller {
319
344
  if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
320
345
  return null;
321
346
  }
322
- #scrollY() {
323
- if (this.#scrollSource === window) {
324
- return window.scrollY ?? window.pageYOffset ?? 0;
325
- }
326
- return this.#scrollSource.scrollTop;
327
- }
328
347
  };
329
348
 
330
349
  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.
@@ -383,6 +422,10 @@ var ScrollspyController = class extends Controller {
383
422
  this.#scrollSource?.removeEventListener("scroll", this.#onScroll);
384
423
  this.#scrollSource = null;
385
424
  }
425
+ /**
426
+ * @stimeoRuntimeOnly `rootMargin` wires the observer this call installs; the active section it
427
+ * republishes is the one already recorded.
428
+ */
386
429
  #initializeObserver() {
387
430
  this.#watcher.stop();
388
431
  this.#intersectionStates.clear();
@@ -428,6 +471,8 @@ var ScrollspyController = class extends Controller {
428
471
  * threshold) against a freshly computed trigger line mixes two moments in
429
472
  * time, which would make the result depend on how the reader arrived at a
430
473
  * position — a smooth scroll and an instant jump to the same offset disagree.
474
+ *
475
+ * @stimeoRenderRoot
431
476
  */
432
477
  #evaluateActiveSection() {
433
478
  const rootEl = this.#scrollRoot();