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
data/dist/index.js CHANGED
@@ -547,39 +547,6 @@ var AlertDialogController = class extends Controller {
547
547
  }
548
548
  };
549
549
 
550
- // src/utils/microtask_coalescer.ts
551
- var MicrotaskCoalescer = class {
552
- #run;
553
- #queued = false;
554
- #active = false;
555
- #generation = 0;
556
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
557
- constructor(run) {
558
- this.#run = run;
559
- }
560
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
561
- activate() {
562
- this.#active = true;
563
- }
564
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
565
- cancel() {
566
- this.#active = false;
567
- this.#queued = false;
568
- this.#generation += 1;
569
- }
570
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
571
- schedule() {
572
- if (!this.#active || this.#queued) return;
573
- this.#queued = true;
574
- const generation = this.#generation;
575
- queueMicrotask(() => {
576
- if (generation !== this.#generation || !this.#queued || !this.#active) return;
577
- this.#queued = false;
578
- this.#run();
579
- });
580
- }
581
- };
582
-
583
550
  // src/utils/safe_timeout.ts
584
551
  var TimerRegistry = class {
585
552
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -647,6 +614,82 @@ var SafeInterval = class extends TimerRegistry {
647
614
  }
648
615
  };
649
616
 
617
+ // src/utils/keyed_timers.ts
618
+ var KeyedTimers = class {
619
+ #timers = new SafeTimeout();
620
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
621
+ #pending = /* @__PURE__ */ new Map();
622
+ /**
623
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
624
+ * pending. The entry is dropped before the callback runs, so the callback sees
625
+ * the key unarmed and may arm it again for the next round.
626
+ */
627
+ set(key, callback, delay) {
628
+ this.clear(key);
629
+ const id = this.#timers.set(() => {
630
+ this.#pending.delete(key);
631
+ callback();
632
+ }, delay);
633
+ this.#pending.set(key, id);
634
+ }
635
+ /**
636
+ * Cancels `key`'s pending timer, if it has one.
637
+ *
638
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
639
+ * the registry ignores an id it does not own — the unarmed case needs no
640
+ * branch of its own, and no other key's timer can be reached from here.
641
+ */
642
+ clear(key) {
643
+ this.#timers.clear(this.#pending.get(key) ?? -1);
644
+ this.#pending.delete(key);
645
+ }
646
+ /**
647
+ * Cancels every pending timer and forgets every key. Call this from a
648
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
649
+ */
650
+ clearAll() {
651
+ this.#timers.clearAll();
652
+ this.#pending.clear();
653
+ }
654
+ /** Whether `key` has a timer pending. */
655
+ has(key) {
656
+ return this.#pending.has(key);
657
+ }
658
+ };
659
+
660
+ // src/utils/microtask_coalescer.ts
661
+ var MicrotaskCoalescer = class {
662
+ #run;
663
+ #queued = false;
664
+ #active = false;
665
+ #generation = 0;
666
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
667
+ constructor(run) {
668
+ this.#run = run;
669
+ }
670
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
671
+ activate() {
672
+ this.#active = true;
673
+ }
674
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
675
+ cancel() {
676
+ this.#active = false;
677
+ this.#queued = false;
678
+ this.#generation += 1;
679
+ }
680
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
681
+ schedule() {
682
+ if (!this.#active || this.#queued) return;
683
+ this.#queued = true;
684
+ const generation = this.#generation;
685
+ queueMicrotask(() => {
686
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
687
+ this.#queued = false;
688
+ this.#run();
689
+ });
690
+ }
691
+ };
692
+
650
693
  // src/controllers/announcer_controller.ts
651
694
  var LEVELS = ["polite", "assertive"];
652
695
  var AnnouncerController = class extends Controller {
@@ -656,8 +699,14 @@ var AnnouncerController = class extends Controller {
656
699
  dedupeReannounce: { type: Boolean, default: true }
657
700
  };
658
701
  static actions = ["announce"];
659
- /** Clear/re-announce timers; one `clearAll()` in disconnect tears them all down. */
702
+ /** Drain timers, which belong to a politeness level rather than to a region. */
660
703
  #timers = new SafeTimeout();
704
+ /**
705
+ * The one timer a region may have outstanding — its dedupe re-set, then its
706
+ * auto-clear. A region carries at most one, and the registry releases the
707
+ * previous one whenever a newer message takes the region over.
708
+ */
709
+ #regionTimers = new KeyedTimers();
661
710
  /** Live regions generated to stand in for absent targets, for teardown. */
662
711
  #generated = /* @__PURE__ */ new Map();
663
712
  /** Messages waiting to be written, oldest first, one queue per politeness. */
@@ -677,13 +726,6 @@ var AnnouncerController = class extends Controller {
677
726
  });
678
727
  /** Rewinds to an announceable initial state for the snapshot; see the remarks. */
679
728
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
680
- /**
681
- * The one timer a region may have outstanding — its dedupe re-set, then its
682
- * auto-clear. Held weakly so a swapped-out target is not retained; a leftover
683
- * id is harmless because {@link SafeTimeout.clear} no-ops on an id it does not
684
- * own.
685
- */
686
- #pending = /* @__PURE__ */ new WeakMap();
687
729
  /**
688
730
  * Guards against handling the same CustomEvent twice. An event dispatched on
689
731
  * the controller element with `bubbles: true` reaches both the element and the
@@ -714,6 +756,7 @@ var AnnouncerController = class extends Controller {
714
756
  this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
715
757
  window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
716
758
  this.#timers.clearAll();
759
+ this.#regionTimers.clearAll();
717
760
  this.#queues.clear();
718
761
  this.#draining.clear();
719
762
  this.#removeGenerated();
@@ -844,13 +887,13 @@ var AnnouncerController = class extends Controller {
844
887
  }
845
888
  const region = this.#regionFor(level);
846
889
  if (this.dedupeReannounceValue && region.textContent === message) {
847
- this.#cancelPending(region);
890
+ this.#regionTimers.clear(region);
848
891
  region.textContent = "";
849
892
  this.#scheduleDrain(level);
850
893
  return;
851
894
  }
852
895
  queue.shift();
853
- this.#cancelPending(region);
896
+ this.#regionTimers.clear(region);
854
897
  region.textContent = message;
855
898
  this.#scheduleClear(region, message);
856
899
  if (queue.length > 0) this.#scheduleDrain(level);
@@ -858,7 +901,7 @@ var AnnouncerController = class extends Controller {
858
901
  /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
859
902
  #scheduleClear(region, message) {
860
903
  if (this.clearAfterValue <= 0) return;
861
- this.#schedule(
904
+ this.#regionTimers.set(
862
905
  region,
863
906
  () => {
864
907
  if (region.textContent === message) region.textContent = "";
@@ -866,23 +909,6 @@ var AnnouncerController = class extends Controller {
866
909
  this.clearAfterValue
867
910
  );
868
911
  }
869
- /**
870
- * Arms `region`'s single pending timer. Callers reach here with the slot
871
- * already free — `#drain` releases it before writing, and a fired timer clears its own
872
- * entry below — so this does not cancel again.
873
- */
874
- #schedule(region, callback, delay) {
875
- const id = this.#timers.set(() => {
876
- this.#pending.delete(region);
877
- callback();
878
- }, delay);
879
- this.#pending.set(region, id);
880
- }
881
- /** Releases `region`'s pending timer, if it has one. */
882
- #cancelPending(region) {
883
- this.#timers.clear(this.#pending.get(region) ?? -1);
884
- this.#pending.delete(region);
885
- }
886
912
  /**
887
913
  * Resolves the live region for a politeness level.
888
914
  *
@@ -908,6 +934,7 @@ var AnnouncerController = class extends Controller {
908
934
  this.#queues.clear();
909
935
  this.#draining.clear();
910
936
  this.#timers.clearAll();
937
+ this.#regionTimers.clearAll();
911
938
  for (const level of LEVELS) {
912
939
  if (this.#hasTargetFor(level)) {
913
940
  const target = level === "assertive" ? this.assertiveTarget : this.politeTarget;
@@ -981,6 +1008,23 @@ var AspectRatioController = class extends Controller {
981
1008
  }
982
1009
  };
983
1010
 
1011
+ // src/utils/announce.ts
1012
+ function announce(message, options = {}) {
1013
+ const text = message.trim();
1014
+ if (text.length === 0) return;
1015
+ window.dispatchEvent(
1016
+ new CustomEvent("stimeo--announcer:announce", {
1017
+ detail: { message: text, assertive: options.assertive === true }
1018
+ })
1019
+ );
1020
+ }
1021
+ function fillTemplate(template, values) {
1022
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
1023
+ const replacement = values[name];
1024
+ return replacement === void 0 ? match : String(replacement);
1025
+ });
1026
+ }
1027
+
984
1028
  // src/utils/composition_tracker.ts
985
1029
  var CompositionTracker = class {
986
1030
  #observedTargets = /* @__PURE__ */ new Set();
@@ -1028,6 +1072,30 @@ var CompositionTracker = class {
1028
1072
  };
1029
1073
  };
1030
1074
 
1075
+ // src/utils/listener_set.ts
1076
+ var ListenerSet = class {
1077
+ /** The generation every `add` joins until the next `dispose()`. */
1078
+ #abort = new AbortController();
1079
+ /**
1080
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
1081
+ *
1082
+ * The set supplies the signal, so `options` carries everything else the DOM
1083
+ * accepts — `capture` included, which has to match at release time and no
1084
+ * longer has a second place to drift from.
1085
+ */
1086
+ add(target, type, handler, options) {
1087
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
1088
+ }
1089
+ /**
1090
+ * Releases every listener of the open generation, synchronously, and opens the
1091
+ * next one. Idempotent, and safe before anything has been added.
1092
+ */
1093
+ dispose() {
1094
+ this.#abort.abort();
1095
+ this.#abort = new AbortController();
1096
+ }
1097
+ };
1098
+
1031
1099
  // src/controllers/auto_submit_controller.ts
1032
1100
  var AutoSubmitController = class extends Controller {
1033
1101
  static targets = ["form"];
@@ -1043,6 +1111,8 @@ var AutoSubmitController = class extends Controller {
1043
1111
  #timers = new SafeTimeout();
1044
1112
  /** Id of the pending debounce timer, so a new keystroke can reset it. */
1045
1113
  #pendingId = 0;
1114
+ /** Listeners that live with the bound form rather than with the connection. */
1115
+ #formListeners = new ListenerSet();
1046
1116
  /** The form the listeners are attached to; target callbacks rebind it. */
1047
1117
  #boundForm = null;
1048
1118
  /** Rewinds the transient state hooks just before Turbo snapshots the page. */
@@ -1052,9 +1122,7 @@ var AutoSubmitController = class extends Controller {
1052
1122
  this.#boundForm?.removeAttribute("aria-busy");
1053
1123
  const message = this.messageValue;
1054
1124
  this.dispatch("done", { detail: { message: message || void 0 } });
1055
- if (this.announceValue && message) {
1056
- window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
1057
- }
1125
+ if (this.announceValue) announce(message);
1058
1126
  };
1059
1127
  /** Owns delegated IME lifecycle state and submits confirmed input text. */
1060
1128
  #composition = new CompositionTracker({
@@ -1125,12 +1193,12 @@ var AutoSubmitController = class extends Controller {
1125
1193
  if (previous) {
1126
1194
  this.#cancelPending();
1127
1195
  previous.removeAttribute("data-auto-submit-pending");
1128
- previous.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
1196
+ this.#formListeners.dispose();
1129
1197
  this.#composition.unobserve(previous);
1130
1198
  }
1131
1199
  this.#boundForm = form;
1132
1200
  if (!form) return;
1133
- form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
1201
+ this.#formListeners.add(form, "turbo:submit-end", this.#onSubmitEnd);
1134
1202
  this.#composition.observe(form);
1135
1203
  }
1136
1204
  /** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */
@@ -1407,10 +1475,15 @@ var LayoutObserver = class {
1407
1475
  #resizeObserverFactory;
1408
1476
  #resizeObserver = null;
1409
1477
  #observingViewport = false;
1478
+ #loadContainer = null;
1410
1479
  /** Stable bound handler so add/removeEventListener target the same reference. */
1411
1480
  #handleViewportResize = () => {
1412
1481
  this.#callback();
1413
1482
  };
1483
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
1484
+ #handleDescendantLoad = () => {
1485
+ this.#callback();
1486
+ };
1414
1487
  constructor(callback, options = {}) {
1415
1488
  this.#callback = callback;
1416
1489
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -1446,14 +1519,35 @@ var LayoutObserver = class {
1446
1519
  window.removeEventListener("resize", this.#handleViewportResize);
1447
1520
  }
1448
1521
  /**
1449
- * Releases every observation: disconnects the {@link ResizeObserver} and
1450
- * removes the viewport listener. Safe to call multiple times. Call this from a
1451
- * controller's `disconnect()`.
1522
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
1523
+ * frame settling changes the box it sits in, and it measures as zero high until
1524
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
1525
+ * on the container itself and nothing the caller spells.
1526
+ *
1527
+ * **One container at a time.** A further call moves the observation, so a widget
1528
+ * whose content element is swapped at runtime releases the element it let go by
1529
+ * naming the new one — there is no second place for the release to drift from.
1530
+ */
1531
+ observeDescendantLoads(container) {
1532
+ this.unobserveDescendantLoads();
1533
+ this.#loadContainer = container;
1534
+ container.addEventListener("load", this.#handleDescendantLoad, true);
1535
+ }
1536
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
1537
+ unobserveDescendantLoads() {
1538
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
1539
+ this.#loadContainer = null;
1540
+ }
1541
+ /**
1542
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
1543
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
1544
+ * this from a controller's `disconnect()`.
1452
1545
  */
1453
1546
  disconnect() {
1454
1547
  this.#resizeObserver?.disconnect();
1455
1548
  this.#resizeObserver = null;
1456
1549
  this.unobserveViewport();
1550
+ this.unobserveDescendantLoads();
1457
1551
  }
1458
1552
  };
1459
1553
 
@@ -1677,25 +1771,6 @@ var BreadcrumbController = class extends Controller {
1677
1771
  return active instanceof HTMLElement && element.contains(active);
1678
1772
  }
1679
1773
  };
1680
-
1681
- // src/utils/announce.ts
1682
- function announce(message, options = {}) {
1683
- const text = message.trim();
1684
- if (text.length === 0) return;
1685
- window.dispatchEvent(
1686
- new CustomEvent("stimeo--announcer:announce", {
1687
- detail: { message: text, assertive: options.assertive === true }
1688
- })
1689
- );
1690
- }
1691
- function fillTemplate(template, values) {
1692
- return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
1693
- const replacement = values[name];
1694
- return replacement === void 0 ? match : String(replacement);
1695
- });
1696
- }
1697
-
1698
- // src/controllers/bulk_select_controller.ts
1699
1774
  var BulkSelectController = class extends Controller {
1700
1775
  static targets = ["all", "item", "bar", "count", "selectAllPages"];
1701
1776
  static values = {
@@ -1921,6 +1996,11 @@ function monthLabelFormatter(locale) {
1921
1996
  return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
1922
1997
  }
1923
1998
 
1999
+ // src/utils/locale.ts
2000
+ function resolveLocale(element, declared = "") {
2001
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
2002
+ }
2003
+
1924
2004
  // src/controllers/calendar_controller.ts
1925
2005
  var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
1926
2006
  var CalendarController = class extends Controller {
@@ -1930,8 +2010,9 @@ var CalendarController = class extends Controller {
1930
2010
  selected: { type: String, default: "" },
1931
2011
  min: { type: String, default: "" },
1932
2012
  max: { type: String, default: "" },
1933
- weekStart: { type: Number, default: 0 }
2013
+ weekStart: { type: Number, default: 0 },
1934
2014
  // 0 = Sunday, 1 = Monday, etc.
2015
+ locale: { type: String, default: "" }
1935
2016
  };
1936
2017
  static actions = ["next", "onKeydown", "prev", "selectByClick"];
1937
2018
  static events = ["monthchange", "select"];
@@ -1973,6 +2054,17 @@ var CalendarController = class extends Controller {
1973
2054
  this.#syncFocusedDateWithMonth();
1974
2055
  this.render();
1975
2056
  }
2057
+ /**
2058
+ * Repaints the label when application code changes `locale` at runtime.
2059
+ *
2060
+ * Stimulus runs this for the initial Value too, before `connect()`. The paint
2061
+ * it triggers is idempotent — the month comes from `month` (or, while that is
2062
+ * still empty, from `focusedDate`), and `monthchange` only reports a move away
2063
+ * from a month already announced, so the first paint never reports one.
2064
+ */
2065
+ localeValueChanged() {
2066
+ this.render();
2067
+ }
1976
2068
  /**
1977
2069
  * Stimulus lifecycle callback triggered automatically when the selectedValue changes.
1978
2070
  * Re-renders grid cells to update `aria-selected` indicators.
@@ -2103,8 +2195,8 @@ var CalendarController = class extends Controller {
2103
2195
  const year = monthStart.getFullYear();
2104
2196
  const month = monthStart.getMonth() + 1;
2105
2197
  if (this.hasLabelTarget) {
2106
- const lang = document.documentElement.lang || "en";
2107
- this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
2198
+ const locale = resolveLocale(this.element, this.localeValue);
2199
+ this.labelTarget.textContent = monthLabelFormatter(locale).format(monthStart);
2108
2200
  }
2109
2201
  const days = this.#calculateGridDays(year, month);
2110
2202
  const dayElements = this.dayTargets;
@@ -3115,6 +3207,7 @@ var CheckboxController = class extends Controller {
3115
3207
  static events = ["change", "reconcile"];
3116
3208
  /** Collapses every lifecycle signal from one DOM update into one derived pass. */
3117
3209
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
3210
+ #listeners = new ListenerSet();
3118
3211
  /** Aggregate this root last settled on, so a derived repair is reported once. */
3119
3212
  #committedState = null;
3120
3213
  /** Watches authored checked-attribute changes on retained target elements. */
@@ -3132,14 +3225,14 @@ var CheckboxController = class extends Controller {
3132
3225
  attributeFilter: ["checked"],
3133
3226
  subtree: true
3134
3227
  });
3135
- this.element.addEventListener("turbo:morph-element", this.#onMorph);
3228
+ this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
3136
3229
  document.addEventListener("reset", this.#onReset, true);
3137
3230
  }
3138
3231
  /** Releases the observer, global reset listener, and every pending reconciliation. */
3139
3232
  disconnect() {
3140
3233
  this.#reconcile.cancel();
3141
3234
  this.#checkedObserver.disconnect();
3142
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
3235
+ this.#listeners.dispose();
3143
3236
  document.removeEventListener("reset", this.#onReset, true);
3144
3237
  }
3145
3238
  /** Reconciles the aggregate for a parent added or replaced at runtime. */
@@ -4849,6 +4942,7 @@ var ConditionalFieldsController = class extends Controller {
4849
4942
  #observing = false;
4850
4943
  /** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
4851
4944
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
4945
+ #listeners = new ListenerSet();
4852
4946
  /** Provides the last-resort focus landmark without claiming an authored tabindex. */
4853
4947
  #rootTabindex = new TabindexLoan();
4854
4948
  /** Watches retained targets and controls whose declarative or reflected state changed. */
@@ -4887,9 +4981,9 @@ var ConditionalFieldsController = class extends Controller {
4887
4981
  subtree: true
4888
4982
  });
4889
4983
  this.#observing = true;
4890
- this.element.addEventListener("change", this.#onTriggerInput);
4891
- this.element.addEventListener("input", this.#onTriggerInput);
4892
- this.element.addEventListener("turbo:morph-element", this.#onMorph);
4984
+ this.#listeners.add(this.element, "change", this.#onTriggerInput);
4985
+ this.#listeners.add(this.element, "input", this.#onTriggerInput);
4986
+ this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
4893
4987
  document.addEventListener("reset", this.#onReset, true);
4894
4988
  }
4895
4989
  /** Releases all external resources and returns the temporary focus landmark loan. */
@@ -4897,9 +4991,7 @@ var ConditionalFieldsController = class extends Controller {
4897
4991
  this.#reconcile.cancel();
4898
4992
  this.#observing = false;
4899
4993
  this.#observer.disconnect();
4900
- this.element.removeEventListener("change", this.#onTriggerInput);
4901
- this.element.removeEventListener("input", this.#onTriggerInput);
4902
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
4994
+ this.#listeners.dispose();
4903
4995
  document.removeEventListener("reset", this.#onReset, true);
4904
4996
  this.#rootTabindex.returnAll();
4905
4997
  }
@@ -5708,10 +5800,11 @@ function toHalfWidth(text) {
5708
5800
  }
5709
5801
 
5710
5802
  // src/controllers/currency_input_controller.ts
5803
+ var FALLBACK_LOCALE = "en-US";
5711
5804
  var CurrencyInputController = class extends Controller {
5712
5805
  static targets = ["display", "field", "srValue"];
5713
5806
  static values = {
5714
- locale: { type: String, default: "en-US" },
5807
+ locale: { type: String, default: "" },
5715
5808
  currency: { type: String, default: "" },
5716
5809
  precision: { type: Number, default: 2 }
5717
5810
  };
@@ -5721,7 +5814,7 @@ var CurrencyInputController = class extends Controller {
5721
5814
  #lastValue = null;
5722
5815
  #started = false;
5723
5816
  /** Validated mirrors of the Values; the hot path never reads a raw Value. */
5724
- #locale = "en-US";
5817
+ #locale;
5725
5818
  #currency = "";
5726
5819
  #precision = 2;
5727
5820
  /** Formatters rebuilt only when a Value changes, never per keystroke. */
@@ -5958,8 +6051,9 @@ var CurrencyInputController = class extends Controller {
5958
6051
  * formatters from the validated set.
5959
6052
  */
5960
6053
  #revalidate() {
5961
- const declaredLocale = intlFormatter(Intl.NumberFormat, this.localeValue, {});
5962
- this.#locale = declaredLocale === null ? "en-US" : this.localeValue;
6054
+ const resolved = resolveLocale(this.element, this.localeValue);
6055
+ const usable = intlFormatter(Intl.NumberFormat, resolved, {});
6056
+ this.#locale = usable === null ? FALLBACK_LOCALE : resolved;
5963
6057
  const precision = this.precisionValue;
5964
6058
  this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
5965
6059
  this.#currency = "";
@@ -5974,7 +6068,7 @@ var CurrencyInputController = class extends Controller {
5974
6068
  Intl.NumberFormat,
5975
6069
  this.#locale,
5976
6070
  { useGrouping: true, maximumFractionDigits: 0 },
5977
- this.#locale
6071
+ FALLBACK_LOCALE
5978
6072
  );
5979
6073
  this.#fixed = intlFormatter(
5980
6074
  Intl.NumberFormat,
@@ -5984,15 +6078,15 @@ var CurrencyInputController = class extends Controller {
5984
6078
  minimumFractionDigits: this.#precision,
5985
6079
  maximumFractionDigits: this.#precision
5986
6080
  },
5987
- this.#locale
6081
+ FALLBACK_LOCALE
5988
6082
  );
5989
6083
  this.#accessible = this.#currency ? intlFormatter(
5990
6084
  Intl.NumberFormat,
5991
6085
  this.#locale,
5992
6086
  { style: "currency", currency: this.#currency },
5993
- this.#locale
6087
+ FALLBACK_LOCALE
5994
6088
  ) : this.#fixed;
5995
- const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, this.#locale).formatToParts(
6089
+ const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, FALLBACK_LOCALE).formatToParts(
5996
6090
  11111.1
5997
6091
  );
5998
6092
  this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
@@ -6002,7 +6096,7 @@ var CurrencyInputController = class extends Controller {
6002
6096
  Intl.NumberFormat,
6003
6097
  this.#locale,
6004
6098
  { useGrouping: false },
6005
- this.#locale
6099
+ FALLBACK_LOCALE
6006
6100
  );
6007
6101
  for (let i = 0; i <= 9; i++) {
6008
6102
  const digit = digitFormatter.format(i);
@@ -6377,7 +6471,8 @@ var DateRangePickerController = class extends Controller {
6377
6471
  // A JSON list read through `parseStringList` rather than Stimulus's `Array`
6378
6472
  // type: that reader throws out of the value observer before any callback
6379
6473
  // runs, so one malformed attribute would stop the picker from connecting.
6380
- disabledDates: { type: String, default: "" }
6474
+ disabledDates: { type: String, default: "" },
6475
+ locale: { type: String, default: "" }
6381
6476
  };
6382
6477
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
6383
6478
  static events = ["change", "monthchange"];
@@ -6430,6 +6525,10 @@ var DateRangePickerController = class extends Controller {
6430
6525
  this.#beforeCache.deactivate();
6431
6526
  this.#focusTimer.clearAll();
6432
6527
  }
6528
+ /** Repaints the label when application code changes `locale` at runtime. */
6529
+ localeValueChanged() {
6530
+ this.#repaint.schedule();
6531
+ }
6433
6532
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
6434
6533
  minValueChanged() {
6435
6534
  this.#repaint.schedule();
@@ -6618,8 +6717,7 @@ var DateRangePickerController = class extends Controller {
6618
6717
  const { year, month } = info;
6619
6718
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
6620
6719
  if (this.hasMonthLabelTarget) {
6621
- const lang = document.documentElement.lang || "en";
6622
- const formatter = monthLabelFormatter(lang);
6720
+ const formatter = monthLabelFormatter(resolveLocale(this.element, this.localeValue));
6623
6721
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
6624
6722
  }
6625
6723
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -7198,6 +7296,7 @@ var DirtyFormController = class extends Controller {
7198
7296
  #dirty = false;
7199
7297
  #beforeunloadBound = false;
7200
7298
  #timeouts = new SafeTimeout();
7299
+ #listeners = new ListenerSet();
7201
7300
  /** Whether the active submission still represents the form's current values. */
7202
7301
  #submitting = false;
7203
7302
  #submitAttempt = null;
@@ -7255,24 +7354,19 @@ var DirtyFormController = class extends Controller {
7255
7354
  this.#baseline = this.#serialize();
7256
7355
  this.#setDirty(false);
7257
7356
  this.element.removeAttribute("data-dirty");
7258
- this.element.addEventListener("input", this.#onFieldChange);
7259
- this.element.addEventListener("change", this.#onFieldChange);
7260
- this.element.addEventListener("submit", this.#onSubmit);
7261
- this.element.addEventListener("formdata", this.#onFormData);
7262
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
7263
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
7357
+ this.#listeners.add(this.element, "input", this.#onFieldChange);
7358
+ this.#listeners.add(this.element, "change", this.#onFieldChange);
7359
+ this.#listeners.add(this.element, "submit", this.#onSubmit);
7360
+ this.#listeners.add(this.element, "formdata", this.#onFormData);
7361
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
7362
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
7264
7363
  this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
7265
7364
  form: this.element,
7266
7365
  evaluate: (event) => this.#evaluateVisit(event)
7267
7366
  });
7268
7367
  }
7269
7368
  disconnect() {
7270
- this.element.removeEventListener("input", this.#onFieldChange);
7271
- this.element.removeEventListener("change", this.#onFieldChange);
7272
- this.element.removeEventListener("submit", this.#onSubmit);
7273
- this.element.removeEventListener("formdata", this.#onFormData);
7274
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
7275
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
7369
+ this.#listeners.dispose();
7276
7370
  this.#unregisterVisit?.();
7277
7371
  this.#unregisterVisit = null;
7278
7372
  this.#unbindBeforeUnload();
@@ -8590,6 +8684,80 @@ var FilterController = class extends Controller {
8590
8684
  return this.itemTargets.some((item) => !item.hidden && group.contains(item));
8591
8685
  }
8592
8686
  };
8687
+
8688
+ // src/utils/pausable_timers.ts
8689
+ var PausableTimers = class {
8690
+ #timers = new KeyedTimers();
8691
+ /** Every key armed or held; an entry goes as its timer fires, or with the key. */
8692
+ #entries = /* @__PURE__ */ new Map();
8693
+ /**
8694
+ * Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
8695
+ * A held key stays held and banks `delay` for its resume.
8696
+ */
8697
+ set(key, callback, delay) {
8698
+ const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
8699
+ const entry = { callback, startedAt: 0, remaining: delay, reasons };
8700
+ this.#entries.set(key, entry);
8701
+ if (reasons.size === 0) this.#arm(key, entry);
8702
+ }
8703
+ /**
8704
+ * Holds `key` for `reason` and banks the time left, with a floor of one
8705
+ * millisecond. Reports whether this call is the one that stopped a running
8706
+ * timer, so the caller can write its held-state hook exactly once.
8707
+ */
8708
+ pause(key, reason) {
8709
+ const entry = this.#entries.get(key);
8710
+ if (entry === void 0) return false;
8711
+ entry.reasons.add(reason);
8712
+ if (!this.#timers.has(key)) return false;
8713
+ this.#timers.clear(key);
8714
+ entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
8715
+ return true;
8716
+ }
8717
+ /**
8718
+ * Releases `reason` on `key`, arming the banked time again once no reason is
8719
+ * left. Reports whether this call is the one that started the timer again.
8720
+ */
8721
+ resume(key, reason) {
8722
+ const entry = this.#entries.get(key);
8723
+ if (entry === void 0) return false;
8724
+ entry.reasons.delete(reason);
8725
+ if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
8726
+ this.#arm(key, entry);
8727
+ return true;
8728
+ }
8729
+ /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
8730
+ tracks(key) {
8731
+ return this.#entries.has(key);
8732
+ }
8733
+ /** Cancels `key`'s timer and drops its hold. */
8734
+ clear(key) {
8735
+ this.#timers.clear(key);
8736
+ this.#entries.delete(key);
8737
+ }
8738
+ /**
8739
+ * Cancels every timer and forgets every key. Call this from a controller's
8740
+ * `disconnect()` so neither a timer nor a hold outlives the element.
8741
+ */
8742
+ clearAll() {
8743
+ this.#timers.clearAll();
8744
+ this.#entries.clear();
8745
+ }
8746
+ /** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
8747
+ #arm(key, entry) {
8748
+ entry.startedAt = Date.now();
8749
+ this.#timers.set(
8750
+ key,
8751
+ () => {
8752
+ this.#entries.delete(key);
8753
+ entry.callback();
8754
+ },
8755
+ entry.remaining
8756
+ );
8757
+ }
8758
+ };
8759
+
8760
+ // src/controllers/flash_controller.ts
8593
8761
  var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
8594
8762
  var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
8595
8763
  var FlashController = class extends Controller {
@@ -8601,12 +8769,13 @@ var FlashController = class extends Controller {
8601
8769
  };
8602
8770
  static actions = ["dismiss"];
8603
8771
  static events = ["show", "dismiss", "reconcile"];
8772
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
8604
8773
  #timers = new SafeTimeout();
8774
+ /** Per-message auto-dismiss, held open while the message is hovered or focused. */
8775
+ #dismiss = new PausableTimers();
8605
8776
  #observer = null;
8606
8777
  /** Whether the controller is between `connect()` and `disconnect()`. */
8607
8778
  #connected = false;
8608
- /** Auto-dismiss timer state keyed by message element. */
8609
- #state = /* @__PURE__ */ new Map();
8610
8779
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
8611
8780
  #order = [];
8612
8781
  /**
@@ -8632,8 +8801,8 @@ var FlashController = class extends Controller {
8632
8801
  this.#beforeCache.deactivate();
8633
8802
  this.#stopObserving();
8634
8803
  this.#timers.clearAll();
8804
+ this.#dismiss.clearAll();
8635
8805
  for (const message of this.#order) this.#unbindPause(message);
8636
- this.#state.clear();
8637
8806
  this.#order.length = 0;
8638
8807
  this.#leaving.clear();
8639
8808
  }
@@ -8762,8 +8931,7 @@ var FlashController = class extends Controller {
8762
8931
  * own role to do the announcing (dynamic inserts). Idempotent per message.
8763
8932
  */
8764
8933
  #process(message, bridge) {
8765
- if (this.#state.has(message) || this.#order.includes(message)) return;
8766
- if (this.#leaving.has(message)) return;
8934
+ if (this.#order.includes(message) || this.#leaving.has(message)) return;
8767
8935
  const type = message.getAttribute("data-flash-type") ?? "";
8768
8936
  const assertive = ASSERTIVE_TYPES.has(type);
8769
8937
  if (!message.hasAttribute("role")) {
@@ -8774,11 +8942,7 @@ var FlashController = class extends Controller {
8774
8942
  this.#bindPause(message);
8775
8943
  const text = message.textContent?.trim() ?? "";
8776
8944
  this.dispatch("show", { target: message, detail: { type, message: text } });
8777
- if (bridge && text) {
8778
- window.dispatchEvent(
8779
- new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
8780
- );
8781
- }
8945
+ if (bridge) announce(text, { assertive });
8782
8946
  this.#startTimer(message);
8783
8947
  this.#enforceMax();
8784
8948
  }
@@ -8791,53 +8955,33 @@ var FlashController = class extends Controller {
8791
8955
  this.#beginDismiss(oldest, "limit");
8792
8956
  }
8793
8957
  }
8794
- #startTimer(message, duration = this.durationValue) {
8795
- if (duration <= 0) return;
8796
- const existing = this.#state.get(message);
8797
- if (existing?.id) this.#timers.clear(existing.id);
8798
- const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
8799
- this.#state.set(message, {
8800
- id,
8801
- startedAt: Date.now(),
8802
- remaining: duration,
8803
- paused: existing?.paused ?? /* @__PURE__ */ new Set()
8804
- });
8958
+ /** Arms a message's auto-dismiss; a non-positive `duration` means it never expires. */
8959
+ #startTimer(message) {
8960
+ if (this.durationValue <= 0) return;
8961
+ this.#dismiss.set(message, () => this.#beginDismiss(message, "timeout"), this.durationValue);
8805
8962
  }
8806
8963
  /**
8807
- * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
8808
- * Hover and focus are independent reasons: the remaining time is banked on the
8809
- * first of them, and {@link FlashController.#resume} waits for the last one.
8964
+ * Holds a message's auto-dismiss open while it is hovered or focused
8965
+ * (WCAG 2.2 2.2.1). Hover and focus are independent reasons, and the registry
8966
+ * banks the time left on the first of them and waits for the last.
8810
8967
  */
8811
8968
  #pause(message, reason) {
8812
- const timer = this.#state.get(message);
8813
- if (!timer) return;
8814
- timer.paused.add(reason);
8815
- if (timer.id === 0) return;
8816
- this.#timers.clear(timer.id);
8817
- const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
8818
- this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
8819
- }
8820
- /** Resumes a paused message's auto-dismiss with the banked time. */
8969
+ this.#dismiss.pause(message, reason);
8970
+ }
8971
+ /** Releases one reason, resuming the banked time once no reason is left. */
8821
8972
  #resume(message, reason) {
8822
- const timer = this.#state.get(message);
8823
- if (!timer) return;
8824
- timer.paused.delete(reason);
8825
- if (timer.paused.size > 0) return;
8826
- if (timer.id !== 0) return;
8827
- this.#startTimer(message, timer.remaining);
8973
+ this.#dismiss.resume(message, reason);
8828
8974
  }
8829
8975
  /** Releases every per-message resource: timer, stacking slot, pause listeners. */
8830
8976
  #forget(message) {
8831
- const timer = this.#state.get(message);
8832
- if (timer?.id) this.#timers.clear(timer.id);
8833
- this.#state.delete(message);
8977
+ this.#dismiss.clear(message);
8834
8978
  const index = this.#order.indexOf(message);
8835
8979
  if (index !== -1) this.#order.splice(index, 1);
8836
8980
  this.#unbindPause(message);
8837
8981
  }
8838
8982
  /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
8839
8983
  #beginDismiss(message, reason) {
8840
- if (!this.#state.has(message) && !this.#order.includes(message)) return;
8984
+ if (!this.#order.includes(message)) return;
8841
8985
  this.#forget(message);
8842
8986
  this.#leaving.add(message);
8843
8987
  message.setAttribute("data-flash-state", "leaving");
@@ -9536,6 +9680,7 @@ var FrameLoadingController = class extends Controller {
9536
9680
  #floor = new MinDurationFloor(this.#timeouts);
9537
9681
  #gate = new DetachGate();
9538
9682
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
9683
+ #listeners = new ListenerSet();
9539
9684
  #loading = false;
9540
9685
  /**
9541
9686
  * The optional targets this controller revealed, and the content it marked inert.
@@ -9562,14 +9707,12 @@ var FrameLoadingController = class extends Controller {
9562
9707
  connect() {
9563
9708
  this.#gate.cancel();
9564
9709
  this.#beforeCache.activate();
9565
- this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
9566
- this.element.addEventListener("turbo:frame-load", this.#onEnd);
9567
- this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
9710
+ this.#listeners.add(this.element, "turbo:before-fetch-request", this.#onStart);
9711
+ this.#listeners.add(this.element, "turbo:frame-load", this.#onEnd);
9712
+ this.#listeners.add(this.element, "turbo:fetch-request-error", this.#onEnd);
9568
9713
  }
9569
9714
  disconnect() {
9570
- this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
9571
- this.element.removeEventListener("turbo:frame-load", this.#onEnd);
9572
- this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
9715
+ this.#listeners.dispose();
9573
9716
  this.#beforeCache.deactivate();
9574
9717
  this.#gate.disconnected(this, () => this.#teardown());
9575
9718
  }
@@ -9731,14 +9874,11 @@ var HighlightController = class extends Controller {
9731
9874
  observe: { type: Boolean, default: false }
9732
9875
  };
9733
9876
  static events = ["start", "end"];
9734
- #timeouts = new SafeTimeout();
9735
9877
  /**
9736
- * The removal timer this connection has outstanding for an element. Held weakly so
9737
- * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
9738
- * so a cleared id can never be matched against a recycled one. Which connection owns
9739
- * an element's hook is answered by the shared owner registry above.
9878
+ * The removal timer this connection has outstanding for each element. Which
9879
+ * connection owns an element's hook is answered by the shared owner registry above.
9740
9880
  */
9741
- #pending = /* @__PURE__ */ new WeakMap();
9881
+ #removal = new KeyedTimers();
9742
9882
  #observer = null;
9743
9883
  connect() {
9744
9884
  this.#clearArrivedHook(this.element);
@@ -9755,8 +9895,7 @@ var HighlightController = class extends Controller {
9755
9895
  disconnect() {
9756
9896
  this.#observer?.disconnect();
9757
9897
  this.#observer = null;
9758
- this.#timeouts.clearAll();
9759
- this.#pending = /* @__PURE__ */ new WeakMap();
9898
+ this.#removal.clearAll();
9760
9899
  }
9761
9900
  /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
9762
9901
  #clearArrivedHook(el) {
@@ -9777,13 +9916,15 @@ var HighlightController = class extends Controller {
9777
9916
  this.#releasePending(el);
9778
9917
  el.setAttribute("data-highlight", "true");
9779
9918
  this.dispatch("start", { target: el, detail: { element: el } });
9780
- const id = this.#timeouts.set(() => {
9781
- this.#pending.delete(el);
9782
- hookOwners.delete(el);
9783
- el.removeAttribute("data-highlight");
9784
- this.dispatch("end", { target: el, detail: { element: el } });
9785
- }, this.durationValue);
9786
- this.#pending.set(el, id);
9919
+ this.#removal.set(
9920
+ el,
9921
+ () => {
9922
+ hookOwners.delete(el);
9923
+ el.removeAttribute("data-highlight");
9924
+ this.dispatch("end", { target: el, detail: { element: el } });
9925
+ },
9926
+ this.durationValue
9927
+ );
9787
9928
  hookOwners.set(el, this);
9788
9929
  }
9789
9930
  /**
@@ -9796,10 +9937,9 @@ var HighlightController = class extends Controller {
9796
9937
  if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
9797
9938
  this.#cancelPending(el);
9798
9939
  }
9799
- /** Releases `el`'s pending removal timer, if it has one. */
9940
+ /** Releases `el`'s pending removal timer, if it has one, and this connection's claim. */
9800
9941
  #cancelPending(el) {
9801
- this.#timeouts.clear(this.#pending.get(el) ?? -1);
9802
- this.#pending.delete(el);
9942
+ this.#removal.clear(el);
9803
9943
  hookOwners.delete(el);
9804
9944
  }
9805
9945
  };
@@ -11147,13 +11287,13 @@ var LocalTimeController = class extends Controller {
11147
11287
  };
11148
11288
  if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
11149
11289
  if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
11150
- const formatter = intlFormatter(Intl.DateTimeFormat, this.#locale, options);
11290
+ const formatter = intlFormatter(
11291
+ Intl.DateTimeFormat,
11292
+ resolveLocale(this.element, this.localeValue),
11293
+ options
11294
+ );
11151
11295
  return formatter === null ? null : formatter.format(date);
11152
11296
  }
11153
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
11154
- get #locale() {
11155
- return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
11156
- }
11157
11297
  };
11158
11298
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
11159
11299
  var DEFAULT_MIN_COLUMN_WIDTH = 240;
@@ -11190,13 +11330,6 @@ var MasonryController = class extends Controller {
11190
11330
  #mutationObserver = null;
11191
11331
  /** Last published column count, so `layout` fires only on real changes. */
11192
11332
  #lastColumns = 0;
11193
- /**
11194
- * Re-pack when a descendant resource finishes loading. Images/iframes report a
11195
- * height of 0 until loaded, which would skew the shortest-column packing if the
11196
- * first pass ran before they settled; `load` does not bubble, so this is bound in
11197
- * the capture phase to catch every descendant.
11198
- */
11199
- #onLoad = () => this.#reconcile.schedule();
11200
11333
  /** Resolves the declared column width once, falling back when it is unreadable. */
11201
11334
  minColumnWidthValueChanged() {
11202
11335
  this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
@@ -11231,18 +11364,17 @@ var MasonryController = class extends Controller {
11231
11364
  this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
11232
11365
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
11233
11366
  }
11234
- this.element.addEventListener("load", this.#onLoad, true);
11367
+ this.#layout.observeDescendantLoads(this.element);
11235
11368
  this.#relayout();
11236
11369
  this.#reconcile.activate();
11237
11370
  }
11238
- /** Releases both observers and the load listener so nothing fires after detach. */
11371
+ /** Releases every observation so nothing fires after detach. */
11239
11372
  disconnect() {
11240
11373
  this.#reconcile.cancel();
11241
11374
  this.#released.clear();
11242
11375
  this.#layout.disconnect();
11243
11376
  this.#mutationObserver?.disconnect();
11244
11377
  this.#mutationObserver = null;
11245
- this.element.removeEventListener("load", this.#onLoad, true);
11246
11378
  this.#lastColumns = 0;
11247
11379
  }
11248
11380
  /**
@@ -14297,6 +14429,7 @@ var OptimisticController = class extends Controller {
14297
14429
  /** The submission that owns the applied optimistic state, if any. */
14298
14430
  #pending = null;
14299
14431
  #gate = new DetachGate();
14432
+ #listeners = new ListenerSet();
14300
14433
  /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
14301
14434
  #connected = false;
14302
14435
  connect() {
@@ -14304,13 +14437,12 @@ var OptimisticController = class extends Controller {
14304
14437
  this.#gate.cancel();
14305
14438
  this.#connected = true;
14306
14439
  if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
14307
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
14308
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
14440
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
14441
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
14309
14442
  }
14310
14443
  disconnect() {
14311
14444
  this.#connected = false;
14312
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
14313
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
14445
+ this.#listeners.dispose();
14314
14446
  this.#gate.disconnected(this, () => {
14315
14447
  this.#pending = null;
14316
14448
  });
@@ -15002,7 +15134,7 @@ var OverflowIndicatorController = class extends Controller {
15002
15134
  this.#observedViewport = next;
15003
15135
  this.#layout.observe(next);
15004
15136
  this.#layout.observeViewport();
15005
- next.addEventListener("load", this.#onContentLoad, true);
15137
+ this.#layout.observeDescendantLoads(next);
15006
15138
  this.#syncContentObservation();
15007
15139
  if (typeof MutationObserver !== "undefined") {
15008
15140
  this.#mutationObserver = new MutationObserver(() => {
@@ -15040,16 +15172,13 @@ var OverflowIndicatorController = class extends Controller {
15040
15172
  #stopObservingViewport() {
15041
15173
  this.#mutationObserver?.disconnect();
15042
15174
  this.#mutationObserver = null;
15043
- this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
15175
+ this.#layout.unobserveDescendantLoads();
15044
15176
  if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
15045
15177
  for (const content of this.#observedContent) this.#layout.unobserve(content);
15046
15178
  this.#observedContent.clear();
15047
15179
  this.#observedViewport = null;
15048
15180
  this.#layout.unobserveViewport();
15049
15181
  }
15050
- #onContentLoad = () => {
15051
- if (this.#connected) this.update();
15052
- };
15053
15182
  get #threshold() {
15054
15183
  const value = this.thresholdValue;
15055
15184
  return Number.isFinite(value) ? Math.max(0, value) : 1;
@@ -18580,7 +18709,7 @@ var ReadMoreController = class extends Controller {
18580
18709
  this.#observedContent = next;
18581
18710
  this.#layout.observe(next);
18582
18711
  this.#layout.observeViewport();
18583
- next.addEventListener("load", this.#update, true);
18712
+ this.#layout.observeDescendantLoads(next);
18584
18713
  if (typeof MutationObserver !== "undefined") {
18585
18714
  this.#contentMutationObserver = new MutationObserver(this.#update);
18586
18715
  this.#contentMutationObserver.observe(next, {
@@ -18594,7 +18723,7 @@ var ReadMoreController = class extends Controller {
18594
18723
  this.#deferredHide.releaseAll();
18595
18724
  if (this.#observedContent) {
18596
18725
  this.#layout.unobserve(this.#observedContent);
18597
- this.#observedContent.removeEventListener("load", this.#update, true);
18726
+ this.#layout.unobserveDescendantLoads();
18598
18727
  }
18599
18728
  this.#observedContent = null;
18600
18729
  this.#contentMutationObserver?.disconnect();
@@ -18620,6 +18749,38 @@ var ReadMoreController = class extends Controller {
18620
18749
  trigger.hidden = true;
18621
18750
  }
18622
18751
  };
18752
+
18753
+ // src/utils/frame_coalescer.ts
18754
+ var FrameCoalescer = class {
18755
+ #frame = null;
18756
+ /**
18757
+ * Runs `run` on the next frame, unless a frame is already pending — the first
18758
+ * request of a burst wins and the rest are dropped. The pending frame is
18759
+ * released before `run`, so `run` may request the next one.
18760
+ */
18761
+ schedule(run) {
18762
+ if (this.#frame !== null) return;
18763
+ this.#frame = requestAnimationFrame(() => {
18764
+ this.#frame = null;
18765
+ run();
18766
+ });
18767
+ }
18768
+ /**
18769
+ * Drops the pending frame, and reaches the platform only when there is one.
18770
+ *
18771
+ * There is no handle value that stands for "nothing pending":
18772
+ * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
18773
+ * arrives as a large positive number that the same allocator can hand out, and
18774
+ * an idle cancel would then drop a frame belonging to someone else.
18775
+ */
18776
+ cancel() {
18777
+ if (this.#frame === null) return;
18778
+ cancelAnimationFrame(this.#frame);
18779
+ this.#frame = null;
18780
+ }
18781
+ };
18782
+
18783
+ // src/controllers/reading_progress_controller.ts
18623
18784
  var PROGRESS_PROPERTY = "--stimeo--reading-progress";
18624
18785
  var ReadingProgressController = class extends Controller {
18625
18786
  static events = ["change", "complete"];
@@ -18628,18 +18789,13 @@ var ReadingProgressController = class extends Controller {
18628
18789
  /** The article's own box and the viewport: either changes the span. */
18629
18790
  #layout = new LayoutObserver(() => this.#onScroll());
18630
18791
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
18631
- #frame = null;
18792
+ /** Coalesces scroll bursts, and the connect baseline, into one frame. */
18793
+ #frames = new FrameCoalescer();
18632
18794
  /** Last published progress, so `change`/`complete` fire only on movement. */
18633
18795
  #progress = -1;
18634
18796
  /** False until the connect frame has run: `complete` needs real reading. */
18635
18797
  #baselined = false;
18636
- #onScroll = () => {
18637
- if (this.#frame !== null) return;
18638
- this.#frame = requestAnimationFrame(() => {
18639
- this.#frame = null;
18640
- this.#measure();
18641
- });
18642
- };
18798
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
18643
18799
  connect() {
18644
18800
  this.#progress = -1;
18645
18801
  this.#baselined = false;
@@ -18648,8 +18804,7 @@ var ReadingProgressController = class extends Controller {
18648
18804
  this.#layout.observeViewport();
18649
18805
  this.#beforeCache.activate();
18650
18806
  this.#measure();
18651
- this.#frame = requestAnimationFrame(() => {
18652
- this.#frame = null;
18807
+ this.#frames.schedule(() => {
18653
18808
  this.#measure();
18654
18809
  this.#baselined = true;
18655
18810
  });
@@ -18658,7 +18813,7 @@ var ReadingProgressController = class extends Controller {
18658
18813
  window.removeEventListener("scroll", this.#onScroll, { capture: true });
18659
18814
  this.#layout.disconnect();
18660
18815
  this.#beforeCache.deactivate();
18661
- this.#cancelFrame();
18816
+ this.#frames.cancel();
18662
18817
  this.#lease.returnAll();
18663
18818
  }
18664
18819
  /**
@@ -18669,14 +18824,10 @@ var ReadingProgressController = class extends Controller {
18669
18824
  * value that has already been handed back.
18670
18825
  */
18671
18826
  #rewindForCache() {
18672
- this.#cancelFrame();
18827
+ this.#frames.cancel();
18673
18828
  this.#lease.returnAll();
18674
18829
  this.#progress = -1;
18675
18830
  }
18676
- #cancelFrame() {
18677
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
18678
- this.#frame = null;
18679
- }
18680
18831
  /** Computes and publishes the progress; emits on movement only. */
18681
18832
  #measure() {
18682
18833
  const rect = this.element.getBoundingClientRect();
@@ -18812,11 +18963,11 @@ var RelativeTimeController = class extends Controller {
18812
18963
  * when the runtime rejects that locale.
18813
18964
  */
18814
18965
  get #formatter() {
18815
- return intlFormatter(Intl.RelativeTimeFormat, this.#locale, RELATIVE_OPTIONS);
18816
- }
18817
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
18818
- get #locale() {
18819
- return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
18966
+ return intlFormatter(
18967
+ Intl.RelativeTimeFormat,
18968
+ resolveLocale(this.element, this.localeValue),
18969
+ RELATIVE_OPTIONS
18970
+ );
18820
18971
  }
18821
18972
  };
18822
18973
  var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
@@ -19347,20 +19498,14 @@ var ScrollAreaController = class extends Controller {
19347
19498
  #observedNameIds = /* @__PURE__ */ new Set();
19348
19499
  #observedNameSources = [];
19349
19500
  #fonts = null;
19350
- #scrollFrame = null;
19501
+ /** Coalesces scroll bursts into one position sync per frame. */
19502
+ #frames = new FrameCoalescer();
19351
19503
  #overflowing = false;
19352
19504
  #lastEdge = null;
19353
19505
  #ownedHostMutations = /* @__PURE__ */ new Map();
19354
- #onScroll = () => {
19355
- if (this.#scrollFrame !== null) return;
19356
- this.#scrollFrame = requestAnimationFrame(() => {
19357
- this.#scrollFrame = null;
19358
- const viewport = this.#viewport;
19359
- if (viewport) this.#syncPosition(viewport, this.#overflowing);
19360
- });
19361
- };
19362
- #onLoad = () => {
19363
- this.#refresh();
19506
+ #onScroll = (event) => {
19507
+ const viewport = event.currentTarget;
19508
+ this.#frames.schedule(() => this.#syncPosition(viewport, this.#overflowing));
19364
19509
  };
19365
19510
  #onFontsSettled = () => {
19366
19511
  this.#refresh();
@@ -19375,7 +19520,7 @@ var ScrollAreaController = class extends Controller {
19375
19520
  disconnect() {
19376
19521
  this.#beforeCache.deactivate();
19377
19522
  this.#rebind.cancel();
19378
- this.#cancelScrollFrame();
19523
+ this.#frames.cancel();
19379
19524
  if (this.#viewport) this.#unbindViewport(this.#viewport);
19380
19525
  this.#layout.disconnect();
19381
19526
  this.#unbindFonts();
@@ -19408,7 +19553,7 @@ var ScrollAreaController = class extends Controller {
19408
19553
  return;
19409
19554
  }
19410
19555
  next.addEventListener("scroll", this.#onScroll, { passive: true });
19411
- next.addEventListener("load", this.#onLoad, true);
19556
+ this.#layout.observeDescendantLoads(next);
19412
19557
  this.#layout.observe(next);
19413
19558
  this.#bindContentObserver(next);
19414
19559
  this.#refresh();
@@ -19443,9 +19588,9 @@ var ScrollAreaController = class extends Controller {
19443
19588
  }
19444
19589
  /** Releases every resource and borrowed attribute owned by one former viewport. */
19445
19590
  #unbindViewport(viewport) {
19446
- this.#cancelScrollFrame();
19591
+ this.#frames.cancel();
19447
19592
  viewport.removeEventListener("scroll", this.#onScroll);
19448
- viewport.removeEventListener("load", this.#onLoad, true);
19593
+ this.#layout.unobserveDescendantLoads();
19449
19594
  this.#layout.unobserve(viewport);
19450
19595
  this.#content?.disconnect();
19451
19596
  this.#content = null;
@@ -19460,7 +19605,7 @@ var ScrollAreaController = class extends Controller {
19460
19605
  #refresh() {
19461
19606
  const viewport = this.#viewport;
19462
19607
  if (!viewport) return;
19463
- this.#cancelScrollFrame();
19608
+ this.#frames.cancel();
19464
19609
  this.#syncNameSources(viewport);
19465
19610
  this.#overflowing = this.#syncOverflow(viewport);
19466
19611
  this.#syncKeyboardReach(viewport, this.#overflowing);
@@ -19668,16 +19813,10 @@ var ScrollAreaController = class extends Controller {
19668
19813
  this.#fonts?.removeEventListener("loadingerror", this.#onFontsSettled);
19669
19814
  this.#fonts = null;
19670
19815
  }
19671
- /** Cancels a pending scroll frame. */
19672
- #cancelScrollFrame() {
19673
- if (this.#scrollFrame === null) return;
19674
- cancelAnimationFrame(this.#scrollFrame);
19675
- this.#scrollFrame = null;
19676
- }
19677
19816
  /** Suspends live resources and returns all derived state before snapshotting. */
19678
19817
  #rewindForCache() {
19679
19818
  this.#rebind.cancel();
19680
- this.#cancelScrollFrame();
19819
+ this.#frames.cancel();
19681
19820
  if (this.#viewport) this.#unbindViewport(this.#viewport);
19682
19821
  this.#layout.disconnect();
19683
19822
  this.#unbindFonts();
@@ -19700,8 +19839,8 @@ var ScrollRestoreController = class extends Controller {
19700
19839
  key: { type: String, default: "" },
19701
19840
  axis: { type: String, default: "vertical" }
19702
19841
  };
19703
- /** Pending rAF id that coalesces scroll bursts into one save. */
19704
- #rafId = null;
19842
+ /** Coalesces scroll bursts into one save per frame. */
19843
+ #frames = new FrameCoalescer();
19705
19844
  /** Resolved storage key; empty disables persistence (no key and no id). */
19706
19845
  #storageKey = "";
19707
19846
  /** Last offset captured while the element was live; persisted as-is on teardown. */
@@ -19729,11 +19868,7 @@ var ScrollRestoreController = class extends Controller {
19729
19868
  #connected = false;
19730
19869
  #onScroll = () => {
19731
19870
  this.#capture();
19732
- if (this.#rafId !== null) return;
19733
- this.#rafId = requestAnimationFrame(() => {
19734
- this.#rafId = null;
19735
- this.#persist();
19736
- });
19871
+ this.#frames.schedule(() => this.#persist());
19737
19872
  };
19738
19873
  connect() {
19739
19874
  this.#connected = true;
@@ -19744,7 +19879,7 @@ var ScrollRestoreController = class extends Controller {
19744
19879
  disconnect() {
19745
19880
  this.#connected = false;
19746
19881
  this.element.removeEventListener("scroll", this.#onScroll);
19747
- this.#cancelFrame();
19882
+ this.#frames.cancel();
19748
19883
  this.#persist();
19749
19884
  }
19750
19885
  /** Re-derives the namespace when application code or a Turbo morph moves `key`. */
@@ -19758,7 +19893,7 @@ var ScrollRestoreController = class extends Controller {
19758
19893
  /** Rebuilds the persistence state around the Values as they now read. */
19759
19894
  #resync() {
19760
19895
  if (!this.#connected) return;
19761
- this.#cancelFrame();
19896
+ this.#frames.cancel();
19762
19897
  this.#storageKey = this.#resolveKey();
19763
19898
  this.#echoTop = null;
19764
19899
  this.#echoLeft = null;
@@ -19767,13 +19902,6 @@ var ScrollRestoreController = class extends Controller {
19767
19902
  this.#capturedLeft = false;
19768
19903
  if (this.#storageKey) this.#restore();
19769
19904
  }
19770
- /** Drops the pending coalesced save, if one is queued. */
19771
- #cancelFrame() {
19772
- if (this.#rafId !== null) {
19773
- cancelAnimationFrame(this.#rafId);
19774
- this.#rafId = null;
19775
- }
19776
- }
19777
19905
  /**
19778
19906
  * Records the live scroll offset for the configured axis.
19779
19907
  *
@@ -19870,6 +19998,20 @@ var ScrollRestoreController = class extends Controller {
19870
19998
  return this.axisValue === "horizontal" || this.axisValue === "both";
19871
19999
  }
19872
20000
  };
20001
+
20002
+ // src/utils/scroll_source.ts
20003
+ function resolveScrollContainer(selector) {
20004
+ const match = selector ? document.querySelector(selector) : null;
20005
+ return match instanceof HTMLElement ? match : null;
20006
+ }
20007
+ function resolveScrollSource(selector) {
20008
+ return resolveScrollContainer(selector) ?? window;
20009
+ }
20010
+ function scrollOffset(source) {
20011
+ return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
20012
+ }
20013
+
20014
+ // src/controllers/scroll_visibility_controller.ts
19873
20015
  var DEFAULT_OFFSET = 400;
19874
20016
  var ScrollVisibilityController = class extends Controller {
19875
20017
  static targets = ["element"];
@@ -19881,8 +20023,8 @@ var ScrollVisibilityController = class extends Controller {
19881
20023
  };
19882
20024
  static actions = ["toTop"];
19883
20025
  static events = ["change"];
19884
- /** Pending rAF id that coalesces scroll bursts into one measurement. */
19885
- #rafId = null;
20026
+ /** Coalesces scroll bursts into one measurement per frame. */
20027
+ #frames = new FrameCoalescer();
19886
20028
  /** Previous scroll position, for `direction` mode delta detection. */
19887
20029
  #lastScrollY = 0;
19888
20030
  /** Current visibility, tracked to dispatch `change` only on real transitions. */
@@ -19918,16 +20060,10 @@ var ScrollVisibilityController = class extends Controller {
19918
20060
  #pendingHide = new BlurDeferral(() => {
19919
20061
  if (this.#connected) this.#evaluate();
19920
20062
  });
19921
- #onScroll = () => {
19922
- if (this.#rafId !== null) return;
19923
- this.#rafId = requestAnimationFrame(() => {
19924
- this.#rafId = null;
19925
- this.#evaluate();
19926
- });
19927
- };
20063
+ #onScroll = () => this.#frames.schedule(() => this.#evaluate());
19928
20064
  connect() {
19929
- this.#scrollSource = this.#resolveScrollSource();
19930
- this.#lastScrollY = this.#scrollY();
20065
+ this.#scrollSource = resolveScrollSource(this.#rootSelector);
20066
+ this.#lastScrollY = scrollOffset(this.#scrollSource);
19931
20067
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
19932
20068
  this.#evaluate(false);
19933
20069
  this.#connected = true;
@@ -19935,10 +20071,7 @@ var ScrollVisibilityController = class extends Controller {
19935
20071
  disconnect() {
19936
20072
  this.#connected = false;
19937
20073
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
19938
- if (this.#rafId !== null) {
19939
- cancelAnimationFrame(this.#rafId);
19940
- this.#rafId = null;
19941
- }
20074
+ this.#frames.cancel();
19942
20075
  this.#pendingHide.releaseAll();
19943
20076
  this.#tabindex.returnAll();
19944
20077
  this.#visible = null;
@@ -19997,7 +20130,7 @@ var ScrollVisibilityController = class extends Controller {
19997
20130
  * @stimeoRenderRoot
19998
20131
  */
19999
20132
  #evaluate(notify = true) {
20000
- const y = this.#scrollY();
20133
+ const y = scrollOffset(this.#scrollSource);
20001
20134
  let nextVisible;
20002
20135
  if (this.modeValue === "direction") {
20003
20136
  if (y <= this.#offset) {
@@ -20027,13 +20160,6 @@ var ScrollVisibilityController = class extends Controller {
20027
20160
  if (notify) this.dispatch("change", { detail: { visible: next } });
20028
20161
  }
20029
20162
  /** Resolves the scroll source from `root` (falling back to the window). */
20030
- #resolveScrollSource() {
20031
- if (this.#rootSelector) {
20032
- const root = document.querySelector(this.#rootSelector);
20033
- if (root) return root;
20034
- }
20035
- return window;
20036
- }
20037
20163
  /**
20038
20164
  * The focus owner inside the target, or `null` when focus is elsewhere.
20039
20165
  *
@@ -20046,12 +20172,6 @@ var ScrollVisibilityController = class extends Controller {
20046
20172
  if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
20047
20173
  return null;
20048
20174
  }
20049
- #scrollY() {
20050
- if (this.#scrollSource === window) {
20051
- return window.scrollY ?? window.pageYOffset ?? 0;
20052
- }
20053
- return this.#scrollSource.scrollTop;
20054
- }
20055
20175
  };
20056
20176
  var ANCHOR_ATTRIBUTES = ["href", "data-href"];
20057
20177
  var ScrollspyController = class extends Controller {
@@ -20098,8 +20218,10 @@ var ScrollspyController = class extends Controller {
20098
20218
  * rather than from whatever the selector resolves to now.
20099
20219
  */
20100
20220
  #scrollSource = null;
20101
- /** Pending re-evaluation frame; coalesces a scroll burst into one measurement. */
20102
- #frame = null;
20221
+ /** Coalesces a scroll burst into one measurement per frame. */
20222
+ #frames = new FrameCoalescer();
20223
+ /** The validated `rootSelector`, or `""` when the declaration cannot be parsed. */
20224
+ #rootSelector = "";
20103
20225
  /** Watches the link targets' anchor attributes for an in-place morph rewrite. */
20104
20226
  #anchorObserver = null;
20105
20227
  /** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
@@ -20116,10 +20238,7 @@ var ScrollspyController = class extends Controller {
20116
20238
  this.#anchorObserver?.disconnect();
20117
20239
  this.#anchorObserver = null;
20118
20240
  this.#detachScrollListener();
20119
- if (this.#frame !== null) {
20120
- cancelAnimationFrame(this.#frame);
20121
- this.#frame = null;
20122
- }
20241
+ this.#frames.cancel();
20123
20242
  this.#intersectionStates.clear();
20124
20243
  this.#activeSectionId = "";
20125
20244
  this.#rootElement = null;
@@ -20136,6 +20255,7 @@ var ScrollspyController = class extends Controller {
20136
20255
  this.#initializeObserver();
20137
20256
  }
20138
20257
  rootSelectorValueChanged() {
20258
+ this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
20139
20259
  if (!this.#isConnected) return;
20140
20260
  this.#initializeObserver();
20141
20261
  }
@@ -20229,10 +20349,10 @@ var ScrollspyController = class extends Controller {
20229
20349
  const offset = this.#offset;
20230
20350
  if (rootElement) {
20231
20351
  const containerRect = rootElement.getBoundingClientRect();
20232
- const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - offset;
20352
+ const scrollPosition = scrollOffset(rootElement) + (targetRect.top - containerRect.top) - offset;
20233
20353
  rootElement.scrollTo({ top: scrollPosition, behavior });
20234
20354
  } else {
20235
- const scrollPosition = window.scrollY + targetRect.top - offset;
20355
+ const scrollPosition = scrollOffset(window) + targetRect.top - offset;
20236
20356
  window.scrollTo({ top: scrollPosition, behavior });
20237
20357
  }
20238
20358
  if (this.focusSectionValue) this.#focusSection(targetElement);
@@ -20267,19 +20387,14 @@ var ScrollspyController = class extends Controller {
20267
20387
  /**
20268
20388
  * Resolves `rootSelector` to a scrollable element.
20269
20389
  *
20270
- * @returns The container, or `null` meaning "spy the viewport" when the value
20271
- * is empty, matches nothing, matches a non-HTML element (an SVG node is not a
20272
- * scroll container), or is not a valid selector — a typo in a data attribute
20273
- * must degrade to viewport spying, not leave the controller inert.
20390
+ * @returns The container, or `null` meaning "spy the viewport" when the
20391
+ * declaration is empty, matches nothing, or matches a non-HTML element. A typo
20392
+ * reads as empty: the declaration is validated once when it changes, so a
20393
+ * selector that cannot be parsed degrades to viewport spying rather than
20394
+ * leaving the controller inert.
20274
20395
  */
20275
20396
  #queryRootElement() {
20276
- if (!this.rootSelectorValue) return null;
20277
- try {
20278
- const root = document.querySelector(this.rootSelectorValue);
20279
- return root instanceof HTMLElement ? root : null;
20280
- } catch {
20281
- return null;
20282
- }
20397
+ return resolveScrollContainer(this.#rootSelector);
20283
20398
  }
20284
20399
  /**
20285
20400
  * Re-evaluates once per frame while the reader scrolls.
@@ -20294,13 +20409,7 @@ var ScrollspyController = class extends Controller {
20294
20409
  * end in {@link #evaluateActiveSection} — which measures section rects and the
20295
20410
  * root's top edge at that instant — they converge on the same answer.
20296
20411
  */
20297
- #onScroll = () => {
20298
- if (this.#frame !== null) return;
20299
- this.#frame = requestAnimationFrame(() => {
20300
- this.#frame = null;
20301
- this.#evaluateActiveSection();
20302
- });
20303
- };
20412
+ #onScroll = () => this.#frames.schedule(() => this.#evaluateActiveSection());
20304
20413
  /**
20305
20414
  * Points the `scroll` listener at whatever the reader actually scrolls: the
20306
20415
  * resolved root, or the window when the viewport is spied.
@@ -21198,7 +21307,8 @@ var SmartStickyHeaderController = class extends Controller {
21198
21307
  };
21199
21308
  static events = ["change"];
21200
21309
  #connected = false;
21201
- #frame = null;
21310
+ /** Coalesces scroll bursts into one measurement per frame. */
21311
+ #frames = new FrameCoalescer();
21202
21312
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
21203
21313
  #scrollerEl = window;
21204
21314
  /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
@@ -21215,13 +21325,7 @@ var SmartStickyHeaderController = class extends Controller {
21215
21325
  get #offset() {
21216
21326
  return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET2;
21217
21327
  }
21218
- #onScroll = () => {
21219
- if (this.#frame !== null) return;
21220
- this.#frame = requestAnimationFrame(() => {
21221
- this.#frame = null;
21222
- this.#measure();
21223
- });
21224
- };
21328
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
21225
21329
  /**
21226
21330
  * Focus inside a hidden header must reveal it (WCAG 2.4.7 / 2.4.11); the
21227
21331
  * hold while focus *stays* inside is the `#apply` hide invariant.
@@ -21237,8 +21341,8 @@ var SmartStickyHeaderController = class extends Controller {
21237
21341
  }
21238
21342
  connect() {
21239
21343
  this.#hidden = null;
21240
- this.#scrollerEl = this.#resolveScroller();
21241
- this.#lastY = this.#scrollY;
21344
+ this.#scrollerEl = resolveScrollSource(this.#containerSelector);
21345
+ this.#lastY = scrollOffset(this.#scrollerEl);
21242
21346
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
21243
21347
  this.element.addEventListener("focusin", this.#onFocusin);
21244
21348
  this.#apply(false, false);
@@ -21248,23 +21352,10 @@ var SmartStickyHeaderController = class extends Controller {
21248
21352
  this.#connected = false;
21249
21353
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
21250
21354
  this.element.removeEventListener("focusin", this.#onFocusin);
21251
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
21252
- this.#frame = null;
21253
- }
21254
- /** Resolves the scroll source: the `containerSelector` match, else the window. */
21255
- #resolveScroller() {
21256
- if (this.#containerSelector) {
21257
- const container = document.querySelector(this.#containerSelector);
21258
- if (container) return container;
21259
- }
21260
- return window;
21261
- }
21262
- get #scrollY() {
21263
- const scroller = this.#scrollerEl;
21264
- return scroller === window ? window.scrollY : scroller.scrollTop;
21355
+ this.#frames.cancel();
21265
21356
  }
21266
21357
  #measure() {
21267
- const y = this.#scrollY;
21358
+ const y = scrollOffset(this.#scrollerEl);
21268
21359
  if (y <= this.#offset) {
21269
21360
  this.#lastY = y;
21270
21361
  this.#apply(false);
@@ -22235,6 +22326,7 @@ var SubmitOnceController = class extends Controller {
22235
22326
  static events = ["start", "end", "reconcile"];
22236
22327
  #timers = new SafeTimeout();
22237
22328
  #gate = new DetachGate();
22329
+ #listeners = new ListenerSet();
22238
22330
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
22239
22331
  #sessions = /* @__PURE__ */ new Map();
22240
22332
  #disabled = new AttributeLease("disabled");
@@ -22259,14 +22351,12 @@ var SubmitOnceController = class extends Controller {
22259
22351
  connect() {
22260
22352
  this.#gate.cancel();
22261
22353
  this.#beforeCache.activate();
22262
- this.element.addEventListener("submit", this.#onNativeSubmit, true);
22263
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
22264
- this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
22354
+ this.#listeners.add(this.element, "submit", this.#onNativeSubmit, { capture: true });
22355
+ this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
22356
+ this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
22265
22357
  }
22266
22358
  disconnect() {
22267
- this.element.removeEventListener("submit", this.#onNativeSubmit, true);
22268
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
22269
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
22359
+ this.#listeners.dispose();
22270
22360
  this.#beforeCache.deactivate();
22271
22361
  this.#gate.disconnected(this, () => this.#teardown());
22272
22362
  }
@@ -23693,30 +23783,23 @@ var ToastController = class extends Controller {
23693
23783
  };
23694
23784
  static actions = ["dismiss", "onKeydown", "pause", "resume", "show"];
23695
23785
  static events = ["dismiss", "show"];
23696
- /**
23697
- * Registry for every auto-dismiss and transition-finalize timer the controller
23698
- * schedules. `SafeTimeout` owns *registration and teardown only*; the
23699
- * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
23700
- * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
23701
- */
23786
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
23702
23787
  #timers = new SafeTimeout();
23788
+ /** Per-toast auto-dismiss, held open while the toast is hovered or focused. */
23789
+ #dismiss = new PausableTimers();
23703
23790
  /**
23704
23791
  * Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).
23705
23792
  * Tracked so {@link disconnect} can cancel any that have not fired, preventing a
23706
23793
  * detached element from being mutated after it leaves the DOM (Turbo).
23707
23794
  */
23708
23795
  #rafHandles = /* @__PURE__ */ new Map();
23709
- /** Track active timeouts mapped by each toast element for safe cancellation. */
23710
- #activeTimeouts = /* @__PURE__ */ new Map();
23711
- /** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
23712
- #pauseReasons = /* @__PURE__ */ new Map();
23713
23796
  /** The stable list that owns delegated listeners for dynamically added items. */
23714
23797
  #delegatedList = null;
23715
23798
  connect() {
23716
23799
  this.#connectDelegatedEvents();
23717
23800
  this.enforceMaxLimit();
23718
23801
  for (const item of this.itemTargets) {
23719
- if (!this.#activeTimeouts.has(item) && item.dataset.state !== "leaving") {
23802
+ if (!this.#dismiss.tracks(item) && item.dataset.state !== "leaving") {
23720
23803
  this.#startTimer(item);
23721
23804
  }
23722
23805
  }
@@ -23724,12 +23807,11 @@ var ToastController = class extends Controller {
23724
23807
  disconnect() {
23725
23808
  this.#disconnectDelegatedEvents();
23726
23809
  this.#timers.clearAll();
23810
+ this.#dismiss.clearAll();
23727
23811
  for (const handle of this.#rafHandles.values()) {
23728
23812
  window.cancelAnimationFrame(handle);
23729
23813
  }
23730
23814
  this.#rafHandles.clear();
23731
- this.#activeTimeouts.clear();
23732
- this.#pauseReasons.clear();
23733
23815
  }
23734
23816
  /** Rebinds delegated interaction when Turbo replaces the list target in place. */
23735
23817
  listTargetConnected(element) {
@@ -23742,18 +23824,9 @@ var ToastController = class extends Controller {
23742
23824
  durationValueChanged() {
23743
23825
  for (const item of this.itemTargets) {
23744
23826
  if (item.dataset.state === "leaving") continue;
23745
- const pauseReasons = this.#pauseReasons.get(item);
23746
- this.#clearTimer(item);
23747
23827
  if (this.durationValue <= 0) {
23828
+ this.#dismiss.clear(item);
23748
23829
  item.removeAttribute("data-paused");
23749
- } else if (pauseReasons && pauseReasons.size > 0) {
23750
- this.#pauseReasons.set(item, pauseReasons);
23751
- this.#activeTimeouts.set(item, {
23752
- id: 0,
23753
- startedAt: 0,
23754
- remaining: this.durationValue
23755
- });
23756
- item.setAttribute("data-paused", "true");
23757
23830
  } else {
23758
23831
  this.#startTimer(item);
23759
23832
  }
@@ -23782,7 +23855,7 @@ var ToastController = class extends Controller {
23782
23855
  }
23783
23856
  /** Clears any active timer when a toast is removed from the DOM. */
23784
23857
  itemTargetDisconnected(element) {
23785
- this.#clearTimer(element);
23858
+ this.#dismiss.clear(element);
23786
23859
  this.#cancelAnimation(element);
23787
23860
  }
23788
23861
  /**
@@ -23854,35 +23927,16 @@ var ToastController = class extends Controller {
23854
23927
  */
23855
23928
  pause(event) {
23856
23929
  const item = this.#itemFromEvent(event);
23857
- if (!item || this.durationValue <= 0) return;
23858
- const timeout = this.#activeTimeouts.get(item);
23859
- if (!timeout) return;
23860
- const reasons = this.#pauseReasonsFor(item);
23861
- const wasActive = reasons.size > 0;
23862
- reasons.add(this.#pauseReason(event));
23863
- if (wasActive || timeout.id === 0) return;
23864
- this.#timers.clear(timeout.id);
23865
- const elapsed = Date.now() - timeout.startedAt;
23866
- const remaining = Math.max(0, timeout.remaining - elapsed);
23867
- if (remaining <= 0) {
23868
- this.#removeWithTransition(item, "timeout");
23869
- return;
23930
+ if (item && this.#dismiss.pause(item, this.#pauseReason(event))) {
23931
+ item.setAttribute("data-paused", "true");
23870
23932
  }
23871
- this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
23872
- item.setAttribute("data-paused", "true");
23873
23933
  }
23874
23934
  /** Resumes the auto-dismiss timer once both hover and focus have been released. */
23875
23935
  resume(event) {
23876
23936
  const item = this.#itemFromEvent(event);
23877
- if (!item || this.durationValue <= 0) return;
23878
- const reasons = this.#pauseReasonsFor(item);
23879
- reasons.delete(this.#pauseReason(event));
23880
- if (reasons.size > 0) return;
23881
- const timeout = this.#activeTimeouts.get(item);
23882
- if (!timeout) return;
23883
- if (timeout.id !== 0 || timeout.remaining <= 0) return;
23884
- item.removeAttribute("data-paused");
23885
- this.#startTimer(item, timeout.remaining);
23937
+ if (item && this.#dismiss.resume(item, this.#pauseReason(event))) {
23938
+ item.removeAttribute("data-paused");
23939
+ }
23886
23940
  }
23887
23941
  /** Resolves the toast item element a pause/resume event targets. */
23888
23942
  #itemFromEvent(event) {
@@ -23895,36 +23949,20 @@ var ToastController = class extends Controller {
23895
23949
  #pauseReason(event) {
23896
23950
  return event.type === "focusin" || event.type === "focusout" ? "focus" : "hover";
23897
23951
  }
23898
- /** Lazily creates and returns the active pause-reason set for an item. */
23899
- #pauseReasonsFor(item) {
23900
- let reasons = this.#pauseReasons.get(item);
23901
- if (!reasons) {
23902
- reasons = /* @__PURE__ */ new Set();
23903
- this.#pauseReasons.set(item, reasons);
23904
- }
23905
- return reasons;
23906
- }
23907
- #startTimer(element, duration = this.durationValue) {
23908
- if (duration <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
23952
+ /** Arms a toast's auto-dismiss; a non-positive `duration` means it never expires. */
23953
+ #startTimer(element) {
23954
+ if (this.durationValue <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
23909
23955
  return;
23910
23956
  }
23911
- this.#clearTimer(element);
23912
- const id = this.#timers.set(() => {
23913
- this.#removeWithTransition(element, "timeout");
23914
- }, duration);
23915
- this.#activeTimeouts.set(element, { id, startedAt: Date.now(), remaining: duration });
23916
- }
23917
- #clearTimer(element) {
23918
- const timeout = this.#activeTimeouts.get(element);
23919
- if (timeout) {
23920
- if (timeout.id) this.#timers.clear(timeout.id);
23921
- this.#activeTimeouts.delete(element);
23922
- }
23923
- this.#pauseReasons.delete(element);
23957
+ this.#dismiss.set(
23958
+ element,
23959
+ () => this.#removeWithTransition(element, "timeout"),
23960
+ this.durationValue
23961
+ );
23924
23962
  }
23925
23963
  #removeWithTransition(element, reason) {
23926
23964
  if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
23927
- this.#clearTimer(element);
23965
+ this.#dismiss.clear(element);
23928
23966
  this.#cancelAnimation(element);
23929
23967
  element.setAttribute("data-state", "leaving");
23930
23968
  const finalize = () => {