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
@@ -26,6 +26,30 @@ function canTakeFocus(element) {
26
26
  return !inheritsFieldsetDisabled(element);
27
27
  }
28
28
 
29
+ // src/utils/listener_set.ts
30
+ var ListenerSet = class {
31
+ /** The generation every `add` joins until the next `dispose()`. */
32
+ #abort = new AbortController();
33
+ /**
34
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
35
+ *
36
+ * The set supplies the signal, so `options` carries everything else the DOM
37
+ * accepts — `capture` included, which has to match at release time and no
38
+ * longer has a second place to drift from.
39
+ */
40
+ add(target, type, handler, options) {
41
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
42
+ }
43
+ /**
44
+ * Releases every listener of the open generation, synchronously, and opens the
45
+ * next one. Idempotent, and safe before anything has been added.
46
+ */
47
+ dispose() {
48
+ this.#abort.abort();
49
+ this.#abort = new AbortController();
50
+ }
51
+ };
52
+
29
53
  // src/utils/microtask_coalescer.ts
30
54
  var MicrotaskCoalescer = class {
31
55
  #run;
@@ -150,6 +174,7 @@ var ConditionalFieldsController = class extends Controller {
150
174
  #observing = false;
151
175
  /** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
152
176
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
177
+ #listeners = new ListenerSet();
153
178
  /** Provides the last-resort focus landmark without claiming an authored tabindex. */
154
179
  #rootTabindex = new TabindexLoan();
155
180
  /** Watches retained targets and controls whose declarative or reflected state changed. */
@@ -188,9 +213,9 @@ var ConditionalFieldsController = class extends Controller {
188
213
  subtree: true
189
214
  });
190
215
  this.#observing = true;
191
- this.element.addEventListener("change", this.#onTriggerInput);
192
- this.element.addEventListener("input", this.#onTriggerInput);
193
- this.element.addEventListener("turbo:morph-element", this.#onMorph);
216
+ this.#listeners.add(this.element, "change", this.#onTriggerInput);
217
+ this.#listeners.add(this.element, "input", this.#onTriggerInput);
218
+ this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
194
219
  document.addEventListener("reset", this.#onReset, true);
195
220
  }
196
221
  /** Releases all external resources and returns the temporary focus landmark loan. */
@@ -198,9 +223,7 @@ var ConditionalFieldsController = class extends Controller {
198
223
  this.#reconcile.cancel();
199
224
  this.#observing = false;
200
225
  this.#observer.disconnect();
201
- this.element.removeEventListener("change", this.#onTriggerInput);
202
- this.element.removeEventListener("input", this.#onTriggerInput);
203
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
226
+ this.#listeners.dispose();
204
227
  document.removeEventListener("reset", this.#onReset, true);
205
228
  this.#rootTabindex.returnAll();
206
229
  }
@@ -88,11 +88,17 @@ function construct(factory, locale, options, fallbackLocale) {
88
88
  }
89
89
  }
90
90
 
91
+ // src/utils/locale.ts
92
+ function resolveLocale(element, declared = "") {
93
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
94
+ }
95
+
91
96
  // src/controllers/currency_input_controller.ts
97
+ var FALLBACK_LOCALE = "en-US";
92
98
  var CurrencyInputController = class extends Controller {
93
99
  static targets = ["display", "field", "srValue"];
94
100
  static values = {
95
- locale: { type: String, default: "en-US" },
101
+ locale: { type: String, default: "" },
96
102
  currency: { type: String, default: "" },
97
103
  precision: { type: Number, default: 2 }
98
104
  };
@@ -102,7 +108,7 @@ var CurrencyInputController = class extends Controller {
102
108
  #lastValue = null;
103
109
  #started = false;
104
110
  /** Validated mirrors of the Values; the hot path never reads a raw Value. */
105
- #locale = "en-US";
111
+ #locale;
106
112
  #currency = "";
107
113
  #precision = 2;
108
114
  /** Formatters rebuilt only when a Value changes, never per keystroke. */
@@ -339,8 +345,9 @@ var CurrencyInputController = class extends Controller {
339
345
  * formatters from the validated set.
340
346
  */
341
347
  #revalidate() {
342
- const declaredLocale = intlFormatter(Intl.NumberFormat, this.localeValue, {});
343
- this.#locale = declaredLocale === null ? "en-US" : this.localeValue;
348
+ const resolved = resolveLocale(this.element, this.localeValue);
349
+ const usable = intlFormatter(Intl.NumberFormat, resolved, {});
350
+ this.#locale = usable === null ? FALLBACK_LOCALE : resolved;
344
351
  const precision = this.precisionValue;
345
352
  this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
346
353
  this.#currency = "";
@@ -355,7 +362,7 @@ var CurrencyInputController = class extends Controller {
355
362
  Intl.NumberFormat,
356
363
  this.#locale,
357
364
  { useGrouping: true, maximumFractionDigits: 0 },
358
- this.#locale
365
+ FALLBACK_LOCALE
359
366
  );
360
367
  this.#fixed = intlFormatter(
361
368
  Intl.NumberFormat,
@@ -365,15 +372,15 @@ var CurrencyInputController = class extends Controller {
365
372
  minimumFractionDigits: this.#precision,
366
373
  maximumFractionDigits: this.#precision
367
374
  },
368
- this.#locale
375
+ FALLBACK_LOCALE
369
376
  );
370
377
  this.#accessible = this.#currency ? intlFormatter(
371
378
  Intl.NumberFormat,
372
379
  this.#locale,
373
380
  { style: "currency", currency: this.#currency },
374
- this.#locale
381
+ FALLBACK_LOCALE
375
382
  ) : this.#fixed;
376
- const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, this.#locale).formatToParts(
383
+ const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, FALLBACK_LOCALE).formatToParts(
377
384
  11111.1
378
385
  );
379
386
  this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
@@ -383,7 +390,7 @@ var CurrencyInputController = class extends Controller {
383
390
  Intl.NumberFormat,
384
391
  this.#locale,
385
392
  { useGrouping: false },
386
- this.#locale
393
+ FALLBACK_LOCALE
387
394
  );
388
395
  for (let i = 0; i <= 9; i++) {
389
396
  const digit = digitFormatter.format(i);
@@ -114,6 +114,11 @@ function monthLabelFormatter(locale) {
114
114
  return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
115
115
  }
116
116
 
117
+ // src/utils/locale.ts
118
+ function resolveLocale(element, declared = "") {
119
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
120
+ }
121
+
117
122
  // src/utils/microtask_coalescer.ts
118
123
  var MicrotaskCoalescer = class {
119
124
  #run;
@@ -224,7 +229,8 @@ var DateRangePickerController = class extends Controller {
224
229
  // A JSON list read through `parseStringList` rather than Stimulus's `Array`
225
230
  // type: that reader throws out of the value observer before any callback
226
231
  // runs, so one malformed attribute would stop the picker from connecting.
227
- disabledDates: { type: String, default: "" }
232
+ disabledDates: { type: String, default: "" },
233
+ locale: { type: String, default: "" }
228
234
  };
229
235
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
230
236
  static events = ["change", "monthchange"];
@@ -277,6 +283,10 @@ var DateRangePickerController = class extends Controller {
277
283
  this.#beforeCache.deactivate();
278
284
  this.#focusTimer.clearAll();
279
285
  }
286
+ /** Repaints the label when application code changes `locale` at runtime. */
287
+ localeValueChanged() {
288
+ this.#repaint.schedule();
289
+ }
280
290
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
281
291
  minValueChanged() {
282
292
  this.#repaint.schedule();
@@ -465,8 +475,7 @@ var DateRangePickerController = class extends Controller {
465
475
  const { year, month } = info;
466
476
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
467
477
  if (this.hasMonthLabelTarget) {
468
- const lang = document.documentElement.lang || "en";
469
- const formatter = monthLabelFormatter(lang);
478
+ const formatter = monthLabelFormatter(resolveLocale(this.element, this.localeValue));
470
479
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
471
480
  }
472
481
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -2,6 +2,30 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dirty_form_controller.ts
4
4
 
5
+ // src/utils/listener_set.ts
6
+ var ListenerSet = class {
7
+ /** The generation every `add` joins until the next `dispose()`. */
8
+ #abort = new AbortController();
9
+ /**
10
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
11
+ *
12
+ * The set supplies the signal, so `options` carries everything else the DOM
13
+ * accepts — `capture` included, which has to match at release time and no
14
+ * longer has a second place to drift from.
15
+ */
16
+ add(target, type, handler, options) {
17
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
18
+ }
19
+ /**
20
+ * Releases every listener of the open generation, synchronously, and opens the
21
+ * next one. Idempotent, and safe before anything has been added.
22
+ */
23
+ dispose() {
24
+ this.#abort.abort();
25
+ this.#abort = new AbortController();
26
+ }
27
+ };
28
+
5
29
  // src/utils/safe_timeout.ts
6
30
  var TimerRegistry = class {
7
31
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -125,6 +149,7 @@ var DirtyFormController = class extends Controller {
125
149
  #dirty = false;
126
150
  #beforeunloadBound = false;
127
151
  #timeouts = new SafeTimeout();
152
+ #listeners = new ListenerSet();
128
153
  /** Whether the active submission still represents the form's current values. */
129
154
  #submitting = false;
130
155
  #submitAttempt = null;
@@ -182,24 +207,19 @@ var DirtyFormController = class extends Controller {
182
207
  this.#baseline = this.#serialize();
183
208
  this.#setDirty(false);
184
209
  this.element.removeAttribute("data-dirty");
185
- this.element.addEventListener("input", this.#onFieldChange);
186
- this.element.addEventListener("change", this.#onFieldChange);
187
- this.element.addEventListener("submit", this.#onSubmit);
188
- this.element.addEventListener("formdata", this.#onFormData);
189
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
190
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
210
+ this.#listeners.add(this.element, "input", this.#onFieldChange);
211
+ this.#listeners.add(this.element, "change", this.#onFieldChange);
212
+ this.#listeners.add(this.element, "submit", this.#onSubmit);
213
+ this.#listeners.add(this.element, "formdata", this.#onFormData);
214
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
215
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
191
216
  this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
192
217
  form: this.element,
193
218
  evaluate: (event) => this.#evaluateVisit(event)
194
219
  });
195
220
  }
196
221
  disconnect() {
197
- this.element.removeEventListener("input", this.#onFieldChange);
198
- this.element.removeEventListener("change", this.#onFieldChange);
199
- this.element.removeEventListener("submit", this.#onSubmit);
200
- this.element.removeEventListener("formdata", this.#onFormData);
201
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
202
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
222
+ this.#listeners.dispose();
203
223
  this.#unregisterVisit?.();
204
224
  this.#unregisterVisit = null;
205
225
  this.#unbindBeforeUnload();
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/flash_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+
5
16
  // src/utils/before_cache_reset.ts
6
17
  var BeforeCacheReset = class _BeforeCacheReset {
7
18
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -84,6 +95,121 @@ var SafeTimeout = class extends TimerRegistry {
84
95
  }
85
96
  };
86
97
 
98
+ // src/utils/keyed_timers.ts
99
+ var KeyedTimers = class {
100
+ #timers = new SafeTimeout();
101
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
102
+ #pending = /* @__PURE__ */ new Map();
103
+ /**
104
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
105
+ * pending. The entry is dropped before the callback runs, so the callback sees
106
+ * the key unarmed and may arm it again for the next round.
107
+ */
108
+ set(key, callback, delay) {
109
+ this.clear(key);
110
+ const id = this.#timers.set(() => {
111
+ this.#pending.delete(key);
112
+ callback();
113
+ }, delay);
114
+ this.#pending.set(key, id);
115
+ }
116
+ /**
117
+ * Cancels `key`'s pending timer, if it has one.
118
+ *
119
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
120
+ * the registry ignores an id it does not own — the unarmed case needs no
121
+ * branch of its own, and no other key's timer can be reached from here.
122
+ */
123
+ clear(key) {
124
+ this.#timers.clear(this.#pending.get(key) ?? -1);
125
+ this.#pending.delete(key);
126
+ }
127
+ /**
128
+ * Cancels every pending timer and forgets every key. Call this from a
129
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
130
+ */
131
+ clearAll() {
132
+ this.#timers.clearAll();
133
+ this.#pending.clear();
134
+ }
135
+ /** Whether `key` has a timer pending. */
136
+ has(key) {
137
+ return this.#pending.has(key);
138
+ }
139
+ };
140
+
141
+ // src/utils/pausable_timers.ts
142
+ var PausableTimers = class {
143
+ #timers = new KeyedTimers();
144
+ /** Every key armed or held; an entry goes as its timer fires, or with the key. */
145
+ #entries = /* @__PURE__ */ new Map();
146
+ /**
147
+ * Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
148
+ * A held key stays held and banks `delay` for its resume.
149
+ */
150
+ set(key, callback, delay) {
151
+ const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
152
+ const entry = { callback, startedAt: 0, remaining: delay, reasons };
153
+ this.#entries.set(key, entry);
154
+ if (reasons.size === 0) this.#arm(key, entry);
155
+ }
156
+ /**
157
+ * Holds `key` for `reason` and banks the time left, with a floor of one
158
+ * millisecond. Reports whether this call is the one that stopped a running
159
+ * timer, so the caller can write its held-state hook exactly once.
160
+ */
161
+ pause(key, reason) {
162
+ const entry = this.#entries.get(key);
163
+ if (entry === void 0) return false;
164
+ entry.reasons.add(reason);
165
+ if (!this.#timers.has(key)) return false;
166
+ this.#timers.clear(key);
167
+ entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
168
+ return true;
169
+ }
170
+ /**
171
+ * Releases `reason` on `key`, arming the banked time again once no reason is
172
+ * left. Reports whether this call is the one that started the timer again.
173
+ */
174
+ resume(key, reason) {
175
+ const entry = this.#entries.get(key);
176
+ if (entry === void 0) return false;
177
+ entry.reasons.delete(reason);
178
+ if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
179
+ this.#arm(key, entry);
180
+ return true;
181
+ }
182
+ /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
183
+ tracks(key) {
184
+ return this.#entries.has(key);
185
+ }
186
+ /** Cancels `key`'s timer and drops its hold. */
187
+ clear(key) {
188
+ this.#timers.clear(key);
189
+ this.#entries.delete(key);
190
+ }
191
+ /**
192
+ * Cancels every timer and forgets every key. Call this from a controller's
193
+ * `disconnect()` so neither a timer nor a hold outlives the element.
194
+ */
195
+ clearAll() {
196
+ this.#timers.clearAll();
197
+ this.#entries.clear();
198
+ }
199
+ /** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
200
+ #arm(key, entry) {
201
+ entry.startedAt = Date.now();
202
+ this.#timers.set(
203
+ key,
204
+ () => {
205
+ this.#entries.delete(key);
206
+ entry.callback();
207
+ },
208
+ entry.remaining
209
+ );
210
+ }
211
+ };
212
+
87
213
  // src/utils/transition_completion.ts
88
214
  function timeMs(value) {
89
215
  const trimmed = value.trim();
@@ -130,12 +256,13 @@ var FlashController = class extends Controller {
130
256
  };
131
257
  static actions = ["dismiss"];
132
258
  static events = ["show", "dismiss", "reconcile"];
259
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
133
260
  #timers = new SafeTimeout();
261
+ /** Per-message auto-dismiss, held open while the message is hovered or focused. */
262
+ #dismiss = new PausableTimers();
134
263
  #observer = null;
135
264
  /** Whether the controller is between `connect()` and `disconnect()`. */
136
265
  #connected = false;
137
- /** Auto-dismiss timer state keyed by message element. */
138
- #state = /* @__PURE__ */ new Map();
139
266
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
140
267
  #order = [];
141
268
  /**
@@ -161,8 +288,8 @@ var FlashController = class extends Controller {
161
288
  this.#beforeCache.deactivate();
162
289
  this.#stopObserving();
163
290
  this.#timers.clearAll();
291
+ this.#dismiss.clearAll();
164
292
  for (const message of this.#order) this.#unbindPause(message);
165
- this.#state.clear();
166
293
  this.#order.length = 0;
167
294
  this.#leaving.clear();
168
295
  }
@@ -291,8 +418,7 @@ var FlashController = class extends Controller {
291
418
  * own role to do the announcing (dynamic inserts). Idempotent per message.
292
419
  */
293
420
  #process(message, bridge) {
294
- if (this.#state.has(message) || this.#order.includes(message)) return;
295
- if (this.#leaving.has(message)) return;
421
+ if (this.#order.includes(message) || this.#leaving.has(message)) return;
296
422
  const type = message.getAttribute("data-flash-type") ?? "";
297
423
  const assertive = ASSERTIVE_TYPES.has(type);
298
424
  if (!message.hasAttribute("role")) {
@@ -303,11 +429,7 @@ var FlashController = class extends Controller {
303
429
  this.#bindPause(message);
304
430
  const text = message.textContent?.trim() ?? "";
305
431
  this.dispatch("show", { target: message, detail: { type, message: text } });
306
- if (bridge && text) {
307
- window.dispatchEvent(
308
- new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
309
- );
310
- }
432
+ if (bridge) announce(text, { assertive });
311
433
  this.#startTimer(message);
312
434
  this.#enforceMax();
313
435
  }
@@ -320,53 +442,33 @@ var FlashController = class extends Controller {
320
442
  this.#beginDismiss(oldest, "limit");
321
443
  }
322
444
  }
323
- #startTimer(message, duration = this.durationValue) {
324
- if (duration <= 0) return;
325
- const existing = this.#state.get(message);
326
- if (existing?.id) this.#timers.clear(existing.id);
327
- const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
328
- this.#state.set(message, {
329
- id,
330
- startedAt: Date.now(),
331
- remaining: duration,
332
- paused: existing?.paused ?? /* @__PURE__ */ new Set()
333
- });
445
+ /** Arms a message's auto-dismiss; a non-positive `duration` means it never expires. */
446
+ #startTimer(message) {
447
+ if (this.durationValue <= 0) return;
448
+ this.#dismiss.set(message, () => this.#beginDismiss(message, "timeout"), this.durationValue);
334
449
  }
335
450
  /**
336
- * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
337
- * Hover and focus are independent reasons: the remaining time is banked on the
338
- * first of them, and {@link FlashController.#resume} waits for the last one.
451
+ * Holds a message's auto-dismiss open while it is hovered or focused
452
+ * (WCAG 2.2 2.2.1). Hover and focus are independent reasons, and the registry
453
+ * banks the time left on the first of them and waits for the last.
339
454
  */
340
455
  #pause(message, reason) {
341
- const timer = this.#state.get(message);
342
- if (!timer) return;
343
- timer.paused.add(reason);
344
- if (timer.id === 0) return;
345
- this.#timers.clear(timer.id);
346
- const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
347
- this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
348
- }
349
- /** Resumes a paused message's auto-dismiss with the banked time. */
456
+ this.#dismiss.pause(message, reason);
457
+ }
458
+ /** Releases one reason, resuming the banked time once no reason is left. */
350
459
  #resume(message, reason) {
351
- const timer = this.#state.get(message);
352
- if (!timer) return;
353
- timer.paused.delete(reason);
354
- if (timer.paused.size > 0) return;
355
- if (timer.id !== 0) return;
356
- this.#startTimer(message, timer.remaining);
460
+ this.#dismiss.resume(message, reason);
357
461
  }
358
462
  /** Releases every per-message resource: timer, stacking slot, pause listeners. */
359
463
  #forget(message) {
360
- const timer = this.#state.get(message);
361
- if (timer?.id) this.#timers.clear(timer.id);
362
- this.#state.delete(message);
464
+ this.#dismiss.clear(message);
363
465
  const index = this.#order.indexOf(message);
364
466
  if (index !== -1) this.#order.splice(index, 1);
365
467
  this.#unbindPause(message);
366
468
  }
367
469
  /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
368
470
  #beginDismiss(message, reason) {
369
- if (!this.#state.has(message) && !this.#order.includes(message)) return;
471
+ if (!this.#order.includes(message)) return;
370
472
  this.#forget(message);
371
473
  this.#leaving.add(message);
372
474
  message.setAttribute("data-flash-state", "leaving");
@@ -103,6 +103,30 @@ var DetachGate = class _DetachGate {
103
103
  }
104
104
  };
105
105
 
106
+ // src/utils/listener_set.ts
107
+ var ListenerSet = class {
108
+ /** The generation every `add` joins until the next `dispose()`. */
109
+ #abort = new AbortController();
110
+ /**
111
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
112
+ *
113
+ * The set supplies the signal, so `options` carries everything else the DOM
114
+ * accepts — `capture` included, which has to match at release time and no
115
+ * longer has a second place to drift from.
116
+ */
117
+ add(target, type, handler, options) {
118
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
119
+ }
120
+ /**
121
+ * Releases every listener of the open generation, synchronously, and opens the
122
+ * next one. Idempotent, and safe before anything has been added.
123
+ */
124
+ dispose() {
125
+ this.#abort.abort();
126
+ this.#abort = new AbortController();
127
+ }
128
+ };
129
+
106
130
  // src/utils/min_duration_floor.ts
107
131
  var MinDurationFloor = class {
108
132
  #timers;
@@ -218,6 +242,7 @@ var FrameLoadingController = class extends Controller {
218
242
  #floor = new MinDurationFloor(this.#timeouts);
219
243
  #gate = new DetachGate();
220
244
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
245
+ #listeners = new ListenerSet();
221
246
  #loading = false;
222
247
  /**
223
248
  * The optional targets this controller revealed, and the content it marked inert.
@@ -244,14 +269,12 @@ var FrameLoadingController = class extends Controller {
244
269
  connect() {
245
270
  this.#gate.cancel();
246
271
  this.#beforeCache.activate();
247
- this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
248
- this.element.addEventListener("turbo:frame-load", this.#onEnd);
249
- this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
272
+ this.#listeners.add(this.element, "turbo:before-fetch-request", this.#onStart);
273
+ this.#listeners.add(this.element, "turbo:frame-load", this.#onEnd);
274
+ this.#listeners.add(this.element, "turbo:fetch-request-error", this.#onEnd);
250
275
  }
251
276
  disconnect() {
252
- this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
253
- this.element.removeEventListener("turbo:frame-load", this.#onEnd);
254
- this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
277
+ this.#listeners.dispose();
255
278
  this.#beforeCache.deactivate();
256
279
  this.#gate.disconnected(this, () => this.#teardown());
257
280
  }