stimeo-ui 0.3.0 → 0.5.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 (148) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/dist/controllers/alert_dialog_controller.js +32 -5
  3. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  4. package/dist/controllers/announcer_controller.d.ts +32 -6
  5. package/dist/controllers/announcer_controller.js +255 -20
  6. package/dist/controllers/announcer_controller.js.map +1 -1
  7. package/dist/controllers/aspect_ratio_controller.d.ts +2 -2
  8. package/dist/controllers/aspect_ratio_controller.js +1 -1
  9. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  10. package/dist/controllers/breadcrumb_controller.js +5 -1
  11. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  12. package/dist/controllers/carousel_controller.js +5 -1
  13. package/dist/controllers/carousel_controller.js.map +1 -1
  14. package/dist/controllers/clipboard_controller.js +8 -3
  15. package/dist/controllers/clipboard_controller.js.map +1 -1
  16. package/dist/controllers/collapsible_controller.d.ts +1 -1
  17. package/dist/controllers/collapsible_controller.js +4 -1
  18. package/dist/controllers/collapsible_controller.js.map +1 -1
  19. package/dist/controllers/color_picker_controller.d.ts +4 -3
  20. package/dist/controllers/color_picker_controller.js +52 -2
  21. package/dist/controllers/color_picker_controller.js.map +1 -1
  22. package/dist/controllers/combobox_controller.js.map +1 -1
  23. package/dist/controllers/command_palette_controller.js +32 -5
  24. package/dist/controllers/command_palette_controller.js.map +1 -1
  25. package/dist/controllers/confirm_controller.js +32 -5
  26. package/dist/controllers/confirm_controller.js.map +1 -1
  27. package/dist/controllers/context_menu_controller.d.ts +1 -1
  28. package/dist/controllers/context_menu_controller.js +2 -2
  29. package/dist/controllers/context_menu_controller.js.map +1 -1
  30. package/dist/controllers/countdown_controller.d.ts +24 -8
  31. package/dist/controllers/countdown_controller.js +117 -13
  32. package/dist/controllers/countdown_controller.js.map +1 -1
  33. package/dist/controllers/date_range_picker_controller.d.ts +4 -1
  34. package/dist/controllers/date_range_picker_controller.js +55 -1
  35. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  36. package/dist/controllers/dialog_controller.js +32 -5
  37. package/dist/controllers/dialog_controller.js.map +1 -1
  38. package/dist/controllers/direct_upload_controller.d.ts +1 -1
  39. package/dist/controllers/direct_upload_controller.js +3 -3
  40. package/dist/controllers/direct_upload_controller.js.map +1 -1
  41. package/dist/controllers/dismissible_controller.js.map +1 -1
  42. package/dist/controllers/drawer_controller.js +32 -5
  43. package/dist/controllers/drawer_controller.js.map +1 -1
  44. package/dist/controllers/empty_state_controller.d.ts +36 -11
  45. package/dist/controllers/empty_state_controller.js +128 -23
  46. package/dist/controllers/empty_state_controller.js.map +1 -1
  47. package/dist/controllers/flash_controller.d.ts +25 -5
  48. package/dist/controllers/flash_controller.js +161 -21
  49. package/dist/controllers/flash_controller.js.map +1 -1
  50. package/dist/controllers/focus_controller.js +32 -5
  51. package/dist/controllers/focus_controller.js.map +1 -1
  52. package/dist/controllers/form_validation_controller.js +8 -2
  53. package/dist/controllers/form_validation_controller.js.map +1 -1
  54. package/dist/controllers/frame_loading_controller.d.ts +31 -3
  55. package/dist/controllers/frame_loading_controller.js +261 -27
  56. package/dist/controllers/frame_loading_controller.js.map +1 -1
  57. package/dist/controllers/highlight_controller.d.ts +8 -4
  58. package/dist/controllers/highlight_controller.js +38 -1
  59. package/dist/controllers/highlight_controller.js.map +1 -1
  60. package/dist/controllers/idle_controller.d.ts +2 -1
  61. package/dist/controllers/idle_controller.js +13 -2
  62. package/dist/controllers/idle_controller.js.map +1 -1
  63. package/dist/controllers/listbox_controller.js.map +1 -1
  64. package/dist/controllers/local_time_controller.d.ts +19 -3
  65. package/dist/controllers/local_time_controller.js +102 -6
  66. package/dist/controllers/local_time_controller.js.map +1 -1
  67. package/dist/controllers/masonry_controller.d.ts +1 -1
  68. package/dist/controllers/masonry_controller.js +1 -1
  69. package/dist/controllers/masonry_controller.js.map +1 -1
  70. package/dist/controllers/meter_controller.d.ts +22 -2
  71. package/dist/controllers/meter_controller.js +147 -26
  72. package/dist/controllers/meter_controller.js.map +1 -1
  73. package/dist/controllers/multi_select_controller.js.map +1 -1
  74. package/dist/controllers/network_status_controller.d.ts +25 -11
  75. package/dist/controllers/network_status_controller.js +29 -11
  76. package/dist/controllers/network_status_controller.js.map +1 -1
  77. package/dist/controllers/number_input_controller.d.ts +8 -0
  78. package/dist/controllers/number_input_controller.js +191 -24
  79. package/dist/controllers/number_input_controller.js.map +1 -1
  80. package/dist/controllers/overflow_menu_controller.js +34 -7
  81. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  82. package/dist/controllers/pagination_controller.js +5 -1
  83. package/dist/controllers/pagination_controller.js.map +1 -1
  84. package/dist/controllers/password_strength_controller.d.ts +1 -1
  85. package/dist/controllers/password_strength_controller.js +1 -1
  86. package/dist/controllers/password_strength_controller.js.map +1 -1
  87. package/dist/controllers/pointer_drag_controller.js +10 -0
  88. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  89. package/dist/controllers/portal_controller.js +10 -0
  90. package/dist/controllers/portal_controller.js.map +1 -1
  91. package/dist/controllers/progress_controller.d.ts +17 -2
  92. package/dist/controllers/progress_controller.js +123 -12
  93. package/dist/controllers/progress_controller.js.map +1 -1
  94. package/dist/controllers/range_slider_controller.d.ts +27 -1
  95. package/dist/controllers/range_slider_controller.js +449 -93
  96. package/dist/controllers/range_slider_controller.js.map +1 -1
  97. package/dist/controllers/rating_controller.d.ts +4 -1
  98. package/dist/controllers/rating_controller.js +55 -0
  99. package/dist/controllers/rating_controller.js.map +1 -1
  100. package/dist/controllers/relative_time_controller.d.ts +13 -0
  101. package/dist/controllers/relative_time_controller.js +135 -12
  102. package/dist/controllers/relative_time_controller.js.map +1 -1
  103. package/dist/controllers/scroll_area_controller.d.ts +1 -1
  104. package/dist/controllers/scroll_area_controller.js +1 -1
  105. package/dist/controllers/scroll_area_controller.js.map +1 -1
  106. package/dist/controllers/separator_controller.js +13 -17
  107. package/dist/controllers/separator_controller.js.map +1 -1
  108. package/dist/controllers/sidebar_controller.d.ts +1 -11
  109. package/dist/controllers/sidebar_controller.js +37 -8
  110. package/dist/controllers/sidebar_controller.js.map +1 -1
  111. package/dist/controllers/skeleton_controller.d.ts +7 -2
  112. package/dist/controllers/skeleton_controller.js +143 -22
  113. package/dist/controllers/skeleton_controller.js.map +1 -1
  114. package/dist/controllers/slider_controller.d.ts +17 -1
  115. package/dist/controllers/slider_controller.js +342 -50
  116. package/dist/controllers/slider_controller.js.map +1 -1
  117. package/dist/controllers/spinner_controller.d.ts +50 -12
  118. package/dist/controllers/spinner_controller.js +244 -28
  119. package/dist/controllers/spinner_controller.js.map +1 -1
  120. package/dist/controllers/step_indicator_controller.d.ts +13 -2
  121. package/dist/controllers/step_indicator_controller.js +85 -6
  122. package/dist/controllers/step_indicator_controller.js.map +1 -1
  123. package/dist/controllers/stepper_controller.js +2 -0
  124. package/dist/controllers/stepper_controller.js.map +1 -1
  125. package/dist/controllers/stick_to_bottom_controller.d.ts +26 -2
  126. package/dist/controllers/stick_to_bottom_controller.js +60 -10
  127. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  128. package/dist/controllers/switch_controller.d.ts +12 -8
  129. package/dist/controllers/switch_controller.js +162 -18
  130. package/dist/controllers/switch_controller.js.map +1 -1
  131. package/dist/controllers/textarea_autosize_controller.js +1 -1
  132. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  133. package/dist/controllers/time_picker_controller.d.ts +3 -0
  134. package/dist/controllers/time_picker_controller.js +6 -3
  135. package/dist/controllers/time_picker_controller.js.map +1 -1
  136. package/dist/controllers/tree_view_controller.d.ts +1 -2
  137. package/dist/controllers/tree_view_controller.js +19 -1
  138. package/dist/controllers/tree_view_controller.js.map +1 -1
  139. package/dist/index.js +2278 -596
  140. package/dist/index.js.map +1 -1
  141. package/dist/inspector/cli.d.ts +76 -1
  142. package/dist/inspector/cli.js +937 -76
  143. package/dist/inspector/cli.js.map +1 -1
  144. package/dist/inspector/cli_bin.js +997 -78
  145. package/dist/inspector/cli_bin.js.map +1 -1
  146. package/dist/inspector/examples.json +20 -20
  147. package/dist/inspector/manifest.json +428 -33
  148. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -110,6 +110,35 @@ var AccordionController = class extends Controller {
110
110
  }
111
111
  };
112
112
 
113
+ // src/utils/before_cache_reset.ts
114
+ var BeforeCacheReset = class _BeforeCacheReset {
115
+ /** Every subscribed instance, iterated by the one shared document listener. */
116
+ static #subscribers = /* @__PURE__ */ new Set();
117
+ /** The shared listener; installed while at least one instance is subscribed. */
118
+ static #onBeforeCache = () => {
119
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
120
+ };
121
+ #rewind;
122
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
123
+ constructor(rewind) {
124
+ this.#rewind = rewind;
125
+ }
126
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
127
+ activate() {
128
+ const first = _BeforeCacheReset.#subscribers.size === 0;
129
+ _BeforeCacheReset.#subscribers.add(this);
130
+ if (first) {
131
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
132
+ }
133
+ }
134
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
135
+ deactivate() {
136
+ _BeforeCacheReset.#subscribers.delete(this);
137
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
138
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
139
+ }
140
+ };
141
+
113
142
  // src/utils/escape_layer.ts
114
143
  function claimsWhileFocusWithin(element) {
115
144
  return () => {
@@ -249,7 +278,7 @@ var FocusTrap = class {
249
278
  }
250
279
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
251
280
  document.addEventListener("keydown", this.#onKeydown);
252
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
281
+ this.#beforeCache.activate();
253
282
  const onEscape = this.#options.onEscape;
254
283
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
255
284
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -266,7 +295,7 @@ var FocusTrap = class {
266
295
  this.#activeState = false;
267
296
  this.#escapeLayer.deactivate();
268
297
  document.removeEventListener("keydown", this.#onKeydown);
269
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
298
+ this.#beforeCache.deactivate();
270
299
  if (this.#scrollLocked) {
271
300
  document.body.style.overflow = this.#previousBodyOverflow;
272
301
  this.#scrollLocked = false;
@@ -290,9 +319,7 @@ var FocusTrap = class {
290
319
  * untouched (restore-open designs reopen against a clean baseline), and focus
291
320
  * is left alone mid-navigation. The listener lives only while active.
292
321
  */
293
- #onBeforeCache = () => {
294
- this.deactivate({ restoreFocus: false });
295
- };
322
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
296
323
  /**
297
324
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
298
325
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -427,6 +454,39 @@ var AlertDialogController = class extends Controller {
427
454
  }
428
455
  };
429
456
 
457
+ // src/utils/microtask_coalescer.ts
458
+ var MicrotaskCoalescer = class {
459
+ #run;
460
+ #queued = false;
461
+ #active = false;
462
+ #generation = 0;
463
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
464
+ constructor(run) {
465
+ this.#run = run;
466
+ }
467
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
468
+ activate() {
469
+ this.#active = true;
470
+ }
471
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
472
+ cancel() {
473
+ this.#active = false;
474
+ this.#queued = false;
475
+ this.#generation += 1;
476
+ }
477
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
478
+ schedule() {
479
+ if (!this.#active || this.#queued) return;
480
+ this.#queued = true;
481
+ const generation = this.#generation;
482
+ queueMicrotask(() => {
483
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
484
+ this.#queued = false;
485
+ this.#run();
486
+ });
487
+ }
488
+ };
489
+
430
490
  // src/utils/safe_timeout.ts
431
491
  var TimerRegistry = class {
432
492
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -495,6 +555,7 @@ var SafeInterval = class extends TimerRegistry {
495
555
  };
496
556
 
497
557
  // src/controllers/announcer_controller.ts
558
+ var LEVELS = ["polite", "assertive"];
498
559
  var AnnouncerController = class extends Controller {
499
560
  static targets = ["polite", "assertive"];
500
561
  static values = {
@@ -506,6 +567,30 @@ var AnnouncerController = class extends Controller {
506
567
  #timers = new SafeTimeout();
507
568
  /** Live regions generated to stand in for absent targets, for teardown. */
508
569
  #generated = /* @__PURE__ */ new Map();
570
+ /** Messages waiting to be written, oldest first, one queue per politeness. */
571
+ #queues = /* @__PURE__ */ new Map();
572
+ /** Politeness levels whose next drain is already armed. */
573
+ #draining = /* @__PURE__ */ new Set();
574
+ /** Collapses a batch of target callbacks (and morph removals) into one pass. */
575
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileRegions());
576
+ /**
577
+ * Watches the host's own children for a generated region disappearing. A morph
578
+ * drops it — the server's HTML never had it — and no target callback reports
579
+ * that, because a generated region carries no target attribute. `subtree` stays
580
+ * off so writing a message inside a region does not re-enter this pass.
581
+ */
582
+ #hostWatch = new MutationObserver(() => {
583
+ this.#reconcile.schedule();
584
+ });
585
+ /** Rewinds to an announceable initial state for the snapshot; see the remarks. */
586
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
587
+ /**
588
+ * The one timer a region may have outstanding — its dedupe re-set, then its
589
+ * auto-clear. Held weakly so a swapped-out target is not retained; a leftover
590
+ * id is harmless because {@link SafeTimeout.clear} no-ops on an id it does not
591
+ * own.
592
+ */
593
+ #pending = /* @__PURE__ */ new WeakMap();
509
594
  /**
510
595
  * Guards against handling the same CustomEvent twice. An event dispatched on
511
596
  * the controller element with `bubbles: true` reaches both the element and the
@@ -522,13 +607,86 @@ var AnnouncerController = class extends Controller {
522
607
  this.#announce(message, this.#assertiveFromDetail(detail));
523
608
  };
524
609
  connect() {
610
+ this.#reconcile.activate();
611
+ this.#beforeCache.activate();
612
+ this.#reconcileRegions();
613
+ this.#hostWatch.observe(this.element, { childList: true });
525
614
  this.element.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
526
615
  window.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
527
616
  }
528
617
  disconnect() {
618
+ this.#reconcile.cancel();
619
+ this.#hostWatch.disconnect();
620
+ this.#beforeCache.deactivate();
529
621
  this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
530
622
  window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
531
623
  this.#timers.clearAll();
624
+ this.#queues.clear();
625
+ this.#draining.clear();
626
+ this.#removeGenerated();
627
+ }
628
+ /** Retires the stand-in once the consumer supplies a polite region. */
629
+ politeTargetConnected() {
630
+ this.#reconcile.schedule();
631
+ }
632
+ /** Materialises a stand-in once the consumer's polite region goes away. */
633
+ politeTargetDisconnected() {
634
+ this.#reconcile.schedule();
635
+ }
636
+ /** Retires the stand-in once the consumer supplies an assertive region. */
637
+ assertiveTargetConnected() {
638
+ this.#reconcile.schedule();
639
+ }
640
+ /** Materialises a stand-in once the consumer's assertive region goes away. */
641
+ assertiveTargetDisconnected() {
642
+ this.#reconcile.schedule();
643
+ }
644
+ /**
645
+ * Brings the region set back to exactly one region per politeness and reports
646
+ * whether anything had to be created.
647
+ *
648
+ * A region created here is not written to in the same task: assistive tech
649
+ * reports changes to regions it already knows about, so {@link drain} waits a
650
+ * task whenever this says a region is new.
651
+ */
652
+ #reconcileRegions() {
653
+ let created = false;
654
+ for (const level of LEVELS) {
655
+ if (this.#hasTargetFor(level)) {
656
+ const generated = this.#generated.get(level);
657
+ if (generated) {
658
+ generated.remove();
659
+ this.#generated.delete(level);
660
+ }
661
+ continue;
662
+ }
663
+ const existing = this.#generated.get(level);
664
+ if (existing?.isConnected) continue;
665
+ this.#generated.set(level, this.#createRegion(level));
666
+ created = true;
667
+ }
668
+ return created;
669
+ }
670
+ /** Whether the consumer supplied a target for `level`. */
671
+ #hasTargetFor(level) {
672
+ return level === "assertive" ? this.hasAssertiveTarget : this.hasPoliteTarget;
673
+ }
674
+ /** Builds a visually hidden live region for `level` and attaches it. */
675
+ #createRegion(level) {
676
+ const region = document.createElement("div");
677
+ region.setAttribute("aria-live", level);
678
+ region.setAttribute("aria-atomic", "true");
679
+ visuallyHide(region);
680
+ this.element.appendChild(region);
681
+ return region;
682
+ }
683
+ /**
684
+ * Removes and forgets every region this controller generated. Authored targets
685
+ * belong to the consumer and are left untouched. Forgetting them is what keeps
686
+ * the live page working after a snapshot rewind: the next announcement finds an
687
+ * empty map and materialises a fresh region.
688
+ */
689
+ #removeGenerated() {
532
690
  for (const region of this.#generated.values()) {
533
691
  region.remove();
534
692
  }
@@ -550,47 +708,122 @@ var AnnouncerController = class extends Controller {
550
708
  this.#announce(message, assertive);
551
709
  }
552
710
  /**
553
- * Writes `message` into the matching live region and schedules its clear.
711
+ * Queues `message` for its politeness and arms the drain.
554
712
  *
555
- * When the region already holds the same text, an aria-atomic region is not
556
- * re-read by assistive tech (the node did not change). If `dedupeReannounce`
557
- * is on, the text is cleared and re-set on a later task so the mutation is
558
- * observed and announced again.
713
+ * Queuing is what makes a burst audible: assistive tech announces the changes it
714
+ * observes, so several messages written into one region within a single task are
715
+ * one change and only the last is read.
559
716
  */
560
717
  #announce(message, assertive) {
561
- const region = this.#regionFor(assertive ? "assertive" : "polite");
718
+ const level = assertive ? "assertive" : "polite";
719
+ const queue = this.#queues.get(level);
720
+ if (queue) {
721
+ queue.push(message);
722
+ } else {
723
+ this.#queues.set(level, [message]);
724
+ }
725
+ this.#scheduleDrain(level);
726
+ }
727
+ /** Arms one drain pass for `level`; further messages ride the pass already armed. */
728
+ #scheduleDrain(level) {
729
+ if (this.#draining.has(level)) return;
730
+ this.#draining.add(level);
731
+ this.#timers.set(() => {
732
+ this.#draining.delete(level);
733
+ this.#drain(level);
734
+ }, 0);
735
+ }
736
+ /**
737
+ * Writes one queued message, then arms the next pass while the queue holds more.
738
+ *
739
+ * Two steps take a whole pass without consuming the message: materialising a
740
+ * region (it has to be in the accessibility tree before the text arrives) and
741
+ * emptying a region that already holds this exact text (an unchanged node is not
742
+ * re-read, so `dedupeReannounce` clears first and writes on the following pass).
743
+ */
744
+ #drain(level) {
745
+ const queue = this.#queues.get(level);
746
+ const message = queue?.[0];
747
+ if (queue === void 0 || message === void 0) return;
748
+ if (this.#reconcileRegions()) {
749
+ this.#scheduleDrain(level);
750
+ return;
751
+ }
752
+ const region = this.#regionFor(level);
562
753
  if (this.dedupeReannounceValue && region.textContent === message) {
754
+ this.#cancelPending(region);
563
755
  region.textContent = "";
564
- this.#timers.set(() => {
565
- region.textContent = message;
566
- this.#scheduleClear(region, message);
567
- }, 0);
756
+ this.#scheduleDrain(level);
568
757
  return;
569
758
  }
759
+ queue.shift();
760
+ this.#cancelPending(region);
570
761
  region.textContent = message;
571
762
  this.#scheduleClear(region, message);
763
+ if (queue.length > 0) this.#scheduleDrain(level);
572
764
  }
573
765
  /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
574
766
  #scheduleClear(region, message) {
575
767
  if (this.clearAfterValue <= 0) return;
576
- this.#timers.set(() => {
577
- if (region.textContent === message) region.textContent = "";
578
- }, this.clearAfterValue);
768
+ this.#schedule(
769
+ region,
770
+ () => {
771
+ if (region.textContent === message) region.textContent = "";
772
+ },
773
+ this.clearAfterValue
774
+ );
775
+ }
776
+ /**
777
+ * Arms `region`'s single pending timer. Callers reach here with the slot
778
+ * already free — `#announce` releases it, and a fired timer clears its own
779
+ * entry below — so this does not cancel again.
780
+ */
781
+ #schedule(region, callback, delay) {
782
+ const id = this.#timers.set(() => {
783
+ this.#pending.delete(region);
784
+ callback();
785
+ }, delay);
786
+ this.#pending.set(region, id);
579
787
  }
580
- /** Resolves the live region for a politeness level, generating it if absent. */
788
+ /** Releases `region`'s pending timer, if it has one. */
789
+ #cancelPending(region) {
790
+ this.#timers.clear(this.#pending.get(region) ?? -1);
791
+ this.#pending.delete(region);
792
+ }
793
+ /**
794
+ * Resolves the live region for a politeness level.
795
+ *
796
+ * The remembered stand-in is used only while it is still in the document: a morph
797
+ * can drop it, and writing into the detached node would announce nothing at all.
798
+ */
581
799
  #regionFor(level) {
582
800
  if (level === "assertive" && this.hasAssertiveTarget) return this.assertiveTarget;
583
801
  if (level === "polite" && this.hasPoliteTarget) return this.politeTarget;
584
802
  const existing = this.#generated.get(level);
585
- if (existing) return existing;
586
- const region = document.createElement("div");
587
- region.setAttribute("aria-live", level);
588
- region.setAttribute("aria-atomic", "true");
589
- visuallyHide(region);
590
- this.element.appendChild(region);
803
+ if (existing?.isConnected) return existing;
804
+ const region = this.#createRegion(level);
591
805
  this.#generated.set(level, region);
592
806
  return region;
593
807
  }
808
+ /**
809
+ * Restores the announceable initial state for the snapshot Turbo is about to
810
+ * take: queued and displayed messages go, generated regions go, and the live
811
+ * page — which keeps running when a visit is aborted — gets its regions back on
812
+ * the next task, after the clone.
813
+ */
814
+ #rewindForCache() {
815
+ this.#queues.clear();
816
+ this.#draining.clear();
817
+ this.#timers.clearAll();
818
+ for (const level of LEVELS) {
819
+ if (this.#hasTargetFor(level)) {
820
+ const target = level === "assertive" ? this.assertiveTarget : this.politeTarget;
821
+ target.textContent = "";
822
+ }
823
+ }
824
+ this.#removeGenerated();
825
+ this.#timers.set(() => this.#reconcileRegions(), 0);
826
+ }
594
827
  /** Extracts a non-empty string `message` from a CustomEvent detail, else null. */
595
828
  #messageFromDetail(detail) {
596
829
  if (detail && typeof detail === "object" && "message" in detail) {
@@ -624,7 +857,7 @@ var AspectRatioController = class extends Controller {
624
857
  };
625
858
  /** Applies the ratio on connect and whenever the value changes. */
626
859
  ratioValueChanged() {
627
- this.element.style.setProperty("--stimeo-aspect-ratio", this.#normalizeRatio(this.ratioValue));
860
+ this.element.style.setProperty("--stimeo--aspect-ratio", this.#normalizeRatio(this.ratioValue));
628
861
  }
629
862
  /**
630
863
  * Normalizes a ratio string to a valid CSS `<ratio>`:
@@ -1113,7 +1346,11 @@ var BreadcrumbController = class extends Controller {
1113
1346
  this.ellipsisTarget.hidden = true;
1114
1347
  return this.listTarget.scrollWidth > this.listTarget.clientWidth + OVERFLOW_EPSILON;
1115
1348
  }
1116
- /** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
1349
+ /**
1350
+ * Applies the collapsed/expanded state to the items, ellipsis, and trigger.
1351
+ *
1352
+ * @stimeoRenderRoot
1353
+ */
1117
1354
  #render() {
1118
1355
  const collapsed = this.#overflowing && !this.#expanded;
1119
1356
  const showEllipsis = this.#overflowing && this.collapsibleTargets.length > 0;
@@ -1845,7 +2082,11 @@ var CarouselController = class extends Controller {
1845
2082
  this.#syncTimer();
1846
2083
  if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
1847
2084
  }
1848
- /** Reflects `this.#index` onto slides and pickers (state hooks + roving). */
2085
+ /**
2086
+ * Reflects `this.#index` onto slides and pickers (state hooks + roving).
2087
+ *
2088
+ * @stimeoRenderRoot
2089
+ */
1849
2090
  #render({ focus }) {
1850
2091
  this.slideTargets.forEach((slide, i) => {
1851
2092
  const active = i === this.#index;
@@ -2054,6 +2295,15 @@ var CheckboxController = class extends Controller {
2054
2295
  return "partial";
2055
2296
  }
2056
2297
  };
2298
+
2299
+ // src/utils/default_attribute.ts
2300
+ function setDefaultAttribute(element, name, value) {
2301
+ if (element.hasAttribute(name)) return false;
2302
+ element.setAttribute(name, value);
2303
+ return true;
2304
+ }
2305
+
2306
+ // src/controllers/clipboard_controller.ts
2057
2307
  var ClipboardController = class extends Controller {
2058
2308
  static targets = ["source", "button", "feedback"];
2059
2309
  static values = {
@@ -2073,9 +2323,7 @@ var ClipboardController = class extends Controller {
2073
2323
  */
2074
2324
  #resetTimerId = null;
2075
2325
  connect() {
2076
- if (!this.element.hasAttribute("data-state")) {
2077
- this.element.setAttribute("data-state", "idle");
2078
- }
2326
+ setDefaultAttribute(this.element, "data-state", "idle");
2079
2327
  }
2080
2328
  disconnect() {
2081
2329
  this.#timers.clearAll();
@@ -2382,7 +2630,10 @@ var CollapsibleController = class extends Controller {
2382
2630
  #applyContent(content, open, waitForCloseTransition) {
2383
2631
  if (open) {
2384
2632
  content.hidden = false;
2385
- content.style.setProperty("--stimeo-collapsible-content-height", `${content.scrollHeight}px`);
2633
+ content.style.setProperty(
2634
+ "--stimeo--collapsible-content-height",
2635
+ `${content.scrollHeight}px`
2636
+ );
2386
2637
  content.setAttribute("data-state", "open");
2387
2638
  return;
2388
2639
  }
@@ -2415,7 +2666,7 @@ function toFiniteNumber(raw) {
2415
2666
  }
2416
2667
 
2417
2668
  // src/controllers/color_picker_controller.ts
2418
- var COLOR_PROPERTY = "--stimeo-color";
2669
+ var COLOR_PROPERTY = "--stimeo--color";
2419
2670
  var CHANNEL_RANGE = {
2420
2671
  hue: [0, 360],
2421
2672
  saturation: [0, 100],
@@ -2440,16 +2691,29 @@ var ColorPickerController = class extends Controller {
2440
2691
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
2441
2692
  #dragAbort = null;
2442
2693
  /** Seeds the model from the initial hex value and renders every surface. */
2694
+ /**
2695
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
2696
+ * pass Stimulus delivers before `connect()`.
2697
+ */
2698
+ #repaint = new MicrotaskCoalescer(() => {
2699
+ this.#render();
2700
+ });
2443
2701
  connect() {
2702
+ this.#repaint.activate();
2444
2703
  const parsed = hexToHsla(this.valueValue);
2445
2704
  if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
2446
2705
  this.#render();
2447
2706
  }
2448
2707
  /** Cancels any active pointer drag so document listeners never leak. */
2449
2708
  disconnect() {
2709
+ this.#repaint.cancel();
2450
2710
  this.#dragAbort?.abort();
2451
2711
  this.#dragAbort = null;
2452
2712
  }
2713
+ /** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
2714
+ alphaValueChanged() {
2715
+ this.#repaint.schedule();
2716
+ }
2453
2717
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
2454
2718
  onKeydown(event) {
2455
2719
  if (isReservedArrowChord(event)) return;
@@ -2531,7 +2795,11 @@ var ColorPickerController = class extends Controller {
2531
2795
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
2532
2796
  this.#render();
2533
2797
  }
2534
- /** Reflects the model onto sliders, the hex input, preview, and form field. */
2798
+ /**
2799
+ * Reflects the model onto sliders, the hex input, preview, and form field.
2800
+ *
2801
+ * @stimeoRenderRoot
2802
+ */
2535
2803
  #render() {
2536
2804
  for (const slider of this.sliderTargets) {
2537
2805
  const channel = this.#channelOf(slider);
@@ -2648,39 +2916,6 @@ function syncActiveOption(options, active) {
2648
2916
  return written;
2649
2917
  }
2650
2918
 
2651
- // src/utils/microtask_coalescer.ts
2652
- var MicrotaskCoalescer = class {
2653
- #run;
2654
- #queued = false;
2655
- #active = false;
2656
- #generation = 0;
2657
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
2658
- constructor(run) {
2659
- this.#run = run;
2660
- }
2661
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
2662
- activate() {
2663
- this.#active = true;
2664
- }
2665
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
2666
- cancel() {
2667
- this.#active = false;
2668
- this.#queued = false;
2669
- this.#generation += 1;
2670
- }
2671
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
2672
- schedule() {
2673
- if (!this.#active || this.#queued) return;
2674
- this.#queued = true;
2675
- const generation = this.#generation;
2676
- queueMicrotask(() => {
2677
- if (generation !== this.#generation || !this.#queued || !this.#active) return;
2678
- this.#queued = false;
2679
- this.#run();
2680
- });
2681
- }
2682
- };
2683
-
2684
2919
  // src/utils/option_scroll.ts
2685
2920
  function scrollOptionIntoView(list, option) {
2686
2921
  if (list.scrollHeight <= list.clientHeight) return;
@@ -3691,8 +3926,8 @@ var ContextMenuController = class extends Controller {
3691
3926
  onDismiss: () => this.#closeAndRestore(),
3692
3927
  claims: claimsWhileFocusWithin(this.element)
3693
3928
  });
3694
- this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
3695
- this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
3929
+ this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
3930
+ this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
3696
3931
  this.menuTarget.hidden = false;
3697
3932
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
3698
3933
  this.#navigableItems[0]?.focus();
@@ -3846,6 +4081,26 @@ var CountUpController = class extends Controller {
3846
4081
  this.element.removeAttribute("data-count-up-label");
3847
4082
  }
3848
4083
  };
4084
+
4085
+ // src/utils/announce.ts
4086
+ function announce(message, options = {}) {
4087
+ const text = message.trim();
4088
+ if (text.length === 0) return;
4089
+ window.dispatchEvent(
4090
+ new CustomEvent("stimeo--announcer:announce", {
4091
+ detail: { message: text, assertive: options.assertive === true }
4092
+ })
4093
+ );
4094
+ }
4095
+ function fillTemplate(template, values) {
4096
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
4097
+ const replacement = values[name];
4098
+ return replacement === void 0 ? match : String(replacement);
4099
+ });
4100
+ }
4101
+
4102
+ // src/controllers/countdown_controller.ts
4103
+ var SECOND_MS = 1e3;
3849
4104
  var CountdownController = class extends Controller {
3850
4105
  static targets = ["days", "hours", "minutes", "seconds", "status"];
3851
4106
  static values = {
@@ -3853,34 +4108,79 @@ var CountdownController = class extends Controller {
3853
4108
  interval: { type: Number, default: 1e3 },
3854
4109
  direction: { type: String, default: "down" },
3855
4110
  autostart: { type: Boolean, default: true },
3856
- completeLabel: { type: String, default: "" }
4111
+ completeLabel: { type: String, default: "" },
4112
+ announceText: { type: String, default: "" }
3857
4113
  };
3858
4114
  static actions = ["pause", "reset", "resume", "start"];
3859
4115
  static events = ["complete", "tick"];
3860
4116
  #intervals = new SafeInterval();
3861
4117
  #intervalId = null;
4118
+ /** Collapses a morph that swaps several render inputs at once into one re-derive. */
4119
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
3862
4120
  /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
3863
4121
  #reference = 0;
3864
4122
  /** Amount (ms) captured at pause, so resume can restore the same display. */
3865
4123
  #pausedAmount = 0;
4124
+ /**
4125
+ * The amount the slots are currently showing, floored to the second they render.
4126
+ * It lags {@link currentAmount} by up to one tick, and it — not the live reading —
4127
+ * is what a pause has to preserve: storing the fraction behind the display instead
4128
+ * makes the first tick after a resume step by two units.
4129
+ */
4130
+ #renderedAmount = 0;
3866
4131
  connect() {
4132
+ this.#resync.activate();
3867
4133
  this.#initReference();
3868
- this.#render(this.#currentAmount());
3869
- if (this.autostartValue && this.#isValidDeadline) {
3870
- this.start();
3871
- } else {
4134
+ const amount = this.#currentAmount();
4135
+ this.#render(amount);
4136
+ this.#pausedAmount = this.#renderedAmount;
4137
+ const authored = this.element.getAttribute("data-state");
4138
+ if (authored === null) {
4139
+ if (this.autostartValue && this.#isValidDeadline) {
4140
+ this.start();
4141
+ return;
4142
+ }
3872
4143
  this.element.setAttribute("data-state", "paused");
4144
+ return;
3873
4145
  }
4146
+ if (authored === "complete" && this.#isDown && amount <= 0) {
4147
+ return;
4148
+ }
4149
+ const wasRunning = authored === "running";
4150
+ this.element.setAttribute("data-state", "paused");
4151
+ if (wasRunning) this.start();
3874
4152
  }
3875
4153
  disconnect() {
4154
+ this.#resync.cancel();
3876
4155
  this.#intervals.clearAll();
3877
4156
  this.#intervalId = null;
3878
4157
  }
4158
+ /** Re-derives the display when a morph swaps the deadline in place. */
4159
+ deadlineValueChanged() {
4160
+ this.#resync.schedule();
4161
+ }
4162
+ /** Re-derives the display when a morph flips the counting direction in place. */
4163
+ directionValueChanged() {
4164
+ this.#resync.schedule();
4165
+ }
4166
+ /**
4167
+ * Points the anchor at the current `deadline` / `direction` and repaints.
4168
+ *
4169
+ * Render only: it starts no interval and emits no event, so a morph cannot make a
4170
+ * paused timer run or replay a milestone. A running one needs no restart either —
4171
+ * every tick reads the anchor, so moving it is enough. While paused the stored
4172
+ * amount follows the new reading, or resume would continue from the old deadline.
4173
+ */
4174
+ #resyncToValues() {
4175
+ this.#initReference();
4176
+ this.#render(this.#currentAmount());
4177
+ if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
4178
+ }
3879
4179
  /** Starts (or restarts after pause) ticking toward the deadline. */
3880
4180
  start() {
3881
4181
  if (this.#state === "running" || !this.#isValidDeadline) return;
3882
4182
  if (this.#isDown && this.#currentAmount() <= 0) {
3883
- this.#complete();
4183
+ if (this.#state !== "complete") this.#complete();
3884
4184
  return;
3885
4185
  }
3886
4186
  this.#runInterval();
@@ -3888,7 +4188,7 @@ var CountdownController = class extends Controller {
3888
4188
  /** Pauses ticking, preserving the currently displayed amount. */
3889
4189
  pause() {
3890
4190
  if (this.#state !== "running") return;
3891
- this.#pausedAmount = this.#currentAmount();
4191
+ this.#pausedAmount = this.#renderedAmount;
3892
4192
  this.#teardownInterval();
3893
4193
  this.element.setAttribute("data-state", "paused");
3894
4194
  }
@@ -3904,8 +4204,8 @@ var CountdownController = class extends Controller {
3904
4204
  * run state**: a running timer keeps counting down from the reset amount, while a
3905
4205
  * paused (or completed) one resets the displayed amount but stays paused until the
3906
4206
  * user resumes — it never silently restarts. The run state is read from the DOM,
3907
- * not re-derived from the declarative `autostart` Value (which only governs the
3908
- * initial state on connect); re-deriving it would override a user's pause —
4207
+ * not re-derived from the declarative `autostart` Value (which governs only markup
4208
+ * that states no run state at all); re-deriving it would override a user's pause —
3909
4209
  * the DOM, not a re-run of declarative config, is the source of truth.
3910
4210
  */
3911
4211
  reset() {
@@ -3914,13 +4214,15 @@ var CountdownController = class extends Controller {
3914
4214
  this.#initReference();
3915
4215
  const amount = this.#currentAmount();
3916
4216
  this.#render(amount);
3917
- if (this.hasStatusTarget) this.statusTarget.textContent = "";
4217
+ if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
4218
+ this.statusTarget.textContent = "";
4219
+ }
3918
4220
  this.element.setAttribute("data-state", "paused");
3919
4221
  if (wasRunning && this.#isValidDeadline) {
3920
4222
  this.#pausedAmount = 0;
3921
4223
  this.start();
3922
4224
  } else {
3923
- this.#pausedAmount = amount;
4225
+ this.#pausedAmount = this.#renderedAmount;
3924
4226
  }
3925
4227
  }
3926
4228
  /** Schedules the repeating tick and marks the timer running. */
@@ -3955,6 +4257,7 @@ var CountdownController = class extends Controller {
3955
4257
  this.statusTarget.textContent = this.completeLabelValue;
3956
4258
  }
3957
4259
  this.dispatch("complete", { detail: {} });
4260
+ announce(fillTemplate(this.announceTextValue, {}));
3958
4261
  }
3959
4262
  /** Sets the time anchor from the `deadline` value. */
3960
4263
  #initReference() {
@@ -3967,9 +4270,14 @@ var CountdownController = class extends Controller {
3967
4270
  const raw = this.#isDown ? this.#reference - now : now - this.#reference;
3968
4271
  return Math.max(0, raw);
3969
4272
  }
3970
- /** Writes the amount into the day/hour/minute/second slots. */
4273
+ /**
4274
+ * Writes the amount into the day/hour/minute/second slots.
4275
+ *
4276
+ * @stimeoRenderRoot
4277
+ */
3971
4278
  #render(amount) {
3972
- const totalSeconds = Math.floor(amount / 1e3);
4279
+ const totalSeconds = Math.floor(amount / SECOND_MS);
4280
+ this.#renderedAmount = totalSeconds * SECOND_MS;
3973
4281
  const days = Math.floor(totalSeconds / 86400);
3974
4282
  const hours = Math.floor(totalSeconds % 86400 / 3600);
3975
4283
  const minutes = Math.floor(totalSeconds % 3600 / 60);
@@ -4378,7 +4686,15 @@ var DateRangePickerController = class extends Controller {
4378
4686
  /** Deferred focus after an async month transition (cancelled on teardown). */
4379
4687
  #focusTimer = new SafeTimeout();
4380
4688
  /** Seeds the range from any pre-filled hidden fields and renders the grid. */
4689
+ /**
4690
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
4691
+ * pass Stimulus delivers before `connect()`.
4692
+ */
4693
+ #repaint = new MicrotaskCoalescer(() => {
4694
+ this.#render();
4695
+ });
4381
4696
  connect() {
4697
+ this.#repaint.activate();
4382
4698
  this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
4383
4699
  this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
4384
4700
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
@@ -4389,8 +4705,17 @@ var DateRangePickerController = class extends Controller {
4389
4705
  }
4390
4706
  /** Cancels any pending deferred focus so it never fires on a detached element. */
4391
4707
  disconnect() {
4708
+ this.#repaint.cancel();
4392
4709
  this.#focusTimer.clearAll();
4393
4710
  }
4711
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
4712
+ minValueChanged() {
4713
+ this.#repaint.schedule();
4714
+ }
4715
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
4716
+ maxValueChanged() {
4717
+ this.#repaint.schedule();
4718
+ }
4394
4719
  /** Navigates to the previous month. */
4395
4720
  prev(event) {
4396
4721
  event?.preventDefault();
@@ -4554,7 +4879,11 @@ var DateRangePickerController = class extends Controller {
4554
4879
  this.#focusedDate = target;
4555
4880
  this.#render();
4556
4881
  }
4557
- /** Builds the six-week grid and binds range/roving/disabled state per cell. */
4882
+ /**
4883
+ * Builds the six-week grid and binds range/roving/disabled state per cell.
4884
+ *
4885
+ * @stimeoRenderRoot
4886
+ */
4558
4887
  #render() {
4559
4888
  const info = parseISOMonthString(this.#viewMonth);
4560
4889
  if (!info) return;
@@ -4780,7 +5109,7 @@ var DirectUploadController = class extends Controller {
4780
5109
  const clamped = Math.max(0, Math.min(100, percent));
4781
5110
  row.setAttribute("aria-valuenow", String(clamped));
4782
5111
  row.setAttribute("aria-valuetext", `${clamped}%`);
4783
- row.style.setProperty("--stimeo-upload-progress", `${clamped}%`);
5112
+ row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
4784
5113
  this.#setField(row, "percent", `${clamped}%`);
4785
5114
  this.#syncAggregate();
4786
5115
  this.dispatch("progress", { detail: { id, percent: clamped } });
@@ -4818,7 +5147,7 @@ var DirectUploadController = class extends Controller {
4818
5147
  }
4819
5148
  clone.setAttribute("aria-valuenow", "0");
4820
5149
  clone.setAttribute("data-upload-state", "uploading");
4821
- clone.style.setProperty("--stimeo-upload-progress", "0%");
5150
+ clone.style.setProperty("--stimeo--upload-progress", "0%");
4822
5151
  this.listTarget.appendChild(clone);
4823
5152
  this.#rows.set(key, clone);
4824
5153
  return clone;
@@ -4842,7 +5171,7 @@ var DirectUploadController = class extends Controller {
4842
5171
  }
4843
5172
  const overall = Math.round(total / this.#rows.size);
4844
5173
  this.element.setAttribute("data-upload-progress", String(overall));
4845
- this.element.style.setProperty("--stimeo-upload-progress", `${overall}%`);
5174
+ this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
4846
5175
  }
4847
5176
  /** Writes a consumer label (with `%{name}` substituted) to the status region. */
4848
5177
  #announce(label, row) {
@@ -5483,71 +5812,157 @@ var EmptyStateController = class extends Controller {
5483
5812
  static targets = ["list", "empty"];
5484
5813
  static values = {
5485
5814
  itemSelector: { type: String, default: "" },
5486
- announce: { type: Boolean, default: false }
5815
+ announceText: { type: String, default: "" },
5816
+ announceFilledText: { type: String, default: "" }
5487
5817
  };
5488
5818
  static events = ["change"];
5489
5819
  #observer = null;
5820
+ /** Whether the controller is between `connect()` and `disconnect()`. */
5821
+ #connected = false;
5490
5822
  /** Last applied empty state; `null` until the first sync so connect emits nothing. */
5491
5823
  #empty = null;
5492
5824
  connect() {
5493
- if (!this.hasListTarget) return;
5494
- if (this.announceValue && this.hasEmptyTarget && !this.#isLiveRegion(this.emptyTarget)) {
5495
- this.emptyTarget.setAttribute("role", "status");
5496
- this.emptyTarget.setAttribute("aria-live", "polite");
5497
- }
5498
- if (typeof MutationObserver !== "undefined") {
5499
- this.#observer = new MutationObserver(() => this.#apply());
5500
- this.#observer.observe(this.listTarget, { childList: true });
5501
- }
5502
- this.#apply();
5825
+ this.#connected = true;
5826
+ this.#syncObservation();
5827
+ this.#update();
5503
5828
  }
5504
5829
  disconnect() {
5505
- this.#observer?.disconnect();
5506
- this.#observer = null;
5830
+ this.#connected = false;
5831
+ this.#stopObserving();
5507
5832
  }
5508
- /** Recomputes the count and syncs visibility, hooks, and the change event. */
5509
- #apply() {
5510
- if (!this.hasListTarget) return;
5511
- const count = this.#count();
5512
- const empty = count === 0;
5513
- this.element.setAttribute("data-count", String(count));
5514
- if (empty) {
5515
- this.element.setAttribute("data-empty", "true");
5516
- } else {
5517
- this.element.removeAttribute("data-empty");
5518
- }
5519
- this.listTarget.hidden = empty;
5520
- if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
5521
- if (this.#empty !== null && empty !== this.#empty) {
5522
- this.dispatch("change", { detail: { count, empty } });
5523
- }
5524
- this.#empty = empty;
5833
+ /** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */
5834
+ listTargetConnected() {
5835
+ this.#resync();
5525
5836
  }
5526
- /** Item count: element children matching `itemSelector`, or all element children. */
5527
- #count() {
5528
- const selector = this.itemSelectorValue;
5529
- if (selector.length === 0) return this.listTarget.childElementCount;
5530
- try {
5531
- return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;
5532
- } catch {
5533
- return this.listTarget.childElementCount;
5534
- }
5837
+ /** Releases the observation when the `list` element leaves the target set. */
5838
+ listTargetDisconnected() {
5839
+ this.#resync();
5535
5840
  }
5536
- #isLiveRegion(el) {
5537
- if (el.hasAttribute("aria-live")) return true;
5538
- const role = el.getAttribute("role");
5539
- return role === "status" || role === "alert";
5841
+ /** Syncs an `empty` element that arrives — or is replaced — at runtime. */
5842
+ emptyTargetConnected() {
5843
+ this.#resync();
5540
5844
  }
5541
- };
5542
- var FileDropzoneController = class extends Controller {
5543
- static targets = ["zone", "trigger", "input", "list", "item", "itemTemplate", "status"];
5544
- static values = {
5545
- maxSize: { type: Number, default: 0 },
5546
- maxFiles: { type: Number, default: 0 },
5547
- dragLabel: { type: String, default: "Drop files to add them" }
5548
- };
5549
- static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
5550
- static events = ["change", "reject"];
5845
+ /**
5846
+ * Syncs the `empty` element that remains when one leaves the target set. A
5847
+ * single-target getter resolves to the first `empty` element in document order,
5848
+ * so a swap that inserts the replacement *before* removing the original (Turbo
5849
+ * Stream `after` / `before` / `append` followed by `remove`) leaves the
5850
+ * replacement untouched until the original goes — this callback is that moment.
5851
+ */
5852
+ emptyTargetDisconnected() {
5853
+ this.#resync();
5854
+ }
5855
+ /** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */
5856
+ itemSelectorValueChanged() {
5857
+ this.#resync();
5858
+ }
5859
+ /**
5860
+ * Re-points the observation and re-renders after a target or selector change.
5861
+ * The `#connected` guard is load-bearing: Stimulus runs value and target
5862
+ * callbacks for the initial markup *before* `connect()` and runs target
5863
+ * callbacks during teardown *after* `disconnect()`, and re-observing there
5864
+ * would outlive the controller.
5865
+ */
5866
+ #resync() {
5867
+ if (!this.#connected) return;
5868
+ this.#syncObservation();
5869
+ this.#update();
5870
+ }
5871
+ /**
5872
+ * Points the mutation observation at the current `list` target — re-resolved on
5873
+ * every sync rather than captured at connect, so an element swapped in at
5874
+ * runtime is observed instead of the detached original.
5875
+ *
5876
+ * The observation covers exactly what the count predicate reads. With no
5877
+ * `itemSelector` the count is the child element count, which only `childList`
5878
+ * can change. With one, the predicate reads the children themselves, so
5879
+ * attribute and descendant mutations are watched too — and the controller's own
5880
+ * writes are filtered back out, or toggling `hidden` would re-enter the render.
5881
+ */
5882
+ #syncObservation() {
5883
+ this.#stopObserving();
5884
+ if (!this.hasListTarget || typeof MutationObserver === "undefined") return;
5885
+ const watchesItems = this.itemSelectorValue.length > 0;
5886
+ this.#observer = new MutationObserver((records) => {
5887
+ if (records.some((record) => this.#affectsCount(record))) this.#update();
5888
+ });
5889
+ this.#observer.observe(this.listTarget, {
5890
+ childList: true,
5891
+ subtree: watchesItems,
5892
+ attributes: watchesItems
5893
+ });
5894
+ }
5895
+ #stopObserving() {
5896
+ this.#observer?.disconnect();
5897
+ this.#observer = null;
5898
+ }
5899
+ /**
5900
+ * Whether a mutation can change the item count. An attribute written on a
5901
+ * target this controller owns is its own echo — `hidden` on `list` / `empty`,
5902
+ * and the hooks on the controller element when the list *is* that element.
5903
+ */
5904
+ #affectsCount(record) {
5905
+ if (record.type !== "attributes") return true;
5906
+ const own = record.target === this.listTarget || this.hasEmptyTarget && record.target === this.emptyTarget;
5907
+ return !own;
5908
+ }
5909
+ /**
5910
+ * Renders the count, then reports a crossed boundary. The announcement copy is
5911
+ * read here rather than while rendering: it is the wording of the report, not
5912
+ * an input to what is displayed.
5913
+ */
5914
+ #update() {
5915
+ const count = this.#render();
5916
+ if (count === null) return;
5917
+ const empty = count === 0;
5918
+ const crossed = this.#empty !== null && empty !== this.#empty;
5919
+ this.#empty = empty;
5920
+ if (!crossed) return;
5921
+ this.dispatch("change", { detail: { count, empty } });
5922
+ announce(
5923
+ fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
5924
+ );
5925
+ }
5926
+ /**
5927
+ * Syncs visibility and the state hooks to the current item count, and returns
5928
+ * it. `null` when there is no `list` target to count.
5929
+ *
5930
+ * @stimeoRenderRoot
5931
+ */
5932
+ #render() {
5933
+ if (!this.hasListTarget) return null;
5934
+ const count = this.#count();
5935
+ const empty = count === 0;
5936
+ this.element.setAttribute("data-count", String(count));
5937
+ if (empty) {
5938
+ this.element.setAttribute("data-empty", "true");
5939
+ } else {
5940
+ this.element.removeAttribute("data-empty");
5941
+ }
5942
+ this.listTarget.hidden = empty;
5943
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
5944
+ return count;
5945
+ }
5946
+ /** Item count: element children matching `itemSelector`, or all element children. */
5947
+ #count() {
5948
+ const selector = this.itemSelectorValue;
5949
+ if (selector.length === 0) return this.listTarget.childElementCount;
5950
+ try {
5951
+ return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;
5952
+ } catch {
5953
+ return this.listTarget.childElementCount;
5954
+ }
5955
+ }
5956
+ };
5957
+ var FileDropzoneController = class extends Controller {
5958
+ static targets = ["zone", "trigger", "input", "list", "item", "itemTemplate", "status"];
5959
+ static values = {
5960
+ maxSize: { type: Number, default: 0 },
5961
+ maxFiles: { type: Number, default: 0 },
5962
+ dragLabel: { type: String, default: "Drop files to add them" }
5963
+ };
5964
+ static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
5965
+ static events = ["change", "reject"];
5551
5966
  /** Selected files paired with their rendered item and any preview objectURL. */
5552
5967
  #entries = [];
5553
5968
  /** Wires file removal as a delegated listener on the list container. */
@@ -5789,29 +6204,119 @@ var FlashController = class extends Controller {
5789
6204
  static events = ["show", "dismiss"];
5790
6205
  #timers = new SafeTimeout();
5791
6206
  #observer = null;
6207
+ /** Whether the controller is between `connect()` and `disconnect()`. */
6208
+ #connected = false;
5792
6209
  /** Auto-dismiss timer state keyed by message element. */
5793
6210
  #state = /* @__PURE__ */ new Map();
5794
6211
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
5795
6212
  #order = [];
5796
- #onEnter = (event) => this.#pause(event.currentTarget);
5797
- #onLeave = (event) => this.#resume(event.currentTarget);
6213
+ /**
6214
+ * Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}
6215
+ * releases the bookkeeping above *before* the transition wait, so for that window the
6216
+ * element is in the DOM but in neither collection — without this set a re-scan would
6217
+ * read it as a brand-new flash and show it a second time.
6218
+ */
6219
+ #leaving = /* @__PURE__ */ new Set();
6220
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
6221
+ #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
6222
+ #onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
5798
6223
  connect() {
5799
- if (!this.hasRegionTarget) return;
6224
+ this.#connected = true;
5800
6225
  for (const message of this.messageTargets) {
5801
- this.#process(message, true);
5802
- }
5803
- if (typeof MutationObserver !== "undefined") {
5804
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
5805
- this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
6226
+ if (this.#owns(message)) this.#process(message, true);
5806
6227
  }
6228
+ this.#syncObservation();
6229
+ this.#beforeCache.activate();
5807
6230
  }
5808
6231
  disconnect() {
5809
- this.#observer?.disconnect();
5810
- this.#observer = null;
6232
+ this.#connected = false;
6233
+ this.#beforeCache.deactivate();
6234
+ this.#stopObserving();
5811
6235
  this.#timers.clearAll();
5812
6236
  for (const message of this.#order) this.#unbindPause(message);
5813
6237
  this.#state.clear();
5814
6238
  this.#order.length = 0;
6239
+ this.#leaving.clear();
6240
+ }
6241
+ /**
6242
+ * Takes the managed flashes out of the page just before Turbo freezes it, so a
6243
+ * restored snapshot carries no notification the visitor has already received: the
6244
+ * fresh `connect()` there reads a leftover flash as a brand-new one and announces it
6245
+ * a second time. A message that never auto-dismisses (`duration: 0`) is one of these
6246
+ * too — that value governs the timer, not what belongs in a cached page. Removal
6247
+ * only: `dismiss` reports a dismissal, and freezing the page is not one.
6248
+ */
6249
+ #rewindForCache() {
6250
+ for (const message of [...this.#order]) {
6251
+ message.remove();
6252
+ this.#forget(message);
6253
+ }
6254
+ for (const message of this.#leaving) message.remove();
6255
+ this.#leaving.clear();
6256
+ }
6257
+ /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
6258
+ regionTargetConnected() {
6259
+ this.#resync();
6260
+ }
6261
+ /** Releases the observation when the `region` element leaves the target set. */
6262
+ regionTargetDisconnected() {
6263
+ this.#resync();
6264
+ }
6265
+ /**
6266
+ * Whether this controller owns `message`. Ownership is the current `region`'s
6267
+ * subtree: a message target anywhere else in the controller's scope is the
6268
+ * consumer's, and so is one in a region that has gone away. The initial scan, a
6269
+ * re-scan after a `region` swap, and a departure from the target set all resolve
6270
+ * ownership through this one test; the observation gets it structurally, by watching
6271
+ * that subtree and nothing else.
6272
+ */
6273
+ #owns(message) {
6274
+ return this.hasRegionTarget && this.regionTarget.contains(message);
6275
+ }
6276
+ /**
6277
+ * Releases a message that left the target set (a Turbo Stream `remove`, the consumer
6278
+ * detaching the node, or a morph that rewrote the target attribute in place): it
6279
+ * stops occupying a `max` slot, and both its pending auto-dismiss and an already
6280
+ * scheduled removal are cancelled. A move *within* the region keeps all of them —
6281
+ * which is why the element must still be a message to be treated as one: ownership
6282
+ * alone reads an in-place attribute rewrite as a move, and a node outside the target
6283
+ * set belongs to the consumer, so nothing here may dismiss it.
6284
+ */
6285
+ messageTargetDisconnected(message) {
6286
+ if (!this.#connected) return;
6287
+ const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
6288
+ if (moved) return;
6289
+ this.#forget(message);
6290
+ this.#leaving.delete(message);
6291
+ }
6292
+ /**
6293
+ * Re-points the observation after a `region` swap and picks up the messages the
6294
+ * new element brought with it (dynamic inserts, so their own `role` announces
6295
+ * them). The `#connected` guard is load-bearing: Stimulus runs target callbacks
6296
+ * for the initial markup *before* `connect()` and again during teardown *after*
6297
+ * `disconnect()`, and re-observing there would outlive the controller.
6298
+ */
6299
+ #resync() {
6300
+ if (!this.#connected) return;
6301
+ this.#syncObservation();
6302
+ for (const message of this.messageTargets) {
6303
+ if (this.#owns(message)) this.#process(message, false);
6304
+ }
6305
+ }
6306
+ /**
6307
+ * Points the mutation observation at the current `region` target, re-resolved on
6308
+ * every sync rather than captured at connect, so an element swapped in at runtime
6309
+ * is observed instead of the detached original.
6310
+ */
6311
+ #syncObservation() {
6312
+ this.#stopObserving();
6313
+ if (!this.hasRegionTarget || typeof MutationObserver === "undefined") return;
6314
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
6315
+ this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
6316
+ }
6317
+ #stopObserving() {
6318
+ this.#observer?.disconnect();
6319
+ this.#observer = null;
5815
6320
  }
5816
6321
  /**
5817
6322
  * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
@@ -5857,6 +6362,7 @@ var FlashController = class extends Controller {
5857
6362
  */
5858
6363
  #process(message, bridge) {
5859
6364
  if (this.#state.has(message) || this.#order.includes(message)) return;
6365
+ if (this.#leaving.has(message)) return;
5860
6366
  const type = message.getAttribute("data-flash-type") ?? "";
5861
6367
  const assertive = ASSERTIVE_TYPES.has(type);
5862
6368
  if (!message.hasAttribute("role")) {
@@ -5889,33 +6395,53 @@ var FlashController = class extends Controller {
5889
6395
  const existing = this.#state.get(message);
5890
6396
  if (existing?.id) this.#timers.clear(existing.id);
5891
6397
  const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
5892
- this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });
6398
+ this.#state.set(message, {
6399
+ id,
6400
+ startedAt: Date.now(),
6401
+ remaining: duration,
6402
+ paused: existing?.paused ?? /* @__PURE__ */ new Set()
6403
+ });
5893
6404
  }
5894
- /** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */
5895
- #pause(message) {
6405
+ /**
6406
+ * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
6407
+ * Hover and focus are independent reasons: the remaining time is banked on the
6408
+ * first of them, and {@link FlashController.#resume} waits for the last one.
6409
+ */
6410
+ #pause(message, reason) {
5896
6411
  const timer = this.#state.get(message);
5897
- if (!timer || timer.id === 0) return;
6412
+ if (!timer) return;
6413
+ timer.paused.add(reason);
6414
+ if (timer.id === 0) return;
5898
6415
  this.#timers.clear(timer.id);
5899
- const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
5900
- this.#state.set(message, { id: 0, startedAt: 0, remaining });
6416
+ const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
6417
+ this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
5901
6418
  }
5902
6419
  /** Resumes a paused message's auto-dismiss with the banked time. */
5903
- #resume(message) {
6420
+ #resume(message, reason) {
5904
6421
  const timer = this.#state.get(message);
5905
6422
  if (!timer) return;
5906
- if (timer.id !== 0 || timer.remaining <= 0) return;
6423
+ timer.paused.delete(reason);
6424
+ if (timer.paused.size > 0) return;
6425
+ if (timer.id !== 0) return;
5907
6426
  this.#startTimer(message, timer.remaining);
5908
6427
  }
5909
- /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
5910
- #beginDismiss(message, reason) {
6428
+ /** Releases every per-message resource: timer, stacking slot, pause listeners. */
6429
+ #forget(message) {
5911
6430
  const timer = this.#state.get(message);
5912
6431
  if (timer?.id) this.#timers.clear(timer.id);
5913
6432
  this.#state.delete(message);
5914
6433
  const index = this.#order.indexOf(message);
5915
6434
  if (index !== -1) this.#order.splice(index, 1);
6435
+ this.#unbindPause(message);
6436
+ }
6437
+ /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
6438
+ #beginDismiss(message, reason) {
6439
+ if (!this.#state.has(message) && !this.#order.includes(message)) return;
6440
+ this.#forget(message);
6441
+ this.#leaving.add(message);
5916
6442
  message.setAttribute("data-flash-state", "leaving");
5917
6443
  const finalize = () => {
5918
- this.#unbindPause(message);
6444
+ if (!this.#leaving.delete(message)) return;
5919
6445
  message.remove();
5920
6446
  this.dispatch("dismiss", { detail: { element: message, reason } });
5921
6447
  };
@@ -6184,8 +6710,7 @@ var FormValidationController = class _FormValidationController extends Controlle
6184
6710
  };
6185
6711
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
6186
6712
  connect() {
6187
- if (!this.element.hasAttribute("novalidate")) {
6188
- this.element.setAttribute("novalidate", "");
6713
+ if (setDefaultAttribute(this.element, "novalidate", "")) {
6189
6714
  this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
6190
6715
  }
6191
6716
  document.addEventListener("submit", this.#onSubmit, true);
@@ -6362,35 +6887,150 @@ var FormValidationController = class _FormValidationController extends Controlle
6362
6887
  element.willValidate;
6363
6888
  }
6364
6889
  };
6890
+
6891
+ // src/utils/detach_gate.ts
6892
+ var DetachGate = class _DetachGate {
6893
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
6894
+ #pending = false;
6895
+ /**
6896
+ * True while a probe is queued — the last disconnect was ambiguous and no
6897
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
6898
+ * reconnect half of an in-page move from a first connect: a controller whose
6899
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
6900
+ * counter) must skip it for the move, where nothing actually restarted.
6901
+ */
6902
+ get pending() {
6903
+ return this.#pending;
6904
+ }
6905
+ /**
6906
+ * True when the disconnect is definitely a real detach — the element left
6907
+ * the document, or `data-controller` no longer lists the identifier. False
6908
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
6909
+ */
6910
+ static isDetached(host) {
6911
+ if (!host.element.isConnected) return true;
6912
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
6913
+ return !tokens.includes(host.identifier);
6914
+ }
6915
+ /**
6916
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
6917
+ * detach (fast path), otherwise defers it one microtask — a reconnect
6918
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
6919
+ * One microtask is the whole probe window: Stimulus reconnects a moved
6920
+ * element within the same mutation batch, before the checkpoint drains.
6921
+ */
6922
+ disconnected(host, teardown) {
6923
+ if (_DetachGate.isDetached(host)) {
6924
+ this.#pending = false;
6925
+ teardown();
6926
+ return;
6927
+ }
6928
+ this.#pending = true;
6929
+ queueMicrotask(() => {
6930
+ if (!this.#pending) return;
6931
+ this.#pending = false;
6932
+ teardown();
6933
+ });
6934
+ }
6935
+ /**
6936
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
6937
+ * an in-page move) and from the head of any teardown path not routed through
6938
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
6939
+ * never run the teardown a second time.
6940
+ */
6941
+ cancel() {
6942
+ this.#pending = false;
6943
+ }
6944
+ };
6945
+
6946
+ // src/utils/min_duration_floor.ts
6947
+ var MinDurationFloor = class {
6948
+ #timers;
6949
+ /** Pending finish timer id, or `null` when nothing is held back. */
6950
+ #timerId = null;
6951
+ /** Epoch ms the floor is measured from. */
6952
+ #since = 0;
6953
+ /** @param timers - the controller's registry; the floor schedules into it. */
6954
+ constructor(timers) {
6955
+ this.#timers = timers;
6956
+ }
6957
+ /** Starts the floor: call when the state being held becomes visible. */
6958
+ begin() {
6959
+ this.#since = Date.now();
6960
+ }
6961
+ /** True while a finish is held back waiting for the floor to elapse. */
6962
+ get pending() {
6963
+ return this.#timerId !== null;
6964
+ }
6965
+ /**
6966
+ * Runs `finish` once the floor has elapsed, immediately when it already has.
6967
+ *
6968
+ * A held-back finish is **replaced**, never stacked: only the most recently
6969
+ * queued id is cancellable, so a second timer would outlive every cancel and
6970
+ * end a state that has since restarted. Controllers that want the first signal
6971
+ * to win guard on {@link pending} before calling.
6972
+ */
6973
+ schedule(minDuration, finish) {
6974
+ this.cancel();
6975
+ const remaining = minDuration - (Date.now() - this.#since);
6976
+ if (remaining > 0) {
6977
+ this.#timerId = this.#timers.set(() => {
6978
+ this.#timerId = null;
6979
+ finish();
6980
+ }, remaining);
6981
+ } else {
6982
+ finish();
6983
+ }
6984
+ }
6985
+ /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
6986
+ cancel() {
6987
+ if (this.#timerId !== null) {
6988
+ this.#timers.clear(this.#timerId);
6989
+ this.#timerId = null;
6990
+ }
6991
+ }
6992
+ };
6993
+
6994
+ // src/controllers/frame_loading_controller.ts
6365
6995
  var FrameLoadingController = class extends Controller {
6366
6996
  static targets = ["content", "skeleton", "overlay"];
6367
6997
  static values = {
6998
+ announceText: { type: String, default: "" },
6999
+ announceReadyText: { type: String, default: "" },
6368
7000
  minDuration: { type: Number, default: 0 },
6369
7001
  restoreFocus: { type: Boolean, default: true }
6370
7002
  };
6371
7003
  static events = ["start", "end"];
6372
7004
  #timeouts = new SafeTimeout();
7005
+ #floor = new MinDurationFloor(this.#timeouts);
7006
+ #gate = new DetachGate();
7007
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
6373
7008
  #loading = false;
6374
- #startedAt = 0;
6375
- #inertApplied = false;
7009
+ /**
7010
+ * The optional targets this controller revealed, and the content it marked inert.
7011
+ * Held as references rather than re-resolved on the way out: a detach that keeps
7012
+ * the element takes the identifier off `data-controller` first, and a scope
7013
+ * without its identifier stops resolving targets — the elements to tidy would be
7014
+ * unreachable exactly when the tidying matters. They double as the ownership
7015
+ * marker, so a `hidden` or an `inert` the consumer wrote is never taken over.
7016
+ */
7017
+ #revealedSkeleton = null;
7018
+ #revealedOverlay = null;
7019
+ #inertTarget = null;
6376
7020
  #previousFocus = null;
6377
7021
  /** The id of the retreated element, used to re-find it if the load replaced it. */
6378
7022
  #previousFocusId = "";
6379
7023
  #onStart = () => {
6380
- this.#timeouts.clearAll();
7024
+ this.#floor.cancel();
6381
7025
  if (!this.#loading) this.#begin();
6382
7026
  };
6383
7027
  #onEnd = () => {
6384
7028
  if (!this.#loading) return;
6385
- const remaining = this.minDurationValue - (Date.now() - this.#startedAt);
6386
- if (remaining > 0) {
6387
- this.#timeouts.clearAll();
6388
- this.#timeouts.set(() => this.#finish(), remaining);
6389
- } else {
6390
- this.#finish();
6391
- }
7029
+ this.#floor.schedule(this.minDurationValue, () => this.#finish());
6392
7030
  };
6393
7031
  connect() {
7032
+ this.#gate.cancel();
7033
+ this.#beforeCache.activate();
6394
7034
  this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
6395
7035
  this.element.addEventListener("turbo:frame-load", this.#onEnd);
6396
7036
  this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
@@ -6399,48 +7039,123 @@ var FrameLoadingController = class extends Controller {
6399
7039
  this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
6400
7040
  this.element.removeEventListener("turbo:frame-load", this.#onEnd);
6401
7041
  this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
7042
+ this.#beforeCache.deactivate();
7043
+ this.#gate.disconnected(this, () => this.#teardown());
7044
+ }
7045
+ /**
7046
+ * Drops the held finish and the loading bookkeeping on a real detach, returning
7047
+ * the frame to its idle form. No reconnect is coming, so nothing is left that
7048
+ * could finish the load and clear the hooks — a detach that keeps the element
7049
+ * (a morph dropping the identifier, an exit from a scoped observed root) would
7050
+ * otherwise strand it busy and inert. Focus is left where it is: the element is
7051
+ * leaving this controller's care, and moving it now would be an unexplained jump.
7052
+ */
7053
+ #teardown() {
7054
+ this.#gate.cancel();
6402
7055
  this.#timeouts.clearAll();
6403
- if (this.#loading) {
6404
- this.element.removeAttribute("aria-busy");
6405
- this.element.removeAttribute("data-frame-loading");
6406
- this.#clearInert();
6407
- }
7056
+ this.#floor.cancel();
7057
+ if (this.#loading) this.#rewindHooks();
6408
7058
  this.#loading = false;
6409
7059
  this.#previousFocus = null;
6410
7060
  }
7061
+ /**
7062
+ * Returns the frame to its idle form for the snapshot Turbo is about to take, so
7063
+ * a page reached with the Back button does not restore a frame that is busy and
7064
+ * inert with nothing left to finish it. State only — no `end` event and no focus
7065
+ * move, because the load did not actually complete.
7066
+ *
7067
+ * The load is abandoned rather than paused, so the flag and any finish the floor
7068
+ * still holds drop along with the hooks. A kept finish would surface after the
7069
+ * rewind as exactly the three things this pass exists to avoid — an `end`, a
7070
+ * completion announcement, and a focus move — and a kept flag would leave the
7071
+ * next fetch on a page that survives a cancelled visit skipping the loading
7072
+ * state, its idempotence guard already satisfied.
7073
+ */
7074
+ #rewindForCache() {
7075
+ if (!this.#loading) return;
7076
+ this.#loading = false;
7077
+ this.#floor.cancel();
7078
+ this.#rewindHooks();
7079
+ }
7080
+ /**
7081
+ * Clears every hook the loading state writes. Shared by the three ways a load can
7082
+ * stop — completion, detach, snapshot — so none of them can drift into tidying
7083
+ * only part of it.
7084
+ */
7085
+ #rewindHooks() {
7086
+ this.element.removeAttribute("aria-busy");
7087
+ this.element.removeAttribute("data-frame-loading");
7088
+ if (this.#revealedSkeleton) this.#revealedSkeleton.hidden = true;
7089
+ if (this.#revealedOverlay) this.#revealedOverlay.hidden = true;
7090
+ this.#revealedSkeleton = null;
7091
+ this.#revealedOverlay = null;
7092
+ this.#clearInert();
7093
+ }
7094
+ /**
7095
+ * Re-shows a `skeleton` that arrived mid-load. Turbo's frame renderer empties the
7096
+ * frame and re-inserts the response's children, so a response's authored (hidden)
7097
+ * skeleton can land while a later fetch is still running, and only the controller
7098
+ * knows the frame is still busy.
7099
+ */
7100
+ skeletonTargetConnected() {
7101
+ if (this.#loading) this.#revealSkeleton();
7102
+ }
7103
+ /** Re-shows an `overlay` that arrived mid-load — the same swap as the skeleton. */
7104
+ overlayTargetConnected() {
7105
+ if (this.#loading) this.#revealOverlay();
7106
+ }
7107
+ /**
7108
+ * Re-blocks a `content` that arrived mid-load, so the stale copy stays unusable.
7109
+ * The element that left is released first and ownership is then decided afresh, so
7110
+ * an `inert` the replacement authored stays the consumer's.
7111
+ */
7112
+ contentTargetConnected() {
7113
+ if (!this.#loading) return;
7114
+ this.#clearInert();
7115
+ this.#applyInert();
7116
+ }
7117
+ /** Reveals the optional `skeleton`, noting it as this controller's to hide again. */
7118
+ #revealSkeleton() {
7119
+ if (!this.hasSkeletonTarget) return;
7120
+ this.#revealedSkeleton = this.skeletonTarget;
7121
+ this.skeletonTarget.hidden = false;
7122
+ }
7123
+ /** Reveals the optional `overlay`, noting it as this controller's to hide again. */
7124
+ #revealOverlay() {
7125
+ if (!this.hasOverlayTarget) return;
7126
+ this.#revealedOverlay = this.overlayTarget;
7127
+ this.overlayTarget.hidden = false;
7128
+ }
6411
7129
  /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
6412
7130
  #begin() {
6413
7131
  this.#loading = true;
6414
- this.#startedAt = Date.now();
7132
+ this.#floor.begin();
6415
7133
  this.element.setAttribute("aria-busy", "true");
6416
7134
  this.element.setAttribute("data-frame-loading", "true");
6417
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
6418
- if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
7135
+ this.#revealSkeleton();
7136
+ this.#revealOverlay();
6419
7137
  this.#applyInert();
6420
7138
  this.#retreatFocus();
6421
7139
  this.dispatch("start", { detail: {} });
7140
+ announce(fillTemplate(this.announceTextValue, {}));
6422
7141
  }
6423
7142
  /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
6424
7143
  #finish() {
6425
7144
  this.#loading = false;
6426
- this.element.removeAttribute("aria-busy");
6427
- this.element.removeAttribute("data-frame-loading");
6428
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
6429
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
6430
- this.#clearInert();
7145
+ this.#rewindHooks();
6431
7146
  this.#restoreFocus();
6432
7147
  this.dispatch("end", { detail: {} });
7148
+ announce(fillTemplate(this.announceReadyTextValue, {}));
6433
7149
  }
6434
7150
  /** Marks the content inert to block double-submits while stale (if we own it). */
6435
7151
  #applyInert() {
6436
7152
  if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
6437
7153
  this.contentTarget.setAttribute("inert", "");
6438
- this.#inertApplied = true;
7154
+ this.#inertTarget = this.contentTarget;
6439
7155
  }
6440
7156
  #clearInert() {
6441
- if (!this.#inertApplied) return;
6442
- this.#inertApplied = false;
6443
- if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
7157
+ this.#inertTarget?.removeAttribute("inert");
7158
+ this.#inertTarget = null;
6444
7159
  }
6445
7160
  /** Saves and blurs focus if it sits inside the frame about to go stale. */
6446
7161
  #retreatFocus() {
@@ -6477,6 +7192,7 @@ var FrameLoadingController = class extends Controller {
6477
7192
  }
6478
7193
  }
6479
7194
  };
7195
+ var hookOwners = /* @__PURE__ */ new WeakMap();
6480
7196
  var HighlightController = class extends Controller {
6481
7197
  static values = {
6482
7198
  duration: { type: Number, default: 1500 },
@@ -6484,9 +7200,18 @@ var HighlightController = class extends Controller {
6484
7200
  };
6485
7201
  static events = ["start", "end"];
6486
7202
  #timeouts = new SafeTimeout();
7203
+ /**
7204
+ * The removal timer this connection has outstanding for an element. Held weakly so
7205
+ * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
7206
+ * so a cleared id can never be matched against a recycled one. Which connection owns
7207
+ * an element's hook is answered by the shared owner registry above.
7208
+ */
7209
+ #pending = /* @__PURE__ */ new WeakMap();
6487
7210
  #observer = null;
6488
7211
  connect() {
7212
+ this.#clearArrivedHook(this.element);
6489
7213
  if (this.observeValue) {
7214
+ for (const child of this.element.children) this.#clearArrivedHook(child);
6490
7215
  if (typeof MutationObserver !== "undefined") {
6491
7216
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
6492
7217
  this.#observer.observe(this.element, { childList: true });
@@ -6499,6 +7224,12 @@ var HighlightController = class extends Controller {
6499
7224
  this.#observer?.disconnect();
6500
7225
  this.#observer = null;
6501
7226
  this.#timeouts.clearAll();
7227
+ this.#pending = /* @__PURE__ */ new WeakMap();
7228
+ }
7229
+ /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
7230
+ #clearArrivedHook(el) {
7231
+ if (hookOwners.get(el) === this) hookOwners.delete(el);
7232
+ el.removeAttribute("data-highlight");
6502
7233
  }
6503
7234
  /** Highlights every element child added by a childList mutation. */
6504
7235
  #onMutations(mutations) {
@@ -6511,12 +7242,33 @@ var HighlightController = class extends Controller {
6511
7242
  /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
6512
7243
  #highlight(el) {
6513
7244
  if (prefersReducedMotion()) return;
7245
+ this.#releasePending(el);
6514
7246
  el.setAttribute("data-highlight", "true");
6515
7247
  this.dispatch("start", { target: el, detail: { element: el } });
6516
- this.#timeouts.set(() => {
7248
+ const id = this.#timeouts.set(() => {
7249
+ this.#pending.delete(el);
7250
+ hookOwners.delete(el);
6517
7251
  el.removeAttribute("data-highlight");
6518
7252
  this.dispatch("end", { target: el, detail: { element: el } });
6519
7253
  }, this.durationValue);
7254
+ this.#pending.set(el, id);
7255
+ hookOwners.set(el, this);
7256
+ }
7257
+ /**
7258
+ * Releases whichever removal timer holds `el`'s hook. The row may have been
7259
+ * highlighted inside a different watched container before it moved here, and that
7260
+ * container's timer is reachable only through the shared owner registry.
7261
+ */
7262
+ #releasePending(el) {
7263
+ const owner = hookOwners.get(el);
7264
+ if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
7265
+ this.#cancelPending(el);
7266
+ }
7267
+ /** Releases `el`'s pending removal timer, if it has one. */
7268
+ #cancelPending(el) {
7269
+ this.#timeouts.clear(this.#pending.get(el) ?? -1);
7270
+ this.#pending.delete(el);
7271
+ hookOwners.delete(el);
6520
7272
  }
6521
7273
  };
6522
7274
 
@@ -6666,6 +7418,12 @@ var IdleController = class extends Controller {
6666
7418
  #prompted = false;
6667
7419
  /** Timestamp of the last activity; the timers self-reschedule against it. */
6668
7420
  #lastActivity = 0;
7421
+ /**
7422
+ * Activity types actually registered on `document`, so `disconnect()` unbinds the
7423
+ * same set even when `events` changed while connected (a Turbo morph can rewrite
7424
+ * the Value in place, and the removal must match the registration, not the Value).
7425
+ */
7426
+ #boundEvents = [];
6669
7427
  #onActivity = () => {
6670
7428
  this.#lastActivity = Date.now();
6671
7429
  if (this.#idle || this.#prompted) {
@@ -6680,16 +7438,21 @@ var IdleController = class extends Controller {
6680
7438
  if (document.visibilityState === "visible") this.#onActivity();
6681
7439
  };
6682
7440
  connect() {
6683
- for (const type of this.eventsValue) {
7441
+ this.#idle = false;
7442
+ this.#prompted = false;
7443
+ this.element.removeAttribute("data-idle");
7444
+ this.#boundEvents = [...this.eventsValue];
7445
+ for (const type of this.#boundEvents) {
6684
7446
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
6685
7447
  }
6686
7448
  document.addEventListener("visibilitychange", this.#onVisibility);
6687
7449
  this.#arm();
6688
7450
  }
6689
7451
  disconnect() {
6690
- for (const type of this.eventsValue) {
7452
+ for (const type of this.#boundEvents) {
6691
7453
  document.removeEventListener(type, this.#onActivity, { capture: true });
6692
7454
  }
7455
+ this.#boundEvents = [];
6693
7456
  document.removeEventListener("visibilitychange", this.#onVisibility);
6694
7457
  this.#timeouts.clearAll();
6695
7458
  }
@@ -7535,7 +8298,55 @@ var LocalTimeController = class extends Controller {
7535
8298
  titleFormat: { type: String, default: "" }
7536
8299
  };
7537
8300
  static events = ["format"];
8301
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
8302
+ #resync = new MicrotaskCoalescer(() => this.#render());
8303
+ /**
8304
+ * Watches the one render input that is not a Value. Only `datetime` is filtered
8305
+ * in, so the text and `title` this controller writes cannot re-enter the pass.
8306
+ */
8307
+ #datetimeWatch = new MutationObserver(() => {
8308
+ this.#resync.schedule();
8309
+ });
7538
8310
  connect() {
8311
+ this.#resync.activate();
8312
+ this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
8313
+ this.#render();
8314
+ }
8315
+ disconnect() {
8316
+ this.#resync.cancel();
8317
+ this.#datetimeWatch.disconnect();
8318
+ }
8319
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
8320
+ localeValueChanged() {
8321
+ this.#resync.schedule();
8322
+ }
8323
+ /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
8324
+ timeZoneValueChanged() {
8325
+ this.#resync.schedule();
8326
+ }
8327
+ /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
8328
+ dateStyleValueChanged() {
8329
+ this.#resync.schedule();
8330
+ }
8331
+ /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
8332
+ timeStyleValueChanged() {
8333
+ this.#resync.schedule();
8334
+ }
8335
+ /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
8336
+ titleFormatValueChanged() {
8337
+ this.#resync.schedule();
8338
+ }
8339
+ /**
8340
+ * Formats the instant in `datetime` against the current Values and writes it out.
8341
+ *
8342
+ * The `format` event rides with every pass, including a repaint a morph triggers:
8343
+ * its condition is that formatting was applied, and a repaint applies it with a
8344
+ * new result. A pass that cannot format writes nothing and emits nothing, so the
8345
+ * authored absolute text stays as the fallback.
8346
+ *
8347
+ * @stimeoRenderRoot
8348
+ */
8349
+ #render() {
7539
8350
  const date = this.#parse();
7540
8351
  if (date === null) return;
7541
8352
  const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
@@ -7545,7 +8356,10 @@ var LocalTimeController = class extends Controller {
7545
8356
  if (title !== null) this.element.setAttribute("title", title);
7546
8357
  this.dispatch("format", { detail: { formatted } });
7547
8358
  }
7548
- /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */
8359
+ /**
8360
+ * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
8361
+ * around the attribute value is tolerated.
8362
+ */
7549
8363
  #parse() {
7550
8364
  const raw = this.element.getAttribute("datetime");
7551
8365
  if (!raw) return null;
@@ -7558,11 +8372,21 @@ var LocalTimeController = class extends Controller {
7558
8372
  * the *runtime's* local zone, contradicting "the server emits UTC". Values that
7559
8373
  * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
7560
8374
  * parsed as UTC) are returned unchanged.
8375
+ *
8376
+ * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
8377
+ * left to each engine, so a whole value shaped that way is normalized to the `T`
8378
+ * separator first. The pattern is anchored: a value trailing anything else — a
8379
+ * zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
8380
+ * authored instead of being turned into a string nothing can parse.
7561
8381
  */
7562
8382
  #asUtc(value) {
7563
- const hasTime = /T\d{2}:\d{2}/.test(value);
7564
- const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(value);
7565
- return hasTime && !hasZone ? `${value}Z` : value;
8383
+ const isoLike = value.replace(
8384
+ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
8385
+ "$1T$2"
8386
+ );
8387
+ const hasTime = /T\d{2}:\d{2}/.test(isoLike);
8388
+ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
8389
+ return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
7566
8390
  }
7567
8391
  /**
7568
8392
  * Builds the optional detailed `title`. `titleFormat` is an `Intl` style
@@ -7591,12 +8415,12 @@ var LocalTimeController = class extends Controller {
7591
8415
  return null;
7592
8416
  }
7593
8417
  }
7594
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
8418
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
7595
8419
  get #locale() {
7596
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
8420
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
7597
8421
  }
7598
8422
  };
7599
- var COLUMNS_PROPERTY = "--stimeo-masonry-columns";
8423
+ var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
7600
8424
  var MasonryController = class extends Controller {
7601
8425
  static targets = ["item"];
7602
8426
  static values = {
@@ -8440,9 +9264,29 @@ var MenubarController = class extends Controller {
8440
9264
  if (index !== -1) items[index]?.focus();
8441
9265
  }
8442
9266
  };
9267
+
9268
+ // src/utils/range.ts
9269
+ function rangeFraction(value, min, max) {
9270
+ const span = max - min;
9271
+ if (!(span > 0)) return 0;
9272
+ const clamped = Math.min(max, Math.max(min, value));
9273
+ let fraction;
9274
+ if (Number.isFinite(span)) {
9275
+ fraction = (clamped - min) / span;
9276
+ } else {
9277
+ const scale = Math.max(Math.abs(min), Math.abs(max));
9278
+ fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
9279
+ }
9280
+ if (!Number.isFinite(fraction)) return 0;
9281
+ return Math.min(1, Math.max(0, fraction));
9282
+ }
9283
+
9284
+ // src/controllers/meter_controller.ts
9285
+ var OWNED_VALUE_TEXT = "data-stimeo--meter-owns-valuetext";
8443
9286
  var MeterController = class extends Controller {
8444
9287
  static targets = ["bar"];
8445
9288
  static values = {
9289
+ announceText: { type: String, default: "" },
8446
9290
  value: { type: Number, default: 0 },
8447
9291
  min: { type: Number, default: 0 },
8448
9292
  max: { type: Number, default: 100 },
@@ -8453,9 +9297,24 @@ var MeterController = class extends Controller {
8453
9297
  };
8454
9298
  static actions = ["setValue"];
8455
9299
  static events = ["change"];
9300
+ /**
9301
+ * Collapses a morph that swaps several render inputs at once into one repaint.
9302
+ * A single update usually rewrites the whole set, and each Value would otherwise
9303
+ * repaint on its own.
9304
+ */
9305
+ #repaint = new MicrotaskCoalescer(() => {
9306
+ this.#render();
9307
+ });
9308
+ /** The segment last announced, so only a change is read out. */
9309
+ #announcedState = null;
8456
9310
  connect() {
9311
+ this.#repaint.activate();
8457
9312
  this.#render();
8458
9313
  }
9314
+ /** Closes the window in which a queued repaint may still run. */
9315
+ disconnect() {
9316
+ this.#repaint.cancel();
9317
+ }
8459
9318
  /**
8460
9319
  * Updates the measured value from an action param (`amount`) or a
8461
9320
  * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
@@ -8465,59 +9324,97 @@ var MeterController = class extends Controller {
8465
9324
  const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);
8466
9325
  if (next === null) return;
8467
9326
  this.valueValue = this.#clamp(next);
8468
- this.#render();
8469
- this.dispatch("change", {
8470
- detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state }
8471
- });
9327
+ const reading = this.#render();
9328
+ this.dispatch("change", { detail: reading });
9329
+ if (reading.state !== this.#announcedState) {
9330
+ this.#announcedState = reading.state;
9331
+ announce(
9332
+ fillTemplate(this.announceTextValue, { state: reading.state, value: reading.value })
9333
+ );
9334
+ }
9335
+ }
9336
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
9337
+ valueValueChanged() {
9338
+ this.#repaint.schedule();
9339
+ }
9340
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
9341
+ minValueChanged() {
9342
+ this.#repaint.schedule();
9343
+ }
9344
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
9345
+ maxValueChanged() {
9346
+ this.#repaint.schedule();
9347
+ }
9348
+ /** Repaints when application code (or a Turbo morph) changes `low` at runtime. */
9349
+ lowValueChanged() {
9350
+ this.#repaint.schedule();
9351
+ }
9352
+ /** Repaints when application code (or a Turbo morph) changes `high` at runtime. */
9353
+ highValueChanged() {
9354
+ this.#repaint.schedule();
9355
+ }
9356
+ /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
9357
+ valueTextValueChanged() {
9358
+ this.#repaint.schedule();
8472
9359
  }
8473
9360
  /** Clamps `raw` into the configured `[min, max]` range. */
8474
9361
  #clamp(raw) {
8475
9362
  return Math.min(this.maxValue, Math.max(this.minValue, raw));
8476
9363
  }
8477
- /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
8478
- get #ratio() {
8479
- const span = this.maxValue - this.minValue;
8480
- if (span <= 0) return 0;
8481
- return (this.#clamp(this.valueValue) - this.minValue) / span;
8482
- }
8483
9364
  /** Whether a threshold attribute is present (absent = no threshold). */
8484
9365
  #hasThreshold(name) {
8485
9366
  return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);
8486
9367
  }
8487
9368
  /**
8488
- * Classifies the value into a `low`/`medium`/`high` segment. Values at or
8489
- * below `low` are `low`; at or above `high` are `high`; otherwise `medium`.
8490
- * With neither threshold present, everything is `medium`.
9369
+ * Classifies `value` into a `low`/`medium`/`high` segment. Values at or below
9370
+ * `low` are `low`; at or above `high` are `high`; otherwise `medium`. With
9371
+ * neither threshold present, everything is `medium`.
8491
9372
  */
8492
- get #state() {
8493
- const value = this.#clamp(this.valueValue);
9373
+ #stateOf(value) {
8494
9374
  if (this.#hasThreshold("low") && value <= this.lowValue) return "low";
8495
9375
  if (this.#hasThreshold("high") && value >= this.highValue) return "high";
8496
9376
  return "medium";
8497
9377
  }
8498
- /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */
9378
+ /**
9379
+ * Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
9380
+ * The reading is derived once and returned, so the `change` detail reports the
9381
+ * same numbers the DOM just received.
9382
+ *
9383
+ * @stimeoRenderRoot
9384
+ */
8499
9385
  #render() {
8500
9386
  const value = this.#clamp(this.valueValue);
9387
+ const reading = {
9388
+ value,
9389
+ ratio: rangeFraction(value, this.minValue, this.maxValue),
9390
+ state: this.#stateOf(value)
9391
+ };
8501
9392
  this.element.setAttribute("aria-valuemin", String(this.minValue));
8502
9393
  this.element.setAttribute("aria-valuemax", String(this.maxValue));
8503
- this.element.setAttribute("aria-valuenow", String(value));
8504
- this.element.style.setProperty("--stimeo-meter-ratio", String(this.#ratio));
8505
- this.element.setAttribute("data-state", this.#state);
8506
- this.#applyValueText(value);
9394
+ this.element.setAttribute("aria-valuenow", String(reading.value));
9395
+ this.element.style.setProperty("--stimeo--meter-ratio", String(reading.ratio));
9396
+ this.element.setAttribute("data-state", reading.state);
9397
+ this.#applyValueText(reading);
9398
+ return reading;
8507
9399
  }
8508
9400
  /**
8509
9401
  * Sets `aria-valuetext` from the consumer-provided template, substituting
8510
- * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library;
8511
- * cleared when no template is given.
9402
+ * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library.
9403
+ * With no template the attribute belongs to the consumer, so only a text this
9404
+ * controller wrote is taken back ({@link OWNED_VALUE_TEXT}).
8512
9405
  */
8513
- #applyValueText(value) {
9406
+ #applyValueText({ value, ratio, state }) {
8514
9407
  if (this.valueTextValue.length === 0) {
8515
- this.element.removeAttribute("aria-valuetext");
9408
+ if (this.element.hasAttribute(OWNED_VALUE_TEXT)) {
9409
+ this.element.removeAttribute("aria-valuetext");
9410
+ this.element.removeAttribute(OWNED_VALUE_TEXT);
9411
+ }
8516
9412
  return;
8517
9413
  }
8518
- const percent = Math.round(this.#ratio * 100);
8519
- const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", this.#state);
9414
+ const percent = Math.round(ratio * 100);
9415
+ const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", state);
8520
9416
  this.element.setAttribute("aria-valuetext", text);
9417
+ this.element.setAttribute(OWNED_VALUE_TEXT, "");
8521
9418
  }
8522
9419
  };
8523
9420
 
@@ -9585,20 +10482,17 @@ var NestedFormController = class extends Controller {
9585
10482
  var NetworkStatusController = class extends Controller {
9586
10483
  static targets = ["offline", "online"];
9587
10484
  static values = {
10485
+ announceText: { type: String, default: "" },
10486
+ announceOnlineText: { type: String, default: "" },
9588
10487
  onlineAutoHide: { type: Number, default: 0 }
9589
10488
  };
9590
10489
  static events = ["change"];
9591
10490
  #timers = new SafeTimeout();
9592
10491
  /** Last known connectivity; guards against duplicate-state re-announcements. */
9593
10492
  #online = true;
9594
- /** Banner text captured from the markup so transitions can re-write it. */
9595
- #offlineMessage = "";
9596
- #onlineMessage = "";
9597
10493
  #handleOnline = () => this.#update(true);
9598
10494
  #handleOffline = () => this.#update(false);
9599
10495
  connect() {
9600
- this.#offlineMessage = this.hasOfflineTarget ? (this.offlineTarget.textContent ?? "").trim() : "";
9601
- this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? "").trim() : "";
9602
10496
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
9603
10497
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
9604
10498
  this.#online = navigator.onLine;
@@ -9612,7 +10506,12 @@ var NetworkStatusController = class extends Controller {
9612
10506
  window.removeEventListener("offline", this.#handleOffline);
9613
10507
  this.#timers.clearAll();
9614
10508
  }
9615
- /** Applies a connectivity transition, guarded against duplicate states. */
10509
+ /**
10510
+ * Applies a connectivity transition, guarded against duplicate states.
10511
+ *
10512
+ * The event goes out last, so a listener reading `data-state` or a banner's
10513
+ * visibility sees the state the transition landed on rather than the previous one.
10514
+ */
9616
10515
  #update(online) {
9617
10516
  if (online === this.#online) return;
9618
10517
  this.#online = online;
@@ -9622,22 +10521,21 @@ var NetworkStatusController = class extends Controller {
9622
10521
  } else {
9623
10522
  this.#showOffline();
9624
10523
  }
10524
+ announce(fillTemplate(online ? this.announceOnlineTextValue : this.announceTextValue, {}), {
10525
+ assertive: !online
10526
+ });
9625
10527
  this.dispatch("change", { detail: { online } });
9626
10528
  }
9627
10529
  /** Shows the offline banner and hides the recovery banner. */
9628
10530
  #showOffline() {
9629
10531
  this.#timers.clearAll();
9630
10532
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
9631
- if (this.hasOfflineTarget) {
9632
- this.offlineTarget.textContent = this.#offlineMessage;
9633
- this.offlineTarget.hidden = false;
9634
- }
10533
+ if (this.hasOfflineTarget) this.offlineTarget.hidden = false;
9635
10534
  }
9636
10535
  /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
9637
10536
  #showOnline() {
9638
10537
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
9639
10538
  if (!this.hasOnlineTarget) return;
9640
- this.onlineTarget.textContent = this.#onlineMessage;
9641
10539
  this.onlineTarget.hidden = false;
9642
10540
  if (this.onlineAutoHideValue > 0) {
9643
10541
  this.#timers.set(() => {
@@ -9646,6 +10544,114 @@ var NetworkStatusController = class extends Controller {
9646
10544
  }
9647
10545
  }
9648
10546
  };
10547
+
10548
+ // src/utils/stepped_value.ts
10549
+ function effectiveStep(step) {
10550
+ return Number.isFinite(step) && step > 0 ? step : 1;
10551
+ }
10552
+ function snapSteppedValue(raw, range) {
10553
+ if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
10554
+ const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
10555
+ const clamped = Math.min(range.max, Math.max(range.min, input));
10556
+ const step = effectiveStep(range.step);
10557
+ const base = stepBase(range);
10558
+ const candidates = [];
10559
+ if (Number.isFinite(range.min)) candidates.push(range.min);
10560
+ if (Number.isFinite(range.max)) candidates.push(range.max);
10561
+ const gridPosition = (clamped - base) / step;
10562
+ if (Number.isFinite(gridPosition)) {
10563
+ addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
10564
+ addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
10565
+ }
10566
+ if (candidates.length === 0) return clamped;
10567
+ let nearest = candidates[0];
10568
+ let nearestDistance = Math.abs(clamped - nearest);
10569
+ for (const candidate of candidates.slice(1)) {
10570
+ const distance = Math.abs(clamped - candidate);
10571
+ if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
10572
+ nearest = candidate;
10573
+ nearestDistance = distance;
10574
+ }
10575
+ }
10576
+ return nearest;
10577
+ }
10578
+ function stepSteppedValue(current, count, range) {
10579
+ const value = snapSteppedValue(current, range);
10580
+ const distance = Math.abs(Math.trunc(count));
10581
+ if (!Number.isFinite(distance) || distance === 0) return value;
10582
+ const direction = Math.sign(count);
10583
+ const adjacent = adjacentSteppedValue(value, direction, range);
10584
+ const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
10585
+ return snapSteppedValue(raw, range);
10586
+ }
10587
+ function adjacentSteppedValue(current, direction, range) {
10588
+ const step = effectiveStep(range.step);
10589
+ const base = stepBase(range);
10590
+ const candidates = [];
10591
+ if (direction > 0) {
10592
+ const position2 = (current - base) / step;
10593
+ if (Number.isFinite(position2)) {
10594
+ let gridIndex = Math.floor(position2) + 1;
10595
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
10596
+ if (candidate < current || nearlyEqual(candidate, current)) {
10597
+ gridIndex += 1;
10598
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
10599
+ }
10600
+ if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
10601
+ candidates.push(clamp(candidate, range));
10602
+ }
10603
+ }
10604
+ return clamp(Math.min(...candidates), range);
10605
+ }
10606
+ const position = (current - base) / step;
10607
+ if (Number.isFinite(position)) {
10608
+ let gridIndex = Math.ceil(position) - 1;
10609
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
10610
+ if (candidate > current || nearlyEqual(candidate, current)) {
10611
+ gridIndex -= 1;
10612
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
10613
+ }
10614
+ if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
10615
+ candidates.push(clamp(candidate, range));
10616
+ }
10617
+ }
10618
+ return clamp(Math.max(...candidates), range);
10619
+ }
10620
+ function addGridCandidate(candidates, index, range, base, step) {
10621
+ const candidate = cleanGridValue(base + index * step, base, step);
10622
+ if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
10623
+ }
10624
+ function stepBase(range) {
10625
+ if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
10626
+ return Number.isFinite(range.min) ? range.min : 0;
10627
+ }
10628
+ function cleanGridValue(value, base, step) {
10629
+ const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
10630
+ return precision <= 100 ? Number(value.toFixed(precision)) : value;
10631
+ }
10632
+ function decimalPlaces(value) {
10633
+ const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
10634
+ const fractionLength = coefficient.split(".")[1]?.length ?? 0;
10635
+ const exponent = exponentText === void 0 ? 0 : Number(exponentText);
10636
+ return Math.max(0, fractionLength - exponent);
10637
+ }
10638
+ function within(value, min, max) {
10639
+ return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
10640
+ }
10641
+ function clamp(value, range) {
10642
+ return Math.min(range.max, Math.max(range.min, value));
10643
+ }
10644
+ function nearlyEqual(left, right) {
10645
+ const scale = Math.max(Math.abs(left), Math.abs(right));
10646
+ return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
10647
+ }
10648
+ function finiteFallback(min, max) {
10649
+ if (Number.isFinite(min)) return min;
10650
+ if (Number.isFinite(max)) return max;
10651
+ return 0;
10652
+ }
10653
+
10654
+ // src/controllers/number_input_controller.ts
9649
10655
  var NumberInputController = class _NumberInputController extends Controller {
9650
10656
  static targets = ["input", "increment", "decrement"];
9651
10657
  static values = {
@@ -9679,24 +10685,24 @@ var NumberInputController = class _NumberInputController extends Controller {
9679
10685
  #repeatedDuringHold = false;
9680
10686
  /** True when the next `click` is the trailing one after a hold and must be ignored. */
9681
10687
  #suppressNextClick = false;
10688
+ /** Collapses runtime range/step changes into one silent input reconciliation. */
10689
+ #repaint = new MicrotaskCoalescer(() => this.#reconcile());
9682
10690
  /** Normalizes any initial value and wires the focus/hold pointer guards. */
9683
10691
  connect() {
10692
+ this.#repaint.activate();
9684
10693
  if (!this.hasInputTarget) return;
9685
- if (this.inputTarget.value.trim() !== "") {
9686
- this.#write(this.#normalize(this.#currentValue()));
9687
- } else {
9688
- this.#updateButtons(this.#currentValue());
9689
- }
10694
+ this.#reconcile();
9690
10695
  this.#guards = new AbortController();
9691
10696
  const { signal } = this.#guards;
9692
- if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, this.stepValue, signal);
9693
- if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -this.stepValue, signal);
10697
+ if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, 1, signal);
10698
+ if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -1, signal);
9694
10699
  window.addEventListener("pointerup", this.#stopHold, { signal });
9695
10700
  window.addEventListener("pointercancel", this.#stopHold, { signal });
9696
10701
  window.addEventListener("blur", this.#stopHold, { signal });
9697
10702
  }
9698
10703
  /** Releases the pointer guards and tears down every pending timer and hold state. */
9699
10704
  disconnect() {
10705
+ this.#repaint.cancel();
9700
10706
  this.#guards?.abort();
9701
10707
  this.#guards = null;
9702
10708
  this.#holdActive = false;
@@ -9705,16 +10711,28 @@ var NumberInputController = class _NumberInputController extends Controller {
9705
10711
  this.#holdTimeouts.clearAll();
9706
10712
  this.#holdIntervals.clearAll();
9707
10713
  }
10714
+ /** Silently reconciles a minimum changed by application code or a Turbo morph. */
10715
+ minValueChanged() {
10716
+ this.#repaint.schedule();
10717
+ }
10718
+ /** Silently reconciles a maximum changed by application code or a Turbo morph. */
10719
+ maxValueChanged() {
10720
+ this.#repaint.schedule();
10721
+ }
10722
+ /** Silently reconciles a step changed by application code or a Turbo morph. */
10723
+ stepValueChanged() {
10724
+ this.#repaint.schedule();
10725
+ }
9708
10726
  /** Increases by one step. Bound via `data-action` (click). */
9709
10727
  increment() {
9710
10728
  if (this.#consumeSuppressedClick()) return;
9711
- this.#commit(this.#currentValue() + this.stepValue);
10729
+ this.#commitStep(1);
9712
10730
  this.inputTarget.focus();
9713
10731
  }
9714
10732
  /** Decreases by one step. Bound via `data-action` (click). */
9715
10733
  decrement() {
9716
10734
  if (this.#consumeSuppressedClick()) return;
9717
- this.#commit(this.#currentValue() - this.stepValue);
10735
+ this.#commitStep(-1);
9718
10736
  this.inputTarget.focus();
9719
10737
  }
9720
10738
  /** Clamps and snaps a typed value. Bound via `data-action` (change). */
@@ -9725,20 +10743,19 @@ var NumberInputController = class _NumberInputController extends Controller {
9725
10743
  /** Keyboard stepping per the APG spinbutton model. */
9726
10744
  onKeydown(event) {
9727
10745
  if (isReservedArrowChord(event)) return;
9728
- const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
9729
10746
  let next = null;
9730
10747
  switch (event.key) {
9731
10748
  case "ArrowUp":
9732
- next = this.#currentValue() + this.stepValue;
10749
+ next = stepSteppedValue(this.#currentValue(), 1, this.#steppedRange);
9733
10750
  break;
9734
10751
  case "ArrowDown":
9735
- next = this.#currentValue() - this.stepValue;
10752
+ next = stepSteppedValue(this.#currentValue(), -1, this.#steppedRange);
9736
10753
  break;
9737
10754
  case "PageUp":
9738
- next = this.#currentValue() + page;
10755
+ next = this.pageStepValue > 0 ? this.#currentValue() + this.pageStepValue : stepSteppedValue(this.#currentValue(), 10, this.#steppedRange);
9739
10756
  break;
9740
10757
  case "PageDown":
9741
- next = this.#currentValue() - page;
10758
+ next = this.pageStepValue > 0 ? this.#currentValue() - this.pageStepValue : stepSteppedValue(this.#currentValue(), -10, this.#steppedRange);
9742
10759
  break;
9743
10760
  case "Home":
9744
10761
  if (!Number.isFinite(this.minValue)) return;
@@ -9759,14 +10776,14 @@ var NumberInputController = class _NumberInputController extends Controller {
9759
10776
  * hold; leaving the button while held stops it (the global listeners cover
9760
10777
  * release/cancel/blur).
9761
10778
  */
9762
- #wireButton(button, delta, signal) {
9763
- button.addEventListener("pointerdown", (event) => this.#armHold(event, button, delta), {
10779
+ #wireButton(button, direction, signal) {
10780
+ button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
9764
10781
  signal
9765
10782
  });
9766
10783
  button.addEventListener("pointerleave", this.#stopHold, { signal });
9767
10784
  }
9768
10785
  /** Starts a hold: focus the input, then schedule the first repeat after a delay. */
9769
- #armHold(event, button, delta) {
10786
+ #armHold(event, button, direction) {
9770
10787
  const pointerButton = event.button;
9771
10788
  if (typeof pointerButton === "number" && pointerButton !== 0) return;
9772
10789
  if (button.disabled) return;
@@ -9777,13 +10794,13 @@ var NumberInputController = class _NumberInputController extends Controller {
9777
10794
  this.#repeatedDuringHold = false;
9778
10795
  this.#suppressNextClick = false;
9779
10796
  this.#holdTimeouts.set(() => {
9780
- if (!this.#commit(this.#currentValue() + delta)) {
10797
+ if (!this.#commitStep(direction)) {
9781
10798
  this.#stopHold();
9782
10799
  return;
9783
10800
  }
9784
10801
  this.#repeatedDuringHold = true;
9785
10802
  this.#holdIntervals.set(() => {
9786
- if (!this.#commit(this.#currentValue() + delta)) this.#stopHold();
10803
+ if (!this.#commitStep(direction)) this.#stopHold();
9787
10804
  }, _NumberInputController.#HOLD_REPEAT_MS);
9788
10805
  }, _NumberInputController.#HOLD_DELAY_MS);
9789
10806
  }
@@ -9824,6 +10841,23 @@ var NumberInputController = class _NumberInputController extends Controller {
9824
10841
  if (changed) this.dispatch("change", { detail: { value } });
9825
10842
  return changed;
9826
10843
  }
10844
+ /** Commits an adjacent endpoint/grid value and reports whether it moved. */
10845
+ #commitStep(count) {
10846
+ return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
10847
+ }
10848
+ /**
10849
+ * Silently reflects the current value after a range or step morph.
10850
+ *
10851
+ * @stimeoRenderRoot
10852
+ */
10853
+ #reconcile() {
10854
+ if (!this.hasInputTarget) return;
10855
+ if (this.inputTarget.value.trim() !== "") {
10856
+ this.#write(this.#normalize(this.#currentValue()));
10857
+ } else {
10858
+ this.#updateButtons(this.#currentValue());
10859
+ }
10860
+ }
9827
10861
  /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
9828
10862
  #write(value) {
9829
10863
  this.inputTarget.value = String(value);
@@ -9870,11 +10904,11 @@ var NumberInputController = class _NumberInputController extends Controller {
9870
10904
  }
9871
10905
  /** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
9872
10906
  #normalize(raw) {
9873
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
9874
- if (this.stepValue <= 0) return clamped;
9875
- const base = Number.isFinite(this.minValue) ? this.minValue : 0;
9876
- const stepped = Math.round((clamped - base) / this.stepValue) * this.stepValue + base;
9877
- return Math.min(this.maxValue, Math.max(this.minValue, stepped));
10907
+ return snapSteppedValue(raw, this.#steppedRange);
10908
+ }
10909
+ /** Shared range configuration; finite endpoints remain allowed off the grid. */
10910
+ get #steppedRange() {
10911
+ return { min: this.minValue, max: this.maxValue, step: this.stepValue };
9878
10912
  }
9879
10913
  };
9880
10914
  var TOGGLED_MARKER = "data-optimistic-toggled";
@@ -10384,7 +11418,7 @@ var INDEX = "data-stimeo--overflow-menu-index";
10384
11418
  var BOUNDARY = "data-stimeo--overflow-menu-boundary";
10385
11419
  var SAVED_ROLE = "data-stimeo--overflow-menu-role";
10386
11420
  var SAVED_TABINDEX = "data-stimeo--overflow-menu-tabindex";
10387
- var SAVED_MENU_TARGET = "data-stimeo--overflow-menu-menu-target";
11421
+ var SAVED_MENU_TARGET = "data-stimeo--overflow-menu-saved-menu";
10388
11422
  var MENU_TARGET = "data-stimeo--menu-target";
10389
11423
  var BOOKKEEPING = [BANKED, INDEX, SAVED_ROLE, SAVED_TABINDEX, SAVED_MENU_TARGET];
10390
11424
  var OverflowMenuController = class extends Controller {
@@ -10406,17 +11440,15 @@ var OverflowMenuController = class extends Controller {
10406
11440
  #lastHidden = null;
10407
11441
  /** The `tabindex` this instance lends the root for the focus fallback. */
10408
11442
  #tabindex = new TabindexLoan();
10409
- /** Hands Turbo a pristine snapshot: the cache is written after this event. */
10410
- #onBeforeCache = () => {
10411
- this.#restoreAll();
10412
- };
11443
+ /** Hands Turbo a pristine snapshot of the bar, with every item back in place. */
11444
+ #beforeCache = new BeforeCacheReset(() => this.#restoreAll());
10413
11445
  connect() {
10414
11446
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
10415
11447
  const trigger = this.#trigger();
10416
11448
  if (trigger !== null && this.#isBareTrigger(trigger)) {
10417
11449
  trigger.textContent = this.moreLabelValue;
10418
11450
  }
10419
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
11451
+ this.#beforeCache.activate();
10420
11452
  this.#layout.observe(this.element);
10421
11453
  this.#layout.observeViewport();
10422
11454
  this.update();
@@ -10424,7 +11456,7 @@ var OverflowMenuController = class extends Controller {
10424
11456
  disconnect() {
10425
11457
  this.#layout.disconnect();
10426
11458
  this.#timers.clearAll();
10427
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
11459
+ this.#beforeCache.deactivate();
10428
11460
  this.#restoreAll();
10429
11461
  this.#lastHidden = null;
10430
11462
  }
@@ -10900,7 +11932,11 @@ var PaginationController = class _PaginationController extends Controller {
10900
11932
  if (!Object.is(page, this.pageValue)) this.pageValue = page;
10901
11933
  this.#render();
10902
11934
  }
10903
- /** Syncs `aria-current` on the page buttons and the prev/next `disabled` state. */
11935
+ /**
11936
+ * Syncs `aria-current` on the page buttons and the prev/next `disabled` state.
11937
+ *
11938
+ * @stimeoRenderRoot
11939
+ */
10904
11940
  #render() {
10905
11941
  const page = this.#page;
10906
11942
  for (const button of this.pageTargets) {
@@ -11107,7 +12143,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
11107
12143
  this.#toggle("data-strength", band, band.length > 0);
11108
12144
  this.#toggle("data-below-min", "true", score > 0 && score < this.minScoreValue);
11109
12145
  const ratio = max > 0 ? score / max : 0;
11110
- this.element.style.setProperty("--stimeo-password-strength", String(ratio));
12146
+ this.element.style.setProperty("--stimeo--password-strength", String(ratio));
11111
12147
  }
11112
12148
  #writeLabel(label) {
11113
12149
  if (this.hasLabelTarget) this.labelTarget.textContent = label;
@@ -11347,53 +12383,6 @@ var PersistController = class extends Controller {
11347
12383
  }
11348
12384
  }
11349
12385
  };
11350
-
11351
- // src/utils/detach_gate.ts
11352
- var DetachGate = class _DetachGate {
11353
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
11354
- #pending = false;
11355
- /**
11356
- * True when the disconnect is definitely a real detach — the element left
11357
- * the document, or `data-controller` no longer lists the identifier. False
11358
- * means ambiguous (in-page move or observed-root exit), NOT "alive".
11359
- */
11360
- static isDetached(host) {
11361
- if (!host.element.isConnected) return true;
11362
- const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
11363
- return !tokens.includes(host.identifier);
11364
- }
11365
- /**
11366
- * Call from `disconnect()`: runs `teardown` synchronously on a definite
11367
- * detach (fast path), otherwise defers it one microtask — a reconnect
11368
- * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
11369
- * One microtask is the whole probe window: Stimulus reconnects a moved
11370
- * element within the same mutation batch, before the checkpoint drains.
11371
- */
11372
- disconnected(host, teardown) {
11373
- if (_DetachGate.isDetached(host)) {
11374
- this.#pending = false;
11375
- teardown();
11376
- return;
11377
- }
11378
- this.#pending = true;
11379
- queueMicrotask(() => {
11380
- if (!this.#pending) return;
11381
- this.#pending = false;
11382
- teardown();
11383
- });
11384
- }
11385
- /**
11386
- * Disarms a pending probe. Call from `connect()` (the reconnect that proves
11387
- * an in-page move) and from the head of any teardown path not routed through
11388
- * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
11389
- * never run the teardown a second time.
11390
- */
11391
- cancel() {
11392
- this.#pending = false;
11393
- }
11394
- };
11395
-
11396
- // src/controllers/pointer_drag_controller.ts
11397
12386
  var PointerDragController = class _PointerDragController extends Controller {
11398
12387
  static targets = ["handle"];
11399
12388
  static values = {
@@ -11975,6 +12964,7 @@ var PreviewGuardController = class extends Controller {
11975
12964
  this.element.removeAttribute("data-preview-hidden");
11976
12965
  }
11977
12966
  };
12967
+ var OWNED_VALUE_TEXT2 = "data-stimeo--progress-owns-valuetext";
11978
12968
  var ProgressController = class extends Controller {
11979
12969
  static targets = ["bar"];
11980
12970
  static values = {
@@ -11982,13 +12972,27 @@ var ProgressController = class extends Controller {
11982
12972
  min: { type: Number, default: 0 },
11983
12973
  max: { type: Number, default: 100 },
11984
12974
  indeterminate: { type: Boolean, default: false },
11985
- valueText: { type: String, default: "" }
12975
+ valueText: { type: String, default: "" },
12976
+ announceText: { type: String, default: "" }
11986
12977
  };
11987
12978
  static actions = ["setValue"];
11988
12979
  static events = ["change", "complete"];
12980
+ /**
12981
+ * Collapses a morph that swaps several render inputs at once into one repaint.
12982
+ * A single update usually rewrites the whole set, and each Value would otherwise
12983
+ * repaint on its own.
12984
+ */
12985
+ #repaint = new MicrotaskCoalescer(() => {
12986
+ this.#render();
12987
+ });
11989
12988
  connect() {
12989
+ this.#repaint.activate();
11990
12990
  this.#render();
11991
12991
  }
12992
+ /** Closes the window in which a queued repaint may still run. */
12993
+ disconnect() {
12994
+ this.#repaint.cancel();
12995
+ }
11992
12996
  /**
11993
12997
  * Updates the progress value from an action param (`amount`) or a
11994
12998
  * `detail.value` CustomEvent, normalizes it into range, syncs ARIA, and
@@ -12004,11 +13008,30 @@ var ProgressController = class extends Controller {
12004
13008
  this.dispatch("change", { detail: { value, ratio: this.#ratio } });
12005
13009
  if (value >= this.maxValue) {
12006
13010
  this.dispatch("complete", { detail: { value } });
13011
+ announce(
13012
+ fillTemplate(this.announceTextValue, { value, percent: Math.round(this.#ratio * 100) })
13013
+ );
12007
13014
  }
12008
13015
  }
12009
- /** Re-render when the indeterminate flag is toggled via its data attribute. */
13016
+ /** Repaints when application code (or a Turbo morph) changes `indeterminate` at runtime. */
12010
13017
  indeterminateValueChanged() {
12011
- this.#render();
13018
+ this.#repaint.schedule();
13019
+ }
13020
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
13021
+ valueValueChanged() {
13022
+ this.#repaint.schedule();
13023
+ }
13024
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
13025
+ minValueChanged() {
13026
+ this.#repaint.schedule();
13027
+ }
13028
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
13029
+ maxValueChanged() {
13030
+ this.#repaint.schedule();
13031
+ }
13032
+ /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
13033
+ valueTextValueChanged() {
13034
+ this.#repaint.schedule();
12012
13035
  }
12013
13036
  /** Clamps `raw` into the configured `[min, max]` range. */
12014
13037
  #clamp(raw) {
@@ -12016,40 +13039,51 @@ var ProgressController = class extends Controller {
12016
13039
  }
12017
13040
  /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
12018
13041
  get #ratio() {
12019
- const span = this.maxValue - this.minValue;
12020
- if (span <= 0) return 0;
12021
- return (this.#clamp(this.valueValue) - this.minValue) / span;
13042
+ return rangeFraction(this.valueValue, this.minValue, this.maxValue);
12022
13043
  }
12023
- /** Reflects value/range/indeterminate onto ARIA, `data-state`, and the ratio. */
13044
+ /**
13045
+ * Reflects value/range/indeterminate onto ARIA, `data-state`, and the ratio.
13046
+ *
13047
+ * @stimeoRenderRoot
13048
+ */
12024
13049
  #render() {
12025
13050
  this.element.setAttribute("aria-valuemin", String(this.minValue));
12026
13051
  this.element.setAttribute("aria-valuemax", String(this.maxValue));
12027
13052
  if (this.indeterminateValue) {
12028
13053
  this.element.removeAttribute("aria-valuenow");
12029
- this.element.removeAttribute("aria-valuetext");
12030
- this.element.style.removeProperty("--stimeo-progress-ratio");
13054
+ this.#clearOwnValueText();
13055
+ this.element.style.removeProperty("--stimeo--progress-ratio");
12031
13056
  this.element.setAttribute("data-state", "indeterminate");
12032
13057
  return;
12033
13058
  }
12034
13059
  const value = this.#clamp(this.valueValue);
12035
13060
  this.element.setAttribute("aria-valuenow", String(value));
12036
- this.element.style.setProperty("--stimeo-progress-ratio", String(this.#ratio));
13061
+ this.element.style.setProperty("--stimeo--progress-ratio", String(this.#ratio));
12037
13062
  this.element.setAttribute("data-state", "determinate");
12038
13063
  this.#applyValueText(value);
12039
13064
  }
12040
13065
  /**
12041
13066
  * Sets `aria-valuetext` from the consumer-provided template, substituting
12042
13067
  * `{value}` and `{percent}`. Left to the consumer so the human-readable text
12043
- * stays i18n-neutral in the library; cleared when no template is given.
13068
+ * stays i18n-neutral in the library. With no template the attribute belongs to
13069
+ * the consumer, so only a text this controller wrote is taken back
13070
+ * ({@link OWNED_VALUE_TEXT}).
12044
13071
  */
12045
13072
  #applyValueText(value) {
12046
13073
  if (this.valueTextValue.length === 0) {
12047
- this.element.removeAttribute("aria-valuetext");
13074
+ this.#clearOwnValueText();
12048
13075
  return;
12049
13076
  }
12050
13077
  const percent = Math.round(this.#ratio * 100);
12051
13078
  const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent));
12052
13079
  this.element.setAttribute("aria-valuetext", text);
13080
+ this.element.setAttribute(OWNED_VALUE_TEXT2, "");
13081
+ }
13082
+ /** Removes `aria-valuetext` only when this controller is the one that wrote it. */
13083
+ #clearOwnValueText() {
13084
+ if (!this.element.hasAttribute(OWNED_VALUE_TEXT2)) return;
13085
+ this.element.removeAttribute("aria-valuetext");
13086
+ this.element.removeAttribute(OWNED_VALUE_TEXT2);
12053
13087
  }
12054
13088
  };
12055
13089
  var RadioGroupController = class extends Controller {
@@ -12134,13 +13168,70 @@ var RadioGroupController = class extends Controller {
12134
13168
  return radio.getAttribute("data-value") ?? "";
12135
13169
  }
12136
13170
  };
12137
- var START_PROPERTY = "--stimeo-range-start";
12138
- var END_PROPERTY = "--stimeo-range-end";
13171
+
13172
+ // src/utils/owned_pointer_session.ts
13173
+ var OwnedPointerSession = class {
13174
+ pointerId;
13175
+ #owner;
13176
+ #handlers;
13177
+ #abort = new AbortController();
13178
+ #active = true;
13179
+ constructor(start, owner, handlers) {
13180
+ this.pointerId = start.pointerId;
13181
+ this.#owner = owner;
13182
+ this.#handlers = handlers;
13183
+ const { signal } = this.#abort;
13184
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
13185
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
13186
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
13187
+ owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
13188
+ try {
13189
+ owner.setPointerCapture?.(this.pointerId);
13190
+ } catch {
13191
+ }
13192
+ }
13193
+ /** Whether this session still owns its pointer and listeners. */
13194
+ get active() {
13195
+ return this.#active;
13196
+ }
13197
+ /** Whether `event` belongs to the initiating pointer of the live session. */
13198
+ owns(event) {
13199
+ return this.#active && event.pointerId === this.pointerId;
13200
+ }
13201
+ /** Releases capture/listeners and invokes the end callback exactly once. */
13202
+ end() {
13203
+ if (!this.#active) return;
13204
+ this.#active = false;
13205
+ this.#abort.abort();
13206
+ try {
13207
+ this.#owner.releasePointerCapture?.(this.pointerId);
13208
+ } catch {
13209
+ }
13210
+ this.#handlers.end?.();
13211
+ }
13212
+ #onMove = (event) => {
13213
+ if (this.owns(event)) this.#handlers.move(event);
13214
+ };
13215
+ #onEndEvent = (event) => {
13216
+ if (this.owns(event)) this.end();
13217
+ };
13218
+ #onLostCapture = (event) => {
13219
+ const pointerId = event.pointerId;
13220
+ if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
13221
+ this.end();
13222
+ };
13223
+ };
13224
+
13225
+ // src/controllers/range_slider_controller.ts
13226
+ var START_PROPERTY = "--stimeo--range-slider-start";
13227
+ var END_PROPERTY = "--stimeo--range-slider-end";
13228
+ var DEFAULT_MIN = 0;
13229
+ var DEFAULT_MAX = 100;
12139
13230
  var RangeSliderController = class extends Controller {
12140
13231
  static targets = ["track", "startThumb", "endThumb"];
12141
13232
  static values = {
12142
- min: { type: Number, default: 0 },
12143
- max: { type: Number, default: 100 },
13233
+ min: { type: Number, default: DEFAULT_MIN },
13234
+ max: { type: Number, default: DEFAULT_MAX },
12144
13235
  step: { type: Number, default: 1 },
12145
13236
  start: { type: Number, default: 0 },
12146
13237
  end: { type: Number, default: 100 },
@@ -12148,159 +13239,305 @@ var RangeSliderController = class extends Controller {
12148
13239
  };
12149
13240
  static actions = ["onKeydown", "onPointerDown"];
12150
13241
  static events = ["change"];
12151
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
12152
- #dragAbort = null;
13242
+ /** One initiating pointer owns each live drag and its stable target snapshot. */
13243
+ #drag = null;
12153
13244
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
12154
13245
  get #mirrored() {
12155
13246
  return this.logicalTrackValue && isRtl(this.element);
12156
13247
  }
12157
- /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
13248
+ /**
13249
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
13250
+ * pass Stimulus delivers before `connect()`.
13251
+ */
13252
+ #repaint = new MicrotaskCoalescer(() => this.#render());
12158
13253
  connect() {
12159
- const lo = Math.min(this.startValue, this.endValue);
12160
- const hi = Math.max(this.startValue, this.endValue);
12161
- this.#commit(lo, hi, false);
13254
+ this.#repaint.activate();
13255
+ const range = this.#effectiveRange;
13256
+ const pair = this.#currentPair(range);
13257
+ this.#commit(pair.start, pair.end, null, false);
12162
13258
  }
12163
13259
  /** Cancels any active pointer drag so document listeners never leak. */
12164
13260
  disconnect() {
12165
- this.#dragAbort?.abort();
12166
- this.#dragAbort = null;
13261
+ this.#repaint.cancel();
13262
+ this.#endDrag();
13263
+ }
13264
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
13265
+ minValueChanged() {
13266
+ this.#repaint.schedule();
13267
+ }
13268
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
13269
+ maxValueChanged() {
13270
+ this.#repaint.schedule();
13271
+ }
13272
+ /** Repaints when application code (or a Turbo morph) changes `step` at runtime. */
13273
+ stepValueChanged() {
13274
+ this.#repaint.schedule();
13275
+ }
13276
+ /** Repaints when application code (or a Turbo morph) changes `start` at runtime. */
13277
+ startValueChanged() {
13278
+ this.#repaint.schedule();
13279
+ }
13280
+ /** Repaints when application code (or a Turbo morph) changes `end` at runtime. */
13281
+ endValueChanged() {
13282
+ this.#repaint.schedule();
13283
+ }
13284
+ /** Hydrates a replacement start thumb and restores live-drag focus ownership. */
13285
+ startThumbTargetConnected(thumb) {
13286
+ const range = this.#effectiveRange;
13287
+ const pair = this.#currentPair(range);
13288
+ this.#renderStartThumb(thumb, pair, range);
13289
+ this.#renderFractions(pair, range);
13290
+ if (this.#drag?.kind === "start" && this.#drag.thumb === null) {
13291
+ this.#drag.thumb = thumb;
13292
+ thumb.focus();
13293
+ }
13294
+ this.#repaint.schedule();
13295
+ }
13296
+ /** Drops a stale start-thumb reference without orphaning the track gesture. */
13297
+ startThumbTargetDisconnected(thumb) {
13298
+ if (this.#drag?.kind === "start" && this.#drag.thumb === thumb) this.#drag.thumb = null;
13299
+ }
13300
+ /** Hydrates a replacement end thumb and restores live-drag focus ownership. */
13301
+ endThumbTargetConnected(thumb) {
13302
+ const range = this.#effectiveRange;
13303
+ const pair = this.#currentPair(range);
13304
+ this.#renderEndThumb(thumb, pair, range);
13305
+ this.#renderFractions(pair, range);
13306
+ if (this.#drag?.kind === "end" && this.#drag.thumb === null) {
13307
+ this.#drag.thumb = thumb;
13308
+ thumb.focus();
13309
+ }
13310
+ this.#repaint.schedule();
13311
+ }
13312
+ /** Drops a stale end-thumb reference without orphaning the track gesture. */
13313
+ endThumbTargetDisconnected(thumb) {
13314
+ if (this.#drag?.kind === "end" && this.#drag.thumb === thumb) this.#drag.thumb = null;
13315
+ }
13316
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
13317
+ trackTargetDisconnected(track) {
13318
+ if (this.#drag?.track === track) this.#endDrag();
12167
13319
  }
12168
13320
  /** Keyboard stepping for whichever thumb is focused (the action's element). */
12169
13321
  onKeydown(event) {
12170
13322
  if (isReservedArrowChord(event)) return;
12171
13323
  const thumb = event.currentTarget;
12172
13324
  const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
12173
- const current = isStart ? this.startValue : this.endValue;
12174
- const lower = isStart ? this.minValue : this.startValue;
12175
- const upper = isStart ? this.endValue : this.maxValue;
12176
- const big = this.stepValue * 10;
13325
+ const isEnd = this.hasEndThumbTarget && thumb === this.endThumbTarget;
13326
+ const effectiveRange = this.#effectiveRange;
13327
+ const pair = this.#currentPair(effectiveRange);
13328
+ let kind;
13329
+ let current;
13330
+ let range;
13331
+ if (isStart) {
13332
+ kind = "start";
13333
+ current = pair.start;
13334
+ range = {
13335
+ min: effectiveRange.min,
13336
+ max: pair.end,
13337
+ step: effectiveRange.step,
13338
+ base: effectiveRange.min
13339
+ };
13340
+ } else {
13341
+ if (!isEnd) return;
13342
+ kind = "end";
13343
+ current = pair.end;
13344
+ range = {
13345
+ min: pair.start,
13346
+ max: effectiveRange.max,
13347
+ step: effectiveRange.step,
13348
+ base: effectiveRange.min
13349
+ };
13350
+ }
12177
13351
  let next = null;
12178
13352
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
12179
13353
  case "ArrowRight":
12180
13354
  case "ArrowUp":
12181
- next = current + this.stepValue;
13355
+ next = stepSteppedValue(current, 1, range);
12182
13356
  break;
12183
13357
  case "ArrowLeft":
12184
13358
  case "ArrowDown":
12185
- next = current - this.stepValue;
13359
+ next = stepSteppedValue(current, -1, range);
12186
13360
  break;
12187
13361
  case "PageUp":
12188
- next = current + big;
13362
+ next = stepSteppedValue(current, 10, range);
12189
13363
  break;
12190
13364
  case "PageDown":
12191
- next = current - big;
13365
+ next = stepSteppedValue(current, -10, range);
12192
13366
  break;
12193
13367
  case "Home":
12194
- next = lower;
13368
+ next = range.min;
12195
13369
  break;
12196
13370
  case "End":
12197
- next = upper;
13371
+ next = range.max;
12198
13372
  break;
12199
13373
  default:
12200
13374
  return;
12201
13375
  }
12202
13376
  event.preventDefault();
12203
- this.#moveThumb(isStart, next);
13377
+ this.#moveThumb(kind, next);
12204
13378
  }
12205
13379
  /** Begins a pointer drag on the track, moving the thumb nearest the press. */
12206
13380
  onPointerDown(event) {
12207
- if (!this.hasTrackTarget) return;
13381
+ if (event.button !== 0 || this.#drag || !this.hasTrackTarget || !this.hasStartThumbTarget || !this.hasEndThumbTarget) {
13382
+ return;
13383
+ }
13384
+ const track = this.trackTarget;
12208
13385
  const mirrored = this.#mirrored;
12209
- const value = this.#valueFromClientX(event.clientX, mirrored);
13386
+ const value = this.#valueFromClientX(event.clientX, mirrored, track);
12210
13387
  if (value === null) return;
12211
13388
  event.preventDefault();
12212
- const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
12213
- if (useStart) {
12214
- if (this.hasStartThumbTarget) this.startThumbTarget.focus();
12215
- } else if (this.hasEndThumbTarget) {
12216
- this.endThumbTarget.focus();
12217
- }
12218
- this.#moveThumb(useStart, value);
12219
- this.#dragAbort?.abort();
12220
- const abort = new AbortController();
12221
- this.#dragAbort = abort;
12222
- const onMove = (move) => {
12223
- const moved = this.#valueFromClientX(move.clientX, mirrored);
12224
- if (moved !== null) this.#moveThumb(useStart, moved);
12225
- };
12226
- const onUp = () => {
12227
- abort.abort();
12228
- this.#dragAbort = null;
12229
- };
12230
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
12231
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
12232
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
13389
+ const pair = this.#currentPair(this.#effectiveRange);
13390
+ const kind = this.#nearestThumb(value, pair);
13391
+ let thumb;
13392
+ if (kind === "start") thumb = this.startThumbTarget;
13393
+ else thumb = this.endThumbTarget;
13394
+ this.#moveThumb(kind, value);
13395
+ thumb.focus();
13396
+ const drag = { pointer: null, track, thumb, kind };
13397
+ drag.pointer = new OwnedPointerSession(event, track, {
13398
+ move: (move) => {
13399
+ if (!track.isConnected) {
13400
+ this.#endDrag();
13401
+ return;
13402
+ }
13403
+ const moved = this.#valueFromClientX(move.clientX, mirrored, track);
13404
+ if (moved !== null) this.#moveThumb(kind, moved);
13405
+ },
13406
+ end: () => {
13407
+ if (this.#drag === drag) this.#drag = null;
13408
+ }
13409
+ });
13410
+ this.#drag = drag;
12233
13411
  }
12234
13412
  /** Maps a pointer X coordinate to a raw value using the track geometry. */
12235
- #valueFromClientX(clientX, mirrored) {
12236
- const rect = this.trackTarget.getBoundingClientRect();
13413
+ #valueFromClientX(clientX, mirrored, track) {
13414
+ const rect = track.getBoundingClientRect();
12237
13415
  if (rect.width === 0) return null;
12238
13416
  const offset = (clientX - rect.left) / rect.width;
12239
13417
  const fraction = mirrored ? 1 - offset : offset;
12240
- return this.minValue + fraction * (this.maxValue - this.minValue);
13418
+ const range = this.#effectiveRange;
13419
+ return range.min + fraction * (range.max - range.min);
13420
+ }
13421
+ /**
13422
+ * Chooses the closest thumb while keeping an overlapped pair expandable.
13423
+ * Ordinary midpoint ties stay deterministic on `start`; at an overlap, a
13424
+ * press above the shared value selects `end` and a press below selects
13425
+ * `start`.
13426
+ */
13427
+ #nearestThumb(value, pair) {
13428
+ const startDistance = Math.abs(value - pair.start);
13429
+ const endDistance = Math.abs(value - pair.end);
13430
+ if (endDistance < startDistance) return "end";
13431
+ if (pair.start === pair.end && value > pair.start) return "end";
13432
+ return "start";
12241
13433
  }
12242
13434
  /** Moves one thumb to a new raw value, keeping the pair ordered. */
12243
- #moveThumb(isStart, raw) {
12244
- if (isStart) {
12245
- this.#commit(raw, this.endValue, true);
13435
+ #moveThumb(kind, raw) {
13436
+ const pair = this.#currentPair(this.#effectiveRange);
13437
+ if (kind === "start") {
13438
+ this.#commit(raw, pair.end, "start", true);
12246
13439
  } else {
12247
- this.#commit(this.startValue, raw, true);
13440
+ this.#commit(pair.start, raw, "end", true);
12248
13441
  }
12249
13442
  }
12250
13443
  /**
12251
13444
  * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
12252
13445
  * reflects it onto the thumbs' ARIA attributes and the range custom
12253
- * properties. Dispatches `change` only on user-driven updates (`notify`).
12254
- */
12255
- #commit(start, end, notify) {
12256
- const prevStart = this.startValue;
12257
- const prevEnd = this.endValue;
12258
- let nextStart = this.#snap(start);
12259
- let nextEnd = this.#snap(end);
13446
+ * properties. Dispatches `stimeo--range-slider:change` only when a
13447
+ * user-driven update changes the normalized pair, with
13448
+ * `{ start: number, end: number }` in `detail`.
13449
+ */
13450
+ #commit(start, end, moving, notify) {
13451
+ const range = this.#effectiveRange;
13452
+ const previous = this.#currentPair(range);
13453
+ let nextStart = snapSteppedValue(start, range);
13454
+ let nextEnd = snapSteppedValue(end, range);
12260
13455
  if (nextStart > nextEnd) {
12261
- if (isUserMovingStart(start, prevStart, end, prevEnd)) nextStart = nextEnd;
12262
- else nextEnd = nextStart;
12263
- }
12264
- this.startValue = nextStart;
12265
- this.endValue = nextEnd;
12266
- this.#render(nextStart, nextEnd);
12267
- if (notify && (nextStart !== prevStart || nextEnd !== prevEnd)) {
13456
+ if (moving === "start") nextStart = nextEnd;
13457
+ else if (moving === "end") nextEnd = nextStart;
13458
+ else [nextStart, nextEnd] = [nextEnd, nextStart];
13459
+ }
13460
+ if (!Object.is(this.startValue, nextStart)) this.startValue = nextStart;
13461
+ if (!Object.is(this.endValue, nextEnd)) this.endValue = nextEnd;
13462
+ const pair = { start: nextStart, end: nextEnd };
13463
+ this.#renderPair(pair, range);
13464
+ if (notify && (nextStart !== previous.start || nextEnd !== previous.end)) {
12268
13465
  this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
12269
13466
  }
12270
13467
  }
12271
- /** Reflects the current pair onto thumb ARIA attributes and CSS properties. */
12272
- #render(start, end) {
13468
+ /**
13469
+ * Reflects morph-supplied Values without writing them back or dispatching.
13470
+ *
13471
+ * @stimeoRenderRoot
13472
+ */
13473
+ #render() {
13474
+ const range = this.#effectiveRange;
13475
+ this.#renderPair(this.#currentPair(range), range);
13476
+ }
13477
+ /** Reflects one normalized pair onto both thumbs and CSS properties. */
13478
+ #renderPair(pair, range) {
12273
13479
  if (this.hasStartThumbTarget) {
12274
- this.startThumbTarget.setAttribute("aria-valuemin", String(this.minValue));
12275
- this.startThumbTarget.setAttribute("aria-valuemax", String(end));
12276
- this.startThumbTarget.setAttribute("aria-valuenow", String(start));
13480
+ this.#renderStartThumb(this.startThumbTarget, pair, range);
12277
13481
  }
12278
13482
  if (this.hasEndThumbTarget) {
12279
- this.endThumbTarget.setAttribute("aria-valuemin", String(start));
12280
- this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
12281
- this.endThumbTarget.setAttribute("aria-valuenow", String(end));
12282
- }
12283
- const span = this.maxValue - this.minValue;
12284
- this.element.style.setProperty(
12285
- START_PROPERTY,
12286
- String(span > 0 ? (start - this.minValue) / span : 0)
12287
- );
12288
- this.element.style.setProperty(
12289
- END_PROPERTY,
12290
- String(span > 0 ? (end - this.minValue) / span : 0)
12291
- );
12292
- }
12293
- /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
12294
- #snap(raw) {
12295
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
12296
- if (this.stepValue <= 0) return clamped;
12297
- const stepped = Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue;
12298
- return Math.min(this.maxValue, Math.max(this.minValue, stepped));
13483
+ this.#renderEndThumb(this.endThumbTarget, pair, range);
13484
+ }
13485
+ this.#renderFractions(pair, range);
13486
+ }
13487
+ /** Writes only changed ARIA for the lower thumb. */
13488
+ #renderStartThumb(thumb, pair, range) {
13489
+ this.#setAria(thumb, "aria-valuemin", range.min);
13490
+ this.#setAria(thumb, "aria-valuemax", pair.end);
13491
+ this.#setAria(thumb, "aria-valuenow", pair.start);
13492
+ }
13493
+ /** Writes only changed ARIA for the upper thumb. */
13494
+ #renderEndThumb(thumb, pair, range) {
13495
+ this.#setAria(thumb, "aria-valuemin", pair.start);
13496
+ this.#setAria(thumb, "aria-valuemax", range.max);
13497
+ this.#setAria(thumb, "aria-valuenow", pair.end);
13498
+ }
13499
+ /** Writes one numeric ARIA attribute only when its serialized value changed. */
13500
+ #setAria(thumb, name, value) {
13501
+ const next = String(value);
13502
+ if (thumb.getAttribute(name) !== next) thumb.setAttribute(name, next);
13503
+ }
13504
+ /** Writes the two behavior-only fraction hooks only when they changed. */
13505
+ #renderFractions(pair, range) {
13506
+ const start = String(rangeFraction(pair.start, range.min, range.max));
13507
+ const end = String(rangeFraction(pair.end, range.min, range.max));
13508
+ if (this.element.style.getPropertyValue(START_PROPERTY) !== start) {
13509
+ this.element.style.setProperty(START_PROPERTY, start);
13510
+ }
13511
+ if (this.element.style.getPropertyValue(END_PROPERTY) !== end) {
13512
+ this.element.style.setProperty(END_PROPERTY, end);
13513
+ }
13514
+ }
13515
+ /** Current normalized and ordered pair derived from live declarative Values. */
13516
+ #currentPair(range) {
13517
+ const rawStart = Number.isFinite(this.startValue) ? this.startValue : range.min;
13518
+ const rawEnd = Number.isFinite(this.endValue) ? this.endValue : range.max;
13519
+ const start = snapSteppedValue(rawStart, range);
13520
+ const end = snapSteppedValue(rawEnd, range);
13521
+ if (start <= end) return { start, end };
13522
+ return { start: end, end: start };
13523
+ }
13524
+ /**
13525
+ * Finite ordered range used by ARIA, keyboard, pointer, and CSS reflection.
13526
+ * Invalid endpoints fall back to the public defaults; an authored `max`
13527
+ * below `min` collapses to the finite minimum.
13528
+ */
13529
+ get #effectiveRange() {
13530
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN;
13531
+ const authoredMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX;
13532
+ return { min, max: Math.max(min, authoredMax), step: this.stepValue, base: min };
13533
+ }
13534
+ /** Ends the current pointer session without dispatching another change. */
13535
+ #endDrag() {
13536
+ const drag = this.#drag;
13537
+ this.#drag = null;
13538
+ drag?.pointer?.end();
12299
13539
  }
12300
13540
  };
12301
- function isUserMovingStart(start, prevStart, end, prevEnd) {
12302
- return start !== prevStart && end === prevEnd;
12303
- }
12304
13541
  var RatingController = class extends Controller {
12305
13542
  static targets = ["symbol", "field"];
12306
13543
  static values = {
@@ -12313,13 +13550,33 @@ var RatingController = class extends Controller {
12313
13550
  static events = ["change"];
12314
13551
  #roving = new RovingTabindex(() => this.symbolTargets);
12315
13552
  /** Reflects the initial value, or switches to the non-interactive readonly view. */
13553
+ /**
13554
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
13555
+ * pass Stimulus delivers before `connect()`.
13556
+ */
13557
+ #repaint = new MicrotaskCoalescer(() => {
13558
+ if (this.readonlyValue) {
13559
+ this.#applyReadonly();
13560
+ return;
13561
+ }
13562
+ this.#apply(this.#clamp(this.valueValue), { focus: false });
13563
+ });
12316
13564
  connect() {
13565
+ this.#repaint.activate();
12317
13566
  if (this.readonlyValue) {
12318
13567
  this.#applyReadonly();
12319
13568
  return;
12320
13569
  }
12321
13570
  this.#apply(this.#clamp(this.valueValue), { focus: false });
12322
13571
  }
13572
+ /** Closes the window in which a queued repaint may still run. */
13573
+ disconnect() {
13574
+ this.#repaint.cancel();
13575
+ }
13576
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
13577
+ valueValueChanged() {
13578
+ this.#repaint.schedule();
13579
+ }
12323
13580
  /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
12324
13581
  select(event) {
12325
13582
  if (this.readonlyValue) return;
@@ -12376,6 +13633,8 @@ var RatingController = class extends Controller {
12376
13633
  * Applies `value` (already clamped) everywhere, then dispatches `change`.
12377
13634
  * Use for user-driven changes; on connect call `#apply` directly so
12378
13635
  * initialization mirrors state without emitting an event.
13636
+ *
13637
+ * @stimeoRenderRoot
12379
13638
  */
12380
13639
  #render(value, { focus }) {
12381
13640
  this.#apply(value, { focus });
@@ -12618,11 +13877,16 @@ var RelativeTimeController = class extends Controller {
12618
13877
  tickInterval: { type: Number, default: 6e4 }
12619
13878
  };
12620
13879
  #timers = new SafeTimeout();
13880
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
13881
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
13882
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
12621
13883
  /** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
12622
13884
  #targetMs = Number.NaN;
12623
13885
  /** The authored absolute text, restored when the threshold fallback kicks in. */
12624
13886
  #absoluteText = "";
12625
13887
  connect() {
13888
+ this.#resync.activate();
13889
+ this.#beforeCache.activate();
12626
13890
  if (this.element.getAttribute("data-state") !== "relative") {
12627
13891
  this.#absoluteText = (this.element.textContent ?? "").trim();
12628
13892
  }
@@ -12631,9 +13895,50 @@ var RelativeTimeController = class extends Controller {
12631
13895
  this.#schedule();
12632
13896
  }
12633
13897
  disconnect() {
13898
+ this.#resync.cancel();
13899
+ this.#beforeCache.deactivate();
13900
+ this.#timers.clearAll();
13901
+ }
13902
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
13903
+ localeValueChanged() {
13904
+ this.#resync.schedule();
13905
+ }
13906
+ /** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */
13907
+ thresholdValueChanged() {
13908
+ this.#resync.schedule();
13909
+ }
13910
+ /** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */
13911
+ tickIntervalValueChanged() {
13912
+ this.#resync.schedule();
13913
+ }
13914
+ /**
13915
+ * Renders against the current Values and re-arms the poll from now.
13916
+ *
13917
+ * Render only: it emits no event, and clearing first keeps the single self-arming
13918
+ * timer single — scheduling on top of a pending one would double the poll rate for
13919
+ * the rest of the session. A stamp whose `datetime` never parsed has nothing to
13920
+ * render, and one that already reached its terminal fallback simply renders it
13921
+ * again and stops.
13922
+ */
13923
+ #resyncToValues() {
13924
+ if (Number.isNaN(this.#targetMs)) return;
12634
13925
  this.#timers.clearAll();
13926
+ this.#schedule();
13927
+ }
13928
+ /**
13929
+ * Restores the authored absolute text and the pre-render state for the snapshot
13930
+ * Turbo is about to take, leaving the live page's poll timer alone.
13931
+ *
13932
+ * With no authored text held there is nothing to restore, and `data-state` has to
13933
+ * stay as it is: that marker is what tells the next `connect()` the visible text is
13934
+ * a rendered relative form rather than an absolute fallback to hold on to.
13935
+ */
13936
+ #rewindForCache() {
13937
+ if (!this.#absoluteText) return;
13938
+ this.element.textContent = this.#absoluteText;
13939
+ this.element.removeAttribute("data-state");
12635
13940
  }
12636
- /** Renders the current representation and reschedules unless it is now absolute. */
13941
+ /** Renders the current representation and reschedules unless polling can stop. */
12637
13942
  #schedule() {
12638
13943
  const nextDelay = this.#applyAndComputeDelay();
12639
13944
  if (nextDelay !== null) {
@@ -12641,31 +13946,46 @@ var RelativeTimeController = class extends Controller {
12641
13946
  }
12642
13947
  }
12643
13948
  /**
12644
- * Updates the visible text and returns the next poll delay (ms), or `null`
12645
- * once the absolute fallback is shown (which never changes, so stop polling).
13949
+ * Updates the visible text and returns the next poll delay (ms), or `null` when
13950
+ * polling can stop: a *past* timestamp that fell back to the absolute text can
13951
+ * never leave it again, and a locale the runtime rejects has nothing to render
13952
+ * until that value is corrected.
13953
+ *
13954
+ * @stimeoRenderRoot
12646
13955
  */
12647
13956
  #applyAndComputeDelay() {
12648
13957
  const deltaMs = this.#targetMs - Date.now();
12649
13958
  const absSeconds = Math.abs(deltaMs) / 1e3;
13959
+ const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
13960
+ const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
13961
+ const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
12650
13962
  if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
12651
13963
  this.element.textContent = this.#absoluteText;
12652
13964
  this.element.setAttribute("data-state", "absolute");
12653
- return null;
13965
+ if (deltaMs <= 0) return null;
13966
+ return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
12654
13967
  }
12655
- const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
13968
+ const formatter = this.#formatter;
13969
+ if (formatter === null) return null;
12656
13970
  const value = Math.round(deltaMs / scale.ms);
12657
- this.element.textContent = this.#formatter.format(value, scale.unit);
13971
+ this.element.textContent = formatter.format(value, scale.unit);
12658
13972
  this.element.setAttribute("data-state", "relative");
12659
- const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
12660
- return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
13973
+ return nextDelay;
12661
13974
  }
12662
- /** A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`). */
13975
+ /**
13976
+ * A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
13977
+ * when the runtime rejects that locale.
13978
+ */
12663
13979
  get #formatter() {
12664
- return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
13980
+ try {
13981
+ return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
13982
+ } catch {
13983
+ return null;
13984
+ }
12665
13985
  }
12666
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
13986
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
12667
13987
  get #locale() {
12668
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
13988
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
12669
13989
  }
12670
13990
  };
12671
13991
  var ResetBeforeCacheController = class extends Controller {
@@ -13046,7 +14366,7 @@ var ScrollAreaController = class extends Controller {
13046
14366
  this.#syncKeyboardReach(vp, overflowing);
13047
14367
  const { position, progress } = this.#measurePosition(vp);
13048
14368
  this.element.setAttribute("data-scroll", position);
13049
- this.element.style.setProperty("--stimeo-scroll-progress", String(progress));
14369
+ this.element.style.setProperty("--stimeo--scroll-progress", String(progress));
13050
14370
  const edge = position === "start" ? "start" : position === "end" ? "end" : null;
13051
14371
  if (overflowing && edge && edge !== this.#lastEdge) {
13052
14372
  this.#lastEdge = edge;
@@ -13677,7 +14997,7 @@ var ScrollspyController = class extends Controller {
13677
14997
  *
13678
14998
  * @param announce Whether this sync represents a change of current section.
13679
14999
  */
13680
- #syncActiveStates(announce) {
15000
+ #syncActiveStates(announce2) {
13681
15001
  const activeLinks = this.linkTargets.filter(
13682
15002
  (link) => this.#getAnchorId(link) === this.#activeSectionId
13683
15003
  );
@@ -13688,7 +15008,7 @@ var ScrollspyController = class extends Controller {
13688
15008
  link.removeAttribute("aria-current");
13689
15009
  }
13690
15010
  }
13691
- if (!announce) return;
15011
+ if (!announce2) return;
13692
15012
  const primaryLink = activeLinks[0];
13693
15013
  if (primaryLink) {
13694
15014
  this.dispatch("change", { detail: { id: this.#activeSectionId, link: primaryLink } });
@@ -13740,19 +15060,13 @@ var SeparatorController = class extends Controller {
13740
15060
  static actions = ["onKeydown"];
13741
15061
  static events = ["change"];
13742
15062
  connect() {
13743
- if (!this.element.hasAttribute("role")) {
13744
- this.element.setAttribute("role", "separator");
13745
- }
13746
- if (!this.element.hasAttribute("aria-orientation")) {
13747
- this.element.setAttribute("aria-orientation", this.orientationValue);
13748
- }
15063
+ setDefaultAttribute(this.element, "role", "separator");
15064
+ setDefaultAttribute(this.element, "aria-orientation", this.orientationValue);
13749
15065
  if (this.focusableValue) {
13750
- if (!this.element.hasAttribute("tabindex")) {
13751
- this.element.setAttribute("tabindex", "0");
13752
- }
13753
- this.#setDefault("aria-valuemin", "0");
13754
- this.#setDefault("aria-valuemax", "100");
13755
- this.#setDefault("aria-valuenow", String(this.#clamp(this.#value)));
15066
+ setDefaultAttribute(this.element, "tabindex", "0");
15067
+ setDefaultAttribute(this.element, "aria-valuemin", "0");
15068
+ setDefaultAttribute(this.element, "aria-valuemax", "100");
15069
+ setDefaultAttribute(this.element, "aria-valuenow", String(this.#clamp(this.#value)));
13756
15070
  }
13757
15071
  }
13758
15072
  /** Adjusts the value on arrow / Home / End keys (focusable variant only). */
@@ -13806,11 +15120,6 @@ var SeparatorController = class extends Controller {
13806
15120
  const parsed = Number.parseFloat(this.element.getAttribute(name) ?? "");
13807
15121
  return Number.isNaN(parsed) ? fallback : parsed;
13808
15122
  }
13809
- #setDefault(name, value) {
13810
- if (!this.element.hasAttribute(name)) {
13811
- this.element.setAttribute(name, value);
13812
- }
13813
- }
13814
15123
  };
13815
15124
 
13816
15125
  // src/utils/safe_storage.ts
@@ -13836,7 +15145,7 @@ var SidebarController = class extends Controller {
13836
15145
  key: { type: String, default: "" },
13837
15146
  collapsed: { type: Boolean, default: false }
13838
15147
  };
13839
- static actions = ["beforeCache", "close", "open", "toggle"];
15148
+ static actions = ["close", "open", "toggle"];
13840
15149
  /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
13841
15150
  #activePanel = null;
13842
15151
  /** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
@@ -13858,6 +15167,7 @@ var SidebarController = class extends Controller {
13858
15167
  #connected = false;
13859
15168
  connect() {
13860
15169
  this.#connected = true;
15170
+ this.#beforeCache.activate();
13861
15171
  this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
13862
15172
  this.#collapsed = this.#restoreCollapsed();
13863
15173
  this.#mqlQuery = this.#breakpointQuery;
@@ -13867,6 +15177,7 @@ var SidebarController = class extends Controller {
13867
15177
  }
13868
15178
  disconnect() {
13869
15179
  this.#connected = false;
15180
+ this.#beforeCache.deactivate();
13870
15181
  this.#mql?.removeEventListener("change", this.#onMediaChange);
13871
15182
  this.#mql = null;
13872
15183
  this.#mqlQuery = null;
@@ -13918,12 +15229,12 @@ var SidebarController = class extends Controller {
13918
15229
  * closed immediately, and modal side effects are released without moving
13919
15230
  * focus during navigation.
13920
15231
  */
13921
- beforeCache() {
15232
+ #beforeCache = new BeforeCacheReset(() => {
13922
15233
  if (!this.#connected || !this.#isOverlay) return;
13923
15234
  this.#transition.cancel();
13924
15235
  this.#trap.deactivate({ restoreFocus: false });
13925
15236
  this.#setOverlayClosedImmediate();
13926
- }
15237
+ });
13927
15238
  /** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
13928
15239
  toggle() {
13929
15240
  if (this.#isOverlay) {
@@ -14120,48 +15431,37 @@ var SidebarController = class extends Controller {
14120
15431
  var SkeletonController = class extends Controller {
14121
15432
  static targets = ["placeholder", "content"];
14122
15433
  static values = {
15434
+ announceReadyText: { type: String, default: "" },
14123
15435
  minDuration: { type: Number, default: 0 }
14124
15436
  };
14125
15437
  static actions = ["ready", "reset"];
14126
15438
  static events = ["ready"];
14127
15439
  #timers = new SafeTimeout();
14128
- /** Pending min-duration reveal timer id, or `null` when none is scheduled. */
14129
- #revealTimerId = null;
14130
- /** Epoch ms when the loading state began; `minDuration` is measured from it. */
14131
- #loadingSince = 0;
15440
+ #floor = new MinDurationFloor(this.#timers);
15441
+ #gate = new DetachGate();
14132
15442
  connect() {
14133
- if (this.#state !== "ready") {
15443
+ const moved = this.#gate.pending;
15444
+ this.#gate.cancel();
15445
+ if (!moved && this.#state !== "ready") {
14134
15446
  this.#enterLoading();
14135
15447
  }
14136
15448
  }
14137
15449
  disconnect() {
14138
- this.#timers.clearAll();
14139
- this.#revealTimerId = null;
15450
+ this.#gate.disconnected(this, () => this.#teardown());
14140
15451
  }
14141
15452
  /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
14142
15453
  ready() {
14143
- if (this.#state === "ready" || this.#revealTimerId !== null) return;
14144
- const remaining = this.minDurationValue - (Date.now() - this.#loadingSince);
14145
- if (remaining > 0) {
14146
- this.#revealTimerId = this.#timers.set(() => {
14147
- this.#revealTimerId = null;
14148
- this.#reveal();
14149
- }, remaining);
14150
- } else {
14151
- this.#reveal();
14152
- }
15454
+ if (this.#state === "ready" || this.#floor.pending) return;
15455
+ this.#floor.schedule(this.minDurationValue, () => this.#reveal());
14153
15456
  }
14154
15457
  /** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
14155
15458
  reset() {
14156
- if (this.#revealTimerId !== null) {
14157
- this.#timers.clear(this.#revealTimerId);
14158
- this.#revealTimerId = null;
14159
- }
15459
+ this.#floor.cancel();
14160
15460
  this.#enterLoading();
14161
15461
  }
14162
15462
  /** Shows the placeholder, hides content, and marks the region busy. */
14163
15463
  #enterLoading() {
14164
- this.#loadingSince = Date.now();
15464
+ this.#floor.begin();
14165
15465
  if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
14166
15466
  if (this.hasContentTarget) this.contentTarget.hidden = true;
14167
15467
  this.element.setAttribute("aria-busy", "true");
@@ -14174,6 +15474,18 @@ var SkeletonController = class extends Controller {
14174
15474
  this.element.setAttribute("aria-busy", "false");
14175
15475
  this.element.setAttribute("data-state", "ready");
14176
15476
  this.dispatch("ready", { detail: {} });
15477
+ announce(fillTemplate(this.announceReadyTextValue, {}));
15478
+ }
15479
+ /**
15480
+ * Drops the held reveal on a real detach. The markup keeps whatever it last
15481
+ * held: an element on its way out of the document has no reader left, and one
15482
+ * whose `data-controller` dropped the identifier no longer resolves its own
15483
+ * targets, so the rollback could only ever be partial.
15484
+ */
15485
+ #teardown() {
15486
+ this.#gate.cancel();
15487
+ this.#timers.clearAll();
15488
+ this.#floor.cancel();
14177
15489
  }
14178
15490
  /** Current lifecycle phase as reflected on `data-state`. */
14179
15491
  get #state() {
@@ -14192,40 +15504,79 @@ var SliderController = class extends Controller {
14192
15504
  };
14193
15505
  static actions = ["onKeydown", "onPointerDown"];
14194
15506
  static events = ["change"];
14195
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
14196
- #dragAbort = null;
15507
+ /** One initiating pointer owns each live drag and its stable target snapshot. */
15508
+ #drag = null;
15509
+ /** Collapses a morph that swaps several render Values into one silent repaint. */
15510
+ #repaint = new MicrotaskCoalescer(() => this.#render());
14197
15511
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
14198
15512
  get #mirrored() {
14199
15513
  return this.logicalTrackValue && isRtl(this.element);
14200
15514
  }
14201
15515
  /** Clamps the initial value and renders the starting position. */
14202
15516
  connect() {
14203
- this.#setValue(this.valueValue, { silent: true });
15517
+ this.#repaint.activate();
15518
+ this.#commit(this.valueValue, false);
14204
15519
  }
14205
15520
  /** Cancels any active pointer drag so document listeners never leak. */
14206
15521
  disconnect() {
14207
- this.#dragAbort?.abort();
14208
- this.#dragAbort = null;
15522
+ this.#repaint.cancel();
15523
+ this.#endDrag();
15524
+ }
15525
+ /** Silently repaints a minimum changed by application code or a Turbo morph. */
15526
+ minValueChanged() {
15527
+ this.#repaint.schedule();
15528
+ }
15529
+ /** Silently repaints a maximum changed by application code or a Turbo morph. */
15530
+ maxValueChanged() {
15531
+ this.#repaint.schedule();
15532
+ }
15533
+ /** Silently repaints a step changed by application code or a Turbo morph. */
15534
+ stepValueChanged() {
15535
+ this.#repaint.schedule();
15536
+ }
15537
+ /** Silently repaints a value changed by application code or a Turbo morph. */
15538
+ valueValueChanged() {
15539
+ this.#repaint.schedule();
15540
+ }
15541
+ /** Hydrates a thumb inserted or replaced at runtime with the current ARIA state. */
15542
+ thumbTargetConnected(thumb) {
15543
+ const value = this.#currentValue();
15544
+ this.#renderThumb(thumb, value);
15545
+ this.#renderFraction(value);
15546
+ if (this.#drag && this.#drag.thumb === null) {
15547
+ this.#drag.thumb = thumb;
15548
+ thumb.focus();
15549
+ }
15550
+ this.#repaint.schedule();
15551
+ }
15552
+ /** Drops the stale focus owner while allowing a live track gesture to continue. */
15553
+ thumbTargetDisconnected(thumb) {
15554
+ if (this.#drag?.thumb === thumb) this.#drag.thumb = null;
15555
+ }
15556
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
15557
+ trackTargetDisconnected(track) {
15558
+ if (this.#drag?.track === track) this.#endDrag();
14209
15559
  }
14210
15560
  /** Handles keyboard stepping per the APG slider model. */
14211
15561
  onKeydown(event) {
14212
15562
  if (isReservedArrowChord(event)) return;
14213
- const big = this.stepValue * 10;
14214
15563
  let next = null;
15564
+ const current = this.#currentValue();
15565
+ const range = this.#steppedRange;
14215
15566
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
14216
15567
  case "ArrowRight":
14217
15568
  case "ArrowUp":
14218
- next = this.valueValue + this.stepValue;
15569
+ next = stepSteppedValue(current, 1, range);
14219
15570
  break;
14220
15571
  case "ArrowLeft":
14221
15572
  case "ArrowDown":
14222
- next = this.valueValue - this.stepValue;
15573
+ next = stepSteppedValue(current, -1, range);
14223
15574
  break;
14224
15575
  case "PageUp":
14225
- next = this.valueValue + big;
15576
+ next = stepSteppedValue(current, 10, range);
14226
15577
  break;
14227
15578
  case "PageDown":
14228
- next = this.valueValue - big;
15579
+ next = stepSteppedValue(current, -10, range);
14229
15580
  break;
14230
15581
  case "Home":
14231
15582
  next = this.minValue;
@@ -14237,57 +15588,102 @@ var SliderController = class extends Controller {
14237
15588
  return;
14238
15589
  }
14239
15590
  event.preventDefault();
14240
- this.#setValue(next);
15591
+ this.#commit(next, true);
14241
15592
  }
14242
15593
  /** Begins a pointer drag: sets the value and tracks subsequent movement. */
14243
15594
  onPointerDown(event) {
14244
- if (!this.hasTrackTarget) return;
14245
- event.preventDefault();
15595
+ if (event.button !== 0 || this.#drag || !this.hasTrackTarget || !this.hasThumbTarget) return;
15596
+ const track = this.trackTarget;
15597
+ const thumb = this.thumbTarget;
14246
15598
  const mirrored = this.#mirrored;
14247
- this.#updateFromClientX(event.clientX, mirrored);
14248
- if (this.hasThumbTarget) this.thumbTarget.focus();
14249
- this.#dragAbort?.abort();
14250
- const abort = new AbortController();
14251
- this.#dragAbort = abort;
14252
- const onMove = (move) => this.#updateFromClientX(move.clientX, mirrored);
14253
- const onUp = () => {
14254
- abort.abort();
14255
- this.#dragAbort = null;
14256
- };
14257
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
14258
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
14259
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
15599
+ const value = this.#valueFromClientX(event.clientX, mirrored, track);
15600
+ if (value === null) return;
15601
+ event.preventDefault();
15602
+ this.#commit(value, true);
15603
+ thumb.focus();
15604
+ const drag = { pointer: null, track, thumb };
15605
+ drag.pointer = new OwnedPointerSession(event, track, {
15606
+ move: (move) => {
15607
+ if (!track.isConnected) {
15608
+ this.#endDrag();
15609
+ return;
15610
+ }
15611
+ const moved = this.#valueFromClientX(move.clientX, mirrored, track);
15612
+ if (moved !== null) this.#commit(moved, true);
15613
+ },
15614
+ end: () => {
15615
+ if (this.#drag === drag) this.#drag = null;
15616
+ }
15617
+ });
15618
+ this.#drag = drag;
14260
15619
  }
14261
- /** Maps a pointer X coordinate to a value using the track's geometry. */
14262
- #updateFromClientX(clientX, mirrored) {
14263
- const rect = this.trackTarget.getBoundingClientRect();
14264
- if (rect.width === 0) return;
15620
+ /** Maps a pointer X coordinate to a raw value using a stable track snapshot. */
15621
+ #valueFromClientX(clientX, mirrored, track) {
15622
+ const rect = track.getBoundingClientRect();
15623
+ if (rect.width === 0) return null;
14265
15624
  const offset = (clientX - rect.left) / rect.width;
14266
15625
  const fraction = mirrored ? 1 - offset : offset;
14267
- this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
15626
+ return this.minValue + fraction * (this.maxValue - this.minValue);
14268
15627
  }
14269
15628
  /**
14270
- * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and
14271
- * reflects the new state on the thumb's ARIA attributes and the fraction
14272
- * custom property. Dispatches `change` (detail `{ value }`) on a real value
14273
- * change — symmetric with `range-slider` — unless `silent` (the initial
14274
- * connect render, which is not a user edit).
15629
+ * Stores a normalized value and renders synchronously for responsive input.
15630
+ * Morph callbacks use {@link #render} instead, so they never write Values back
15631
+ * or dispatch a user-facing change event.
14275
15632
  */
14276
- #setValue(raw, { silent = false } = {}) {
14277
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
14278
- const stepped = this.stepValue > 0 ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue : clamped;
14279
- const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));
14280
- const changed = value !== this.valueValue;
14281
- this.valueValue = value;
15633
+ #commit(raw, notify) {
15634
+ const previous = this.#currentValue();
15635
+ const value = snapSteppedValue(raw, this.#steppedRange);
15636
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
15637
+ this.#renderValue(value);
15638
+ if (notify && value !== previous) this.dispatch("change", { detail: { value } });
15639
+ }
15640
+ /**
15641
+ * Reflects the normalized current Value without mutating or dispatching it.
15642
+ *
15643
+ * @stimeoRenderRoot
15644
+ */
15645
+ #render() {
15646
+ this.#renderValue(this.#currentValue());
15647
+ }
15648
+ /** Reflects one normalized value on the current target and CSS output. */
15649
+ #renderValue(value) {
14282
15650
  if (this.hasThumbTarget) {
14283
- this.thumbTarget.setAttribute("aria-valuemin", String(this.minValue));
14284
- this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
14285
- this.thumbTarget.setAttribute("aria-valuenow", String(value));
15651
+ this.#renderThumb(this.thumbTarget, value);
15652
+ }
15653
+ this.#renderFraction(value);
15654
+ }
15655
+ /** Writes only ARIA values that differ, including on a replacement target. */
15656
+ #renderThumb(thumb, value) {
15657
+ const attributes = {
15658
+ "aria-valuemin": String(this.minValue),
15659
+ "aria-valuemax": String(this.maxValue),
15660
+ "aria-valuenow": String(value)
15661
+ };
15662
+ for (const [name, next] of Object.entries(attributes)) {
15663
+ if (thumb.getAttribute(name) !== next) thumb.setAttribute(name, next);
15664
+ }
15665
+ }
15666
+ /** Writes the behavior-only positioning hook only when its value changed. */
15667
+ #renderFraction(value) {
15668
+ const fraction = rangeFraction(value, this.minValue, this.maxValue);
15669
+ const next = String(fraction);
15670
+ if (this.element.style.getPropertyValue(FRACTION_PROPERTY) !== next) {
15671
+ this.element.style.setProperty(FRACTION_PROPERTY, next);
14286
15672
  }
14287
- const span = this.maxValue - this.minValue;
14288
- const fraction = span > 0 ? (value - this.minValue) / span : 0;
14289
- this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
14290
- if (changed && !silent) this.dispatch("change", { detail: { value } });
15673
+ }
15674
+ /** Current normalized value derived from the live declarative inputs. */
15675
+ #currentValue() {
15676
+ return snapSteppedValue(this.valueValue, this.#steppedRange);
15677
+ }
15678
+ /** Shared range configuration; finite endpoints remain allowed off the grid. */
15679
+ get #steppedRange() {
15680
+ return { min: this.minValue, max: this.maxValue, step: this.stepValue };
15681
+ }
15682
+ /** Ends the current pointer session without dispatching another change. */
15683
+ #endDrag() {
15684
+ const drag = this.#drag;
15685
+ this.#drag = null;
15686
+ drag?.pointer?.end();
14291
15687
  }
14292
15688
  };
14293
15689
  var SmartStickyHeaderController = class extends Controller {
@@ -14546,38 +15942,59 @@ var SortableController = class extends Controller {
14546
15942
  var SpinnerController = class extends Controller {
14547
15943
  static targets = ["indicator", "region", "message"];
14548
15944
  static values = {
15945
+ announceText: { type: String, default: "" },
15946
+ announceReadyText: { type: String, default: "" },
14549
15947
  delay: { type: Number, default: 0 },
14550
- minDuration: { type: Number, default: 0 }
15948
+ minDuration: { type: Number, default: 0 },
15949
+ timeout: { type: Number, default: 0 }
14551
15950
  };
14552
15951
  static actions = ["start", "stop"];
14553
- static events = ["hide", "show"];
15952
+ static events = ["hide", "show", "timeout"];
14554
15953
  #timers = new SafeTimeout();
15954
+ #floor = new MinDurationFloor(this.#timers);
15955
+ #gate = new DetachGate();
15956
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
14555
15957
  /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
14556
15958
  #delayTimerId = null;
14557
- /** Pending min-duration hide timer id, or `null` when none is scheduled. */
14558
- #hideTimerId = null;
14559
- /** Epoch ms when the spinner became visible; `minDuration` is measured from it. */
14560
- #shownAt = 0;
15959
+ /** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
15960
+ #timeoutTimerId = null;
14561
15961
  connect() {
14562
- if (!this.element.hasAttribute("data-state")) {
15962
+ this.#gate.cancel();
15963
+ this.#beforeCache.activate();
15964
+ if (this.#state === "pending" && this.#delayTimerId === null) {
15965
+ this.#setBusy(false);
14563
15966
  this.element.setAttribute("data-state", "idle");
15967
+ return;
14564
15968
  }
15969
+ setDefaultAttribute(this.element, "data-state", "idle");
15970
+ }
15971
+ /**
15972
+ * Re-applies the current phase to an indicator that arrived after `connect()`.
15973
+ *
15974
+ * A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
15975
+ * carries the markup contract's `hidden`. Without this the spinner would vanish
15976
+ * while `data-state` still says `loading`, and nothing but the next cycle would
15977
+ * bring it back.
15978
+ */
15979
+ indicatorTargetConnected(target) {
15980
+ target.hidden = this.#state !== "loading";
14565
15981
  }
14566
15982
  disconnect() {
14567
- this.#timers.clearAll();
14568
- this.#delayTimerId = null;
14569
- this.#hideTimerId = null;
15983
+ this.#beforeCache.deactivate();
15984
+ this.#gate.disconnected(this, () => this.#teardown());
14570
15985
  }
14571
15986
  /** Begins loading. Honors `delay` before the spinner actually appears. */
14572
15987
  start() {
14573
15988
  if (this.#state === "loading") {
14574
15989
  this.#setBusy(true);
14575
- this.#cancelHide();
15990
+ this.#floor.cancel();
15991
+ this.#armTimeout();
14576
15992
  return;
14577
15993
  }
14578
15994
  if (this.#state !== "idle") return;
14579
15995
  this.#setBusy(true);
14580
- this.#cancelHide();
15996
+ this.#floor.cancel();
15997
+ this.#armTimeout();
14581
15998
  if (this.delayValue > 0) {
14582
15999
  this.element.setAttribute("data-state", "pending");
14583
16000
  this.#delayTimerId = this.#timers.set(() => {
@@ -14591,6 +16008,7 @@ var SpinnerController = class extends Controller {
14591
16008
  /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
14592
16009
  stop() {
14593
16010
  const state = this.#state;
16011
+ this.#cancelTimeout();
14594
16012
  if (state === "pending") {
14595
16013
  this.#cancelDelay();
14596
16014
  this.#setBusy(false);
@@ -14599,28 +16017,52 @@ var SpinnerController = class extends Controller {
14599
16017
  }
14600
16018
  if (state !== "loading") return;
14601
16019
  this.#setBusy(false);
14602
- const remaining = this.minDurationValue - (Date.now() - this.#shownAt);
14603
- if (remaining > 0) {
14604
- this.#hideTimerId = this.#timers.set(() => {
14605
- this.#hideTimerId = null;
14606
- this.#hide();
14607
- }, remaining);
14608
- } else {
14609
- this.#hide();
14610
- }
16020
+ this.#floor.schedule(this.minDurationValue, () => this.#hide());
14611
16021
  }
14612
16022
  /** Reveals the indicator, marks the moment shown, and announces via the live region. */
14613
16023
  #show() {
14614
- this.#shownAt = Date.now();
16024
+ this.#floor.begin();
16025
+ this.#setBusy(true);
14615
16026
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
14616
16027
  this.element.setAttribute("data-state", "loading");
14617
16028
  this.dispatch("show", { detail: {} });
16029
+ announce(fillTemplate(this.announceTextValue, {}));
14618
16030
  }
14619
16031
  /** Hides the indicator and returns to the idle state. */
14620
16032
  #hide() {
14621
16033
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
14622
16034
  this.element.setAttribute("data-state", "idle");
14623
16035
  this.dispatch("hide", { detail: {} });
16036
+ announce(fillTemplate(this.announceReadyTextValue, {}));
16037
+ }
16038
+ /**
16039
+ * Drops both timers on a real detach. The markup keeps whatever it last held: an
16040
+ * element on its way out of the document has no reader left, and one whose
16041
+ * `data-controller` dropped the identifier no longer resolves its own targets, so
16042
+ * the rollback could only ever be partial. The snapshot is rewound where it is
16043
+ * still whole, on `turbo:before-cache`.
16044
+ */
16045
+ #teardown() {
16046
+ this.#gate.cancel();
16047
+ this.#timers.clearAll();
16048
+ this.#delayTimerId = null;
16049
+ this.#timeoutTimerId = null;
16050
+ this.#floor.cancel();
16051
+ }
16052
+ /**
16053
+ * Returns the loading state to idle for the snapshot Turbo is about to take,
16054
+ * so a page reached with the Back button is not restored mid-load with a
16055
+ * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
16056
+ * and `aria-busy`. No `hide` is dispatched — the load was never observed to
16057
+ * finish, and a snapshot rewind is not a lifecycle event the consumer can act
16058
+ * on. The live page keeps its timers, so a navigation that never completes
16059
+ * leaves the running cycle intact.
16060
+ */
16061
+ #rewindForCache() {
16062
+ this.#cancelTimeout();
16063
+ this.#setBusy(false);
16064
+ if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
16065
+ this.element.setAttribute("data-state", "idle");
14624
16066
  }
14625
16067
  /** Reflects busy state onto the controlled region (if present). */
14626
16068
  #setBusy(busy) {
@@ -14628,18 +16070,32 @@ var SpinnerController = class extends Controller {
14628
16070
  this.regionTarget.setAttribute("aria-busy", String(busy));
14629
16071
  }
14630
16072
  }
16073
+ /**
16074
+ * Arms the safety net so a `stop` that never arrives cannot strand the spinner.
16075
+ * Off by default: the consumer owns the async work, so only it knows whether a
16076
+ * ceiling makes sense. Re-arming on a restart measures from the newest start.
16077
+ */
16078
+ #armTimeout() {
16079
+ this.#cancelTimeout();
16080
+ if (this.timeoutValue <= 0) return;
16081
+ this.#timeoutTimerId = this.#timers.set(() => {
16082
+ this.#timeoutTimerId = null;
16083
+ this.dispatch("timeout", { detail: {} });
16084
+ this.stop();
16085
+ }, this.timeoutValue);
16086
+ }
16087
+ #cancelTimeout() {
16088
+ if (this.#timeoutTimerId !== null) {
16089
+ this.#timers.clear(this.#timeoutTimerId);
16090
+ this.#timeoutTimerId = null;
16091
+ }
16092
+ }
14631
16093
  #cancelDelay() {
14632
16094
  if (this.#delayTimerId !== null) {
14633
16095
  this.#timers.clear(this.#delayTimerId);
14634
16096
  this.#delayTimerId = null;
14635
16097
  }
14636
16098
  }
14637
- #cancelHide() {
14638
- if (this.#hideTimerId !== null) {
14639
- this.#timers.clear(this.#hideTimerId);
14640
- this.#hideTimerId = null;
14641
- }
14642
- }
14643
16099
  /** Current lifecycle phase as reflected on `data-state`. */
14644
16100
  get #state() {
14645
16101
  return this.element.getAttribute("data-state") ?? "idle";
@@ -14652,26 +16108,65 @@ var StepIndicatorController = class extends Controller {
14652
16108
  };
14653
16109
  static actions = ["setCurrent"];
14654
16110
  static events = ["change"];
16111
+ /**
16112
+ * Whether the target callbacks may render. Stimulus reports the authored steps
16113
+ * as connected before `connect()` and the remaining ones as disconnected after
16114
+ * `disconnect()`, so this keeps a connect at one render pass, not one per step.
16115
+ */
16116
+ /**
16117
+ * Collapses a batch of step callbacks — and a morph that swaps `current` with
16118
+ * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
16119
+ * each one would otherwise rewrite every step's state.
16120
+ */
16121
+ #repaint = new MicrotaskCoalescer(() => this.#render());
14655
16122
  /** Renders the initial state from the `current` value. */
14656
16123
  connect() {
16124
+ this.#repaint.activate();
14657
16125
  this.#render();
14658
16126
  }
16127
+ /** Closes the window in which a queued repaint may still run. */
16128
+ disconnect() {
16129
+ this.#repaint.cancel();
16130
+ }
16131
+ /** Syncs a step appended or replaced at runtime (the consumer owns the list). */
16132
+ stepTargetConnected() {
16133
+ this.#repaint.schedule();
16134
+ }
16135
+ /** Re-derives the remaining steps when one is removed at runtime. */
16136
+ stepTargetDisconnected() {
16137
+ this.#repaint.schedule();
16138
+ }
16139
+ /** Repaints when application code (or a Turbo morph) changes `current` at runtime. */
16140
+ currentValueChanged() {
16141
+ this.#repaint.schedule();
16142
+ }
14659
16143
  /**
14660
16144
  * Updates the current step from an external event (`detail.current`, 0-based)
14661
- * and dispatches `change`. Out-of-range indices are clamped to the step set.
16145
+ * and dispatches `change`. Out-of-range indices are clamped to the step set,
16146
+ * and both sides of the no-op test are clamped, so moving onto the step an
16147
+ * out-of-range `current` already renders is not reported as a change.
14662
16148
  */
14663
16149
  setCurrent(event) {
14664
16150
  const next = event.detail?.current;
14665
16151
  if (typeof next !== "number" || !Number.isFinite(next)) return;
14666
16152
  const clamped = this.#clamp(next);
14667
- if (clamped === this.currentValue) return;
16153
+ const moved = clamped !== this.#clamp(this.currentValue);
14668
16154
  this.currentValue = clamped;
16155
+ if (!moved) return;
14669
16156
  this.#render();
14670
16157
  this.dispatch("change", {
14671
16158
  detail: { current: clamped, total: this.stepTargets.length }
14672
16159
  });
14673
16160
  }
14674
- /** Applies `data-state`, `aria-current`, and the progress ratio custom property. */
16161
+ /**
16162
+ * Applies `data-state`, `aria-current`, and the progress ratio custom property.
16163
+ *
16164
+ * A pure function of the step set and `current`, so running it again writes the
16165
+ * same values — which is what lets the action path paint synchronously (the event
16166
+ * goes out after the DOM is updated) while a coalesced pass may still follow.
16167
+ *
16168
+ * @stimeoRenderRoot
16169
+ */
14675
16170
  #render() {
14676
16171
  const total = this.stepTargets.length;
14677
16172
  const current = this.#clamp(this.currentValue);
@@ -14684,12 +16179,17 @@ var StepIndicatorController = class extends Controller {
14684
16179
  }
14685
16180
  });
14686
16181
  const ratio = total > 1 ? current / (total - 1) : 0;
14687
- this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
16182
+ this.element.style.setProperty("--stimeo--step-indicator-ratio", String(ratio));
14688
16183
  }
14689
- /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
16184
+ /**
16185
+ * Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
16186
+ * non-finite index falls back to the first step: `current` is read from markup,
16187
+ * so an unparsable attribute arrives as `NaN` and would otherwise propagate
16188
+ * into every state hook.
16189
+ */
14690
16190
  #clamp(index) {
14691
16191
  const last = this.stepTargets.length - 1;
14692
- if (last < 0) return 0;
16192
+ if (last < 0 || !Number.isFinite(index)) return 0;
14693
16193
  return Math.min(last, Math.max(0, Math.trunc(index)));
14694
16194
  }
14695
16195
  };
@@ -14760,6 +16260,8 @@ var StepperController = class extends Controller {
14760
16260
  * `aria-current="step"` is placed on the step's **first** `<button>`; the markup
14761
16261
  * contract assumes one operable button per step. If a step needs multiple
14762
16262
  * buttons, mark the navigational one first (or this would target the wrong one).
16263
+ *
16264
+ * @stimeoRenderRoot
14763
16265
  */
14764
16266
  #render(current) {
14765
16267
  this.stepTargets.forEach((step, index) => {
@@ -14789,14 +16291,18 @@ var StickToBottomController = class extends Controller {
14789
16291
  static targets = ["content"];
14790
16292
  static values = {
14791
16293
  threshold: { type: Number, default: 80 },
14792
- behavior: { type: String, default: "auto" }
16294
+ behavior: { type: String, default: "auto" },
16295
+ pinOnConnect: { type: Boolean, default: false }
14793
16296
  };
14794
16297
  static actions = ["scrollToBottom"];
14795
16298
  static events = ["pin", "new"];
14796
16299
  #observer = null;
16300
+ /** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
16301
+ #layout = null;
14797
16302
  #pinned = false;
14798
16303
  #onScroll = () => this.#updatePinned();
14799
16304
  connect() {
16305
+ if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
14800
16306
  this.#pinned = this.#isPinned();
14801
16307
  this.#reflectPinned();
14802
16308
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
@@ -14804,21 +16310,29 @@ var StickToBottomController = class extends Controller {
14804
16310
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
14805
16311
  this.#observer.observe(this.#watched(), { childList: true });
14806
16312
  }
16313
+ if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
14807
16314
  }
14808
16315
  disconnect() {
14809
16316
  this.element.removeEventListener("scroll", this.#onScroll);
14810
16317
  this.#observer?.disconnect();
14811
16318
  this.#observer = null;
16319
+ this.#stopWaitingForLayout();
14812
16320
  }
14813
- /** Jumps to the bottom and re-pins (wired to a "new messages" button). */
16321
+ /**
16322
+ * Jumps to the bottom and re-pins (wired to a "new messages" button).
16323
+ *
16324
+ * The has-new flag clears on request — the user has acknowledged the arrival — while
16325
+ * pinned is read back from where the scroll landed: a jump that arrives by the time
16326
+ * this returns pins immediately, an animated one settles from its own scroll events,
16327
+ * and a jump the engine cannot honor leaves the container unpinned, so the next append
16328
+ * flags it again instead of being swallowed by a pinned state that does not hold.
16329
+ *
16330
+ * Which of those happens is not this method's to decide — see {@link behaviorValue}.
16331
+ */
14814
16332
  scrollToBottom() {
14815
16333
  this.#scrollToBottom();
14816
16334
  this.element.removeAttribute("data-has-new");
14817
- if (!this.#pinned) {
14818
- this.#pinned = true;
14819
- this.element.setAttribute("data-pinned", "true");
14820
- this.dispatch("pin", { detail: { pinned: true } });
14821
- }
16335
+ this.#updatePinned();
14822
16336
  }
14823
16337
  /** Follows appended children while pinned; otherwise flags new content. */
14824
16338
  #onMutations(mutations) {
@@ -14853,10 +16367,43 @@ var StickToBottomController = class extends Controller {
14853
16367
  const el = this.element;
14854
16368
  return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
14855
16369
  }
14856
- #scrollToBottom() {
16370
+ /**
16371
+ * Whether the container has a box to scroll and to measure. One that is not rendered
16372
+ * (inside a closed panel) reports every metric as 0, which reads as "already at the
16373
+ * bottom" — a position describing no layout the user will ever see.
16374
+ */
16375
+ #measurable() {
16376
+ return this.element.clientHeight > 0;
16377
+ }
16378
+ /**
16379
+ * Holds the `pinOnConnect` jump until the container is laid out, then runs it and
16380
+ * re-reads the state — otherwise the panel opens at the top still claiming the bottom.
16381
+ */
16382
+ #pinWhenLaidOut() {
16383
+ if (typeof ResizeObserver === "undefined") return;
16384
+ this.#layout = new ResizeObserver(() => {
16385
+ if (!this.#measurable()) return;
16386
+ this.#stopWaitingForLayout();
16387
+ this.#scrollToBottom("instant");
16388
+ this.#updatePinned();
16389
+ });
16390
+ this.#layout.observe(this.element);
16391
+ }
16392
+ /** Releases the layout watch, whether or not the deferred jump ever ran. */
16393
+ #stopWaitingForLayout() {
16394
+ this.#layout?.disconnect();
16395
+ this.#layout = null;
16396
+ }
16397
+ /**
16398
+ * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
16399
+ * 0 for a container tall enough to hold its whole content, so the jump moves nothing
16400
+ * there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
16401
+ * jump that must not animate.
16402
+ */
16403
+ #scrollToBottom(behavior = this.#behavior()) {
14857
16404
  const top = this.element.scrollHeight;
14858
16405
  if (typeof this.element.scrollTo === "function") {
14859
- this.element.scrollTo({ top, behavior: this.#behavior() });
16406
+ this.element.scrollTo({ top, behavior });
14860
16407
  } else {
14861
16408
  this.element.scrollTop = top;
14862
16409
  }
@@ -14865,7 +16412,12 @@ var StickToBottomController = class extends Controller {
14865
16412
  #watched() {
14866
16413
  return this.hasContentTarget ? this.contentTarget : this.element;
14867
16414
  }
14868
- /** Forces reduced-motion jumps while preserving the configured normal behavior. */
16415
+ /**
16416
+ * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
16417
+ * once: it hands the decision to the element's computed `scroll-behavior`, so a
16418
+ * consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
16419
+ * overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
16420
+ */
14869
16421
  #behavior() {
14870
16422
  if (prefersReducedMotion()) return "instant";
14871
16423
  return this.behaviorValue === "smooth" ? "smooth" : "auto";
@@ -15114,39 +16666,163 @@ var SubmitOnceController = class extends Controller {
15114
16666
  }
15115
16667
  }
15116
16668
  };
16669
+
16670
+ // src/utils/interactive_host.ts
16671
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
16672
+ function isInteractiveHost(element) {
16673
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
16674
+ let current = element;
16675
+ while (current) {
16676
+ const raw = current.getAttribute("contenteditable");
16677
+ if (raw !== null) {
16678
+ const value = raw.trim().toLowerCase();
16679
+ if (value === "false") return false;
16680
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
16681
+ }
16682
+ current = current.parentElement;
16683
+ }
16684
+ return false;
16685
+ }
16686
+
16687
+ // src/controllers/switch_controller.ts
16688
+ var OBSERVED_ATTRIBUTES = [
16689
+ "role",
16690
+ "aria-checked",
16691
+ "tabindex",
16692
+ "type",
16693
+ "href",
16694
+ "contenteditable",
16695
+ "controls"
16696
+ ];
16697
+ var OBSERVED_ANCESTOR_ATTRIBUTES = ["contenteditable"];
15117
16698
  var SwitchController = class extends Controller {
15118
16699
  static actions = ["onKeydown", "toggle"];
15119
16700
  static events = ["changed"];
16701
+ /** Defaults this instance introduced and may therefore remove safely. */
16702
+ #ownedDefaults = /* @__PURE__ */ new Set();
16703
+ /** Controller writes that must not be mistaken for authored morph changes. */
16704
+ #internalAttributeValues = /* @__PURE__ */ new Map();
16705
+ #attributeObserver = new MutationObserver((records) => {
16706
+ this.#releaseAuthoredDefaults(records);
16707
+ this.#attributeObserver.disconnect();
16708
+ this.#reconcileDefaults();
16709
+ this.#observeAttributes();
16710
+ });
16711
+ /** Blocks disabled pointer/native-key activation before consumer click handlers. */
16712
+ #onClickCapture = (event) => {
16713
+ if (!this.#isSupportedHost || !this.#isActivationDisabled) return;
16714
+ event.preventDefault();
16715
+ event.stopImmediatePropagation();
16716
+ };
15120
16717
  /** Ensures the switch exposes a role and is keyboard-reachable. */
15121
16718
  connect() {
15122
- if (!this.element.hasAttribute("role")) {
15123
- this.element.setAttribute("role", "switch");
15124
- }
15125
- if (!this.element.hasAttribute("aria-checked")) {
15126
- this.element.setAttribute("aria-checked", "false");
15127
- }
15128
- if (!(this.element instanceof HTMLButtonElement) && !this.element.hasAttribute("tabindex")) {
15129
- this.element.setAttribute("tabindex", "0");
15130
- }
16719
+ this.#reconcileDefaults();
16720
+ this.element.addEventListener("click", this.#onClickCapture, true);
16721
+ this.#observeAttributes();
16722
+ }
16723
+ /** Releases the explicit click guard and retained-element attribute observer. */
16724
+ disconnect() {
16725
+ this.element.removeEventListener("click", this.#onClickCapture, true);
16726
+ this.#attributeObserver.disconnect();
16727
+ this.#internalAttributeValues.clear();
15131
16728
  }
15132
16729
  /** Toggles the checked state. Bound via `data-action` (click). */
15133
16730
  toggle() {
16731
+ if (!this.#isSupportedHost || this.#isActivationDisabled) return;
15134
16732
  this.#checked = !this.#checked;
15135
16733
  }
15136
16734
  /**
15137
- * Activates the switch on Space/Enter for non-native hosts and prevents the
15138
- * default Space scroll. Bound via `data-action` (keydown). Native `<button>`
15139
- * hosts are skipped because the browser already turns Space/Enter into a click,
15140
- * which would otherwise toggle the switch twice.
16735
+ * Activates the switch on Space/Enter for non-native hosts and suppresses key
16736
+ * repeat. Bound via `data-action` (keydown). A native `<button type="button">`
16737
+ * owns the initial key-to-click synthesis; repeated keydowns are canceled before
16738
+ * the browser can synthesize additional clicks.
15141
16739
  */
15142
16740
  onKeydown(event) {
15143
16741
  if (event.defaultPrevented) return;
15144
- if (this.element instanceof HTMLButtonElement) return;
15145
- if (event.repeat) return;
15146
- if (event.key === " " || event.key === "Enter") {
16742
+ if (!this.#isSupportedHost) return;
16743
+ if (event.key !== " " && event.key !== "Enter") return;
16744
+ if (this.#isActivationDisabled) {
15147
16745
  event.preventDefault();
15148
- this.toggle();
16746
+ event.stopImmediatePropagation();
16747
+ return;
16748
+ }
16749
+ if (this.element instanceof HTMLButtonElement) {
16750
+ if (event.repeat) event.preventDefault();
16751
+ return;
16752
+ }
16753
+ event.preventDefault();
16754
+ if (event.repeat) return;
16755
+ this.toggle();
16756
+ }
16757
+ /** Re-adds only missing defaults, preserving every authored attribute value. */
16758
+ #reconcileDefaults() {
16759
+ if (!this.#isSupportedHost) {
16760
+ this.#removeOwnedDefaults();
16761
+ return;
16762
+ }
16763
+ this.#setOwnedDefault("role", "switch");
16764
+ this.#setOwnedDefault("aria-checked", "false");
16765
+ if (!(this.element instanceof HTMLButtonElement)) {
16766
+ this.#setOwnedDefault("tabindex", "0");
16767
+ }
16768
+ }
16769
+ /** Records a newly introduced default without claiming authored markup. */
16770
+ #setOwnedDefault(name, value) {
16771
+ if (setDefaultAttribute(this.element, name, value)) this.#ownedDefaults.add(name);
16772
+ }
16773
+ /**
16774
+ * Gives ownership back when a retained-element morph authors a value. Missing
16775
+ * owned attributes stay owned so reconciliation can restore them.
16776
+ */
16777
+ #releaseAuthoredDefaults(records) {
16778
+ for (const record of records) {
16779
+ if (record.target === this.element) {
16780
+ const name = record.attributeName;
16781
+ if (name && this.#ownedDefaults.has(name)) {
16782
+ const value = this.element.getAttribute(name);
16783
+ if (value !== null && this.#internalAttributeValues.get(name) !== value) {
16784
+ this.#ownedDefaults.delete(name);
16785
+ }
16786
+ }
16787
+ }
16788
+ }
16789
+ this.#internalAttributeValues.clear();
16790
+ }
16791
+ /** Removes only defaults introduced by this instance when a host becomes invalid. */
16792
+ #removeOwnedDefaults() {
16793
+ for (const name of this.#ownedDefaults) this.element.removeAttribute(name);
16794
+ this.#ownedDefaults.clear();
16795
+ this.#internalAttributeValues.clear();
16796
+ }
16797
+ /** Watches retained host attributes that Turbo can morph without reconnecting. */
16798
+ #observeAttributes() {
16799
+ this.#attributeObserver.observe(this.element, {
16800
+ attributes: true,
16801
+ attributeFilter: OBSERVED_ATTRIBUTES
16802
+ });
16803
+ let ancestor = this.element.parentElement;
16804
+ while (ancestor) {
16805
+ this.#attributeObserver.observe(ancestor, {
16806
+ attributes: true,
16807
+ attributeFilter: OBSERVED_ANCESTOR_ATTRIBUTES
16808
+ });
16809
+ ancestor = ancestor.parentElement;
16810
+ }
16811
+ }
16812
+ /** Whether the host has one activation model that this controller can own. */
16813
+ get #isSupportedHost() {
16814
+ if (this.element instanceof HTMLButtonElement) return this.element.type === "button";
16815
+ return !isInteractiveHost(this.element);
16816
+ }
16817
+ /** Whether ARIA or native HTML semantics make the supported switch inoperable. */
16818
+ get #isActivationDisabled() {
16819
+ let current = this.element;
16820
+ while (current) {
16821
+ if (current.getAttribute("aria-disabled") === "true") return true;
16822
+ current = current.parentElement;
15149
16823
  }
16824
+ if (!(this.element instanceof HTMLButtonElement)) return false;
16825
+ return this.element.disabled || inheritsFieldsetDisabled(this.element);
15150
16826
  }
15151
16827
  /** Whether the switch is currently on. */
15152
16828
  get #checked() {
@@ -15154,7 +16830,9 @@ var SwitchController = class extends Controller {
15154
16830
  }
15155
16831
  /** Reflects the new state on `aria-checked` and notifies listeners. */
15156
16832
  set #checked(value) {
15157
- this.element.setAttribute("aria-checked", value ? "true" : "false");
16833
+ const reflected = value ? "true" : "false";
16834
+ this.#internalAttributeValues.set("aria-checked", reflected);
16835
+ this.element.setAttribute("aria-checked", reflected);
15158
16836
  this.dispatch("changed", { detail: { checked: value } });
15159
16837
  }
15160
16838
  };
@@ -15465,7 +17143,7 @@ var TextareaAutosizeController = class extends Controller {
15465
17143
  } else {
15466
17144
  el.removeAttribute("data-at-max-rows");
15467
17145
  }
15468
- el.style.setProperty("--stimeo-textarea-rows", String(rows));
17146
+ el.style.setProperty("--stimeo--textarea-rows", String(rows));
15469
17147
  if (height !== this.#lastHeight) {
15470
17148
  this.#lastHeight = height;
15471
17149
  this.dispatch("resize", { detail: { height, rows } });
@@ -15722,7 +17400,6 @@ var TimePickerController = class extends Controller {
15722
17400
  }
15723
17401
  /** Propagates a wrap carry from `kind` into the next larger segment. */
15724
17402
  #carry(kind, amount) {
15725
- if (amount === 0) return;
15726
17403
  if (kind === "second") this.#step("minute", amount);
15727
17404
  else if (kind === "minute") this.#step("hour", amount);
15728
17405
  }
@@ -15784,15 +17461,19 @@ var TimePickerController = class extends Controller {
15784
17461
  segment.setAttribute("aria-valuemax", String(max));
15785
17462
  segment.textContent = text;
15786
17463
  }
15787
- /** Composes `HH:MM[:SS]` (24-hour) into the hidden field; dispatches `change`. */
17464
+ /** Composes `HH:MM[:SS]` and notifies both native-form and widget consumers. */
15788
17465
  #syncField(notify) {
15789
17466
  const h24 = this.#hours24();
15790
17467
  const parts = [pad(h24), pad(this.#state.minute)];
15791
17468
  if (this.secondsValue) parts.push(pad(this.#state.second));
15792
17469
  const value = parts.join(":");
15793
- if (this.hasFieldTarget && this.fieldTarget.value !== value) {
17470
+ const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;
17471
+ if (fieldChanged) {
15794
17472
  this.fieldTarget.value = value;
15795
17473
  }
17474
+ if (notify && fieldChanged) {
17475
+ this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
17476
+ }
15796
17477
  if (notify && value !== this.#lastValue) this.dispatch("change", { detail: { value } });
15797
17478
  this.#lastValue = value;
15798
17479
  }
@@ -16686,7 +18367,7 @@ var TransitionController = class extends Controller {
16686
18367
  else window.clearTimeout(id);
16687
18368
  }
16688
18369
  };
16689
- var NESTED_INTERACTIVE = 'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"])';
18370
+ var NESTED_INTERACTIVE = INTERACTIVE_HOST_SELECTOR;
16690
18371
  var TreeViewController = class extends Controller {
16691
18372
  static targets = ["item", "group"];
16692
18373
  static actions = ["onClick", "onKeydown", "toggle"];
@@ -16947,6 +18628,7 @@ var TreeViewController = class extends Controller {
16947
18628
  if (target.closest('[role="treeitem"]') !== item) return null;
16948
18629
  const control = target.closest(NESTED_INTERACTIVE);
16949
18630
  if (control && item.contains(control)) return null;
18631
+ if (isInteractiveHost(target)) return null;
16950
18632
  return item;
16951
18633
  }
16952
18634
  /**