stimeo-ui 0.16.0 → 0.18.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +27 -11
  4. data/dist/cable/index.js +384 -449
  5. data/dist/controllers/accordion_controller.js +174 -55
  6. data/dist/controllers/alert_dialog_controller.js +903 -235
  7. data/dist/controllers/announcer_controller.js +238 -205
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +414 -145
  10. data/dist/controllers/avatar_controller.js +185 -107
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +282 -58
  13. data/dist/controllers/calendar_controller.js +228 -260
  14. data/dist/controllers/carousel_controller.js +536 -436
  15. data/dist/controllers/character_counter_controller.js +493 -207
  16. data/dist/controllers/checkbox_controller.js +38 -74
  17. data/dist/controllers/clipboard_controller.js +261 -149
  18. data/dist/controllers/collapsible_controller.js +304 -120
  19. data/dist/controllers/color_picker_controller.js +178 -146
  20. data/dist/controllers/combobox_controller.js +522 -156
  21. data/dist/controllers/command_palette_controller.js +1156 -395
  22. data/dist/controllers/conditional_fields_controller.js +300 -171
  23. data/dist/controllers/confirm_controller.js +980 -262
  24. data/dist/controllers/context_menu_controller.js +346 -153
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +281 -158
  27. data/dist/controllers/currency_input_controller.js +575 -225
  28. data/dist/controllers/data_grid_controller.js +248 -214
  29. data/dist/controllers/date_range_picker_controller.js +338 -235
  30. data/dist/controllers/dialog_controller.js +899 -237
  31. data/dist/controllers/direct_upload_controller.js +97 -167
  32. data/dist/controllers/dirty_form_controller.js +72 -69
  33. data/dist/controllers/dismissible_controller.js +196 -24
  34. data/dist/controllers/drawer_controller.js +974 -355
  35. data/dist/controllers/dropdown_controller.js +339 -96
  36. data/dist/controllers/editable_controller.js +315 -88
  37. data/dist/controllers/empty_state_controller.js +79 -58
  38. data/dist/controllers/file_dropzone_controller.js +322 -211
  39. data/dist/controllers/filter_controller.js +77 -42
  40. data/dist/controllers/flash_controller.js +238 -347
  41. data/dist/controllers/focus_controller.js +679 -240
  42. data/dist/controllers/form_field_controller.js +156 -132
  43. data/dist/controllers/form_validation_controller.js +31 -97
  44. data/dist/controllers/frame_loading_controller.js +275 -217
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +327 -130
  47. data/dist/controllers/idle_controller.js +271 -101
  48. data/dist/controllers/input_mask_controller.js +483 -97
  49. data/dist/controllers/intersection_controller.js +150 -106
  50. data/dist/controllers/lazy_frame_controller.js +24 -68
  51. data/dist/controllers/listbox_controller.js +523 -240
  52. data/dist/controllers/local_time_controller.js +102 -77
  53. data/dist/controllers/masonry_controller.js +80 -96
  54. data/dist/controllers/menu_controller.js +302 -188
  55. data/dist/controllers/menubar_controller.js +232 -345
  56. data/dist/controllers/meter_controller.js +417 -87
  57. data/dist/controllers/multi_select_controller.js +785 -393
  58. data/dist/controllers/navigation_menu_controller.js +190 -250
  59. data/dist/controllers/nested_form_controller.js +396 -192
  60. data/dist/controllers/network_status_controller.js +268 -62
  61. data/dist/controllers/number_input_controller.js +610 -303
  62. data/dist/controllers/optimistic_controller.js +45 -87
  63. data/dist/controllers/otp_controller.js +658 -255
  64. data/dist/controllers/overflow_indicator_controller.js +122 -113
  65. data/dist/controllers/overflow_menu_controller.js +470 -295
  66. data/dist/controllers/pagination_controller.js +332 -123
  67. data/dist/controllers/password_reveal_controller.js +392 -121
  68. data/dist/controllers/password_strength_controller.js +111 -158
  69. data/dist/controllers/persist_controller.js +165 -111
  70. data/dist/controllers/pointer_drag_controller.js +434 -188
  71. data/dist/controllers/popover_controller.js +269 -113
  72. data/dist/controllers/portal_controller.js +215 -76
  73. data/dist/controllers/preview_guard_controller.js +243 -116
  74. data/dist/controllers/progress_controller.js +391 -78
  75. data/dist/controllers/radio_group_controller.js +278 -211
  76. data/dist/controllers/range_slider_controller.js +220 -113
  77. data/dist/controllers/rating_controller.js +458 -211
  78. data/dist/controllers/read_more_controller.js +138 -63
  79. data/dist/controllers/reading_progress_controller.js +166 -137
  80. data/dist/controllers/relative_time_controller.js +211 -112
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  82. data/dist/controllers/resizable_controller.js +299 -150
  83. data/dist/controllers/roving_controller.js +49 -88
  84. data/dist/controllers/scroll_area_controller.js +490 -246
  85. data/dist/controllers/scroll_restore_controller.js +14 -55
  86. data/dist/controllers/scroll_visibility_controller.js +432 -149
  87. data/dist/controllers/scrollspy_controller.js +193 -248
  88. data/dist/controllers/separator_controller.js +285 -142
  89. data/dist/controllers/sidebar_controller.js +1069 -404
  90. data/dist/controllers/skeleton_controller.js +259 -101
  91. data/dist/controllers/slider_controller.js +187 -86
  92. data/dist/controllers/smart_sticky_header_controller.js +173 -66
  93. data/dist/controllers/sortable_controller.js +26 -125
  94. data/dist/controllers/spinner_controller.js +288 -173
  95. data/dist/controllers/step_indicator_controller.js +105 -46
  96. data/dist/controllers/stepper_controller.js +120 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +275 -140
  98. data/dist/controllers/sticky_observer_controller.js +220 -49
  99. data/dist/controllers/submit_once_controller.js +376 -227
  100. data/dist/controllers/switch_controller.js +201 -84
  101. data/dist/controllers/tabs_controller.js +41 -30
  102. data/dist/controllers/tags_input_controller.js +293 -173
  103. data/dist/controllers/textarea_autosize_controller.js +108 -74
  104. data/dist/controllers/theme_controller.js +140 -116
  105. data/dist/controllers/time_picker_controller.js +331 -127
  106. data/dist/controllers/toast_controller.js +244 -324
  107. data/dist/controllers/toggle_group_controller.js +252 -191
  108. data/dist/controllers/toolbar_controller.js +219 -120
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +283 -369
  112. data/dist/index.js +8573 -10410
  113. data/dist/positioning/index.js +154 -47
  114. data/lib/generators/stimeo/install/install_generator.rb +7 -2
  115. data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
  116. data/lib/stimeo/ui/version.rb +1 -1
  117. metadata +3 -3
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/intersection_controller.ts
4
-
5
- // src/utils/declared_value.ts
6
3
  function parseDeclared(raw, parse, fallback) {
7
4
  try {
8
5
  return parse(raw);
@@ -10,8 +7,18 @@ function parseDeclared(raw, parse, fallback) {
10
7
  return fallback;
11
8
  }
12
9
  }
10
+ function validSelector(element, raw, fallback) {
11
+ if (raw.length === 0) return fallback;
12
+ return parseDeclared(
13
+ raw,
14
+ (selector) => {
15
+ element.matches(selector);
16
+ return selector;
17
+ },
18
+ fallback
19
+ );
20
+ }
13
21
 
14
- // src/utils/intersection_watcher.ts
15
22
  function isBeforeRootStart(entry) {
16
23
  const rect = entry.boundingClientRect;
17
24
  if (rect.width === 0 && rect.height === 0) return false;
@@ -30,30 +37,12 @@ var IntersectionWatcher = class {
30
37
  constructor(onEntries) {
31
38
  this.#onEntries = onEntries;
32
39
  }
33
- /** Whether an observer is live (started, `IntersectionObserver` supported). */
34
40
  get active() {
35
41
  return this.#active;
36
42
  }
37
- /** Whether the live observer discarded configured options after construction failed. */
38
43
  get usingPlatformDefaults() {
39
44
  return this.#usingPlatformDefaults;
40
45
  }
41
- /**
42
- * (Re)creates the observer and observes `targets`. Returns `false` — leaving
43
- * the watcher inert — without `IntersectionObserver` support (very old
44
- * browsers; the caller's no-JS fallback stays in charge) or with no targets.
45
- * If initial construction with the configured options fails, the watcher
46
- * warns and retries once with the same root and platform defaults. A
47
- * `rootSelector` that does not parse resolves to the viewport (see
48
- * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
49
- * call.
50
- *
51
- * @throws The fallback constructor error if both construction attempts fail,
52
- * or whatever the platform throws from `observe()`. The exception is passed
53
- * through unchanged, but the watcher rolls back first: every target observed
54
- * so far is released and `active` stays `false`, so a caller that retries
55
- * starts from a clean slate.
56
- */
57
46
  start(targets, options = {}) {
58
47
  this.stop();
59
48
  if (typeof IntersectionObserver === "undefined") return false;
@@ -91,14 +80,6 @@ var IntersectionWatcher = class {
91
80
  throw error;
92
81
  }
93
82
  }
94
- /**
95
- * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
96
- * only reports *changes*, but `observe()` always reports the present state,
97
- * so unobserve→observe turns "still intersecting" into a fresh callback.
98
- *
99
- * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
100
- * first, so it never stays live with a half-rearmed target.
101
- */
102
83
  rearm(target) {
103
84
  if (!this.#observer) return;
104
85
  try {
@@ -109,7 +90,6 @@ var IntersectionWatcher = class {
109
90
  throw error;
110
91
  }
111
92
  }
112
- /** Severs the observer; late queued callbacks become no-ops via the guard. */
113
93
  stop() {
114
94
  this.#active = false;
115
95
  this.#observer?.disconnect();
@@ -118,10 +98,85 @@ var IntersectionWatcher = class {
118
98
  }
119
99
  };
120
100
 
121
- // src/controllers/intersection_controller.ts
101
+ var MicrotaskCoalescer = class {
102
+ #run;
103
+ #queued = false;
104
+ #active = false;
105
+ #generation = 0;
106
+ constructor(run) {
107
+ this.#run = run;
108
+ }
109
+ activate() {
110
+ this.#active = true;
111
+ }
112
+ cancel() {
113
+ this.#active = false;
114
+ this.#queued = false;
115
+ this.#generation += 1;
116
+ }
117
+ get pending() {
118
+ return this.#queued;
119
+ }
120
+ schedule() {
121
+ if (!this.#active || this.#queued) return;
122
+ this.#queued = true;
123
+ const generation = this.#generation;
124
+ queueMicrotask(() => {
125
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
126
+ this.#queued = false;
127
+ this.#run();
128
+ });
129
+ }
130
+ };
131
+
132
+ var NUMBER_BOUNDS = {
133
+ finite: { finite: true }};
134
+ function matchesNumberBounds(value, bounds) {
135
+ if (!Number.isFinite(value)) {
136
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
137
+ if (direction === null) return false;
138
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
139
+ }
140
+ if (bounds.min !== void 0 && value < bounds.min) return false;
141
+ if (bounds.max !== void 0 && value > bounds.max) return false;
142
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
143
+ if (bounds.integer && !Number.isInteger(value)) return false;
144
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
145
+ return true;
146
+ }
147
+
148
+ function readNumber(raw, fallback, bounds) {
149
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
150
+ }
151
+
152
+ var NumberValueReader = class {
153
+ #lastRejected = /* @__PURE__ */ new Map();
154
+ read(owner, name, raw, fallback, bounds) {
155
+ const resolved = readNumber(raw, fallback, bounds);
156
+ if (matchesNumberBounds(raw, bounds)) {
157
+ this.#lastRejected.delete(name);
158
+ return resolved;
159
+ }
160
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
161
+ const literal = owner.element.getAttribute(attribute);
162
+ if (literal === null) {
163
+ this.#lastRejected.delete(name);
164
+ return resolved;
165
+ }
166
+ if (this.#lastRejected.get(name) !== literal) {
167
+ this.#lastRejected.set(name, literal);
168
+ console.warn(
169
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
170
+ );
171
+ }
172
+ return resolved;
173
+ }
174
+ };
175
+
122
176
  var RATIO_PROPERTY = "--stimeo--intersection-ratio";
123
177
  var RATIO_EPSILON = 0.01;
124
- var IntersectionController = class extends Controller {
178
+ var IntersectionController = class _IntersectionController extends Controller {
179
+ #numbers = new NumberValueReader();
125
180
  static values = {
126
181
  threshold: { type: Number, default: 0 },
127
182
  ratioSteps: { type: Number, default: 0 },
@@ -129,13 +184,18 @@ var IntersectionController = class extends Controller {
129
184
  rootSelector: { type: String, default: "" },
130
185
  once: { type: Boolean, default: false }
131
186
  };
187
+ static valueConstraints = {
188
+ threshold: NUMBER_BOUNDS.finite,
189
+ ratioSteps: { finite: true, min: 0, max: 1e3 }
190
+ };
132
191
  static actions = ["refresh"];
133
192
  static events = ["enter", "exit", "change", "passed"];
134
- /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */
135
193
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
136
- /** Threshold actually installed in the live observer (0 after option fallback). */
194
+ #rebuild = new MicrotaskCoalescer(() => this.#sync());
195
+ #rootSelector = "";
196
+ #builtRoot = null;
197
+ #builtOptions = "";
137
198
  #effectiveThreshold = 0;
138
- /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */
139
199
  #generation = 0;
140
200
  #onIntersect(entries) {
141
201
  const generation = this.#generation;
@@ -151,46 +211,49 @@ var IntersectionController = class extends Controller {
151
211
  }
152
212
  }
153
213
  connect() {
154
- if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
155
- this.#observe();
214
+ this.#rebuild.activate();
215
+ this.#sync();
156
216
  }
157
217
  disconnect() {
218
+ this.#rebuild.cancel();
158
219
  this.#watcher.stop();
159
220
  }
160
- /**
161
- * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing
162
- * rewrites the attribute in place without a reconnect, and the line is what
163
- * the intersection callback compares every ratio against, so a value frozen at
164
- * connect time would decide `data-intersecting` wrongly for the rest of the
165
- * page's life. Nothing to rebuild before the first `connect()`; after a spent
166
- * one-shot the watcher is deliberately stopped, and re-observing would deliver
167
- * the current state and fire `enter` a second time.
168
- */
169
221
  thresholdValueChanged() {
170
- if (this.#watcher.active) this.#observe();
222
+ this.#rebuild.schedule();
223
+ }
224
+ ratioStepsValueChanged() {
225
+ this.#rebuild.schedule();
226
+ }
227
+ rootMarginValueChanged() {
228
+ this.#rebuild.schedule();
229
+ }
230
+ rootSelectorValueChanged() {
231
+ this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
232
+ this.#rebuild.schedule();
171
233
  }
172
- /** (Re)installs the observer from the current Values. */
173
- #observe() {
174
- this.#effectiveThreshold = this.#clampedThreshold();
234
+ onceValueChanged() {
235
+ this.#rebuild.schedule();
236
+ }
237
+ #sync() {
238
+ if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") {
239
+ this.#watcher.stop();
240
+ return;
241
+ }
242
+ const root = this.#rootSelector ? document.querySelector(this.#rootSelector) : null;
243
+ const threshold = this.#clampedThreshold();
244
+ const thresholds = this.#thresholds();
245
+ const options = `${this.rootMarginValue} ${threshold} ${thresholds.join(",")}`;
246
+ if (this.#watcher.active && root === this.#builtRoot && options === this.#builtOptions) return;
247
+ this.#builtRoot = root;
248
+ this.#builtOptions = options;
249
+ this.#effectiveThreshold = threshold;
175
250
  this.#watcher.start(this.element, {
176
- rootSelector: this.rootSelectorValue,
251
+ root,
177
252
  rootMargin: this.rootMarginValue,
178
- threshold: this.#thresholds()
253
+ threshold: thresholds
179
254
  });
180
255
  if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
181
256
  }
182
- /**
183
- * Re-delivers the current intersection state as a fresh transition. Bound via
184
- * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).
185
- *
186
- * `IntersectionObserver` only reports state *changes*, so a sentinel that
187
- * stays visible while content is appended below it never fires `enter` again
188
- * and a hand-rolled infinite scroll stalls. `observe()` always delivers the
189
- * current state, and clearing the recorded `data-intersecting`/`data-passed`
190
- * makes that delivery count as a transition — a still-visible sentinel
191
- * re-fires `enter`. No-op once the observer is gone (`once` fired, no
192
- * `IntersectionObserver` support, or after `disconnect()`).
193
- */
194
257
  refresh() {
195
258
  if (!this.#watcher.active) return;
196
259
  this.#generation += 1;
@@ -198,16 +261,6 @@ var IntersectionController = class extends Controller {
198
261
  this.element.removeAttribute("data-passed");
199
262
  this.#watcher.rearm(this.element);
200
263
  }
201
- /**
202
- * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`
203
- * on transitions. The previous state is the DOM attribute (source of truth),
204
- * so the observer's initial callback fires `enter` for an element that starts
205
- * visible but stays silent after a cache restore that already recorded it.
206
- * An initial not-visible state is established silently (no `exit`).
207
- *
208
- * @stimeoRuntimeOnly `once` decides whether this enter spends the watcher's one shot; the hook it
209
- * writes follows the entry.
210
- */
211
264
  #syncIntersecting(intersecting, ratio, entry) {
212
265
  const previous = this.element.getAttribute("data-intersecting");
213
266
  this.element.setAttribute("data-intersecting", intersecting ? "true" : "false");
@@ -220,57 +273,48 @@ var IntersectionController = class extends Controller {
220
273
  });
221
274
  }
222
275
  }
223
- /**
224
- * Which edge the element left across, for the `exit` detail. A non-zero
225
- * `threshold` withdraws visibility while the element still overlaps the root,
226
- * so the leaving rect can straddle the start edge — the direction is the
227
- * element's own top against that edge, not whether it has cleared the root
228
- * entirely (that is what `passed` reports). An element with no layout box
229
- * (`display: none`, a collapsed `<details>`) is reported with an empty rect
230
- * that carries no position at all, so it is deliberately neither direction
231
- * and takes the "still ahead" reading.
232
- */
233
276
  #leftViaStartEdge(entry) {
234
277
  const rect = entry.boundingClientRect;
235
278
  if (rect.width === 0 && rect.height === 0) return false;
236
279
  return rect.top < (entry.rootBounds?.top ?? 0);
237
280
  }
238
- /**
239
- * Reflects the "scrolled past" state onto `data-passed` and fires `passed` on
240
- * transitions — the line sticky headers and reading progress key off. Like
241
- * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the
242
- * initial `false` is established silently.
243
- */
244
281
  #syncPassed(passed) {
245
282
  const previous = this.element.getAttribute("data-passed");
246
283
  this.element.setAttribute("data-passed", passed ? "true" : "false");
247
284
  const changed = previous === null ? passed : previous === "true" !== passed;
248
285
  if (changed) this.dispatch("passed", { detail: { passed } });
249
286
  }
250
- /** The configured `threshold`, clamped to the 0..1 the observer accepts. */
251
287
  #clampedThreshold() {
252
- return Math.min(1, Math.max(0, this.thresholdValue));
288
+ return Math.min(1, Math.max(0, this.#safeThreshold));
253
289
  }
254
- /**
255
- * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly
256
- * spaced steps when fine-grained `change` ratios are wanted (progress bars).
257
- *
258
- * 0 is always observed. An observer notifies only at the lines it was given,
259
- * so a non-zero `threshold` on its own delivers its last callback while the
260
- * element is still partly visible: the element leaving for good would never be
261
- * reported, freezing the ratio and `data-passed` mid-departure.
262
- */
263
290
  #thresholds() {
264
291
  const thresholds = /* @__PURE__ */ new Set([0, this.#clampedThreshold()]);
265
- if (this.ratioStepsValue > 0) {
266
- for (let i = 0; i <= this.ratioStepsValue; i += 1) {
267
- thresholds.add(i / this.ratioStepsValue);
292
+ if (this.#safeRatioSteps > 0) {
293
+ for (let i = 0; i <= this.#safeRatioSteps; i += 1) {
294
+ thresholds.add(i / this.#safeRatioSteps);
268
295
  }
269
296
  }
270
297
  return [...thresholds].sort((a, b) => a - b);
271
298
  }
299
+ get #safeThreshold() {
300
+ return this.#numbers.read(
301
+ this,
302
+ "threshold",
303
+ this.thresholdValue,
304
+ _IntersectionController.values.threshold.default,
305
+ _IntersectionController.valueConstraints.threshold
306
+ );
307
+ }
308
+ get #safeRatioSteps() {
309
+ return this.#numbers.read(
310
+ this,
311
+ "ratioSteps",
312
+ this.ratioStepsValue,
313
+ _IntersectionController.values.ratioSteps.default,
314
+ _IntersectionController.valueConstraints.ratioSteps
315
+ );
316
+ }
272
317
  };
273
318
 
274
319
  export { IntersectionController };
275
- //# sourceMappingURL=intersection_controller.js.map
276
320
  //# sourceMappingURL=intersection_controller.js.map
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/lazy_frame_controller.ts
4
-
5
- // src/utils/declared_value.ts
6
3
  function parseDeclared(raw, parse, fallback) {
7
4
  try {
8
5
  return parse(raw);
@@ -11,7 +8,6 @@ function parseDeclared(raw, parse, fallback) {
11
8
  }
12
9
  }
13
10
 
14
- // src/utils/intersection_watcher.ts
15
11
  function queryRoot(selector) {
16
12
  if (!selector) return null;
17
13
  return parseDeclared(selector, (raw) => document.querySelector(raw), null);
@@ -24,30 +20,12 @@ var IntersectionWatcher = class {
24
20
  constructor(onEntries) {
25
21
  this.#onEntries = onEntries;
26
22
  }
27
- /** Whether an observer is live (started, `IntersectionObserver` supported). */
28
23
  get active() {
29
24
  return this.#active;
30
25
  }
31
- /** Whether the live observer discarded configured options after construction failed. */
32
26
  get usingPlatformDefaults() {
33
27
  return this.#usingPlatformDefaults;
34
28
  }
35
- /**
36
- * (Re)creates the observer and observes `targets`. Returns `false` — leaving
37
- * the watcher inert — without `IntersectionObserver` support (very old
38
- * browsers; the caller's no-JS fallback stays in charge) or with no targets.
39
- * If initial construction with the configured options fails, the watcher
40
- * warns and retries once with the same root and platform defaults. A
41
- * `rootSelector` that does not parse resolves to the viewport (see
42
- * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
43
- * call.
44
- *
45
- * @throws The fallback constructor error if both construction attempts fail,
46
- * or whatever the platform throws from `observe()`. The exception is passed
47
- * through unchanged, but the watcher rolls back first: every target observed
48
- * so far is released and `active` stays `false`, so a caller that retries
49
- * starts from a clean slate.
50
- */
51
29
  start(targets, options = {}) {
52
30
  this.stop();
53
31
  if (typeof IntersectionObserver === "undefined") return false;
@@ -85,14 +63,6 @@ var IntersectionWatcher = class {
85
63
  throw error;
86
64
  }
87
65
  }
88
- /**
89
- * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
90
- * only reports *changes*, but `observe()` always reports the present state,
91
- * so unobserve→observe turns "still intersecting" into a fresh callback.
92
- *
93
- * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
94
- * first, so it never stays live with a half-rearmed target.
95
- */
96
66
  rearm(target) {
97
67
  if (!this.#observer) return;
98
68
  try {
@@ -103,7 +73,6 @@ var IntersectionWatcher = class {
103
73
  throw error;
104
74
  }
105
75
  }
106
- /** Severs the observer; late queued callbacks become no-ops via the guard. */
107
76
  stop() {
108
77
  this.#active = false;
109
78
  this.#observer?.disconnect();
@@ -112,7 +81,17 @@ var IntersectionWatcher = class {
112
81
  }
113
82
  };
114
83
 
115
- // src/controllers/lazy_frame_controller.ts
84
+ var ListenerSet = class {
85
+ #abort = new AbortController();
86
+ add(target, type, handler, options) {
87
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
88
+ }
89
+ dispose() {
90
+ this.#abort.abort();
91
+ this.#abort = new AbortController();
92
+ }
93
+ };
94
+
116
95
  var LazyFrameController = class extends Controller {
117
96
  static values = {
118
97
  url: { type: String, default: "" },
@@ -120,38 +99,29 @@ var LazyFrameController = class extends Controller {
120
99
  once: { type: Boolean, default: true }
121
100
  };
122
101
  static events = ["load"];
123
- /** Shared IO plumbing (support guard, active guard, teardown). */
124
102
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
125
103
  #connected = false;
126
104
  #loaded = false;
127
- /**
128
- * Whether the observer last saw the frame inside the observed area — `null`
129
- * until it has reported. A re-entry needs a frame that was seen inside and
130
- * then left, so an unknown position is never a departure.
131
- */
132
105
  #inside = null;
133
- /**
134
- * Focus reaching the frame starts the FIRST load before it intersects (keyboard / AT).
135
- * `#load` releases this listener, so focus never reaches an already-loaded frame.
136
- */
137
106
  #onFocus = () => this.#load();
138
- /**
139
- * Follows the held URL at runtime: one that arrives arms the frame, one that is
140
- * taken away disarms it, so an empty `url` holds no triggers either way.
141
- *
142
- * Stimulus runs value callbacks before `connect()`, so the connected guard keeps
143
- * arming in one place. A frame that already loaded keeps its held URL for the next
144
- * re-entry instead of fetching on the spot.
145
- */
107
+ #focusListeners = new ListenerSet();
146
108
  urlValueChanged() {
147
109
  if (!this.#connected || this.#loaded) return;
148
110
  if (this.urlValue) this.#arm();
149
111
  else this.#stopObserving();
150
112
  }
151
- /** Rebuilds the observer when the early-load margin changes at runtime. */
152
113
  rootMarginValueChanged() {
153
114
  if (this.#connected && this.#watcher.active) this.#observe();
154
115
  }
116
+ onceValueChanged() {
117
+ if (!this.#connected || !this.#loaded) return;
118
+ if (this.onceValue) {
119
+ this.#watcher.stop();
120
+ } else if (!this.#watcher.active) {
121
+ this.#inside = null;
122
+ this.#arm();
123
+ }
124
+ }
155
125
  connect() {
156
126
  this.#loaded = this.element.hasAttribute("data-lazy-loaded");
157
127
  this.#inside = null;
@@ -163,10 +133,9 @@ var LazyFrameController = class extends Controller {
163
133
  this.#connected = false;
164
134
  this.#stopObserving();
165
135
  }
166
- /** Attaches the triggers that still have a job to do. */
167
136
  #arm() {
168
137
  if (!this.urlValue) return;
169
- if (!this.#loaded) this.element.addEventListener("focusin", this.#onFocus);
138
+ if (!this.#loaded) this.#focusListeners.add(this.element, "focusin", this.#onFocus);
170
139
  this.#observe();
171
140
  }
172
141
  #observe() {
@@ -183,7 +152,6 @@ var LazyFrameController = class extends Controller {
183
152
  }
184
153
  }
185
154
  }
186
- /** Applies an intersecting entry: the first one loads, a return re-fetches. */
187
155
  #enter(returning) {
188
156
  if (!this.#loaded) {
189
157
  this.#load();
@@ -192,26 +160,15 @@ var LazyFrameController = class extends Controller {
192
160
  if (this.onceValue || !returning) return;
193
161
  this.#reload();
194
162
  }
195
- /**
196
- * Starts the load by writing the held URL to `src`.
197
- *
198
- * @stimeoRuntimeOnly `url` is the address this one load fetches and `once` decides whether it
199
- * stops the watcher.
200
- */
201
163
  #load() {
202
164
  if (!this.urlValue) return;
203
165
  this.#loaded = true;
204
166
  this.element.setAttribute("src", this.urlValue);
205
167
  this.element.setAttribute("data-lazy-loaded", "true");
206
168
  this.dispatch("load", { detail: { url: this.urlValue } });
207
- this.element.removeEventListener("focusin", this.#onFocus);
169
+ this.#focusListeners.dispose();
208
170
  if (this.onceValue) this.#watcher.stop();
209
171
  }
210
- /**
211
- * Re-entry while `once` is off: fetch the held URL again.
212
- *
213
- * @stimeoRuntimeOnly `url` is the address this one re-entry reloads.
214
- */
215
172
  #reload() {
216
173
  const src = this.element.getAttribute("src");
217
174
  if (this.urlValue && this.urlValue !== src) {
@@ -226,10 +183,9 @@ var LazyFrameController = class extends Controller {
226
183
  }
227
184
  #stopObserving() {
228
185
  this.#watcher.stop();
229
- this.element.removeEventListener("focusin", this.#onFocus);
186
+ this.#focusListeners.dispose();
230
187
  }
231
188
  };
232
189
 
233
190
  export { LazyFrameController };
234
- //# sourceMappingURL=lazy_frame_controller.js.map
235
191
  //# sourceMappingURL=lazy_frame_controller.js.map