stimeo-ui 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -5
  3. data/dist/cable/index.js +60 -14
  4. data/dist/controllers/announcer_controller.js +88 -61
  5. data/dist/controllers/auto_submit_controller.js +40 -5
  6. data/dist/controllers/breadcrumb_controller.js +29 -3
  7. data/dist/controllers/calendar_controller.js +51 -10
  8. data/dist/controllers/carousel_controller.js +8 -6
  9. data/dist/controllers/checkbox_controller.js +27 -2
  10. data/dist/controllers/conditional_fields_controller.js +36 -7
  11. data/dist/controllers/currency_input_controller.js +77 -25
  12. data/dist/controllers/date_range_picker_controller.js +43 -10
  13. data/dist/controllers/direct_upload_controller.js +21 -10
  14. data/dist/controllers/dirty_form_controller.js +32 -12
  15. data/dist/controllers/flash_controller.js +145 -43
  16. data/dist/controllers/form_field_controller.js +7 -1
  17. data/dist/controllers/frame_loading_controller.js +29 -6
  18. data/dist/controllers/highlight_controller.js +63 -23
  19. data/dist/controllers/input_mask_controller.js +33 -12
  20. data/dist/controllers/intersection_controller.js +10 -5
  21. data/dist/controllers/lazy_frame_controller.js +10 -5
  22. data/dist/controllers/local_time_controller.js +43 -11
  23. data/dist/controllers/masonry_controller.js +32 -14
  24. data/dist/controllers/menu_controller.js +10 -2
  25. data/dist/controllers/menubar_controller.js +10 -2
  26. data/dist/controllers/navigation_menu_controller.js +8 -2
  27. data/dist/controllers/optimistic_controller.js +28 -4
  28. data/dist/controllers/otp_controller.js +23 -10
  29. data/dist/controllers/overflow_indicator_controller.js +31 -8
  30. data/dist/controllers/overflow_menu_controller.js +39 -4
  31. data/dist/controllers/pointer_drag_controller.js +10 -3
  32. data/dist/controllers/portal_controller.js +21 -10
  33. data/dist/controllers/radio_group_controller.js +11 -3
  34. data/dist/controllers/read_more_controller.js +31 -5
  35. data/dist/controllers/reading_progress_controller.js +65 -19
  36. data/dist/controllers/relative_time_controller.js +40 -9
  37. data/dist/controllers/reset_before_cache_controller.js +23 -10
  38. data/dist/controllers/resizable_controller.js +7 -2
  39. data/dist/controllers/roving_controller.js +7 -3
  40. data/dist/controllers/scroll_area_controller.js +70 -26
  41. data/dist/controllers/scroll_restore_controller.js +37 -16
  42. data/dist/controllers/scroll_visibility_controller.js +71 -42
  43. data/dist/controllers/scrollspy_controller.js +76 -32
  44. data/dist/controllers/smart_sticky_header_controller.js +72 -37
  45. data/dist/controllers/stick_to_bottom_controller.js +29 -3
  46. data/dist/controllers/sticky_observer_controller.js +10 -5
  47. data/dist/controllers/submit_once_controller.js +29 -6
  48. data/dist/controllers/textarea_autosize_controller.js +29 -3
  49. data/dist/controllers/theme_controller.js +23 -13
  50. data/dist/controllers/toast_controller.js +136 -73
  51. data/dist/controllers/toggle_group_controller.js +11 -3
  52. data/dist/controllers/toolbar_controller.js +7 -3
  53. data/dist/index.js +629 -597
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. metadata +2 -2
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 = {
@@ -1853,6 +1928,36 @@ var BulkSelectController = class extends Controller {
1853
1928
  }
1854
1929
  };
1855
1930
 
1931
+ // src/utils/intl_format.ts
1932
+ var formatters = /* @__PURE__ */ new WeakMap();
1933
+ function intlFormatter(factory, locale, options, fallbackLocale) {
1934
+ const cache = cacheFor(factory);
1935
+ const key = cacheKey(locale, options, fallbackLocale);
1936
+ if (cache.has(key)) return cache.get(key);
1937
+ const formatter = construct(factory, locale, options, fallbackLocale);
1938
+ cache.set(key, formatter);
1939
+ return formatter;
1940
+ }
1941
+ function cacheFor(factory) {
1942
+ const existing = formatters.get(factory);
1943
+ if (existing) return existing;
1944
+ const created = /* @__PURE__ */ new Map();
1945
+ formatters.set(factory, created);
1946
+ return created;
1947
+ }
1948
+ function cacheKey(locale, options, fallbackLocale) {
1949
+ return JSON.stringify([locale, options, fallbackLocale]);
1950
+ }
1951
+ function construct(factory, locale, options, fallbackLocale) {
1952
+ try {
1953
+ return new factory(locale, options);
1954
+ } catch (error) {
1955
+ if (!(error instanceof RangeError)) throw error;
1956
+ if (fallbackLocale === void 0) return null;
1957
+ return new factory(fallbackLocale, options);
1958
+ }
1959
+ }
1960
+
1856
1961
  // src/utils/dates.ts
1857
1962
  function toISODateString(date) {
1858
1963
  const year = date.getFullYear();
@@ -1886,14 +1991,14 @@ function parseISOMonthString(monthStr) {
1886
1991
  function toISOMonthString(date) {
1887
1992
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
1888
1993
  }
1994
+ var MONTH_LABEL_OPTIONS = { month: "long", year: "numeric" };
1889
1995
  function monthLabelFormatter(locale) {
1890
- const options = { month: "long", year: "numeric" };
1891
- try {
1892
- return new Intl.DateTimeFormat(locale, options);
1893
- } catch (error) {
1894
- if (!(error instanceof RangeError)) throw error;
1895
- return new Intl.DateTimeFormat("en", options);
1896
- }
1996
+ return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
1997
+ }
1998
+
1999
+ // src/utils/locale.ts
2000
+ function resolveLocale(element, declared = "") {
2001
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
1897
2002
  }
1898
2003
 
1899
2004
  // src/controllers/calendar_controller.ts
@@ -1905,8 +2010,9 @@ var CalendarController = class extends Controller {
1905
2010
  selected: { type: String, default: "" },
1906
2011
  min: { type: String, default: "" },
1907
2012
  max: { type: String, default: "" },
1908
- weekStart: { type: Number, default: 0 }
2013
+ weekStart: { type: Number, default: 0 },
1909
2014
  // 0 = Sunday, 1 = Monday, etc.
2015
+ locale: { type: String, default: "" }
1910
2016
  };
1911
2017
  static actions = ["next", "onKeydown", "prev", "selectByClick"];
1912
2018
  static events = ["monthchange", "select"];
@@ -1948,6 +2054,17 @@ var CalendarController = class extends Controller {
1948
2054
  this.#syncFocusedDateWithMonth();
1949
2055
  this.render();
1950
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
+ }
1951
2068
  /**
1952
2069
  * Stimulus lifecycle callback triggered automatically when the selectedValue changes.
1953
2070
  * Re-renders grid cells to update `aria-selected` indicators.
@@ -2078,8 +2195,8 @@ var CalendarController = class extends Controller {
2078
2195
  const year = monthStart.getFullYear();
2079
2196
  const month = monthStart.getMonth() + 1;
2080
2197
  if (this.hasLabelTarget) {
2081
- const lang = document.documentElement.lang || "en";
2082
- this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
2198
+ const locale = resolveLocale(this.element, this.localeValue);
2199
+ this.labelTarget.textContent = monthLabelFormatter(locale).format(monthStart);
2083
2200
  }
2084
2201
  const days = this.#calculateGridDays(year, month);
2085
2202
  const dayElements = this.dayTargets;
@@ -2246,6 +2363,15 @@ var CalendarController = class extends Controller {
2246
2363
  }
2247
2364
  };
2248
2365
 
2366
+ // src/utils/event_owner.ts
2367
+ function ownerIndex(candidates, node) {
2368
+ if (!(node instanceof Node)) return -1;
2369
+ return candidates.findIndex((candidate) => candidate.contains(node));
2370
+ }
2371
+ function ownerOf(candidates, node) {
2372
+ return candidates[ownerIndex(candidates, node)] ?? null;
2373
+ }
2374
+
2249
2375
  // src/utils/reduced_motion.ts
2250
2376
  function prefersReducedMotion() {
2251
2377
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -2589,9 +2715,7 @@ var CarouselController = class extends Controller {
2589
2715
  }
2590
2716
  /** Position of the picker that is or contains `node`, or `-1` when none does. */
2591
2717
  #pickerIndexFor(node) {
2592
- return this.pickerTargets.findIndex(
2593
- (picker) => node instanceof Node && (picker === node || picker.contains(node))
2594
- );
2718
+ return ownerIndex(this.pickerTargets, node);
2595
2719
  }
2596
2720
  /** Whether a focus transition lands on another control of this same carousel. */
2597
2721
  #focusStaysInside(event) {
@@ -2810,9 +2934,7 @@ function hasModifier(event) {
2810
2934
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
2811
2935
  }
2812
2936
  function hits(elements, node) {
2813
- return elements.some(
2814
- (element) => node instanceof Node && (element === node || element.contains(node))
2815
- );
2937
+ return ownerIndex(elements, node) !== -1;
2816
2938
  }
2817
2939
  function setAttributeIfChanged(element, name, value) {
2818
2940
  if (element.getAttribute(name) !== value) element.setAttribute(name, value);
@@ -3085,6 +3207,7 @@ var CheckboxController = class extends Controller {
3085
3207
  static events = ["change", "reconcile"];
3086
3208
  /** Collapses every lifecycle signal from one DOM update into one derived pass. */
3087
3209
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
3210
+ #listeners = new ListenerSet();
3088
3211
  /** Aggregate this root last settled on, so a derived repair is reported once. */
3089
3212
  #committedState = null;
3090
3213
  /** Watches authored checked-attribute changes on retained target elements. */
@@ -3102,14 +3225,14 @@ var CheckboxController = class extends Controller {
3102
3225
  attributeFilter: ["checked"],
3103
3226
  subtree: true
3104
3227
  });
3105
- this.element.addEventListener("turbo:morph-element", this.#onMorph);
3228
+ this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
3106
3229
  document.addEventListener("reset", this.#onReset, true);
3107
3230
  }
3108
3231
  /** Releases the observer, global reset listener, and every pending reconciliation. */
3109
3232
  disconnect() {
3110
3233
  this.#reconcile.cancel();
3111
3234
  this.#checkedObserver.disconnect();
3112
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
3235
+ this.#listeners.dispose();
3113
3236
  document.removeEventListener("reset", this.#onReset, true);
3114
3237
  }
3115
3238
  /** Reconciles the aggregate for a parent added or replaced at runtime. */
@@ -4819,6 +4942,7 @@ var ConditionalFieldsController = class extends Controller {
4819
4942
  #observing = false;
4820
4943
  /** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
4821
4944
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
4945
+ #listeners = new ListenerSet();
4822
4946
  /** Provides the last-resort focus landmark without claiming an authored tabindex. */
4823
4947
  #rootTabindex = new TabindexLoan();
4824
4948
  /** Watches retained targets and controls whose declarative or reflected state changed. */
@@ -4857,9 +4981,9 @@ var ConditionalFieldsController = class extends Controller {
4857
4981
  subtree: true
4858
4982
  });
4859
4983
  this.#observing = true;
4860
- this.element.addEventListener("change", this.#onTriggerInput);
4861
- this.element.addEventListener("input", this.#onTriggerInput);
4862
- 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);
4863
4987
  document.addEventListener("reset", this.#onReset, true);
4864
4988
  }
4865
4989
  /** Releases all external resources and returns the temporary focus landmark loan. */
@@ -4867,9 +4991,7 @@ var ConditionalFieldsController = class extends Controller {
4867
4991
  this.#reconcile.cancel();
4868
4992
  this.#observing = false;
4869
4993
  this.#observer.disconnect();
4870
- this.element.removeEventListener("change", this.#onTriggerInput);
4871
- this.element.removeEventListener("input", this.#onTriggerInput);
4872
- this.element.removeEventListener("turbo:morph-element", this.#onMorph);
4994
+ this.#listeners.dispose();
4873
4995
  document.removeEventListener("reset", this.#onReset, true);
4874
4996
  this.#rootTabindex.returnAll();
4875
4997
  }
@@ -5068,7 +5190,7 @@ var ConditionalFieldsController = class extends Controller {
5068
5190
  }
5069
5191
  /** Whether an authored mutation target belongs to a trigger, region, or managed control. */
5070
5192
  #elementAffectsState(target, triggers, regions) {
5071
- return triggers.some((trigger) => trigger === target || trigger.contains(target)) || regions.some((region) => region === target || region.contains(target));
5193
+ return ownerIndex(triggers, target) !== -1 || ownerIndex(regions, target) !== -1;
5072
5194
  }
5073
5195
  /** Whether a form owns at least one current trigger, including `form=` associations. */
5074
5196
  #hasTriggerOwnedBy(form) {
@@ -5678,10 +5800,11 @@ function toHalfWidth(text) {
5678
5800
  }
5679
5801
 
5680
5802
  // src/controllers/currency_input_controller.ts
5803
+ var FALLBACK_LOCALE = "en-US";
5681
5804
  var CurrencyInputController = class extends Controller {
5682
5805
  static targets = ["display", "field", "srValue"];
5683
5806
  static values = {
5684
- locale: { type: String, default: "en-US" },
5807
+ locale: { type: String, default: "" },
5685
5808
  currency: { type: String, default: "" },
5686
5809
  precision: { type: Number, default: 2 }
5687
5810
  };
@@ -5691,7 +5814,7 @@ var CurrencyInputController = class extends Controller {
5691
5814
  #lastValue = null;
5692
5815
  #started = false;
5693
5816
  /** Validated mirrors of the Values; the hot path never reads a raw Value. */
5694
- #locale = "en-US";
5817
+ #locale;
5695
5818
  #currency = "";
5696
5819
  #precision = 2;
5697
5820
  /** Formatters rebuilt only when a Value changes, never per keystroke. */
@@ -5928,37 +6051,53 @@ var CurrencyInputController = class extends Controller {
5928
6051
  * formatters from the validated set.
5929
6052
  */
5930
6053
  #revalidate() {
5931
- this.#locale = "en-US";
5932
- try {
5933
- new Intl.NumberFormat(this.localeValue);
5934
- this.#locale = this.localeValue;
5935
- } catch {
5936
- }
6054
+ const resolved = resolveLocale(this.element, this.localeValue);
6055
+ const usable = intlFormatter(Intl.NumberFormat, resolved, {});
6056
+ this.#locale = usable === null ? FALLBACK_LOCALE : resolved;
5937
6057
  const precision = this.precisionValue;
5938
6058
  this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
5939
6059
  this.#currency = "";
5940
6060
  if (this.currencyValue !== "") {
5941
- try {
5942
- new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.currencyValue });
5943
- this.#currency = this.currencyValue;
5944
- } catch {
5945
- }
6061
+ const declaredCurrency = intlFormatter(Intl.NumberFormat, this.#locale, {
6062
+ style: "currency",
6063
+ currency: this.currencyValue
6064
+ });
6065
+ if (declaredCurrency !== null) this.#currency = this.currencyValue;
5946
6066
  }
5947
- this.#grouping = new Intl.NumberFormat(this.#locale, {
5948
- useGrouping: true,
5949
- maximumFractionDigits: 0
5950
- });
5951
- this.#fixed = new Intl.NumberFormat(this.#locale, {
5952
- useGrouping: true,
5953
- minimumFractionDigits: this.#precision,
5954
- maximumFractionDigits: this.#precision
5955
- });
5956
- this.#accessible = this.#currency ? new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.#currency }) : this.#fixed;
5957
- const parts = new Intl.NumberFormat(this.#locale).formatToParts(11111.1);
6067
+ this.#grouping = intlFormatter(
6068
+ Intl.NumberFormat,
6069
+ this.#locale,
6070
+ { useGrouping: true, maximumFractionDigits: 0 },
6071
+ FALLBACK_LOCALE
6072
+ );
6073
+ this.#fixed = intlFormatter(
6074
+ Intl.NumberFormat,
6075
+ this.#locale,
6076
+ {
6077
+ useGrouping: true,
6078
+ minimumFractionDigits: this.#precision,
6079
+ maximumFractionDigits: this.#precision
6080
+ },
6081
+ FALLBACK_LOCALE
6082
+ );
6083
+ this.#accessible = this.#currency ? intlFormatter(
6084
+ Intl.NumberFormat,
6085
+ this.#locale,
6086
+ { style: "currency", currency: this.#currency },
6087
+ FALLBACK_LOCALE
6088
+ ) : this.#fixed;
6089
+ const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, FALLBACK_LOCALE).formatToParts(
6090
+ 11111.1
6091
+ );
5958
6092
  this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
5959
6093
  this.#decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
5960
6094
  this.#digits.clear();
5961
- const digitFormatter = new Intl.NumberFormat(this.#locale, { useGrouping: false });
6095
+ const digitFormatter = intlFormatter(
6096
+ Intl.NumberFormat,
6097
+ this.#locale,
6098
+ { useGrouping: false },
6099
+ FALLBACK_LOCALE
6100
+ );
5962
6101
  for (let i = 0; i <= 9; i++) {
5963
6102
  const digit = digitFormatter.format(i);
5964
6103
  if (digit !== String(i)) this.#digits.set(digit, String(i));
@@ -6332,7 +6471,8 @@ var DateRangePickerController = class extends Controller {
6332
6471
  // A JSON list read through `parseStringList` rather than Stimulus's `Array`
6333
6472
  // type: that reader throws out of the value observer before any callback
6334
6473
  // runs, so one malformed attribute would stop the picker from connecting.
6335
- disabledDates: { type: String, default: "" }
6474
+ disabledDates: { type: String, default: "" },
6475
+ locale: { type: String, default: "" }
6336
6476
  };
6337
6477
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
6338
6478
  static events = ["change", "monthchange"];
@@ -6385,6 +6525,10 @@ var DateRangePickerController = class extends Controller {
6385
6525
  this.#beforeCache.deactivate();
6386
6526
  this.#focusTimer.clearAll();
6387
6527
  }
6528
+ /** Repaints the label when application code changes `locale` at runtime. */
6529
+ localeValueChanged() {
6530
+ this.#repaint.schedule();
6531
+ }
6388
6532
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
6389
6533
  minValueChanged() {
6390
6534
  this.#repaint.schedule();
@@ -6573,8 +6717,7 @@ var DateRangePickerController = class extends Controller {
6573
6717
  const { year, month } = info;
6574
6718
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
6575
6719
  if (this.hasMonthLabelTarget) {
6576
- const lang = document.documentElement.lang || "en";
6577
- const formatter = monthLabelFormatter(lang);
6720
+ const formatter = monthLabelFormatter(resolveLocale(this.element, this.localeValue));
6578
6721
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
6579
6722
  }
6580
6723
  const [rangeStart, rangeEnd] = this.#visualRange();
@@ -6790,6 +6933,43 @@ var DialogController = class extends Controller {
6790
6933
  return this.hasDialogTarget && !this.dialogTarget.hidden;
6791
6934
  }
6792
6935
  };
6936
+
6937
+ // src/utils/declared_value.ts
6938
+ function parseDeclared(raw, parse, fallback) {
6939
+ try {
6940
+ return parse(raw);
6941
+ } catch {
6942
+ return fallback;
6943
+ }
6944
+ }
6945
+ function validSelector(element, raw, fallback) {
6946
+ if (raw.length === 0) return fallback;
6947
+ return parseDeclared(
6948
+ raw,
6949
+ (selector) => {
6950
+ element.matches(selector);
6951
+ return selector;
6952
+ },
6953
+ fallback
6954
+ );
6955
+ }
6956
+ function compileRegExp(source, anchor = "none") {
6957
+ return parseDeclared(
6958
+ source,
6959
+ (text) => {
6960
+ const bare = new RegExp(text);
6961
+ return anchor === "exact" ? new RegExp(`^(?:${text})$`) : bare;
6962
+ },
6963
+ null
6964
+ );
6965
+ }
6966
+ function parseJsonObject(raw) {
6967
+ const parsed = parseDeclared(raw, (text) => JSON.parse(text), null);
6968
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
6969
+ return parsed;
6970
+ }
6971
+
6972
+ // src/controllers/direct_upload_controller.ts
6793
6973
  var REMOVE_DELAY = 4e3;
6794
6974
  var DirectUploadController = class extends Controller {
6795
6975
  static targets = ["list", "row"];
@@ -6844,16 +7024,7 @@ var DirectUploadController = class extends Controller {
6844
7024
  }
6845
7025
  /** Validates `scope` once so the per-event path never parses or throws. */
6846
7026
  scopeValueChanged() {
6847
- const selector = this.scopeValue;
6848
- if (selector.length > 0) {
6849
- try {
6850
- this.element.matches(selector);
6851
- this.#scopeSelector = selector;
6852
- return;
6853
- } catch {
6854
- }
6855
- }
6856
- this.#scopeSelector = "";
7027
+ this.#scopeSelector = validSelector(this.element, this.scopeValue, "");
6857
7028
  }
6858
7029
  /** Updates a row's progress and the aggregate, emitting `progress`. */
6859
7030
  #updateProgress(id, percent, name) {
@@ -7125,6 +7296,7 @@ var DirtyFormController = class extends Controller {
7125
7296
  #dirty = false;
7126
7297
  #beforeunloadBound = false;
7127
7298
  #timeouts = new SafeTimeout();
7299
+ #listeners = new ListenerSet();
7128
7300
  /** Whether the active submission still represents the form's current values. */
7129
7301
  #submitting = false;
7130
7302
  #submitAttempt = null;
@@ -7182,24 +7354,19 @@ var DirtyFormController = class extends Controller {
7182
7354
  this.#baseline = this.#serialize();
7183
7355
  this.#setDirty(false);
7184
7356
  this.element.removeAttribute("data-dirty");
7185
- this.element.addEventListener("input", this.#onFieldChange);
7186
- this.element.addEventListener("change", this.#onFieldChange);
7187
- this.element.addEventListener("submit", this.#onSubmit);
7188
- this.element.addEventListener("formdata", this.#onFormData);
7189
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
7190
- 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);
7191
7363
  this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
7192
7364
  form: this.element,
7193
7365
  evaluate: (event) => this.#evaluateVisit(event)
7194
7366
  });
7195
7367
  }
7196
7368
  disconnect() {
7197
- this.element.removeEventListener("input", this.#onFieldChange);
7198
- this.element.removeEventListener("change", this.#onFieldChange);
7199
- this.element.removeEventListener("submit", this.#onSubmit);
7200
- this.element.removeEventListener("formdata", this.#onFormData);
7201
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
7202
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
7369
+ this.#listeners.dispose();
7203
7370
  this.#unregisterVisit?.();
7204
7371
  this.#unregisterVisit = null;
7205
7372
  this.#unbindBeforeUnload();
@@ -8517,6 +8684,80 @@ var FilterController = class extends Controller {
8517
8684
  return this.itemTargets.some((item) => !item.hidden && group.contains(item));
8518
8685
  }
8519
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
8520
8761
  var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
8521
8762
  var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
8522
8763
  var FlashController = class extends Controller {
@@ -8528,12 +8769,13 @@ var FlashController = class extends Controller {
8528
8769
  };
8529
8770
  static actions = ["dismiss"];
8530
8771
  static events = ["show", "dismiss", "reconcile"];
8772
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
8531
8773
  #timers = new SafeTimeout();
8774
+ /** Per-message auto-dismiss, held open while the message is hovered or focused. */
8775
+ #dismiss = new PausableTimers();
8532
8776
  #observer = null;
8533
8777
  /** Whether the controller is between `connect()` and `disconnect()`. */
8534
8778
  #connected = false;
8535
- /** Auto-dismiss timer state keyed by message element. */
8536
- #state = /* @__PURE__ */ new Map();
8537
8779
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
8538
8780
  #order = [];
8539
8781
  /**
@@ -8559,8 +8801,8 @@ var FlashController = class extends Controller {
8559
8801
  this.#beforeCache.deactivate();
8560
8802
  this.#stopObserving();
8561
8803
  this.#timers.clearAll();
8804
+ this.#dismiss.clearAll();
8562
8805
  for (const message of this.#order) this.#unbindPause(message);
8563
- this.#state.clear();
8564
8806
  this.#order.length = 0;
8565
8807
  this.#leaving.clear();
8566
8808
  }
@@ -8689,8 +8931,7 @@ var FlashController = class extends Controller {
8689
8931
  * own role to do the announcing (dynamic inserts). Idempotent per message.
8690
8932
  */
8691
8933
  #process(message, bridge) {
8692
- if (this.#state.has(message) || this.#order.includes(message)) return;
8693
- if (this.#leaving.has(message)) return;
8934
+ if (this.#order.includes(message) || this.#leaving.has(message)) return;
8694
8935
  const type = message.getAttribute("data-flash-type") ?? "";
8695
8936
  const assertive = ASSERTIVE_TYPES.has(type);
8696
8937
  if (!message.hasAttribute("role")) {
@@ -8701,11 +8942,7 @@ var FlashController = class extends Controller {
8701
8942
  this.#bindPause(message);
8702
8943
  const text = message.textContent?.trim() ?? "";
8703
8944
  this.dispatch("show", { target: message, detail: { type, message: text } });
8704
- if (bridge && text) {
8705
- window.dispatchEvent(
8706
- new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
8707
- );
8708
- }
8945
+ if (bridge) announce(text, { assertive });
8709
8946
  this.#startTimer(message);
8710
8947
  this.#enforceMax();
8711
8948
  }
@@ -8718,53 +8955,33 @@ var FlashController = class extends Controller {
8718
8955
  this.#beginDismiss(oldest, "limit");
8719
8956
  }
8720
8957
  }
8721
- #startTimer(message, duration = this.durationValue) {
8722
- if (duration <= 0) return;
8723
- const existing = this.#state.get(message);
8724
- if (existing?.id) this.#timers.clear(existing.id);
8725
- const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
8726
- this.#state.set(message, {
8727
- id,
8728
- startedAt: Date.now(),
8729
- remaining: duration,
8730
- paused: existing?.paused ?? /* @__PURE__ */ new Set()
8731
- });
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);
8732
8962
  }
8733
8963
  /**
8734
- * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
8735
- * Hover and focus are independent reasons: the remaining time is banked on the
8736
- * 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.
8737
8967
  */
8738
8968
  #pause(message, reason) {
8739
- const timer = this.#state.get(message);
8740
- if (!timer) return;
8741
- timer.paused.add(reason);
8742
- if (timer.id === 0) return;
8743
- this.#timers.clear(timer.id);
8744
- const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
8745
- this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
8746
- }
8747
- /** 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. */
8748
8972
  #resume(message, reason) {
8749
- const timer = this.#state.get(message);
8750
- if (!timer) return;
8751
- timer.paused.delete(reason);
8752
- if (timer.paused.size > 0) return;
8753
- if (timer.id !== 0) return;
8754
- this.#startTimer(message, timer.remaining);
8973
+ this.#dismiss.resume(message, reason);
8755
8974
  }
8756
8975
  /** Releases every per-message resource: timer, stacking slot, pause listeners. */
8757
8976
  #forget(message) {
8758
- const timer = this.#state.get(message);
8759
- if (timer?.id) this.#timers.clear(timer.id);
8760
- this.#state.delete(message);
8977
+ this.#dismiss.clear(message);
8761
8978
  const index = this.#order.indexOf(message);
8762
8979
  if (index !== -1) this.#order.splice(index, 1);
8763
8980
  this.#unbindPause(message);
8764
8981
  }
8765
8982
  /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
8766
8983
  #beginDismiss(message, reason) {
8767
- if (!this.#state.has(message) && !this.#order.includes(message)) return;
8984
+ if (!this.#order.includes(message)) return;
8768
8985
  this.#forget(message);
8769
8986
  this.#leaving.add(message);
8770
8987
  message.setAttribute("data-flash-state", "leaving");
@@ -9088,7 +9305,7 @@ var FormFieldController = class _FormFieldController extends Controller {
9088
9305
  return this.errorTargets.includes(target);
9089
9306
  return record.attributeName === "id" && [...this.descriptionTargets, ...this.errorTargets].includes(target);
9090
9307
  }
9091
- return this.errorTargets.some((error) => error === target || error.contains(target));
9308
+ return ownerIndex(this.errorTargets, target) !== -1;
9092
9309
  }
9093
9310
  /** Returns borrowed control ARIA before Turbo snapshots the page. */
9094
9311
  #rewindForCache() {
@@ -9463,6 +9680,7 @@ var FrameLoadingController = class extends Controller {
9463
9680
  #floor = new MinDurationFloor(this.#timeouts);
9464
9681
  #gate = new DetachGate();
9465
9682
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
9683
+ #listeners = new ListenerSet();
9466
9684
  #loading = false;
9467
9685
  /**
9468
9686
  * The optional targets this controller revealed, and the content it marked inert.
@@ -9489,14 +9707,12 @@ var FrameLoadingController = class extends Controller {
9489
9707
  connect() {
9490
9708
  this.#gate.cancel();
9491
9709
  this.#beforeCache.activate();
9492
- this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
9493
- this.element.addEventListener("turbo:frame-load", this.#onEnd);
9494
- 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);
9495
9713
  }
9496
9714
  disconnect() {
9497
- this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
9498
- this.element.removeEventListener("turbo:frame-load", this.#onEnd);
9499
- this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
9715
+ this.#listeners.dispose();
9500
9716
  this.#beforeCache.deactivate();
9501
9717
  this.#gate.disconnected(this, () => this.#teardown());
9502
9718
  }
@@ -9658,14 +9874,11 @@ var HighlightController = class extends Controller {
9658
9874
  observe: { type: Boolean, default: false }
9659
9875
  };
9660
9876
  static events = ["start", "end"];
9661
- #timeouts = new SafeTimeout();
9662
9877
  /**
9663
- * The removal timer this connection has outstanding for an element. Held weakly so
9664
- * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
9665
- * so a cleared id can never be matched against a recycled one. Which connection owns
9666
- * 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.
9667
9880
  */
9668
- #pending = /* @__PURE__ */ new WeakMap();
9881
+ #removal = new KeyedTimers();
9669
9882
  #observer = null;
9670
9883
  connect() {
9671
9884
  this.#clearArrivedHook(this.element);
@@ -9682,8 +9895,7 @@ var HighlightController = class extends Controller {
9682
9895
  disconnect() {
9683
9896
  this.#observer?.disconnect();
9684
9897
  this.#observer = null;
9685
- this.#timeouts.clearAll();
9686
- this.#pending = /* @__PURE__ */ new WeakMap();
9898
+ this.#removal.clearAll();
9687
9899
  }
9688
9900
  /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
9689
9901
  #clearArrivedHook(el) {
@@ -9704,13 +9916,15 @@ var HighlightController = class extends Controller {
9704
9916
  this.#releasePending(el);
9705
9917
  el.setAttribute("data-highlight", "true");
9706
9918
  this.dispatch("start", { target: el, detail: { element: el } });
9707
- const id = this.#timeouts.set(() => {
9708
- this.#pending.delete(el);
9709
- hookOwners.delete(el);
9710
- el.removeAttribute("data-highlight");
9711
- this.dispatch("end", { target: el, detail: { element: el } });
9712
- }, this.durationValue);
9713
- 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
+ );
9714
9928
  hookOwners.set(el, this);
9715
9929
  }
9716
9930
  /**
@@ -9723,10 +9937,9 @@ var HighlightController = class extends Controller {
9723
9937
  if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
9724
9938
  this.#cancelPending(el);
9725
9939
  }
9726
- /** 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. */
9727
9941
  #cancelPending(el) {
9728
- this.#timeouts.clear(this.#pending.get(el) ?? -1);
9729
- this.#pending.delete(el);
9942
+ this.#removal.clear(el);
9730
9943
  hookOwners.delete(el);
9731
9944
  }
9732
9945
  };
@@ -10008,22 +10221,19 @@ function applyMask(value, pattern, tokens) {
10008
10221
  }
10009
10222
  function compileTokens(declaration) {
10010
10223
  const map = /* @__PURE__ */ new Map();
10011
- for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {
10012
- try {
10013
- map.set(key, new RegExp(`^(?:${source})$`));
10014
- } catch {
10015
- }
10224
+ for (const [key, source] of Object.entries(DEFAULT_TOKENS)) {
10225
+ const compiled = compileRegExp(source, "exact");
10226
+ if (compiled) map.set(key, compiled);
10227
+ }
10228
+ for (const [key, source] of Object.entries(parseTokens(declaration))) {
10229
+ const compiled = compileRegExp(source, "exact");
10230
+ if (compiled) map.set(key, compiled);
10016
10231
  }
10017
10232
  return map;
10018
10233
  }
10019
10234
  function parseTokens(declaration) {
10020
- let parsed;
10021
- try {
10022
- parsed = JSON.parse(declaration);
10023
- } catch {
10024
- return {};
10025
- }
10026
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
10235
+ const parsed = parseJsonObject(declaration);
10236
+ if (parsed === null) return {};
10027
10237
  const tokens = {};
10028
10238
  for (const [key, source] of Object.entries(parsed)) {
10029
10239
  if (typeof source === "string") tokens[key] = source;
@@ -10235,11 +10445,7 @@ function isBeforeRootStart(entry) {
10235
10445
  }
10236
10446
  function queryRoot(selector) {
10237
10447
  if (!selector) return null;
10238
- try {
10239
- return document.querySelector(selector);
10240
- } catch {
10241
- }
10242
- return null;
10448
+ return parseDeclared(selector, (raw) => document.querySelector(raw), null);
10243
10449
  }
10244
10450
  var IntersectionWatcher = class {
10245
10451
  #onEntries;
@@ -11070,8 +11276,9 @@ var LocalTimeController = class extends Controller {
11070
11276
  /**
11071
11277
  * Formats `date` with `Intl.DateTimeFormat`, including each style only when it
11072
11278
  * is a valid keyword (so a consumer can show date-only or time-only by clearing
11073
- * the other). Returns `null` when neither style is usable or `Intl` throws, so
11074
- * the caller can leave the authored text untouched.
11279
+ * the other). Returns `null` when neither style is usable or the declaration
11280
+ * names a locale or time zone `Intl` rejects, so the caller can leave the
11281
+ * authored text untouched.
11075
11282
  */
11076
11283
  #applyFormat(date, dateStyle, timeStyle) {
11077
11284
  const options = {
@@ -11080,15 +11287,12 @@ var LocalTimeController = class extends Controller {
11080
11287
  };
11081
11288
  if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
11082
11289
  if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
11083
- try {
11084
- return new Intl.DateTimeFormat(this.#locale, options).format(date);
11085
- } catch {
11086
- return null;
11087
- }
11088
- }
11089
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
11090
- get #locale() {
11091
- return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
11290
+ const formatter = intlFormatter(
11291
+ Intl.DateTimeFormat,
11292
+ resolveLocale(this.element, this.localeValue),
11293
+ options
11294
+ );
11295
+ return formatter === null ? null : formatter.format(date);
11092
11296
  }
11093
11297
  };
11094
11298
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
@@ -11126,13 +11330,6 @@ var MasonryController = class extends Controller {
11126
11330
  #mutationObserver = null;
11127
11331
  /** Last published column count, so `layout` fires only on real changes. */
11128
11332
  #lastColumns = 0;
11129
- /**
11130
- * Re-pack when a descendant resource finishes loading. Images/iframes report a
11131
- * height of 0 until loaded, which would skew the shortest-column packing if the
11132
- * first pass ran before they settled; `load` does not bubble, so this is bound in
11133
- * the capture phase to catch every descendant.
11134
- */
11135
- #onLoad = () => this.#reconcile.schedule();
11136
11333
  /** Resolves the declared column width once, falling back when it is unreadable. */
11137
11334
  minColumnWidthValueChanged() {
11138
11335
  this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
@@ -11152,7 +11349,7 @@ var MasonryController = class extends Controller {
11152
11349
  *
11153
11350
  * The removal is queued rather than immediate because teardown reports every
11154
11351
  * target as disconnected: doing it here would strip the whole grid just before
11155
- * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
11352
+ * a Turbo snapshot is taken. The coalescer's `cancel` drops the queue
11156
11353
  * with the pass, so only a genuine target change reaches it.
11157
11354
  */
11158
11355
  itemTargetDisconnected(item) {
@@ -11167,18 +11364,17 @@ var MasonryController = class extends Controller {
11167
11364
  this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
11168
11365
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
11169
11366
  }
11170
- this.element.addEventListener("load", this.#onLoad, true);
11367
+ this.#layout.observeDescendantLoads(this.element);
11171
11368
  this.#relayout();
11172
11369
  this.#reconcile.activate();
11173
11370
  }
11174
- /** Releases both observers and the load listener so nothing fires after detach. */
11371
+ /** Releases every observation so nothing fires after detach. */
11175
11372
  disconnect() {
11176
11373
  this.#reconcile.cancel();
11177
11374
  this.#released.clear();
11178
11375
  this.#layout.disconnect();
11179
11376
  this.#mutationObserver?.disconnect();
11180
11377
  this.#mutationObserver = null;
11181
- this.element.removeEventListener("load", this.#onLoad, true);
11182
11378
  this.#lastColumns = 0;
11183
11379
  }
11184
11380
  /**
@@ -11433,8 +11629,7 @@ var MenuController = class extends Controller {
11433
11629
  };
11434
11630
  /** The item target that is, or contains, `node`; `null` when it is neither. */
11435
11631
  #itemFrom(node) {
11436
- if (!(node instanceof Node)) return null;
11437
- return this.itemTargets.find((item) => item === node || item.contains(node)) ?? null;
11632
+ return ownerOf(this.itemTargets, node);
11438
11633
  }
11439
11634
  /**
11440
11635
  * First look at every click inside the controller, and the only point at which
@@ -11810,8 +12005,7 @@ var MenubarController = class extends Controller {
11810
12005
  * menu without moving focus.
11811
12006
  */
11812
12007
  #dismissOpenMenu() {
11813
- const active = document.activeElement;
11814
- const menu = this.menuTargets.find((candidate) => candidate.contains(active));
12008
+ const menu = ownerOf(this.menuTargets, document.activeElement);
11815
12009
  if (menu) {
11816
12010
  const top = this.#topFor(menu);
11817
12011
  this.#closeMenu(top);
@@ -13225,7 +13419,7 @@ var NavigationMenuController = class extends Controller {
13225
13419
  */
13226
13420
  #onPointerLeave = (event) => {
13227
13421
  const next = event instanceof MouseEvent ? event.relatedTarget : null;
13228
- if (next instanceof Node && this.#hoverElements.some((el) => el.contains(next))) return;
13422
+ if (ownerIndex(this.#hoverElements, next) !== -1) return;
13229
13423
  this.#hoveredTrigger = null;
13230
13424
  this.#hoverTimers.clearAll();
13231
13425
  this.#hoverTimers.set(() => this.#closeFromHover(), this.hoverDelayValue);
@@ -13295,7 +13489,7 @@ var NavigationMenuController = class extends Controller {
13295
13489
  */
13296
13490
  get #hoverElements() {
13297
13491
  const areas = this.hoverAreaTargets;
13298
- const covered = (element) => areas.some((area) => area.contains(element));
13492
+ const covered = (element) => ownerIndex(areas, element) !== -1;
13299
13493
  return [
13300
13494
  ...areas,
13301
13495
  ...this.triggerTargets.filter((trigger) => !covered(trigger)),
@@ -14235,6 +14429,7 @@ var OptimisticController = class extends Controller {
14235
14429
  /** The submission that owns the applied optimistic state, if any. */
14236
14430
  #pending = null;
14237
14431
  #gate = new DetachGate();
14432
+ #listeners = new ListenerSet();
14238
14433
  /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
14239
14434
  #connected = false;
14240
14435
  connect() {
@@ -14242,13 +14437,12 @@ var OptimisticController = class extends Controller {
14242
14437
  this.#gate.cancel();
14243
14438
  this.#connected = true;
14244
14439
  if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
14245
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
14246
- 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);
14247
14442
  }
14248
14443
  disconnect() {
14249
14444
  this.#connected = false;
14250
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
14251
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
14445
+ this.#listeners.dispose();
14252
14446
  this.#gate.disconnected(this, () => {
14253
14447
  this.#pending = null;
14254
14448
  });
@@ -14336,13 +14530,7 @@ var OptimisticController = class extends Controller {
14336
14530
  }
14337
14531
  };
14338
14532
  var DEFAULT_PATTERN = "[0-9]";
14339
- function compilePattern(source) {
14340
- try {
14341
- return new RegExp(`^${source}$`);
14342
- } catch {
14343
- return null;
14344
- }
14345
- }
14533
+ var DEFAULT_MATCHER = new RegExp(`^(?:${DEFAULT_PATTERN})$`);
14346
14534
  function hasModifier2(event) {
14347
14535
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
14348
14536
  }
@@ -14357,7 +14545,7 @@ var OtpController = class extends Controller {
14357
14545
  static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
14358
14546
  static events = ["change", "complete", "invalid", "reconcile"];
14359
14547
  /** Validated matcher; the hot path never compiles a raw declaration. */
14360
- #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
14548
+ #pattern = DEFAULT_MATCHER;
14361
14549
  /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
14362
14550
  #patternSource = DEFAULT_PATTERN;
14363
14551
  /** Public state carried by the last dispatch; keeps a no-op sync silent. */
@@ -14430,9 +14618,9 @@ var OtpController = class extends Controller {
14430
14618
  * the new pattern no longer accepts, so the combined value stays interpretable.
14431
14619
  */
14432
14620
  patternValueChanged() {
14433
- const compiled = compilePattern(this.patternValue);
14621
+ const compiled = compileRegExp(this.patternValue, "exact");
14434
14622
  this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
14435
- this.#pattern = compiled ?? new RegExp(`^${DEFAULT_PATTERN}$`);
14623
+ this.#pattern = compiled ?? DEFAULT_MATCHER;
14436
14624
  if (!this.#connected) return;
14437
14625
  let dropped = false;
14438
14626
  for (const field of this.fieldTargets) {
@@ -14946,7 +15134,7 @@ var OverflowIndicatorController = class extends Controller {
14946
15134
  this.#observedViewport = next;
14947
15135
  this.#layout.observe(next);
14948
15136
  this.#layout.observeViewport();
14949
- next.addEventListener("load", this.#onContentLoad, true);
15137
+ this.#layout.observeDescendantLoads(next);
14950
15138
  this.#syncContentObservation();
14951
15139
  if (typeof MutationObserver !== "undefined") {
14952
15140
  this.#mutationObserver = new MutationObserver(() => {
@@ -14984,16 +15172,13 @@ var OverflowIndicatorController = class extends Controller {
14984
15172
  #stopObservingViewport() {
14985
15173
  this.#mutationObserver?.disconnect();
14986
15174
  this.#mutationObserver = null;
14987
- this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
15175
+ this.#layout.unobserveDescendantLoads();
14988
15176
  if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
14989
15177
  for (const content of this.#observedContent) this.#layout.unobserve(content);
14990
15178
  this.#observedContent.clear();
14991
15179
  this.#observedViewport = null;
14992
15180
  this.#layout.unobserveViewport();
14993
15181
  }
14994
- #onContentLoad = () => {
14995
- if (this.#connected) this.update();
14996
- };
14997
15182
  get #threshold() {
14998
15183
  const value = this.thresholdValue;
14999
15184
  return Number.isFinite(value) ? Math.max(0, value) : 1;
@@ -15316,7 +15501,7 @@ var OverflowMenuController = class extends Controller {
15316
15501
  }
15317
15502
  /** The managed item that is, or contains, `el` — focus rescue works on either. */
15318
15503
  #ownerOf(el) {
15319
- return this.#items.find((item) => item === el || item.contains(el)) ?? null;
15504
+ return ownerOf(this.#items, el);
15320
15505
  }
15321
15506
  /** Whether the item takes up room in the bar; an authored `hidden` one does not. */
15322
15507
  #rendered(item) {
@@ -16833,9 +17018,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16833
17018
  }
16834
17019
  /** Resolves the handle owning an event target (the handle or a descendant). */
16835
17020
  #handleFor(target) {
16836
- const node = target;
16837
- if (!node) return null;
16838
- return this.#handles().find((handle) => handle === node || handle.contains(node)) ?? null;
17021
+ return ownerOf(this.#handles(), target);
16839
17022
  }
16840
17023
  #pointerTypeOf(event) {
16841
17024
  const type = event.pointerType;
@@ -17016,16 +17199,7 @@ var PortalController = class extends Controller {
17016
17199
  * instead, which reads as a mistake and can be traced back to the declaration.
17017
17200
  */
17018
17201
  toValueChanged() {
17019
- const selector = this.toValue;
17020
- if (selector.length > 0) {
17021
- try {
17022
- this.element.matches(selector);
17023
- this.#toSelector = selector;
17024
- return;
17025
- } catch {
17026
- }
17027
- }
17028
- this.#toSelector = "body";
17202
+ this.#toSelector = validSelector(this.element, this.toValue, "body");
17029
17203
  }
17030
17204
  connect() {
17031
17205
  this.#gate.cancel();
@@ -17751,9 +17925,8 @@ var RadioGroupController = class extends Controller {
17751
17925
  }
17752
17926
  /** Finds this group's radio containing an event target, excluding nested groups. */
17753
17927
  #radioForEventTarget(target) {
17754
- if (!this.#ownsEventTarget(target)) return void 0;
17755
- const node = target;
17756
- return this.radioTargets.find((radio) => radio === node || radio.contains(node));
17928
+ if (!this.#ownsEventTarget(target)) return null;
17929
+ return ownerOf(this.radioTargets, target);
17757
17930
  }
17758
17931
  /** Whether the closest Radio Group scope around a target is this instance. */
17759
17932
  #ownsEventTarget(target) {
@@ -18536,7 +18709,7 @@ var ReadMoreController = class extends Controller {
18536
18709
  this.#observedContent = next;
18537
18710
  this.#layout.observe(next);
18538
18711
  this.#layout.observeViewport();
18539
- next.addEventListener("load", this.#update, true);
18712
+ this.#layout.observeDescendantLoads(next);
18540
18713
  if (typeof MutationObserver !== "undefined") {
18541
18714
  this.#contentMutationObserver = new MutationObserver(this.#update);
18542
18715
  this.#contentMutationObserver.observe(next, {
@@ -18550,7 +18723,7 @@ var ReadMoreController = class extends Controller {
18550
18723
  this.#deferredHide.releaseAll();
18551
18724
  if (this.#observedContent) {
18552
18725
  this.#layout.unobserve(this.#observedContent);
18553
- this.#observedContent.removeEventListener("load", this.#update, true);
18726
+ this.#layout.unobserveDescendantLoads();
18554
18727
  }
18555
18728
  this.#observedContent = null;
18556
18729
  this.#contentMutationObserver?.disconnect();
@@ -18576,6 +18749,38 @@ var ReadMoreController = class extends Controller {
18576
18749
  trigger.hidden = true;
18577
18750
  }
18578
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
18579
18784
  var PROGRESS_PROPERTY = "--stimeo--reading-progress";
18580
18785
  var ReadingProgressController = class extends Controller {
18581
18786
  static events = ["change", "complete"];
@@ -18584,18 +18789,13 @@ var ReadingProgressController = class extends Controller {
18584
18789
  /** The article's own box and the viewport: either changes the span. */
18585
18790
  #layout = new LayoutObserver(() => this.#onScroll());
18586
18791
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
18587
- #frame = null;
18792
+ /** Coalesces scroll bursts, and the connect baseline, into one frame. */
18793
+ #frames = new FrameCoalescer();
18588
18794
  /** Last published progress, so `change`/`complete` fire only on movement. */
18589
18795
  #progress = -1;
18590
18796
  /** False until the connect frame has run: `complete` needs real reading. */
18591
18797
  #baselined = false;
18592
- #onScroll = () => {
18593
- if (this.#frame !== null) return;
18594
- this.#frame = requestAnimationFrame(() => {
18595
- this.#frame = null;
18596
- this.#measure();
18597
- });
18598
- };
18798
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
18599
18799
  connect() {
18600
18800
  this.#progress = -1;
18601
18801
  this.#baselined = false;
@@ -18604,8 +18804,7 @@ var ReadingProgressController = class extends Controller {
18604
18804
  this.#layout.observeViewport();
18605
18805
  this.#beforeCache.activate();
18606
18806
  this.#measure();
18607
- this.#frame = requestAnimationFrame(() => {
18608
- this.#frame = null;
18807
+ this.#frames.schedule(() => {
18609
18808
  this.#measure();
18610
18809
  this.#baselined = true;
18611
18810
  });
@@ -18614,7 +18813,7 @@ var ReadingProgressController = class extends Controller {
18614
18813
  window.removeEventListener("scroll", this.#onScroll, { capture: true });
18615
18814
  this.#layout.disconnect();
18616
18815
  this.#beforeCache.deactivate();
18617
- this.#cancelFrame();
18816
+ this.#frames.cancel();
18618
18817
  this.#lease.returnAll();
18619
18818
  }
18620
18819
  /**
@@ -18625,14 +18824,10 @@ var ReadingProgressController = class extends Controller {
18625
18824
  * value that has already been handed back.
18626
18825
  */
18627
18826
  #rewindForCache() {
18628
- this.#cancelFrame();
18827
+ this.#frames.cancel();
18629
18828
  this.#lease.returnAll();
18630
18829
  this.#progress = -1;
18631
18830
  }
18632
- #cancelFrame() {
18633
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
18634
- this.#frame = null;
18635
- }
18636
18831
  /** Computes and publishes the progress; emits on movement only. */
18637
18832
  #measure() {
18638
18833
  const rect = this.element.getBoundingClientRect();
@@ -18660,6 +18855,7 @@ var UNITS = [
18660
18855
  { limit: 31557600, unit: "month", ms: 26298e5 },
18661
18856
  YEAR_SCALE
18662
18857
  ];
18858
+ var RELATIVE_OPTIONS = { numeric: "auto" };
18663
18859
  var RelativeTimeController = class extends Controller {
18664
18860
  static values = {
18665
18861
  locale: { type: String, default: "" },
@@ -18767,15 +18963,11 @@ var RelativeTimeController = class extends Controller {
18767
18963
  * when the runtime rejects that locale.
18768
18964
  */
18769
18965
  get #formatter() {
18770
- try {
18771
- return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
18772
- } catch {
18773
- return null;
18774
- }
18775
- }
18776
- /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
18777
- get #locale() {
18778
- 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
+ );
18779
18971
  }
18780
18972
  };
18781
18973
  var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
@@ -18820,16 +19012,7 @@ var ResetBeforeCacheController = class extends Controller {
18820
19012
  * silently taking the sweep down.
18821
19013
  */
18822
19014
  scopeValueChanged() {
18823
- const selector = this.scopeValue;
18824
- if (selector.length > 0) {
18825
- try {
18826
- this.element.matches(selector);
18827
- this.#scopeSelector = selector;
18828
- return;
18829
- } catch {
18830
- }
18831
- }
18832
- this.#scopeSelector = "";
19015
+ this.#scopeSelector = validSelector(this.element, this.scopeValue, "");
18833
19016
  }
18834
19017
  connect() {
18835
19018
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
@@ -18931,8 +19114,7 @@ var ResizableController = class extends Controller {
18931
19114
  const panes = [];
18932
19115
  if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
18933
19116
  if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
18934
- const target = event.target;
18935
- const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
19117
+ const current = ownerIndex(panes, event.target);
18936
19118
  const next = panes[(current + 1) % panes.length];
18937
19119
  if (!next) return;
18938
19120
  event.preventDefault();
@@ -19184,9 +19366,7 @@ var RovingController = class extends Controller {
19184
19366
  };
19185
19367
  /** Resolves the item index owning an event target (the item or a descendant). */
19186
19368
  #indexOf(target) {
19187
- const node = target;
19188
- if (!node) return -1;
19189
- return this.itemTargets.findIndex((item) => item === node || item.contains(node));
19369
+ return ownerIndex(this.itemTargets, target);
19190
19370
  }
19191
19371
  /** Makes `index` the tab stop (optionally focusing it), emitting `change` once. */
19192
19372
  #activate(index, focus) {
@@ -19318,20 +19498,14 @@ var ScrollAreaController = class extends Controller {
19318
19498
  #observedNameIds = /* @__PURE__ */ new Set();
19319
19499
  #observedNameSources = [];
19320
19500
  #fonts = null;
19321
- #scrollFrame = null;
19501
+ /** Coalesces scroll bursts into one position sync per frame. */
19502
+ #frames = new FrameCoalescer();
19322
19503
  #overflowing = false;
19323
19504
  #lastEdge = null;
19324
19505
  #ownedHostMutations = /* @__PURE__ */ new Map();
19325
- #onScroll = () => {
19326
- if (this.#scrollFrame !== null) return;
19327
- this.#scrollFrame = requestAnimationFrame(() => {
19328
- this.#scrollFrame = null;
19329
- const viewport = this.#viewport;
19330
- if (viewport) this.#syncPosition(viewport, this.#overflowing);
19331
- });
19332
- };
19333
- #onLoad = () => {
19334
- this.#refresh();
19506
+ #onScroll = (event) => {
19507
+ const viewport = event.currentTarget;
19508
+ this.#frames.schedule(() => this.#syncPosition(viewport, this.#overflowing));
19335
19509
  };
19336
19510
  #onFontsSettled = () => {
19337
19511
  this.#refresh();
@@ -19346,7 +19520,7 @@ var ScrollAreaController = class extends Controller {
19346
19520
  disconnect() {
19347
19521
  this.#beforeCache.deactivate();
19348
19522
  this.#rebind.cancel();
19349
- this.#cancelScrollFrame();
19523
+ this.#frames.cancel();
19350
19524
  if (this.#viewport) this.#unbindViewport(this.#viewport);
19351
19525
  this.#layout.disconnect();
19352
19526
  this.#unbindFonts();
@@ -19379,7 +19553,7 @@ var ScrollAreaController = class extends Controller {
19379
19553
  return;
19380
19554
  }
19381
19555
  next.addEventListener("scroll", this.#onScroll, { passive: true });
19382
- next.addEventListener("load", this.#onLoad, true);
19556
+ this.#layout.observeDescendantLoads(next);
19383
19557
  this.#layout.observe(next);
19384
19558
  this.#bindContentObserver(next);
19385
19559
  this.#refresh();
@@ -19414,9 +19588,9 @@ var ScrollAreaController = class extends Controller {
19414
19588
  }
19415
19589
  /** Releases every resource and borrowed attribute owned by one former viewport. */
19416
19590
  #unbindViewport(viewport) {
19417
- this.#cancelScrollFrame();
19591
+ this.#frames.cancel();
19418
19592
  viewport.removeEventListener("scroll", this.#onScroll);
19419
- viewport.removeEventListener("load", this.#onLoad, true);
19593
+ this.#layout.unobserveDescendantLoads();
19420
19594
  this.#layout.unobserve(viewport);
19421
19595
  this.#content?.disconnect();
19422
19596
  this.#content = null;
@@ -19431,7 +19605,7 @@ var ScrollAreaController = class extends Controller {
19431
19605
  #refresh() {
19432
19606
  const viewport = this.#viewport;
19433
19607
  if (!viewport) return;
19434
- this.#cancelScrollFrame();
19608
+ this.#frames.cancel();
19435
19609
  this.#syncNameSources(viewport);
19436
19610
  this.#overflowing = this.#syncOverflow(viewport);
19437
19611
  this.#syncKeyboardReach(viewport, this.#overflowing);
@@ -19639,16 +19813,10 @@ var ScrollAreaController = class extends Controller {
19639
19813
  this.#fonts?.removeEventListener("loadingerror", this.#onFontsSettled);
19640
19814
  this.#fonts = null;
19641
19815
  }
19642
- /** Cancels a pending scroll frame. */
19643
- #cancelScrollFrame() {
19644
- if (this.#scrollFrame === null) return;
19645
- cancelAnimationFrame(this.#scrollFrame);
19646
- this.#scrollFrame = null;
19647
- }
19648
19816
  /** Suspends live resources and returns all derived state before snapshotting. */
19649
19817
  #rewindForCache() {
19650
19818
  this.#rebind.cancel();
19651
- this.#cancelScrollFrame();
19819
+ this.#frames.cancel();
19652
19820
  if (this.#viewport) this.#unbindViewport(this.#viewport);
19653
19821
  this.#layout.disconnect();
19654
19822
  this.#unbindFonts();
@@ -19671,8 +19839,8 @@ var ScrollRestoreController = class extends Controller {
19671
19839
  key: { type: String, default: "" },
19672
19840
  axis: { type: String, default: "vertical" }
19673
19841
  };
19674
- /** Pending rAF id that coalesces scroll bursts into one save. */
19675
- #rafId = null;
19842
+ /** Coalesces scroll bursts into one save per frame. */
19843
+ #frames = new FrameCoalescer();
19676
19844
  /** Resolved storage key; empty disables persistence (no key and no id). */
19677
19845
  #storageKey = "";
19678
19846
  /** Last offset captured while the element was live; persisted as-is on teardown. */
@@ -19700,11 +19868,7 @@ var ScrollRestoreController = class extends Controller {
19700
19868
  #connected = false;
19701
19869
  #onScroll = () => {
19702
19870
  this.#capture();
19703
- if (this.#rafId !== null) return;
19704
- this.#rafId = requestAnimationFrame(() => {
19705
- this.#rafId = null;
19706
- this.#persist();
19707
- });
19871
+ this.#frames.schedule(() => this.#persist());
19708
19872
  };
19709
19873
  connect() {
19710
19874
  this.#connected = true;
@@ -19715,7 +19879,7 @@ var ScrollRestoreController = class extends Controller {
19715
19879
  disconnect() {
19716
19880
  this.#connected = false;
19717
19881
  this.element.removeEventListener("scroll", this.#onScroll);
19718
- this.#cancelFrame();
19882
+ this.#frames.cancel();
19719
19883
  this.#persist();
19720
19884
  }
19721
19885
  /** Re-derives the namespace when application code or a Turbo morph moves `key`. */
@@ -19729,7 +19893,7 @@ var ScrollRestoreController = class extends Controller {
19729
19893
  /** Rebuilds the persistence state around the Values as they now read. */
19730
19894
  #resync() {
19731
19895
  if (!this.#connected) return;
19732
- this.#cancelFrame();
19896
+ this.#frames.cancel();
19733
19897
  this.#storageKey = this.#resolveKey();
19734
19898
  this.#echoTop = null;
19735
19899
  this.#echoLeft = null;
@@ -19738,13 +19902,6 @@ var ScrollRestoreController = class extends Controller {
19738
19902
  this.#capturedLeft = false;
19739
19903
  if (this.#storageKey) this.#restore();
19740
19904
  }
19741
- /** Drops the pending coalesced save, if one is queued. */
19742
- #cancelFrame() {
19743
- if (this.#rafId !== null) {
19744
- cancelAnimationFrame(this.#rafId);
19745
- this.#rafId = null;
19746
- }
19747
- }
19748
19905
  /**
19749
19906
  * Records the live scroll offset for the configured axis.
19750
19907
  *
@@ -19841,6 +19998,20 @@ var ScrollRestoreController = class extends Controller {
19841
19998
  return this.axisValue === "horizontal" || this.axisValue === "both";
19842
19999
  }
19843
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
19844
20015
  var DEFAULT_OFFSET = 400;
19845
20016
  var ScrollVisibilityController = class extends Controller {
19846
20017
  static targets = ["element"];
@@ -19852,8 +20023,8 @@ var ScrollVisibilityController = class extends Controller {
19852
20023
  };
19853
20024
  static actions = ["toTop"];
19854
20025
  static events = ["change"];
19855
- /** Pending rAF id that coalesces scroll bursts into one measurement. */
19856
- #rafId = null;
20026
+ /** Coalesces scroll bursts into one measurement per frame. */
20027
+ #frames = new FrameCoalescer();
19857
20028
  /** Previous scroll position, for `direction` mode delta detection. */
19858
20029
  #lastScrollY = 0;
19859
20030
  /** Current visibility, tracked to dispatch `change` only on real transitions. */
@@ -19889,16 +20060,10 @@ var ScrollVisibilityController = class extends Controller {
19889
20060
  #pendingHide = new BlurDeferral(() => {
19890
20061
  if (this.#connected) this.#evaluate();
19891
20062
  });
19892
- #onScroll = () => {
19893
- if (this.#rafId !== null) return;
19894
- this.#rafId = requestAnimationFrame(() => {
19895
- this.#rafId = null;
19896
- this.#evaluate();
19897
- });
19898
- };
20063
+ #onScroll = () => this.#frames.schedule(() => this.#evaluate());
19899
20064
  connect() {
19900
- this.#scrollSource = this.#resolveScrollSource();
19901
- this.#lastScrollY = this.#scrollY();
20065
+ this.#scrollSource = resolveScrollSource(this.#rootSelector);
20066
+ this.#lastScrollY = scrollOffset(this.#scrollSource);
19902
20067
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
19903
20068
  this.#evaluate(false);
19904
20069
  this.#connected = true;
@@ -19906,10 +20071,7 @@ var ScrollVisibilityController = class extends Controller {
19906
20071
  disconnect() {
19907
20072
  this.#connected = false;
19908
20073
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
19909
- if (this.#rafId !== null) {
19910
- cancelAnimationFrame(this.#rafId);
19911
- this.#rafId = null;
19912
- }
20074
+ this.#frames.cancel();
19913
20075
  this.#pendingHide.releaseAll();
19914
20076
  this.#tabindex.returnAll();
19915
20077
  this.#visible = null;
@@ -19941,11 +20103,11 @@ var ScrollVisibilityController = class extends Controller {
19941
20103
  }
19942
20104
  /** Validates `root` once so connect never parses a selector that throws. */
19943
20105
  rootValueChanged() {
19944
- this.#rootSelector = this.#validSelector(this.rootValue);
20106
+ this.#rootSelector = validSelector(this.element, this.rootValue, "");
19945
20107
  }
19946
20108
  /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
19947
20109
  focusSelectorValueChanged() {
19948
- this.#focusSelector = this.#validSelector(this.focusSelectorValue);
20110
+ this.#focusSelector = validSelector(this.element, this.focusSelectorValue, "");
19949
20111
  }
19950
20112
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
19951
20113
  toTop() {
@@ -19968,7 +20130,7 @@ var ScrollVisibilityController = class extends Controller {
19968
20130
  * @stimeoRenderRoot
19969
20131
  */
19970
20132
  #evaluate(notify = true) {
19971
- const y = this.#scrollY();
20133
+ const y = scrollOffset(this.#scrollSource);
19972
20134
  let nextVisible;
19973
20135
  if (this.modeValue === "direction") {
19974
20136
  if (y <= this.#offset) {
@@ -19998,13 +20160,6 @@ var ScrollVisibilityController = class extends Controller {
19998
20160
  if (notify) this.dispatch("change", { detail: { visible: next } });
19999
20161
  }
20000
20162
  /** Resolves the scroll source from `root` (falling back to the window). */
20001
- #resolveScrollSource() {
20002
- if (this.#rootSelector) {
20003
- const root = document.querySelector(this.#rootSelector);
20004
- if (root) return root;
20005
- }
20006
- return window;
20007
- }
20008
20163
  /**
20009
20164
  * The focus owner inside the target, or `null` when focus is elsewhere.
20010
20165
  *
@@ -20017,23 +20172,6 @@ var ScrollVisibilityController = class extends Controller {
20017
20172
  if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
20018
20173
  return null;
20019
20174
  }
20020
- /** Returns `declared` when it parses as a selector, and `""` when it does not. */
20021
- #validSelector(declared) {
20022
- if (declared.length > 0) {
20023
- try {
20024
- this.element.matches(declared);
20025
- return declared;
20026
- } catch {
20027
- }
20028
- }
20029
- return "";
20030
- }
20031
- #scrollY() {
20032
- if (this.#scrollSource === window) {
20033
- return window.scrollY ?? window.pageYOffset ?? 0;
20034
- }
20035
- return this.#scrollSource.scrollTop;
20036
- }
20037
20175
  };
20038
20176
  var ANCHOR_ATTRIBUTES = ["href", "data-href"];
20039
20177
  var ScrollspyController = class extends Controller {
@@ -20080,8 +20218,10 @@ var ScrollspyController = class extends Controller {
20080
20218
  * rather than from whatever the selector resolves to now.
20081
20219
  */
20082
20220
  #scrollSource = null;
20083
- /** Pending re-evaluation frame; coalesces a scroll burst into one measurement. */
20084
- #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 = "";
20085
20225
  /** Watches the link targets' anchor attributes for an in-place morph rewrite. */
20086
20226
  #anchorObserver = null;
20087
20227
  /** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
@@ -20098,10 +20238,7 @@ var ScrollspyController = class extends Controller {
20098
20238
  this.#anchorObserver?.disconnect();
20099
20239
  this.#anchorObserver = null;
20100
20240
  this.#detachScrollListener();
20101
- if (this.#frame !== null) {
20102
- cancelAnimationFrame(this.#frame);
20103
- this.#frame = null;
20104
- }
20241
+ this.#frames.cancel();
20105
20242
  this.#intersectionStates.clear();
20106
20243
  this.#activeSectionId = "";
20107
20244
  this.#rootElement = null;
@@ -20118,6 +20255,7 @@ var ScrollspyController = class extends Controller {
20118
20255
  this.#initializeObserver();
20119
20256
  }
20120
20257
  rootSelectorValueChanged() {
20258
+ this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
20121
20259
  if (!this.#isConnected) return;
20122
20260
  this.#initializeObserver();
20123
20261
  }
@@ -20161,7 +20299,7 @@ var ScrollspyController = class extends Controller {
20161
20299
  * only rewrites attributes, so Stimulus fires no target callback: without
20162
20300
  * this, a link re-pointed from `#intro` to `#faq` would keep the controller
20163
20301
  * observing `#intro` for the rest of the page's life. The filter is exactly
20164
- * {@link ANCHOR_ATTRIBUTES}; guarding on `MutationObserver` keeps the
20302
+ * `href` and `data-href`; guarding on `MutationObserver` keeps the
20165
20303
  * controller usable where the API is absent, matching `IntersectionWatcher`'s
20166
20304
  * own support guard.
20167
20305
  */
@@ -20211,10 +20349,10 @@ var ScrollspyController = class extends Controller {
20211
20349
  const offset = this.#offset;
20212
20350
  if (rootElement) {
20213
20351
  const containerRect = rootElement.getBoundingClientRect();
20214
- const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - offset;
20352
+ const scrollPosition = scrollOffset(rootElement) + (targetRect.top - containerRect.top) - offset;
20215
20353
  rootElement.scrollTo({ top: scrollPosition, behavior });
20216
20354
  } else {
20217
- const scrollPosition = window.scrollY + targetRect.top - offset;
20355
+ const scrollPosition = scrollOffset(window) + targetRect.top - offset;
20218
20356
  window.scrollTo({ top: scrollPosition, behavior });
20219
20357
  }
20220
20358
  if (this.focusSectionValue) this.#focusSection(targetElement);
@@ -20249,19 +20387,14 @@ var ScrollspyController = class extends Controller {
20249
20387
  /**
20250
20388
  * Resolves `rootSelector` to a scrollable element.
20251
20389
  *
20252
- * @returns The container, or `null` meaning "spy the viewport" when the value
20253
- * is empty, matches nothing, matches a non-HTML element (an SVG node is not a
20254
- * scroll container), or is not a valid selector — a typo in a data attribute
20255
- * 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.
20256
20395
  */
20257
20396
  #queryRootElement() {
20258
- if (!this.rootSelectorValue) return null;
20259
- try {
20260
- const root = document.querySelector(this.rootSelectorValue);
20261
- return root instanceof HTMLElement ? root : null;
20262
- } catch {
20263
- return null;
20264
- }
20397
+ return resolveScrollContainer(this.#rootSelector);
20265
20398
  }
20266
20399
  /**
20267
20400
  * Re-evaluates once per frame while the reader scrolls.
@@ -20276,13 +20409,7 @@ var ScrollspyController = class extends Controller {
20276
20409
  * end in {@link #evaluateActiveSection} — which measures section rects and the
20277
20410
  * root's top edge at that instant — they converge on the same answer.
20278
20411
  */
20279
- #onScroll = () => {
20280
- if (this.#frame !== null) return;
20281
- this.#frame = requestAnimationFrame(() => {
20282
- this.#frame = null;
20283
- this.#evaluateActiveSection();
20284
- });
20285
- };
20412
+ #onScroll = () => this.#frames.schedule(() => this.#evaluateActiveSection());
20286
20413
  /**
20287
20414
  * Points the `scroll` listener at whatever the reader actually scrolls: the
20288
20415
  * resolved root, or the window when the viewport is spied.
@@ -21180,7 +21307,8 @@ var SmartStickyHeaderController = class extends Controller {
21180
21307
  };
21181
21308
  static events = ["change"];
21182
21309
  #connected = false;
21183
- #frame = null;
21310
+ /** Coalesces scroll bursts into one measurement per frame. */
21311
+ #frames = new FrameCoalescer();
21184
21312
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
21185
21313
  #scrollerEl = window;
21186
21314
  /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
@@ -21197,13 +21325,7 @@ var SmartStickyHeaderController = class extends Controller {
21197
21325
  get #offset() {
21198
21326
  return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET2;
21199
21327
  }
21200
- #onScroll = () => {
21201
- if (this.#frame !== null) return;
21202
- this.#frame = requestAnimationFrame(() => {
21203
- this.#frame = null;
21204
- this.#measure();
21205
- });
21206
- };
21328
+ #onScroll = () => this.#frames.schedule(() => this.#measure());
21207
21329
  /**
21208
21330
  * Focus inside a hidden header must reveal it (WCAG 2.4.7 / 2.4.11); the
21209
21331
  * hold while focus *stays* inside is the `#apply` hide invariant.
@@ -21211,7 +21333,7 @@ var SmartStickyHeaderController = class extends Controller {
21211
21333
  #onFocusin = () => this.#apply(false);
21212
21334
  /** Validates `containerSelector` once so connect never parses a selector that throws. */
21213
21335
  containerSelectorValueChanged() {
21214
- this.#containerSelector = this.#validSelector(this.containerSelectorValue);
21336
+ this.#containerSelector = validSelector(this.element, this.containerSelectorValue, "");
21215
21337
  }
21216
21338
  /** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
21217
21339
  offsetValueChanged() {
@@ -21219,8 +21341,8 @@ var SmartStickyHeaderController = class extends Controller {
21219
21341
  }
21220
21342
  connect() {
21221
21343
  this.#hidden = null;
21222
- this.#scrollerEl = this.#resolveScroller();
21223
- this.#lastY = this.#scrollY;
21344
+ this.#scrollerEl = resolveScrollSource(this.#containerSelector);
21345
+ this.#lastY = scrollOffset(this.#scrollerEl);
21224
21346
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
21225
21347
  this.element.addEventListener("focusin", this.#onFocusin);
21226
21348
  this.#apply(false, false);
@@ -21230,34 +21352,10 @@ var SmartStickyHeaderController = class extends Controller {
21230
21352
  this.#connected = false;
21231
21353
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
21232
21354
  this.element.removeEventListener("focusin", this.#onFocusin);
21233
- if (this.#frame !== null) cancelAnimationFrame(this.#frame);
21234
- this.#frame = null;
21235
- }
21236
- /** Resolves the scroll source: the `containerSelector` match, else the window. */
21237
- #resolveScroller() {
21238
- if (this.#containerSelector) {
21239
- const container = document.querySelector(this.#containerSelector);
21240
- if (container) return container;
21241
- }
21242
- return window;
21243
- }
21244
- /** Returns `declared` when it parses as a selector, and `""` when it does not. */
21245
- #validSelector(declared) {
21246
- if (declared.length > 0) {
21247
- try {
21248
- this.element.matches(declared);
21249
- return declared;
21250
- } catch {
21251
- }
21252
- }
21253
- return "";
21254
- }
21255
- get #scrollY() {
21256
- const scroller = this.#scrollerEl;
21257
- return scroller === window ? window.scrollY : scroller.scrollTop;
21355
+ this.#frames.cancel();
21258
21356
  }
21259
21357
  #measure() {
21260
- const y = this.#scrollY;
21358
+ const y = scrollOffset(this.#scrollerEl);
21261
21359
  if (y <= this.#offset) {
21262
21360
  this.#lastY = y;
21263
21361
  this.#apply(false);
@@ -22228,6 +22326,7 @@ var SubmitOnceController = class extends Controller {
22228
22326
  static events = ["start", "end", "reconcile"];
22229
22327
  #timers = new SafeTimeout();
22230
22328
  #gate = new DetachGate();
22329
+ #listeners = new ListenerSet();
22231
22330
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
22232
22331
  #sessions = /* @__PURE__ */ new Map();
22233
22332
  #disabled = new AttributeLease("disabled");
@@ -22252,14 +22351,12 @@ var SubmitOnceController = class extends Controller {
22252
22351
  connect() {
22253
22352
  this.#gate.cancel();
22254
22353
  this.#beforeCache.activate();
22255
- this.element.addEventListener("submit", this.#onNativeSubmit, true);
22256
- this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
22257
- 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);
22258
22357
  }
22259
22358
  disconnect() {
22260
- this.element.removeEventListener("submit", this.#onNativeSubmit, true);
22261
- this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
22262
- this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
22359
+ this.#listeners.dispose();
22263
22360
  this.#beforeCache.deactivate();
22264
22361
  this.#gate.disconnected(this, () => this.#teardown());
22265
22362
  }
@@ -23199,16 +23296,7 @@ var ThemeController = class extends Controller {
23199
23296
  }
23200
23297
  /** Validates the `target` declaration once, so the render path never parses. */
23201
23298
  targetValueChanged() {
23202
- const selector = this.targetValue;
23203
- if (selector.length > 0) {
23204
- try {
23205
- this.element.matches(selector);
23206
- this.#targetSelector = selector;
23207
- return;
23208
- } catch {
23209
- }
23210
- }
23211
- this.#targetSelector = DEFAULT_TARGET;
23299
+ this.#targetSelector = validSelector(this.element, this.targetValue, DEFAULT_TARGET);
23212
23300
  }
23213
23301
  /** Re-derives the single Tab stop and ARIA for an option set that changed. */
23214
23302
  optionTargetConnected() {
@@ -23221,9 +23309,8 @@ var ThemeController = class extends Controller {
23221
23309
  /**
23222
23310
  * Selects the mode the activated option declares.
23223
23311
  *
23224
- * Read through {@link ThemeController.#optionMode}, the same lane that decides
23225
- * which option is checked, so the two can never disagree about what an option
23226
- * declares.
23312
+ * Read through the same lane that decides which option is checked, so the two
23313
+ * can never disagree about what an option declares.
23227
23314
  */
23228
23315
  set(event) {
23229
23316
  const option = event.currentTarget;
@@ -23696,30 +23783,23 @@ var ToastController = class extends Controller {
23696
23783
  };
23697
23784
  static actions = ["dismiss", "onKeydown", "pause", "resume", "show"];
23698
23785
  static events = ["dismiss", "show"];
23699
- /**
23700
- * Registry for every auto-dismiss and transition-finalize timer the controller
23701
- * schedules. `SafeTimeout` owns *registration and teardown only*; the
23702
- * pause/resume remaining-time accounting stays in `#activeTimeouts` so the
23703
- * per-widget WCAG 2.2.1 semantics are not flattened into the helper.
23704
- */
23786
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
23705
23787
  #timers = new SafeTimeout();
23788
+ /** Per-toast auto-dismiss, held open while the toast is hovered or focused. */
23789
+ #dismiss = new PausableTimers();
23706
23790
  /**
23707
23791
  * Pending one-shot `requestAnimationFrame` handles (the entering→visible flip).
23708
23792
  * Tracked so {@link disconnect} can cancel any that have not fired, preventing a
23709
23793
  * detached element from being mutated after it leaves the DOM (Turbo).
23710
23794
  */
23711
23795
  #rafHandles = /* @__PURE__ */ new Map();
23712
- /** Track active timeouts mapped by each toast element for safe cancellation. */
23713
- #activeTimeouts = /* @__PURE__ */ new Map();
23714
- /** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
23715
- #pauseReasons = /* @__PURE__ */ new Map();
23716
23796
  /** The stable list that owns delegated listeners for dynamically added items. */
23717
23797
  #delegatedList = null;
23718
23798
  connect() {
23719
23799
  this.#connectDelegatedEvents();
23720
23800
  this.enforceMaxLimit();
23721
23801
  for (const item of this.itemTargets) {
23722
- if (!this.#activeTimeouts.has(item) && item.dataset.state !== "leaving") {
23802
+ if (!this.#dismiss.tracks(item) && item.dataset.state !== "leaving") {
23723
23803
  this.#startTimer(item);
23724
23804
  }
23725
23805
  }
@@ -23727,12 +23807,11 @@ var ToastController = class extends Controller {
23727
23807
  disconnect() {
23728
23808
  this.#disconnectDelegatedEvents();
23729
23809
  this.#timers.clearAll();
23810
+ this.#dismiss.clearAll();
23730
23811
  for (const handle of this.#rafHandles.values()) {
23731
23812
  window.cancelAnimationFrame(handle);
23732
23813
  }
23733
23814
  this.#rafHandles.clear();
23734
- this.#activeTimeouts.clear();
23735
- this.#pauseReasons.clear();
23736
23815
  }
23737
23816
  /** Rebinds delegated interaction when Turbo replaces the list target in place. */
23738
23817
  listTargetConnected(element) {
@@ -23745,18 +23824,9 @@ var ToastController = class extends Controller {
23745
23824
  durationValueChanged() {
23746
23825
  for (const item of this.itemTargets) {
23747
23826
  if (item.dataset.state === "leaving") continue;
23748
- const pauseReasons = this.#pauseReasons.get(item);
23749
- this.#clearTimer(item);
23750
23827
  if (this.durationValue <= 0) {
23828
+ this.#dismiss.clear(item);
23751
23829
  item.removeAttribute("data-paused");
23752
- } else if (pauseReasons && pauseReasons.size > 0) {
23753
- this.#pauseReasons.set(item, pauseReasons);
23754
- this.#activeTimeouts.set(item, {
23755
- id: 0,
23756
- startedAt: 0,
23757
- remaining: this.durationValue
23758
- });
23759
- item.setAttribute("data-paused", "true");
23760
23830
  } else {
23761
23831
  this.#startTimer(item);
23762
23832
  }
@@ -23785,7 +23855,7 @@ var ToastController = class extends Controller {
23785
23855
  }
23786
23856
  /** Clears any active timer when a toast is removed from the DOM. */
23787
23857
  itemTargetDisconnected(element) {
23788
- this.#clearTimer(element);
23858
+ this.#dismiss.clear(element);
23789
23859
  this.#cancelAnimation(element);
23790
23860
  }
23791
23861
  /**
@@ -23857,35 +23927,16 @@ var ToastController = class extends Controller {
23857
23927
  */
23858
23928
  pause(event) {
23859
23929
  const item = this.#itemFromEvent(event);
23860
- if (!item || this.durationValue <= 0) return;
23861
- const timeout = this.#activeTimeouts.get(item);
23862
- if (!timeout) return;
23863
- const reasons = this.#pauseReasonsFor(item);
23864
- const wasActive = reasons.size > 0;
23865
- reasons.add(this.#pauseReason(event));
23866
- if (wasActive || timeout.id === 0) return;
23867
- this.#timers.clear(timeout.id);
23868
- const elapsed = Date.now() - timeout.startedAt;
23869
- const remaining = Math.max(0, timeout.remaining - elapsed);
23870
- if (remaining <= 0) {
23871
- this.#removeWithTransition(item, "timeout");
23872
- return;
23930
+ if (item && this.#dismiss.pause(item, this.#pauseReason(event))) {
23931
+ item.setAttribute("data-paused", "true");
23873
23932
  }
23874
- this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
23875
- item.setAttribute("data-paused", "true");
23876
23933
  }
23877
23934
  /** Resumes the auto-dismiss timer once both hover and focus have been released. */
23878
23935
  resume(event) {
23879
23936
  const item = this.#itemFromEvent(event);
23880
- if (!item || this.durationValue <= 0) return;
23881
- const reasons = this.#pauseReasonsFor(item);
23882
- reasons.delete(this.#pauseReason(event));
23883
- if (reasons.size > 0) return;
23884
- const timeout = this.#activeTimeouts.get(item);
23885
- if (!timeout) return;
23886
- if (timeout.id !== 0 || timeout.remaining <= 0) return;
23887
- item.removeAttribute("data-paused");
23888
- this.#startTimer(item, timeout.remaining);
23937
+ if (item && this.#dismiss.resume(item, this.#pauseReason(event))) {
23938
+ item.removeAttribute("data-paused");
23939
+ }
23889
23940
  }
23890
23941
  /** Resolves the toast item element a pause/resume event targets. */
23891
23942
  #itemFromEvent(event) {
@@ -23898,36 +23949,20 @@ var ToastController = class extends Controller {
23898
23949
  #pauseReason(event) {
23899
23950
  return event.type === "focusin" || event.type === "focusout" ? "focus" : "hover";
23900
23951
  }
23901
- /** Lazily creates and returns the active pause-reason set for an item. */
23902
- #pauseReasonsFor(item) {
23903
- let reasons = this.#pauseReasons.get(item);
23904
- if (!reasons) {
23905
- reasons = /* @__PURE__ */ new Set();
23906
- this.#pauseReasons.set(item, reasons);
23907
- }
23908
- return reasons;
23909
- }
23910
- #startTimer(element, duration = this.durationValue) {
23911
- 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) {
23912
23955
  return;
23913
23956
  }
23914
- this.#clearTimer(element);
23915
- const id = this.#timers.set(() => {
23916
- this.#removeWithTransition(element, "timeout");
23917
- }, duration);
23918
- this.#activeTimeouts.set(element, { id, startedAt: Date.now(), remaining: duration });
23919
- }
23920
- #clearTimer(element) {
23921
- const timeout = this.#activeTimeouts.get(element);
23922
- if (timeout) {
23923
- if (timeout.id) this.#timers.clear(timeout.id);
23924
- this.#activeTimeouts.delete(element);
23925
- }
23926
- this.#pauseReasons.delete(element);
23957
+ this.#dismiss.set(
23958
+ element,
23959
+ () => this.#removeWithTransition(element, "timeout"),
23960
+ this.durationValue
23961
+ );
23927
23962
  }
23928
23963
  #removeWithTransition(element, reason) {
23929
23964
  if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
23930
- this.#clearTimer(element);
23965
+ this.#dismiss.clear(element);
23931
23966
  this.#cancelAnimation(element);
23932
23967
  element.setAttribute("data-state", "leaving");
23933
23968
  const finalize = () => {
@@ -24280,9 +24315,8 @@ var ToggleGroupController = class extends Controller {
24280
24315
  }
24281
24316
  /** Finds this group's item containing an event target, excluding nested groups. */
24282
24317
  #itemForEventTarget(target) {
24283
- if (!this.#ownsEventTarget(target)) return void 0;
24284
- const node = target;
24285
- return this.itemTargets.find((item) => item === node || item.contains(node));
24318
+ if (!this.#ownsEventTarget(target)) return null;
24319
+ return ownerOf(this.itemTargets, target);
24286
24320
  }
24287
24321
  /** Whether the closest Toggle Group scope around a target is this instance. */
24288
24322
  #ownsEventTarget(target) {
@@ -24540,9 +24574,7 @@ var ToolbarController = class extends Controller {
24540
24574
  }
24541
24575
  /** Index in `controlTargets` of the control owning `target` (it or a descendant). */
24542
24576
  #indexOf(target) {
24543
- const node = target;
24544
- if (!node) return -1;
24545
- return this.controlTargets.findIndex((control) => control === node || control.contains(node));
24577
+ return ownerIndex(this.controlTargets, target);
24546
24578
  }
24547
24579
  /** Controls eligible for the roving tab stop (excludes native disabled / hidden). */
24548
24580
  get #navigableControls() {