stimeo-ui 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -5
  3. data/dist/cable/index.js +60 -14
  4. data/dist/controllers/announcer_controller.js +88 -61
  5. data/dist/controllers/auto_submit_controller.js +40 -5
  6. data/dist/controllers/breadcrumb_controller.js +29 -3
  7. data/dist/controllers/calendar_controller.js +51 -10
  8. data/dist/controllers/carousel_controller.js +8 -6
  9. data/dist/controllers/checkbox_controller.js +27 -2
  10. data/dist/controllers/conditional_fields_controller.js +36 -7
  11. data/dist/controllers/currency_input_controller.js +77 -25
  12. data/dist/controllers/date_range_picker_controller.js +43 -10
  13. data/dist/controllers/direct_upload_controller.js +21 -10
  14. data/dist/controllers/dirty_form_controller.js +32 -12
  15. data/dist/controllers/flash_controller.js +145 -43
  16. data/dist/controllers/form_field_controller.js +7 -1
  17. data/dist/controllers/frame_loading_controller.js +29 -6
  18. data/dist/controllers/highlight_controller.js +63 -23
  19. data/dist/controllers/input_mask_controller.js +33 -12
  20. data/dist/controllers/intersection_controller.js +10 -5
  21. data/dist/controllers/lazy_frame_controller.js +10 -5
  22. data/dist/controllers/local_time_controller.js +43 -11
  23. data/dist/controllers/masonry_controller.js +32 -14
  24. data/dist/controllers/menu_controller.js +10 -2
  25. data/dist/controllers/menubar_controller.js +10 -2
  26. data/dist/controllers/navigation_menu_controller.js +8 -2
  27. data/dist/controllers/optimistic_controller.js +28 -4
  28. data/dist/controllers/otp_controller.js +23 -10
  29. data/dist/controllers/overflow_indicator_controller.js +31 -8
  30. data/dist/controllers/overflow_menu_controller.js +39 -4
  31. data/dist/controllers/pointer_drag_controller.js +10 -3
  32. data/dist/controllers/portal_controller.js +21 -10
  33. data/dist/controllers/radio_group_controller.js +11 -3
  34. data/dist/controllers/read_more_controller.js +31 -5
  35. data/dist/controllers/reading_progress_controller.js +65 -19
  36. data/dist/controllers/relative_time_controller.js +40 -9
  37. data/dist/controllers/reset_before_cache_controller.js +23 -10
  38. data/dist/controllers/resizable_controller.js +7 -2
  39. data/dist/controllers/roving_controller.js +7 -3
  40. data/dist/controllers/scroll_area_controller.js +70 -26
  41. data/dist/controllers/scroll_restore_controller.js +37 -16
  42. data/dist/controllers/scroll_visibility_controller.js +71 -42
  43. data/dist/controllers/scrollspy_controller.js +76 -32
  44. data/dist/controllers/smart_sticky_header_controller.js +72 -37
  45. data/dist/controllers/stick_to_bottom_controller.js +29 -3
  46. data/dist/controllers/sticky_observer_controller.js +10 -5
  47. data/dist/controllers/submit_once_controller.js +29 -6
  48. data/dist/controllers/textarea_autosize_controller.js +29 -3
  49. data/dist/controllers/theme_controller.js +23 -13
  50. data/dist/controllers/toast_controller.js +136 -73
  51. data/dist/controllers/toggle_group_controller.js +11 -3
  52. data/dist/controllers/toolbar_controller.js +7 -3
  53. data/dist/index.js +629 -597
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. metadata +2 -2
@@ -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();
@@ -31,6 +31,40 @@ var BeforeCacheReset = class _BeforeCacheReset {
31
31
  }
32
32
  };
33
33
 
34
+ // src/utils/intl_format.ts
35
+ var formatters = /* @__PURE__ */ new WeakMap();
36
+ function intlFormatter(factory, locale, options, fallbackLocale) {
37
+ const cache = cacheFor(factory);
38
+ const key = cacheKey(locale, options, fallbackLocale);
39
+ if (cache.has(key)) return cache.get(key);
40
+ const formatter = construct(factory, locale, options);
41
+ cache.set(key, formatter);
42
+ return formatter;
43
+ }
44
+ function cacheFor(factory) {
45
+ const existing = formatters.get(factory);
46
+ if (existing) return existing;
47
+ const created = /* @__PURE__ */ new Map();
48
+ formatters.set(factory, created);
49
+ return created;
50
+ }
51
+ function cacheKey(locale, options, fallbackLocale) {
52
+ return JSON.stringify([locale, options, fallbackLocale]);
53
+ }
54
+ function construct(factory, locale, options, fallbackLocale) {
55
+ try {
56
+ return new factory(locale, options);
57
+ } catch (error) {
58
+ if (!(error instanceof RangeError)) throw error;
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // src/utils/locale.ts
64
+ function resolveLocale(element, declared = "") {
65
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
66
+ }
67
+
34
68
  // src/utils/microtask_coalescer.ts
35
69
  var MicrotaskCoalescer = class {
36
70
  #run;
@@ -128,6 +162,7 @@ var UNITS = [
128
162
  { limit: 31557600, unit: "month", ms: 26298e5 },
129
163
  YEAR_SCALE
130
164
  ];
165
+ var RELATIVE_OPTIONS = { numeric: "auto" };
131
166
  var RelativeTimeController = class extends Controller {
132
167
  static values = {
133
168
  locale: { type: String, default: "" },
@@ -235,15 +270,11 @@ var RelativeTimeController = class extends Controller {
235
270
  * when the runtime rejects that locale.
236
271
  */
237
272
  get #formatter() {
238
- try {
239
- return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
240
- } catch {
241
- return null;
242
- }
243
- }
244
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
245
- get #locale() {
246
- 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
+ );
247
278
  }
248
279
  };
249
280
 
@@ -1,5 +1,27 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/reset_before_cache_controller.ts
4
+
5
+ // src/utils/declared_value.ts
6
+ function parseDeclared(raw, parse, fallback) {
7
+ try {
8
+ return parse(raw);
9
+ } catch {
10
+ return fallback;
11
+ }
12
+ }
13
+ function validSelector(element, raw, fallback) {
14
+ if (raw.length === 0) return fallback;
15
+ return parseDeclared(
16
+ raw,
17
+ (selector) => {
18
+ element.matches(selector);
19
+ return selector;
20
+ },
21
+ fallback
22
+ );
23
+ }
24
+
3
25
  // src/controllers/reset_before_cache_controller.ts
4
26
  var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
5
27
  function restoreField(element) {
@@ -43,16 +65,7 @@ var ResetBeforeCacheController = class extends Controller {
43
65
  * silently taking the sweep down.
44
66
  */
45
67
  scopeValueChanged() {
46
- const selector = this.scopeValue;
47
- if (selector.length > 0) {
48
- try {
49
- this.element.matches(selector);
50
- this.#scopeSelector = selector;
51
- return;
52
- } catch {
53
- }
54
- }
55
- this.#scopeSelector = "";
68
+ this.#scopeSelector = validSelector(this.element, this.scopeValue, "");
56
69
  }
57
70
  connect() {
58
71
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
@@ -8,6 +8,12 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/event_owner.ts
12
+ function ownerIndex(candidates, node) {
13
+ if (!(node instanceof Node)) return -1;
14
+ return candidates.findIndex((candidate) => candidate.contains(node));
15
+ }
16
+
11
17
  // src/utils/microtask_coalescer.ts
12
18
  var MicrotaskCoalescer = class {
13
19
  #run;
@@ -154,8 +160,7 @@ var ResizableController = class extends Controller {
154
160
  const panes = [];
155
161
  if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
156
162
  if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
157
- const target = event.target;
158
- const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
163
+ const current = ownerIndex(panes, event.target);
159
164
  const next = panes[(current + 1) % panes.length];
160
165
  if (!next) return;
161
166
  event.preventDefault();
@@ -16,6 +16,12 @@ function hasModifierChord(event) {
16
16
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
17
17
  }
18
18
 
19
+ // src/utils/event_owner.ts
20
+ function ownerIndex(candidates, node) {
21
+ if (!(node instanceof Node)) return -1;
22
+ return candidates.findIndex((candidate) => candidate.contains(node));
23
+ }
24
+
19
25
  // src/utils/focus_candidate.ts
20
26
  function inheritsFieldsetDisabled(control) {
21
27
  let fieldset = control.closest("fieldset[disabled]");
@@ -182,9 +188,7 @@ var RovingController = class extends Controller {
182
188
  };
183
189
  /** Resolves the item index owning an event target (the item or a descendant). */
184
190
  #indexOf(target) {
185
- const node = target;
186
- if (!node) return -1;
187
- return this.itemTargets.findIndex((item) => item === node || item.contains(node));
191
+ return ownerIndex(this.itemTargets, target);
188
192
  }
189
193
  /** Makes `index` the tab stop (optionally focusing it), emitting `change` once. */
190
194
  #activate(index, focus) {
@@ -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
  *