stimeo-ui 0.3.0 → 0.4.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/dist/controllers/alert_dialog_controller.js +32 -5
  4. data/dist/controllers/announcer_controller.js +255 -20
  5. data/dist/controllers/color_picker_controller.js +46 -0
  6. data/dist/controllers/command_palette_controller.js +32 -5
  7. data/dist/controllers/confirm_controller.js +32 -5
  8. data/dist/controllers/countdown_controller.js +112 -12
  9. data/dist/controllers/date_range_picker_controller.js +50 -0
  10. data/dist/controllers/dialog_controller.js +32 -5
  11. data/dist/controllers/drawer_controller.js +32 -5
  12. data/dist/controllers/empty_state_controller.js +24 -10
  13. data/dist/controllers/focus_controller.js +32 -5
  14. data/dist/controllers/frame_loading_controller.js +177 -15
  15. data/dist/controllers/local_time_controller.js +100 -6
  16. data/dist/controllers/meter_controller.js +145 -26
  17. data/dist/controllers/network_status_controller.js +28 -8
  18. data/dist/controllers/overflow_menu_controller.js +33 -6
  19. data/dist/controllers/progress_controller.js +116 -9
  20. data/dist/controllers/range_slider_controller.js +68 -3
  21. data/dist/controllers/rating_controller.js +53 -0
  22. data/dist/controllers/relative_time_controller.js +133 -12
  23. data/dist/controllers/sidebar_controller.js +37 -8
  24. data/dist/controllers/skeleton_controller.js +73 -20
  25. data/dist/controllers/slider_controller.js +17 -2
  26. data/dist/controllers/spinner_controller.js +228 -27
  27. data/dist/controllers/step_indicator_controller.js +82 -5
  28. data/dist/controllers/stick_to_bottom_controller.js +60 -10
  29. data/dist/index.js +1056 -281
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
data/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);
787
+ }
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);
579
792
  }
580
- /** Resolves the live region for a politeness level, generating it if absent. */
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) {
@@ -2440,16 +2673,29 @@ var ColorPickerController = class extends Controller {
2440
2673
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
2441
2674
  #dragAbort = null;
2442
2675
  /** Seeds the model from the initial hex value and renders every surface. */
2676
+ /**
2677
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
2678
+ * pass Stimulus delivers before `connect()`.
2679
+ */
2680
+ #repaint = new MicrotaskCoalescer(() => {
2681
+ this.#render();
2682
+ });
2443
2683
  connect() {
2684
+ this.#repaint.activate();
2444
2685
  const parsed = hexToHsla(this.valueValue);
2445
2686
  if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
2446
2687
  this.#render();
2447
2688
  }
2448
2689
  /** Cancels any active pointer drag so document listeners never leak. */
2449
2690
  disconnect() {
2691
+ this.#repaint.cancel();
2450
2692
  this.#dragAbort?.abort();
2451
2693
  this.#dragAbort = null;
2452
2694
  }
2695
+ /** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
2696
+ alphaValueChanged() {
2697
+ this.#repaint.schedule();
2698
+ }
2453
2699
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
2454
2700
  onKeydown(event) {
2455
2701
  if (isReservedArrowChord(event)) return;
@@ -2648,39 +2894,6 @@ function syncActiveOption(options, active) {
2648
2894
  return written;
2649
2895
  }
2650
2896
 
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
2897
  // src/utils/option_scroll.ts
2685
2898
  function scrollOptionIntoView(list, option) {
2686
2899
  if (list.scrollHeight <= list.clientHeight) return;
@@ -3846,6 +4059,26 @@ var CountUpController = class extends Controller {
3846
4059
  this.element.removeAttribute("data-count-up-label");
3847
4060
  }
3848
4061
  };
4062
+
4063
+ // src/utils/announce.ts
4064
+ function announce(message, options = {}) {
4065
+ const text = message.trim();
4066
+ if (text.length === 0) return;
4067
+ window.dispatchEvent(
4068
+ new CustomEvent("stimeo--announcer:announce", {
4069
+ detail: { message: text, assertive: options.assertive === true }
4070
+ })
4071
+ );
4072
+ }
4073
+ function fillTemplate(template, values) {
4074
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
4075
+ const replacement = values[name];
4076
+ return replacement === void 0 ? match : String(replacement);
4077
+ });
4078
+ }
4079
+
4080
+ // src/controllers/countdown_controller.ts
4081
+ var SECOND_MS = 1e3;
3849
4082
  var CountdownController = class extends Controller {
3850
4083
  static targets = ["days", "hours", "minutes", "seconds", "status"];
3851
4084
  static values = {
@@ -3853,34 +4086,79 @@ var CountdownController = class extends Controller {
3853
4086
  interval: { type: Number, default: 1e3 },
3854
4087
  direction: { type: String, default: "down" },
3855
4088
  autostart: { type: Boolean, default: true },
3856
- completeLabel: { type: String, default: "" }
4089
+ completeLabel: { type: String, default: "" },
4090
+ announceText: { type: String, default: "" }
3857
4091
  };
3858
4092
  static actions = ["pause", "reset", "resume", "start"];
3859
4093
  static events = ["complete", "tick"];
3860
4094
  #intervals = new SafeInterval();
3861
4095
  #intervalId = null;
4096
+ /** Collapses a morph that swaps several render inputs at once into one re-derive. */
4097
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
3862
4098
  /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
3863
4099
  #reference = 0;
3864
4100
  /** Amount (ms) captured at pause, so resume can restore the same display. */
3865
4101
  #pausedAmount = 0;
4102
+ /**
4103
+ * The amount the slots are currently showing, floored to the second they render.
4104
+ * It lags {@link currentAmount} by up to one tick, and it — not the live reading —
4105
+ * is what a pause has to preserve: storing the fraction behind the display instead
4106
+ * makes the first tick after a resume step by two units.
4107
+ */
4108
+ #renderedAmount = 0;
3866
4109
  connect() {
4110
+ this.#resync.activate();
3867
4111
  this.#initReference();
3868
- this.#render(this.#currentAmount());
3869
- if (this.autostartValue && this.#isValidDeadline) {
3870
- this.start();
3871
- } else {
4112
+ const amount = this.#currentAmount();
4113
+ this.#render(amount);
4114
+ this.#pausedAmount = this.#renderedAmount;
4115
+ const authored = this.element.getAttribute("data-state");
4116
+ if (authored === null) {
4117
+ if (this.autostartValue && this.#isValidDeadline) {
4118
+ this.start();
4119
+ return;
4120
+ }
3872
4121
  this.element.setAttribute("data-state", "paused");
4122
+ return;
3873
4123
  }
4124
+ if (authored === "complete" && this.#isDown && amount <= 0) {
4125
+ return;
4126
+ }
4127
+ const wasRunning = authored === "running";
4128
+ this.element.setAttribute("data-state", "paused");
4129
+ if (wasRunning) this.start();
3874
4130
  }
3875
4131
  disconnect() {
4132
+ this.#resync.cancel();
3876
4133
  this.#intervals.clearAll();
3877
4134
  this.#intervalId = null;
3878
4135
  }
4136
+ /** Re-derives the display when a morph swaps the deadline in place. */
4137
+ deadlineValueChanged() {
4138
+ this.#resync.schedule();
4139
+ }
4140
+ /** Re-derives the display when a morph flips the counting direction in place. */
4141
+ directionValueChanged() {
4142
+ this.#resync.schedule();
4143
+ }
4144
+ /**
4145
+ * Points the anchor at the current `deadline` / `direction` and repaints.
4146
+ *
4147
+ * Render only: it starts no interval and emits no event, so a morph cannot make a
4148
+ * paused timer run or replay a milestone. A running one needs no restart either —
4149
+ * every tick reads the anchor, so moving it is enough. While paused the stored
4150
+ * amount follows the new reading, or resume would continue from the old deadline.
4151
+ */
4152
+ #resyncToValues() {
4153
+ this.#initReference();
4154
+ this.#render(this.#currentAmount());
4155
+ if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
4156
+ }
3879
4157
  /** Starts (or restarts after pause) ticking toward the deadline. */
3880
4158
  start() {
3881
4159
  if (this.#state === "running" || !this.#isValidDeadline) return;
3882
4160
  if (this.#isDown && this.#currentAmount() <= 0) {
3883
- this.#complete();
4161
+ if (this.#state !== "complete") this.#complete();
3884
4162
  return;
3885
4163
  }
3886
4164
  this.#runInterval();
@@ -3888,7 +4166,7 @@ var CountdownController = class extends Controller {
3888
4166
  /** Pauses ticking, preserving the currently displayed amount. */
3889
4167
  pause() {
3890
4168
  if (this.#state !== "running") return;
3891
- this.#pausedAmount = this.#currentAmount();
4169
+ this.#pausedAmount = this.#renderedAmount;
3892
4170
  this.#teardownInterval();
3893
4171
  this.element.setAttribute("data-state", "paused");
3894
4172
  }
@@ -3904,8 +4182,8 @@ var CountdownController = class extends Controller {
3904
4182
  * run state**: a running timer keeps counting down from the reset amount, while a
3905
4183
  * paused (or completed) one resets the displayed amount but stays paused until the
3906
4184
  * 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 —
4185
+ * not re-derived from the declarative `autostart` Value (which governs only markup
4186
+ * that states no run state at all); re-deriving it would override a user's pause —
3909
4187
  * the DOM, not a re-run of declarative config, is the source of truth.
3910
4188
  */
3911
4189
  reset() {
@@ -3914,13 +4192,15 @@ var CountdownController = class extends Controller {
3914
4192
  this.#initReference();
3915
4193
  const amount = this.#currentAmount();
3916
4194
  this.#render(amount);
3917
- if (this.hasStatusTarget) this.statusTarget.textContent = "";
4195
+ if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
4196
+ this.statusTarget.textContent = "";
4197
+ }
3918
4198
  this.element.setAttribute("data-state", "paused");
3919
4199
  if (wasRunning && this.#isValidDeadline) {
3920
4200
  this.#pausedAmount = 0;
3921
4201
  this.start();
3922
4202
  } else {
3923
- this.#pausedAmount = amount;
4203
+ this.#pausedAmount = this.#renderedAmount;
3924
4204
  }
3925
4205
  }
3926
4206
  /** Schedules the repeating tick and marks the timer running. */
@@ -3955,6 +4235,7 @@ var CountdownController = class extends Controller {
3955
4235
  this.statusTarget.textContent = this.completeLabelValue;
3956
4236
  }
3957
4237
  this.dispatch("complete", { detail: {} });
4238
+ announce(fillTemplate(this.announceTextValue, {}));
3958
4239
  }
3959
4240
  /** Sets the time anchor from the `deadline` value. */
3960
4241
  #initReference() {
@@ -3969,7 +4250,8 @@ var CountdownController = class extends Controller {
3969
4250
  }
3970
4251
  /** Writes the amount into the day/hour/minute/second slots. */
3971
4252
  #render(amount) {
3972
- const totalSeconds = Math.floor(amount / 1e3);
4253
+ const totalSeconds = Math.floor(amount / SECOND_MS);
4254
+ this.#renderedAmount = totalSeconds * SECOND_MS;
3973
4255
  const days = Math.floor(totalSeconds / 86400);
3974
4256
  const hours = Math.floor(totalSeconds % 86400 / 3600);
3975
4257
  const minutes = Math.floor(totalSeconds % 3600 / 60);
@@ -4378,7 +4660,15 @@ var DateRangePickerController = class extends Controller {
4378
4660
  /** Deferred focus after an async month transition (cancelled on teardown). */
4379
4661
  #focusTimer = new SafeTimeout();
4380
4662
  /** Seeds the range from any pre-filled hidden fields and renders the grid. */
4663
+ /**
4664
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
4665
+ * pass Stimulus delivers before `connect()`.
4666
+ */
4667
+ #repaint = new MicrotaskCoalescer(() => {
4668
+ this.#render();
4669
+ });
4381
4670
  connect() {
4671
+ this.#repaint.activate();
4382
4672
  this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
4383
4673
  this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
4384
4674
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
@@ -4389,8 +4679,17 @@ var DateRangePickerController = class extends Controller {
4389
4679
  }
4390
4680
  /** Cancels any pending deferred focus so it never fires on a detached element. */
4391
4681
  disconnect() {
4682
+ this.#repaint.cancel();
4392
4683
  this.#focusTimer.clearAll();
4393
4684
  }
4685
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
4686
+ minValueChanged() {
4687
+ this.#repaint.schedule();
4688
+ }
4689
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
4690
+ maxValueChanged() {
4691
+ this.#repaint.schedule();
4692
+ }
4394
4693
  /** Navigates to the previous month. */
4395
4694
  prev(event) {
4396
4695
  event?.preventDefault();
@@ -5483,7 +5782,8 @@ var EmptyStateController = class extends Controller {
5483
5782
  static targets = ["list", "empty"];
5484
5783
  static values = {
5485
5784
  itemSelector: { type: String, default: "" },
5486
- announce: { type: Boolean, default: false }
5785
+ announceText: { type: String, default: "" },
5786
+ announceFilledText: { type: String, default: "" }
5487
5787
  };
5488
5788
  static events = ["change"];
5489
5789
  #observer = null;
@@ -5491,10 +5791,6 @@ var EmptyStateController = class extends Controller {
5491
5791
  #empty = null;
5492
5792
  connect() {
5493
5793
  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
5794
  if (typeof MutationObserver !== "undefined") {
5499
5795
  this.#observer = new MutationObserver(() => this.#apply());
5500
5796
  this.#observer.observe(this.listTarget, { childList: true });
@@ -5520,6 +5816,9 @@ var EmptyStateController = class extends Controller {
5520
5816
  if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
5521
5817
  if (this.#empty !== null && empty !== this.#empty) {
5522
5818
  this.dispatch("change", { detail: { count, empty } });
5819
+ announce(
5820
+ fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
5821
+ );
5523
5822
  }
5524
5823
  this.#empty = empty;
5525
5824
  }
@@ -5533,11 +5832,6 @@ var EmptyStateController = class extends Controller {
5533
5832
  return this.listTarget.childElementCount;
5534
5833
  }
5535
5834
  }
5536
- #isLiveRegion(el) {
5537
- if (el.hasAttribute("aria-live")) return true;
5538
- const role = el.getAttribute("role");
5539
- return role === "status" || role === "alert";
5540
- }
5541
5835
  };
5542
5836
  var FileDropzoneController = class extends Controller {
5543
5837
  static targets = ["zone", "trigger", "input", "list", "item", "itemTemplate", "status"];
@@ -6362,35 +6656,130 @@ var FormValidationController = class _FormValidationController extends Controlle
6362
6656
  element.willValidate;
6363
6657
  }
6364
6658
  };
6659
+
6660
+ // src/utils/detach_gate.ts
6661
+ var DetachGate = class _DetachGate {
6662
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
6663
+ #pending = false;
6664
+ /**
6665
+ * True when the disconnect is definitely a real detach — the element left
6666
+ * the document, or `data-controller` no longer lists the identifier. False
6667
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
6668
+ */
6669
+ static isDetached(host) {
6670
+ if (!host.element.isConnected) return true;
6671
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
6672
+ return !tokens.includes(host.identifier);
6673
+ }
6674
+ /**
6675
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
6676
+ * detach (fast path), otherwise defers it one microtask — a reconnect
6677
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
6678
+ * One microtask is the whole probe window: Stimulus reconnects a moved
6679
+ * element within the same mutation batch, before the checkpoint drains.
6680
+ */
6681
+ disconnected(host, teardown) {
6682
+ if (_DetachGate.isDetached(host)) {
6683
+ this.#pending = false;
6684
+ teardown();
6685
+ return;
6686
+ }
6687
+ this.#pending = true;
6688
+ queueMicrotask(() => {
6689
+ if (!this.#pending) return;
6690
+ this.#pending = false;
6691
+ teardown();
6692
+ });
6693
+ }
6694
+ /**
6695
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
6696
+ * an in-page move) and from the head of any teardown path not routed through
6697
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
6698
+ * never run the teardown a second time.
6699
+ */
6700
+ cancel() {
6701
+ this.#pending = false;
6702
+ }
6703
+ };
6704
+
6705
+ // src/utils/min_duration_floor.ts
6706
+ var MinDurationFloor = class {
6707
+ #timers;
6708
+ /** Pending finish timer id, or `null` when nothing is held back. */
6709
+ #timerId = null;
6710
+ /** Epoch ms the floor is measured from. */
6711
+ #since = 0;
6712
+ /** @param timers - the controller's registry; the floor schedules into it. */
6713
+ constructor(timers) {
6714
+ this.#timers = timers;
6715
+ }
6716
+ /** Starts the floor: call when the state being held becomes visible. */
6717
+ begin() {
6718
+ this.#since = Date.now();
6719
+ }
6720
+ /** True while a finish is held back waiting for the floor to elapse. */
6721
+ get pending() {
6722
+ return this.#timerId !== null;
6723
+ }
6724
+ /**
6725
+ * Runs `finish` once the floor has elapsed, immediately when it already has.
6726
+ *
6727
+ * A held-back finish is **replaced**, never stacked: only the most recently
6728
+ * queued id is cancellable, so a second timer would outlive every cancel and
6729
+ * end a state that has since restarted. Controllers that want the first signal
6730
+ * to win guard on {@link pending} before calling.
6731
+ */
6732
+ schedule(minDuration, finish) {
6733
+ this.cancel();
6734
+ const remaining = minDuration - (Date.now() - this.#since);
6735
+ if (remaining > 0) {
6736
+ this.#timerId = this.#timers.set(() => {
6737
+ this.#timerId = null;
6738
+ finish();
6739
+ }, remaining);
6740
+ } else {
6741
+ finish();
6742
+ }
6743
+ }
6744
+ /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
6745
+ cancel() {
6746
+ if (this.#timerId !== null) {
6747
+ this.#timers.clear(this.#timerId);
6748
+ this.#timerId = null;
6749
+ }
6750
+ }
6751
+ };
6752
+
6753
+ // src/controllers/frame_loading_controller.ts
6365
6754
  var FrameLoadingController = class extends Controller {
6366
6755
  static targets = ["content", "skeleton", "overlay"];
6367
6756
  static values = {
6757
+ announceText: { type: String, default: "" },
6758
+ announceReadyText: { type: String, default: "" },
6368
6759
  minDuration: { type: Number, default: 0 },
6369
6760
  restoreFocus: { type: Boolean, default: true }
6370
6761
  };
6371
6762
  static events = ["start", "end"];
6372
6763
  #timeouts = new SafeTimeout();
6764
+ #floor = new MinDurationFloor(this.#timeouts);
6765
+ #gate = new DetachGate();
6766
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
6373
6767
  #loading = false;
6374
- #startedAt = 0;
6375
6768
  #inertApplied = false;
6376
6769
  #previousFocus = null;
6377
6770
  /** The id of the retreated element, used to re-find it if the load replaced it. */
6378
6771
  #previousFocusId = "";
6379
6772
  #onStart = () => {
6380
- this.#timeouts.clearAll();
6773
+ this.#floor.cancel();
6381
6774
  if (!this.#loading) this.#begin();
6382
6775
  };
6383
6776
  #onEnd = () => {
6384
6777
  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
- }
6778
+ this.#floor.schedule(this.minDurationValue, () => this.#finish());
6392
6779
  };
6393
6780
  connect() {
6781
+ this.#gate.cancel();
6782
+ this.#beforeCache.activate();
6394
6783
  this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
6395
6784
  this.element.addEventListener("turbo:frame-load", this.#onEnd);
6396
6785
  this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
@@ -6399,19 +6788,40 @@ var FrameLoadingController = class extends Controller {
6399
6788
  this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
6400
6789
  this.element.removeEventListener("turbo:frame-load", this.#onEnd);
6401
6790
  this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
6791
+ this.#beforeCache.deactivate();
6792
+ this.#gate.disconnected(this, () => this.#teardown());
6793
+ }
6794
+ /**
6795
+ * Drops the held finish and the loading bookkeeping on a real detach. The markup
6796
+ * keeps whatever it last held: the page being cached is rewound at
6797
+ * `turbo:before-cache` instead, where the frame is still whole.
6798
+ */
6799
+ #teardown() {
6800
+ this.#gate.cancel();
6402
6801
  this.#timeouts.clearAll();
6403
- if (this.#loading) {
6404
- this.element.removeAttribute("aria-busy");
6405
- this.element.removeAttribute("data-frame-loading");
6406
- this.#clearInert();
6407
- }
6802
+ this.#floor.cancel();
6408
6803
  this.#loading = false;
6409
6804
  this.#previousFocus = null;
6410
6805
  }
6806
+ /**
6807
+ * Returns the frame to its resting hooks for the snapshot Turbo is about to
6808
+ * take, so a page reached with the Back button does not restore a frame that is
6809
+ * busy and inert with nothing left to finish it. State only — no `end` event and
6810
+ * no focus move, because the load did not actually complete. The live page keeps
6811
+ * its held finish, so a navigation that never completes still ends properly.
6812
+ */
6813
+ #rewindForCache() {
6814
+ if (!this.#loading) return;
6815
+ this.element.removeAttribute("aria-busy");
6816
+ this.element.removeAttribute("data-frame-loading");
6817
+ if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
6818
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
6819
+ this.#clearInert();
6820
+ }
6411
6821
  /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
6412
6822
  #begin() {
6413
6823
  this.#loading = true;
6414
- this.#startedAt = Date.now();
6824
+ this.#floor.begin();
6415
6825
  this.element.setAttribute("aria-busy", "true");
6416
6826
  this.element.setAttribute("data-frame-loading", "true");
6417
6827
  if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
@@ -6419,6 +6829,7 @@ var FrameLoadingController = class extends Controller {
6419
6829
  this.#applyInert();
6420
6830
  this.#retreatFocus();
6421
6831
  this.dispatch("start", { detail: {} });
6832
+ announce(fillTemplate(this.announceTextValue, {}));
6422
6833
  }
6423
6834
  /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
6424
6835
  #finish() {
@@ -6430,6 +6841,7 @@ var FrameLoadingController = class extends Controller {
6430
6841
  this.#clearInert();
6431
6842
  this.#restoreFocus();
6432
6843
  this.dispatch("end", { detail: {} });
6844
+ announce(fillTemplate(this.announceReadyTextValue, {}));
6433
6845
  }
6434
6846
  /** Marks the content inert to block double-submits while stale (if we own it). */
6435
6847
  #applyInert() {
@@ -7535,7 +7947,53 @@ var LocalTimeController = class extends Controller {
7535
7947
  titleFormat: { type: String, default: "" }
7536
7948
  };
7537
7949
  static events = ["format"];
7950
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
7951
+ #resync = new MicrotaskCoalescer(() => this.#render());
7952
+ /**
7953
+ * Watches the one render input that is not a Value. Only `datetime` is filtered
7954
+ * in, so the text and `title` this controller writes cannot re-enter the pass.
7955
+ */
7956
+ #datetimeWatch = new MutationObserver(() => {
7957
+ this.#resync.schedule();
7958
+ });
7538
7959
  connect() {
7960
+ this.#resync.activate();
7961
+ this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
7962
+ this.#render();
7963
+ }
7964
+ disconnect() {
7965
+ this.#resync.cancel();
7966
+ this.#datetimeWatch.disconnect();
7967
+ }
7968
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
7969
+ localeValueChanged() {
7970
+ this.#resync.schedule();
7971
+ }
7972
+ /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
7973
+ timeZoneValueChanged() {
7974
+ this.#resync.schedule();
7975
+ }
7976
+ /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
7977
+ dateStyleValueChanged() {
7978
+ this.#resync.schedule();
7979
+ }
7980
+ /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
7981
+ timeStyleValueChanged() {
7982
+ this.#resync.schedule();
7983
+ }
7984
+ /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
7985
+ titleFormatValueChanged() {
7986
+ this.#resync.schedule();
7987
+ }
7988
+ /**
7989
+ * Formats the instant in `datetime` against the current Values and writes it out.
7990
+ *
7991
+ * The `format` event rides with every pass, including a repaint a morph triggers:
7992
+ * its condition is that formatting was applied, and a repaint applies it with a
7993
+ * new result. A pass that cannot format writes nothing and emits nothing, so the
7994
+ * authored absolute text stays as the fallback.
7995
+ */
7996
+ #render() {
7539
7997
  const date = this.#parse();
7540
7998
  if (date === null) return;
7541
7999
  const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
@@ -7545,7 +8003,10 @@ var LocalTimeController = class extends Controller {
7545
8003
  if (title !== null) this.element.setAttribute("title", title);
7546
8004
  this.dispatch("format", { detail: { formatted } });
7547
8005
  }
7548
- /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */
8006
+ /**
8007
+ * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
8008
+ * around the attribute value is tolerated.
8009
+ */
7549
8010
  #parse() {
7550
8011
  const raw = this.element.getAttribute("datetime");
7551
8012
  if (!raw) return null;
@@ -7558,11 +8019,21 @@ var LocalTimeController = class extends Controller {
7558
8019
  * the *runtime's* local zone, contradicting "the server emits UTC". Values that
7559
8020
  * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
7560
8021
  * parsed as UTC) are returned unchanged.
8022
+ *
8023
+ * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
8024
+ * left to each engine, so a whole value shaped that way is normalized to the `T`
8025
+ * separator first. The pattern is anchored: a value trailing anything else — a
8026
+ * zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
8027
+ * authored instead of being turned into a string nothing can parse.
7561
8028
  */
7562
8029
  #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;
8030
+ const isoLike = value.replace(
8031
+ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
8032
+ "$1T$2"
8033
+ );
8034
+ const hasTime = /T\d{2}:\d{2}/.test(isoLike);
8035
+ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
8036
+ return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
7566
8037
  }
7567
8038
  /**
7568
8039
  * Builds the optional detailed `title`. `titleFormat` is an `Intl` style
@@ -7591,9 +8062,9 @@ var LocalTimeController = class extends Controller {
7591
8062
  return null;
7592
8063
  }
7593
8064
  }
7594
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
8065
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
7595
8066
  get #locale() {
7596
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
8067
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
7597
8068
  }
7598
8069
  };
7599
8070
  var COLUMNS_PROPERTY = "--stimeo-masonry-columns";
@@ -8440,9 +8911,29 @@ var MenubarController = class extends Controller {
8440
8911
  if (index !== -1) items[index]?.focus();
8441
8912
  }
8442
8913
  };
8914
+
8915
+ // src/utils/range.ts
8916
+ function rangeFraction(value, min, max) {
8917
+ const span = max - min;
8918
+ if (!(span > 0)) return 0;
8919
+ const clamped = Math.min(max, Math.max(min, value));
8920
+ let fraction;
8921
+ if (Number.isFinite(span)) {
8922
+ fraction = (clamped - min) / span;
8923
+ } else {
8924
+ const scale = Math.max(Math.abs(min), Math.abs(max));
8925
+ fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
8926
+ }
8927
+ if (!Number.isFinite(fraction)) return 0;
8928
+ return Math.min(1, Math.max(0, fraction));
8929
+ }
8930
+
8931
+ // src/controllers/meter_controller.ts
8932
+ var OWNED_VALUE_TEXT = "data-stimeo--meter-owns-valuetext";
8443
8933
  var MeterController = class extends Controller {
8444
8934
  static targets = ["bar"];
8445
8935
  static values = {
8936
+ announceText: { type: String, default: "" },
8446
8937
  value: { type: Number, default: 0 },
8447
8938
  min: { type: Number, default: 0 },
8448
8939
  max: { type: Number, default: 100 },
@@ -8453,9 +8944,24 @@ var MeterController = class extends Controller {
8453
8944
  };
8454
8945
  static actions = ["setValue"];
8455
8946
  static events = ["change"];
8947
+ /**
8948
+ * Collapses a morph that swaps several render inputs at once into one repaint.
8949
+ * A single update usually rewrites the whole set, and each Value would otherwise
8950
+ * repaint on its own.
8951
+ */
8952
+ #repaint = new MicrotaskCoalescer(() => {
8953
+ this.#render();
8954
+ });
8955
+ /** The segment last announced, so only a change is read out. */
8956
+ #announcedState = null;
8456
8957
  connect() {
8958
+ this.#repaint.activate();
8457
8959
  this.#render();
8458
8960
  }
8961
+ /** Closes the window in which a queued repaint may still run. */
8962
+ disconnect() {
8963
+ this.#repaint.cancel();
8964
+ }
8459
8965
  /**
8460
8966
  * Updates the measured value from an action param (`amount`) or a
8461
8967
  * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
@@ -8465,59 +8971,95 @@ var MeterController = class extends Controller {
8465
8971
  const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);
8466
8972
  if (next === null) return;
8467
8973
  this.valueValue = this.#clamp(next);
8468
- this.#render();
8469
- this.dispatch("change", {
8470
- detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state }
8471
- });
8974
+ const reading = this.#render();
8975
+ this.dispatch("change", { detail: reading });
8976
+ if (reading.state !== this.#announcedState) {
8977
+ this.#announcedState = reading.state;
8978
+ announce(
8979
+ fillTemplate(this.announceTextValue, { state: reading.state, value: reading.value })
8980
+ );
8981
+ }
8982
+ }
8983
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
8984
+ valueValueChanged() {
8985
+ this.#repaint.schedule();
8986
+ }
8987
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
8988
+ minValueChanged() {
8989
+ this.#repaint.schedule();
8990
+ }
8991
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
8992
+ maxValueChanged() {
8993
+ this.#repaint.schedule();
8994
+ }
8995
+ /** Repaints when application code (or a Turbo morph) changes `low` at runtime. */
8996
+ lowValueChanged() {
8997
+ this.#repaint.schedule();
8998
+ }
8999
+ /** Repaints when application code (or a Turbo morph) changes `high` at runtime. */
9000
+ highValueChanged() {
9001
+ this.#repaint.schedule();
9002
+ }
9003
+ /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
9004
+ valueTextValueChanged() {
9005
+ this.#repaint.schedule();
8472
9006
  }
8473
9007
  /** Clamps `raw` into the configured `[min, max]` range. */
8474
9008
  #clamp(raw) {
8475
9009
  return Math.min(this.maxValue, Math.max(this.minValue, raw));
8476
9010
  }
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
9011
  /** Whether a threshold attribute is present (absent = no threshold). */
8484
9012
  #hasThreshold(name) {
8485
9013
  return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);
8486
9014
  }
8487
9015
  /**
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`.
9016
+ * Classifies `value` into a `low`/`medium`/`high` segment. Values at or below
9017
+ * `low` are `low`; at or above `high` are `high`; otherwise `medium`. With
9018
+ * neither threshold present, everything is `medium`.
8491
9019
  */
8492
- get #state() {
8493
- const value = this.#clamp(this.valueValue);
9020
+ #stateOf(value) {
8494
9021
  if (this.#hasThreshold("low") && value <= this.lowValue) return "low";
8495
9022
  if (this.#hasThreshold("high") && value >= this.highValue) return "high";
8496
9023
  return "medium";
8497
9024
  }
8498
- /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */
9025
+ /**
9026
+ * Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
9027
+ * The reading is derived once and returned, so the `change` detail reports the
9028
+ * same numbers the DOM just received.
9029
+ */
8499
9030
  #render() {
8500
9031
  const value = this.#clamp(this.valueValue);
9032
+ const reading = {
9033
+ value,
9034
+ ratio: rangeFraction(value, this.minValue, this.maxValue),
9035
+ state: this.#stateOf(value)
9036
+ };
8501
9037
  this.element.setAttribute("aria-valuemin", String(this.minValue));
8502
9038
  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);
9039
+ this.element.setAttribute("aria-valuenow", String(reading.value));
9040
+ this.element.style.setProperty("--stimeo-meter-ratio", String(reading.ratio));
9041
+ this.element.setAttribute("data-state", reading.state);
9042
+ this.#applyValueText(reading);
9043
+ return reading;
8507
9044
  }
8508
9045
  /**
8509
9046
  * 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.
9047
+ * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library.
9048
+ * With no template the attribute belongs to the consumer, so only a text this
9049
+ * controller wrote is taken back ({@link OWNED_VALUE_TEXT}).
8512
9050
  */
8513
- #applyValueText(value) {
9051
+ #applyValueText({ value, ratio, state }) {
8514
9052
  if (this.valueTextValue.length === 0) {
8515
- this.element.removeAttribute("aria-valuetext");
9053
+ if (this.element.hasAttribute(OWNED_VALUE_TEXT)) {
9054
+ this.element.removeAttribute("aria-valuetext");
9055
+ this.element.removeAttribute(OWNED_VALUE_TEXT);
9056
+ }
8516
9057
  return;
8517
9058
  }
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);
9059
+ const percent = Math.round(ratio * 100);
9060
+ const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", state);
8520
9061
  this.element.setAttribute("aria-valuetext", text);
9062
+ this.element.setAttribute(OWNED_VALUE_TEXT, "");
8521
9063
  }
8522
9064
  };
8523
9065
 
@@ -9585,20 +10127,17 @@ var NestedFormController = class extends Controller {
9585
10127
  var NetworkStatusController = class extends Controller {
9586
10128
  static targets = ["offline", "online"];
9587
10129
  static values = {
10130
+ announceText: { type: String, default: "" },
10131
+ announceOnlineText: { type: String, default: "" },
9588
10132
  onlineAutoHide: { type: Number, default: 0 }
9589
10133
  };
9590
10134
  static events = ["change"];
9591
10135
  #timers = new SafeTimeout();
9592
10136
  /** Last known connectivity; guards against duplicate-state re-announcements. */
9593
10137
  #online = true;
9594
- /** Banner text captured from the markup so transitions can re-write it. */
9595
- #offlineMessage = "";
9596
- #onlineMessage = "";
9597
10138
  #handleOnline = () => this.#update(true);
9598
10139
  #handleOffline = () => this.#update(false);
9599
10140
  connect() {
9600
- this.#offlineMessage = this.hasOfflineTarget ? (this.offlineTarget.textContent ?? "").trim() : "";
9601
- this.#onlineMessage = this.hasOnlineTarget ? (this.onlineTarget.textContent ?? "").trim() : "";
9602
10141
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
9603
10142
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
9604
10143
  this.#online = navigator.onLine;
@@ -9612,7 +10151,12 @@ var NetworkStatusController = class extends Controller {
9612
10151
  window.removeEventListener("offline", this.#handleOffline);
9613
10152
  this.#timers.clearAll();
9614
10153
  }
9615
- /** Applies a connectivity transition, guarded against duplicate states. */
10154
+ /**
10155
+ * Applies a connectivity transition, guarded against duplicate states.
10156
+ *
10157
+ * The event goes out last, so a listener reading `data-state` or a banner's
10158
+ * visibility sees the state the transition landed on rather than the previous one.
10159
+ */
9616
10160
  #update(online) {
9617
10161
  if (online === this.#online) return;
9618
10162
  this.#online = online;
@@ -9622,6 +10166,9 @@ var NetworkStatusController = class extends Controller {
9622
10166
  } else {
9623
10167
  this.#showOffline();
9624
10168
  }
10169
+ announce(fillTemplate(online ? this.announceOnlineTextValue : this.announceTextValue, {}), {
10170
+ assertive: !online
10171
+ });
9625
10172
  this.dispatch("change", { detail: { online } });
9626
10173
  }
9627
10174
  /** Shows the offline banner and hides the recovery banner. */
@@ -9629,7 +10176,6 @@ var NetworkStatusController = class extends Controller {
9629
10176
  this.#timers.clearAll();
9630
10177
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
9631
10178
  if (this.hasOfflineTarget) {
9632
- this.offlineTarget.textContent = this.#offlineMessage;
9633
10179
  this.offlineTarget.hidden = false;
9634
10180
  }
9635
10181
  }
@@ -9637,7 +10183,6 @@ var NetworkStatusController = class extends Controller {
9637
10183
  #showOnline() {
9638
10184
  if (this.hasOfflineTarget) this.offlineTarget.hidden = true;
9639
10185
  if (!this.hasOnlineTarget) return;
9640
- this.onlineTarget.textContent = this.#onlineMessage;
9641
10186
  this.onlineTarget.hidden = false;
9642
10187
  if (this.onlineAutoHideValue > 0) {
9643
10188
  this.#timers.set(() => {
@@ -10406,17 +10951,15 @@ var OverflowMenuController = class extends Controller {
10406
10951
  #lastHidden = null;
10407
10952
  /** The `tabindex` this instance lends the root for the focus fallback. */
10408
10953
  #tabindex = new TabindexLoan();
10409
- /** Hands Turbo a pristine snapshot: the cache is written after this event. */
10410
- #onBeforeCache = () => {
10411
- this.#restoreAll();
10412
- };
10954
+ /** Hands Turbo a pristine snapshot of the bar, with every item back in place. */
10955
+ #beforeCache = new BeforeCacheReset(() => this.#restoreAll());
10413
10956
  connect() {
10414
10957
  if (!this.hasItemsTarget || !this.hasMoreTarget) return;
10415
10958
  const trigger = this.#trigger();
10416
10959
  if (trigger !== null && this.#isBareTrigger(trigger)) {
10417
10960
  trigger.textContent = this.moreLabelValue;
10418
10961
  }
10419
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
10962
+ this.#beforeCache.activate();
10420
10963
  this.#layout.observe(this.element);
10421
10964
  this.#layout.observeViewport();
10422
10965
  this.update();
@@ -10424,7 +10967,7 @@ var OverflowMenuController = class extends Controller {
10424
10967
  disconnect() {
10425
10968
  this.#layout.disconnect();
10426
10969
  this.#timers.clearAll();
10427
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
10970
+ this.#beforeCache.deactivate();
10428
10971
  this.#restoreAll();
10429
10972
  this.#lastHidden = null;
10430
10973
  }
@@ -11347,53 +11890,6 @@ var PersistController = class extends Controller {
11347
11890
  }
11348
11891
  }
11349
11892
  };
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
11893
  var PointerDragController = class _PointerDragController extends Controller {
11398
11894
  static targets = ["handle"];
11399
11895
  static values = {
@@ -11975,6 +12471,7 @@ var PreviewGuardController = class extends Controller {
11975
12471
  this.element.removeAttribute("data-preview-hidden");
11976
12472
  }
11977
12473
  };
12474
+ var OWNED_VALUE_TEXT2 = "data-stimeo--progress-owns-valuetext";
11978
12475
  var ProgressController = class extends Controller {
11979
12476
  static targets = ["bar"];
11980
12477
  static values = {
@@ -11982,13 +12479,27 @@ var ProgressController = class extends Controller {
11982
12479
  min: { type: Number, default: 0 },
11983
12480
  max: { type: Number, default: 100 },
11984
12481
  indeterminate: { type: Boolean, default: false },
11985
- valueText: { type: String, default: "" }
12482
+ valueText: { type: String, default: "" },
12483
+ announceText: { type: String, default: "" }
11986
12484
  };
11987
12485
  static actions = ["setValue"];
11988
12486
  static events = ["change", "complete"];
12487
+ /**
12488
+ * Collapses a morph that swaps several render inputs at once into one repaint.
12489
+ * A single update usually rewrites the whole set, and each Value would otherwise
12490
+ * repaint on its own.
12491
+ */
12492
+ #repaint = new MicrotaskCoalescer(() => {
12493
+ this.#render();
12494
+ });
11989
12495
  connect() {
12496
+ this.#repaint.activate();
11990
12497
  this.#render();
11991
12498
  }
12499
+ /** Closes the window in which a queued repaint may still run. */
12500
+ disconnect() {
12501
+ this.#repaint.cancel();
12502
+ }
11992
12503
  /**
11993
12504
  * Updates the progress value from an action param (`amount`) or a
11994
12505
  * `detail.value` CustomEvent, normalizes it into range, syncs ARIA, and
@@ -12004,11 +12515,30 @@ var ProgressController = class extends Controller {
12004
12515
  this.dispatch("change", { detail: { value, ratio: this.#ratio } });
12005
12516
  if (value >= this.maxValue) {
12006
12517
  this.dispatch("complete", { detail: { value } });
12518
+ announce(
12519
+ fillTemplate(this.announceTextValue, { value, percent: Math.round(this.#ratio * 100) })
12520
+ );
12007
12521
  }
12008
12522
  }
12009
- /** Re-render when the indeterminate flag is toggled via its data attribute. */
12523
+ /** Repaints when application code (or a Turbo morph) changes `indeterminate` at runtime. */
12010
12524
  indeterminateValueChanged() {
12011
- this.#render();
12525
+ this.#repaint.schedule();
12526
+ }
12527
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
12528
+ valueValueChanged() {
12529
+ this.#repaint.schedule();
12530
+ }
12531
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
12532
+ minValueChanged() {
12533
+ this.#repaint.schedule();
12534
+ }
12535
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
12536
+ maxValueChanged() {
12537
+ this.#repaint.schedule();
12538
+ }
12539
+ /** Repaints when application code (or a Turbo morph) changes `valueText` at runtime. */
12540
+ valueTextValueChanged() {
12541
+ this.#repaint.schedule();
12012
12542
  }
12013
12543
  /** Clamps `raw` into the configured `[min, max]` range. */
12014
12544
  #clamp(raw) {
@@ -12016,9 +12546,7 @@ var ProgressController = class extends Controller {
12016
12546
  }
12017
12547
  /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
12018
12548
  get #ratio() {
12019
- const span = this.maxValue - this.minValue;
12020
- if (span <= 0) return 0;
12021
- return (this.#clamp(this.valueValue) - this.minValue) / span;
12549
+ return rangeFraction(this.valueValue, this.minValue, this.maxValue);
12022
12550
  }
12023
12551
  /** Reflects value/range/indeterminate onto ARIA, `data-state`, and the ratio. */
12024
12552
  #render() {
@@ -12026,7 +12554,7 @@ var ProgressController = class extends Controller {
12026
12554
  this.element.setAttribute("aria-valuemax", String(this.maxValue));
12027
12555
  if (this.indeterminateValue) {
12028
12556
  this.element.removeAttribute("aria-valuenow");
12029
- this.element.removeAttribute("aria-valuetext");
12557
+ this.#clearOwnValueText();
12030
12558
  this.element.style.removeProperty("--stimeo-progress-ratio");
12031
12559
  this.element.setAttribute("data-state", "indeterminate");
12032
12560
  return;
@@ -12040,16 +12568,25 @@ var ProgressController = class extends Controller {
12040
12568
  /**
12041
12569
  * Sets `aria-valuetext` from the consumer-provided template, substituting
12042
12570
  * `{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.
12571
+ * stays i18n-neutral in the library. With no template the attribute belongs to
12572
+ * the consumer, so only a text this controller wrote is taken back
12573
+ * ({@link OWNED_VALUE_TEXT}).
12044
12574
  */
12045
12575
  #applyValueText(value) {
12046
12576
  if (this.valueTextValue.length === 0) {
12047
- this.element.removeAttribute("aria-valuetext");
12577
+ this.#clearOwnValueText();
12048
12578
  return;
12049
12579
  }
12050
12580
  const percent = Math.round(this.#ratio * 100);
12051
12581
  const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent));
12052
12582
  this.element.setAttribute("aria-valuetext", text);
12583
+ this.element.setAttribute(OWNED_VALUE_TEXT2, "");
12584
+ }
12585
+ /** Removes `aria-valuetext` only when this controller is the one that wrote it. */
12586
+ #clearOwnValueText() {
12587
+ if (!this.element.hasAttribute(OWNED_VALUE_TEXT2)) return;
12588
+ this.element.removeAttribute("aria-valuetext");
12589
+ this.element.removeAttribute(OWNED_VALUE_TEXT2);
12053
12590
  }
12054
12591
  };
12055
12592
  var RadioGroupController = class extends Controller {
@@ -12155,16 +12692,33 @@ var RangeSliderController = class extends Controller {
12155
12692
  return this.logicalTrackValue && isRtl(this.element);
12156
12693
  }
12157
12694
  /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
12695
+ /**
12696
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
12697
+ * pass Stimulus delivers before `connect()`.
12698
+ */
12699
+ #repaint = new MicrotaskCoalescer(() => {
12700
+ this.#render(this.startValue, this.endValue);
12701
+ });
12158
12702
  connect() {
12703
+ this.#repaint.activate();
12159
12704
  const lo = Math.min(this.startValue, this.endValue);
12160
12705
  const hi = Math.max(this.startValue, this.endValue);
12161
12706
  this.#commit(lo, hi, false);
12162
12707
  }
12163
12708
  /** Cancels any active pointer drag so document listeners never leak. */
12164
12709
  disconnect() {
12710
+ this.#repaint.cancel();
12165
12711
  this.#dragAbort?.abort();
12166
12712
  this.#dragAbort = null;
12167
12713
  }
12714
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
12715
+ minValueChanged() {
12716
+ this.#repaint.schedule();
12717
+ }
12718
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
12719
+ maxValueChanged() {
12720
+ this.#repaint.schedule();
12721
+ }
12168
12722
  /** Keyboard stepping for whichever thumb is focused (the action's element). */
12169
12723
  onKeydown(event) {
12170
12724
  if (isReservedArrowChord(event)) return;
@@ -12280,14 +12834,13 @@ var RangeSliderController = class extends Controller {
12280
12834
  this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
12281
12835
  this.endThumbTarget.setAttribute("aria-valuenow", String(end));
12282
12836
  }
12283
- const span = this.maxValue - this.minValue;
12284
12837
  this.element.style.setProperty(
12285
12838
  START_PROPERTY,
12286
- String(span > 0 ? (start - this.minValue) / span : 0)
12839
+ String(rangeFraction(start, this.minValue, this.maxValue))
12287
12840
  );
12288
12841
  this.element.style.setProperty(
12289
12842
  END_PROPERTY,
12290
- String(span > 0 ? (end - this.minValue) / span : 0)
12843
+ String(rangeFraction(end, this.minValue, this.maxValue))
12291
12844
  );
12292
12845
  }
12293
12846
  /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
@@ -12313,13 +12866,33 @@ var RatingController = class extends Controller {
12313
12866
  static events = ["change"];
12314
12867
  #roving = new RovingTabindex(() => this.symbolTargets);
12315
12868
  /** Reflects the initial value, or switches to the non-interactive readonly view. */
12869
+ /**
12870
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
12871
+ * pass Stimulus delivers before `connect()`.
12872
+ */
12873
+ #repaint = new MicrotaskCoalescer(() => {
12874
+ if (this.readonlyValue) {
12875
+ this.#applyReadonly();
12876
+ return;
12877
+ }
12878
+ this.#apply(this.#clamp(this.valueValue), { focus: false });
12879
+ });
12316
12880
  connect() {
12881
+ this.#repaint.activate();
12317
12882
  if (this.readonlyValue) {
12318
12883
  this.#applyReadonly();
12319
12884
  return;
12320
12885
  }
12321
12886
  this.#apply(this.#clamp(this.valueValue), { focus: false });
12322
12887
  }
12888
+ /** Closes the window in which a queued repaint may still run. */
12889
+ disconnect() {
12890
+ this.#repaint.cancel();
12891
+ }
12892
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
12893
+ valueValueChanged() {
12894
+ this.#repaint.schedule();
12895
+ }
12323
12896
  /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
12324
12897
  select(event) {
12325
12898
  if (this.readonlyValue) return;
@@ -12618,11 +13191,16 @@ var RelativeTimeController = class extends Controller {
12618
13191
  tickInterval: { type: Number, default: 6e4 }
12619
13192
  };
12620
13193
  #timers = new SafeTimeout();
13194
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
13195
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
13196
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
12621
13197
  /** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
12622
13198
  #targetMs = Number.NaN;
12623
13199
  /** The authored absolute text, restored when the threshold fallback kicks in. */
12624
13200
  #absoluteText = "";
12625
13201
  connect() {
13202
+ this.#resync.activate();
13203
+ this.#beforeCache.activate();
12626
13204
  if (this.element.getAttribute("data-state") !== "relative") {
12627
13205
  this.#absoluteText = (this.element.textContent ?? "").trim();
12628
13206
  }
@@ -12631,9 +13209,50 @@ var RelativeTimeController = class extends Controller {
12631
13209
  this.#schedule();
12632
13210
  }
12633
13211
  disconnect() {
13212
+ this.#resync.cancel();
13213
+ this.#beforeCache.deactivate();
13214
+ this.#timers.clearAll();
13215
+ }
13216
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
13217
+ localeValueChanged() {
13218
+ this.#resync.schedule();
13219
+ }
13220
+ /** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */
13221
+ thresholdValueChanged() {
13222
+ this.#resync.schedule();
13223
+ }
13224
+ /** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */
13225
+ tickIntervalValueChanged() {
13226
+ this.#resync.schedule();
13227
+ }
13228
+ /**
13229
+ * Renders against the current Values and re-arms the poll from now.
13230
+ *
13231
+ * Render only: it emits no event, and clearing first keeps the single self-arming
13232
+ * timer single — scheduling on top of a pending one would double the poll rate for
13233
+ * the rest of the session. A stamp whose `datetime` never parsed has nothing to
13234
+ * render, and one that already reached its terminal fallback simply renders it
13235
+ * again and stops.
13236
+ */
13237
+ #resyncToValues() {
13238
+ if (Number.isNaN(this.#targetMs)) return;
12634
13239
  this.#timers.clearAll();
13240
+ this.#schedule();
12635
13241
  }
12636
- /** Renders the current representation and reschedules unless it is now absolute. */
13242
+ /**
13243
+ * Restores the authored absolute text and the pre-render state for the snapshot
13244
+ * Turbo is about to take, leaving the live page's poll timer alone.
13245
+ *
13246
+ * With no authored text held there is nothing to restore, and `data-state` has to
13247
+ * stay as it is: that marker is what tells the next `connect()` the visible text is
13248
+ * a rendered relative form rather than an absolute fallback to hold on to.
13249
+ */
13250
+ #rewindForCache() {
13251
+ if (!this.#absoluteText) return;
13252
+ this.element.textContent = this.#absoluteText;
13253
+ this.element.removeAttribute("data-state");
13254
+ }
13255
+ /** Renders the current representation and reschedules unless polling can stop. */
12637
13256
  #schedule() {
12638
13257
  const nextDelay = this.#applyAndComputeDelay();
12639
13258
  if (nextDelay !== null) {
@@ -12641,31 +13260,44 @@ var RelativeTimeController = class extends Controller {
12641
13260
  }
12642
13261
  }
12643
13262
  /**
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).
13263
+ * Updates the visible text and returns the next poll delay (ms), or `null` when
13264
+ * polling can stop: a *past* timestamp that fell back to the absolute text can
13265
+ * never leave it again, and a locale the runtime rejects has nothing to render
13266
+ * until that value is corrected.
12646
13267
  */
12647
13268
  #applyAndComputeDelay() {
12648
13269
  const deltaMs = this.#targetMs - Date.now();
12649
13270
  const absSeconds = Math.abs(deltaMs) / 1e3;
13271
+ const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
13272
+ const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
13273
+ const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
12650
13274
  if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
12651
13275
  this.element.textContent = this.#absoluteText;
12652
13276
  this.element.setAttribute("data-state", "absolute");
12653
- return null;
13277
+ if (deltaMs <= 0) return null;
13278
+ return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
12654
13279
  }
12655
- const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
13280
+ const formatter = this.#formatter;
13281
+ if (formatter === null) return null;
12656
13282
  const value = Math.round(deltaMs / scale.ms);
12657
- this.element.textContent = this.#formatter.format(value, scale.unit);
13283
+ this.element.textContent = formatter.format(value, scale.unit);
12658
13284
  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));
13285
+ return nextDelay;
12661
13286
  }
12662
- /** A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`). */
13287
+ /**
13288
+ * A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
13289
+ * when the runtime rejects that locale.
13290
+ */
12663
13291
  get #formatter() {
12664
- return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
13292
+ try {
13293
+ return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
13294
+ } catch {
13295
+ return null;
13296
+ }
12665
13297
  }
12666
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
13298
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
12667
13299
  get #locale() {
12668
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
13300
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
12669
13301
  }
12670
13302
  };
12671
13303
  var ResetBeforeCacheController = class extends Controller {
@@ -13677,7 +14309,7 @@ var ScrollspyController = class extends Controller {
13677
14309
  *
13678
14310
  * @param announce Whether this sync represents a change of current section.
13679
14311
  */
13680
- #syncActiveStates(announce) {
14312
+ #syncActiveStates(announce2) {
13681
14313
  const activeLinks = this.linkTargets.filter(
13682
14314
  (link) => this.#getAnchorId(link) === this.#activeSectionId
13683
14315
  );
@@ -13688,7 +14320,7 @@ var ScrollspyController = class extends Controller {
13688
14320
  link.removeAttribute("aria-current");
13689
14321
  }
13690
14322
  }
13691
- if (!announce) return;
14323
+ if (!announce2) return;
13692
14324
  const primaryLink = activeLinks[0];
13693
14325
  if (primaryLink) {
13694
14326
  this.dispatch("change", { detail: { id: this.#activeSectionId, link: primaryLink } });
@@ -13836,7 +14468,7 @@ var SidebarController = class extends Controller {
13836
14468
  key: { type: String, default: "" },
13837
14469
  collapsed: { type: Boolean, default: false }
13838
14470
  };
13839
- static actions = ["beforeCache", "close", "open", "toggle"];
14471
+ static actions = ["close", "open", "toggle"];
13840
14472
  /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
13841
14473
  #activePanel = null;
13842
14474
  /** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
@@ -13858,6 +14490,7 @@ var SidebarController = class extends Controller {
13858
14490
  #connected = false;
13859
14491
  connect() {
13860
14492
  this.#connected = true;
14493
+ this.#beforeCache.activate();
13861
14494
  this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
13862
14495
  this.#collapsed = this.#restoreCollapsed();
13863
14496
  this.#mqlQuery = this.#breakpointQuery;
@@ -13867,6 +14500,7 @@ var SidebarController = class extends Controller {
13867
14500
  }
13868
14501
  disconnect() {
13869
14502
  this.#connected = false;
14503
+ this.#beforeCache.deactivate();
13870
14504
  this.#mql?.removeEventListener("change", this.#onMediaChange);
13871
14505
  this.#mql = null;
13872
14506
  this.#mqlQuery = null;
@@ -13918,12 +14552,12 @@ var SidebarController = class extends Controller {
13918
14552
  * closed immediately, and modal side effects are released without moving
13919
14553
  * focus during navigation.
13920
14554
  */
13921
- beforeCache() {
14555
+ #beforeCache = new BeforeCacheReset(() => {
13922
14556
  if (!this.#connected || !this.#isOverlay) return;
13923
14557
  this.#transition.cancel();
13924
14558
  this.#trap.deactivate({ restoreFocus: false });
13925
14559
  this.#setOverlayClosedImmediate();
13926
- }
14560
+ });
13927
14561
  /** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
13928
14562
  toggle() {
13929
14563
  if (this.#isOverlay) {
@@ -14120,15 +14754,13 @@ var SidebarController = class extends Controller {
14120
14754
  var SkeletonController = class extends Controller {
14121
14755
  static targets = ["placeholder", "content"];
14122
14756
  static values = {
14757
+ announceReadyText: { type: String, default: "" },
14123
14758
  minDuration: { type: Number, default: 0 }
14124
14759
  };
14125
14760
  static actions = ["ready", "reset"];
14126
14761
  static events = ["ready"];
14127
14762
  #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;
14763
+ #floor = new MinDurationFloor(this.#timers);
14132
14764
  connect() {
14133
14765
  if (this.#state !== "ready") {
14134
14766
  this.#enterLoading();
@@ -14136,32 +14768,21 @@ var SkeletonController = class extends Controller {
14136
14768
  }
14137
14769
  disconnect() {
14138
14770
  this.#timers.clearAll();
14139
- this.#revealTimerId = null;
14771
+ this.#floor.cancel();
14140
14772
  }
14141
14773
  /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
14142
14774
  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
- }
14775
+ if (this.#state === "ready" || this.#floor.pending) return;
14776
+ this.#floor.schedule(this.minDurationValue, () => this.#reveal());
14153
14777
  }
14154
14778
  /** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
14155
14779
  reset() {
14156
- if (this.#revealTimerId !== null) {
14157
- this.#timers.clear(this.#revealTimerId);
14158
- this.#revealTimerId = null;
14159
- }
14780
+ this.#floor.cancel();
14160
14781
  this.#enterLoading();
14161
14782
  }
14162
14783
  /** Shows the placeholder, hides content, and marks the region busy. */
14163
14784
  #enterLoading() {
14164
- this.#loadingSince = Date.now();
14785
+ this.#floor.begin();
14165
14786
  if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
14166
14787
  if (this.hasContentTarget) this.contentTarget.hidden = true;
14167
14788
  this.element.setAttribute("aria-busy", "true");
@@ -14174,6 +14795,7 @@ var SkeletonController = class extends Controller {
14174
14795
  this.element.setAttribute("aria-busy", "false");
14175
14796
  this.element.setAttribute("data-state", "ready");
14176
14797
  this.dispatch("ready", { detail: {} });
14798
+ announce(fillTemplate(this.announceReadyTextValue, {}));
14177
14799
  }
14178
14800
  /** Current lifecycle phase as reflected on `data-state`. */
14179
14801
  get #state() {
@@ -14284,8 +14906,7 @@ var SliderController = class extends Controller {
14284
14906
  this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
14285
14907
  this.thumbTarget.setAttribute("aria-valuenow", String(value));
14286
14908
  }
14287
- const span = this.maxValue - this.minValue;
14288
- const fraction = span > 0 ? (value - this.minValue) / span : 0;
14909
+ const fraction = rangeFraction(value, this.minValue, this.maxValue);
14289
14910
  this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
14290
14911
  if (changed && !silent) this.dispatch("change", { detail: { value } });
14291
14912
  }
@@ -14546,38 +15167,61 @@ var SortableController = class extends Controller {
14546
15167
  var SpinnerController = class extends Controller {
14547
15168
  static targets = ["indicator", "region", "message"];
14548
15169
  static values = {
15170
+ announceText: { type: String, default: "" },
15171
+ announceReadyText: { type: String, default: "" },
14549
15172
  delay: { type: Number, default: 0 },
14550
- minDuration: { type: Number, default: 0 }
15173
+ minDuration: { type: Number, default: 0 },
15174
+ timeout: { type: Number, default: 0 }
14551
15175
  };
14552
15176
  static actions = ["start", "stop"];
14553
- static events = ["hide", "show"];
15177
+ static events = ["hide", "show", "timeout"];
14554
15178
  #timers = new SafeTimeout();
15179
+ #floor = new MinDurationFloor(this.#timers);
15180
+ #gate = new DetachGate();
15181
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
14555
15182
  /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
14556
15183
  #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;
15184
+ /** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
15185
+ #timeoutTimerId = null;
14561
15186
  connect() {
15187
+ this.#gate.cancel();
15188
+ this.#beforeCache.activate();
15189
+ if (this.#state === "pending" && this.#delayTimerId === null) {
15190
+ this.#setBusy(false);
15191
+ this.element.setAttribute("data-state", "idle");
15192
+ return;
15193
+ }
14562
15194
  if (!this.element.hasAttribute("data-state")) {
14563
15195
  this.element.setAttribute("data-state", "idle");
14564
15196
  }
14565
15197
  }
15198
+ /**
15199
+ * Re-applies the current phase to an indicator that arrived after `connect()`.
15200
+ *
15201
+ * A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
15202
+ * carries the markup contract's `hidden`. Without this the spinner would vanish
15203
+ * while `data-state` still says `loading`, and nothing but the next cycle would
15204
+ * bring it back.
15205
+ */
15206
+ indicatorTargetConnected(target) {
15207
+ target.hidden = this.#state !== "loading";
15208
+ }
14566
15209
  disconnect() {
14567
- this.#timers.clearAll();
14568
- this.#delayTimerId = null;
14569
- this.#hideTimerId = null;
15210
+ this.#beforeCache.deactivate();
15211
+ this.#gate.disconnected(this, () => this.#teardown());
14570
15212
  }
14571
15213
  /** Begins loading. Honors `delay` before the spinner actually appears. */
14572
15214
  start() {
14573
15215
  if (this.#state === "loading") {
14574
15216
  this.#setBusy(true);
14575
- this.#cancelHide();
15217
+ this.#floor.cancel();
15218
+ this.#armTimeout();
14576
15219
  return;
14577
15220
  }
14578
15221
  if (this.#state !== "idle") return;
14579
15222
  this.#setBusy(true);
14580
- this.#cancelHide();
15223
+ this.#floor.cancel();
15224
+ this.#armTimeout();
14581
15225
  if (this.delayValue > 0) {
14582
15226
  this.element.setAttribute("data-state", "pending");
14583
15227
  this.#delayTimerId = this.#timers.set(() => {
@@ -14591,6 +15235,7 @@ var SpinnerController = class extends Controller {
14591
15235
  /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
14592
15236
  stop() {
14593
15237
  const state = this.#state;
15238
+ this.#cancelTimeout();
14594
15239
  if (state === "pending") {
14595
15240
  this.#cancelDelay();
14596
15241
  this.#setBusy(false);
@@ -14599,28 +15244,52 @@ var SpinnerController = class extends Controller {
14599
15244
  }
14600
15245
  if (state !== "loading") return;
14601
15246
  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
- }
15247
+ this.#floor.schedule(this.minDurationValue, () => this.#hide());
14611
15248
  }
14612
15249
  /** Reveals the indicator, marks the moment shown, and announces via the live region. */
14613
15250
  #show() {
14614
- this.#shownAt = Date.now();
15251
+ this.#floor.begin();
15252
+ this.#setBusy(true);
14615
15253
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
14616
15254
  this.element.setAttribute("data-state", "loading");
14617
15255
  this.dispatch("show", { detail: {} });
15256
+ announce(fillTemplate(this.announceTextValue, {}));
14618
15257
  }
14619
15258
  /** Hides the indicator and returns to the idle state. */
14620
15259
  #hide() {
14621
15260
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
14622
15261
  this.element.setAttribute("data-state", "idle");
14623
15262
  this.dispatch("hide", { detail: {} });
15263
+ announce(fillTemplate(this.announceReadyTextValue, {}));
15264
+ }
15265
+ /**
15266
+ * Drops both timers on a real detach. The markup keeps whatever it last held: an
15267
+ * element on its way out of the document has no reader left, and one whose
15268
+ * `data-controller` dropped the identifier no longer resolves its own targets, so
15269
+ * the rollback could only ever be partial. The snapshot is rewound where it is
15270
+ * still whole, on `turbo:before-cache`.
15271
+ */
15272
+ #teardown() {
15273
+ this.#gate.cancel();
15274
+ this.#timers.clearAll();
15275
+ this.#delayTimerId = null;
15276
+ this.#timeoutTimerId = null;
15277
+ this.#floor.cancel();
15278
+ }
15279
+ /**
15280
+ * Returns the loading state to idle for the snapshot Turbo is about to take,
15281
+ * so a page reached with the Back button is not restored mid-load with a
15282
+ * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
15283
+ * and `aria-busy`. No `hide` is dispatched — the load was never observed to
15284
+ * finish, and a snapshot rewind is not a lifecycle event the consumer can act
15285
+ * on. The live page keeps its timers, so a navigation that never completes
15286
+ * leaves the running cycle intact.
15287
+ */
15288
+ #rewindForCache() {
15289
+ this.#cancelTimeout();
15290
+ this.#setBusy(false);
15291
+ if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
15292
+ this.element.setAttribute("data-state", "idle");
14624
15293
  }
14625
15294
  /** Reflects busy state onto the controlled region (if present). */
14626
15295
  #setBusy(busy) {
@@ -14628,18 +15297,32 @@ var SpinnerController = class extends Controller {
14628
15297
  this.regionTarget.setAttribute("aria-busy", String(busy));
14629
15298
  }
14630
15299
  }
15300
+ /**
15301
+ * Arms the safety net so a `stop` that never arrives cannot strand the spinner.
15302
+ * Off by default: the consumer owns the async work, so only it knows whether a
15303
+ * ceiling makes sense. Re-arming on a restart measures from the newest start.
15304
+ */
15305
+ #armTimeout() {
15306
+ this.#cancelTimeout();
15307
+ if (this.timeoutValue <= 0) return;
15308
+ this.#timeoutTimerId = this.#timers.set(() => {
15309
+ this.#timeoutTimerId = null;
15310
+ this.dispatch("timeout", { detail: {} });
15311
+ this.stop();
15312
+ }, this.timeoutValue);
15313
+ }
15314
+ #cancelTimeout() {
15315
+ if (this.#timeoutTimerId !== null) {
15316
+ this.#timers.clear(this.#timeoutTimerId);
15317
+ this.#timeoutTimerId = null;
15318
+ }
15319
+ }
14631
15320
  #cancelDelay() {
14632
15321
  if (this.#delayTimerId !== null) {
14633
15322
  this.#timers.clear(this.#delayTimerId);
14634
15323
  this.#delayTimerId = null;
14635
15324
  }
14636
15325
  }
14637
- #cancelHide() {
14638
- if (this.#hideTimerId !== null) {
14639
- this.#timers.clear(this.#hideTimerId);
14640
- this.#hideTimerId = null;
14641
- }
14642
- }
14643
15326
  /** Current lifecycle phase as reflected on `data-state`. */
14644
15327
  get #state() {
14645
15328
  return this.element.getAttribute("data-state") ?? "idle";
@@ -14652,26 +15335,63 @@ var StepIndicatorController = class extends Controller {
14652
15335
  };
14653
15336
  static actions = ["setCurrent"];
14654
15337
  static events = ["change"];
15338
+ /**
15339
+ * Whether the target callbacks may render. Stimulus reports the authored steps
15340
+ * as connected before `connect()` and the remaining ones as disconnected after
15341
+ * `disconnect()`, so this keeps a connect at one render pass, not one per step.
15342
+ */
15343
+ /**
15344
+ * Collapses a batch of step callbacks — and a morph that swaps `current` with
15345
+ * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
15346
+ * each one would otherwise rewrite every step's state.
15347
+ */
15348
+ #repaint = new MicrotaskCoalescer(() => this.#render());
14655
15349
  /** Renders the initial state from the `current` value. */
14656
15350
  connect() {
15351
+ this.#repaint.activate();
14657
15352
  this.#render();
14658
15353
  }
15354
+ /** Closes the window in which a queued repaint may still run. */
15355
+ disconnect() {
15356
+ this.#repaint.cancel();
15357
+ }
15358
+ /** Syncs a step appended or replaced at runtime (the consumer owns the list). */
15359
+ stepTargetConnected() {
15360
+ this.#repaint.schedule();
15361
+ }
15362
+ /** Re-derives the remaining steps when one is removed at runtime. */
15363
+ stepTargetDisconnected() {
15364
+ this.#repaint.schedule();
15365
+ }
15366
+ /** Repaints when application code (or a Turbo morph) changes `current` at runtime. */
15367
+ currentValueChanged() {
15368
+ this.#repaint.schedule();
15369
+ }
14659
15370
  /**
14660
15371
  * 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.
15372
+ * and dispatches `change`. Out-of-range indices are clamped to the step set,
15373
+ * and both sides of the no-op test are clamped, so moving onto the step an
15374
+ * out-of-range `current` already renders is not reported as a change.
14662
15375
  */
14663
15376
  setCurrent(event) {
14664
15377
  const next = event.detail?.current;
14665
15378
  if (typeof next !== "number" || !Number.isFinite(next)) return;
14666
15379
  const clamped = this.#clamp(next);
14667
- if (clamped === this.currentValue) return;
15380
+ const moved = clamped !== this.#clamp(this.currentValue);
14668
15381
  this.currentValue = clamped;
15382
+ if (!moved) return;
14669
15383
  this.#render();
14670
15384
  this.dispatch("change", {
14671
15385
  detail: { current: clamped, total: this.stepTargets.length }
14672
15386
  });
14673
15387
  }
14674
- /** Applies `data-state`, `aria-current`, and the progress ratio custom property. */
15388
+ /**
15389
+ * Applies `data-state`, `aria-current`, and the progress ratio custom property.
15390
+ *
15391
+ * A pure function of the step set and `current`, so running it again writes the
15392
+ * same values — which is what lets the action path paint synchronously (the event
15393
+ * goes out after the DOM is updated) while a coalesced pass may still follow.
15394
+ */
14675
15395
  #render() {
14676
15396
  const total = this.stepTargets.length;
14677
15397
  const current = this.#clamp(this.currentValue);
@@ -14686,10 +15406,15 @@ var StepIndicatorController = class extends Controller {
14686
15406
  const ratio = total > 1 ? current / (total - 1) : 0;
14687
15407
  this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
14688
15408
  }
14689
- /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
15409
+ /**
15410
+ * Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
15411
+ * non-finite index falls back to the first step: `current` is read from markup,
15412
+ * so an unparsable attribute arrives as `NaN` and would otherwise propagate
15413
+ * into every state hook.
15414
+ */
14690
15415
  #clamp(index) {
14691
15416
  const last = this.stepTargets.length - 1;
14692
- if (last < 0) return 0;
15417
+ if (last < 0 || !Number.isFinite(index)) return 0;
14693
15418
  return Math.min(last, Math.max(0, Math.trunc(index)));
14694
15419
  }
14695
15420
  };
@@ -14789,14 +15514,18 @@ var StickToBottomController = class extends Controller {
14789
15514
  static targets = ["content"];
14790
15515
  static values = {
14791
15516
  threshold: { type: Number, default: 80 },
14792
- behavior: { type: String, default: "auto" }
15517
+ behavior: { type: String, default: "auto" },
15518
+ pinOnConnect: { type: Boolean, default: false }
14793
15519
  };
14794
15520
  static actions = ["scrollToBottom"];
14795
15521
  static events = ["pin", "new"];
14796
15522
  #observer = null;
15523
+ /** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
15524
+ #layout = null;
14797
15525
  #pinned = false;
14798
15526
  #onScroll = () => this.#updatePinned();
14799
15527
  connect() {
15528
+ if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
14800
15529
  this.#pinned = this.#isPinned();
14801
15530
  this.#reflectPinned();
14802
15531
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
@@ -14804,21 +15533,29 @@ var StickToBottomController = class extends Controller {
14804
15533
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
14805
15534
  this.#observer.observe(this.#watched(), { childList: true });
14806
15535
  }
15536
+ if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
14807
15537
  }
14808
15538
  disconnect() {
14809
15539
  this.element.removeEventListener("scroll", this.#onScroll);
14810
15540
  this.#observer?.disconnect();
14811
15541
  this.#observer = null;
15542
+ this.#stopWaitingForLayout();
14812
15543
  }
14813
- /** Jumps to the bottom and re-pins (wired to a "new messages" button). */
15544
+ /**
15545
+ * Jumps to the bottom and re-pins (wired to a "new messages" button).
15546
+ *
15547
+ * The has-new flag clears on request — the user has acknowledged the arrival — while
15548
+ * pinned is read back from where the scroll landed: a jump that arrives by the time
15549
+ * this returns pins immediately, an animated one settles from its own scroll events,
15550
+ * and a jump the engine cannot honor leaves the container unpinned, so the next append
15551
+ * flags it again instead of being swallowed by a pinned state that does not hold.
15552
+ *
15553
+ * Which of those happens is not this method's to decide — see {@link behaviorValue}.
15554
+ */
14814
15555
  scrollToBottom() {
14815
15556
  this.#scrollToBottom();
14816
15557
  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
- }
15558
+ this.#updatePinned();
14822
15559
  }
14823
15560
  /** Follows appended children while pinned; otherwise flags new content. */
14824
15561
  #onMutations(mutations) {
@@ -14853,10 +15590,43 @@ var StickToBottomController = class extends Controller {
14853
15590
  const el = this.element;
14854
15591
  return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
14855
15592
  }
14856
- #scrollToBottom() {
15593
+ /**
15594
+ * Whether the container has a box to scroll and to measure. One that is not rendered
15595
+ * (inside a closed panel) reports every metric as 0, which reads as "already at the
15596
+ * bottom" — a position describing no layout the user will ever see.
15597
+ */
15598
+ #measurable() {
15599
+ return this.element.clientHeight > 0;
15600
+ }
15601
+ /**
15602
+ * Holds the `pinOnConnect` jump until the container is laid out, then runs it and
15603
+ * re-reads the state — otherwise the panel opens at the top still claiming the bottom.
15604
+ */
15605
+ #pinWhenLaidOut() {
15606
+ if (typeof ResizeObserver === "undefined") return;
15607
+ this.#layout = new ResizeObserver(() => {
15608
+ if (!this.#measurable()) return;
15609
+ this.#stopWaitingForLayout();
15610
+ this.#scrollToBottom("instant");
15611
+ this.#updatePinned();
15612
+ });
15613
+ this.#layout.observe(this.element);
15614
+ }
15615
+ /** Releases the layout watch, whether or not the deferred jump ever ran. */
15616
+ #stopWaitingForLayout() {
15617
+ this.#layout?.disconnect();
15618
+ this.#layout = null;
15619
+ }
15620
+ /**
15621
+ * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
15622
+ * 0 for a container tall enough to hold its whole content, so the jump moves nothing
15623
+ * there. `behavior` defaults to the configured follow behavior; pass `"instant"` for a
15624
+ * jump that must not animate.
15625
+ */
15626
+ #scrollToBottom(behavior = this.#behavior()) {
14857
15627
  const top = this.element.scrollHeight;
14858
15628
  if (typeof this.element.scrollTo === "function") {
14859
- this.element.scrollTo({ top, behavior: this.#behavior() });
15629
+ this.element.scrollTo({ top, behavior });
14860
15630
  } else {
14861
15631
  this.element.scrollTop = top;
14862
15632
  }
@@ -14865,7 +15635,12 @@ var StickToBottomController = class extends Controller {
14865
15635
  #watched() {
14866
15636
  return this.hasContentTarget ? this.contentTarget : this.element;
14867
15637
  }
14868
- /** Forces reduced-motion jumps while preserving the configured normal behavior. */
15638
+ /**
15639
+ * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
15640
+ * once: it hands the decision to the element's computed `scroll-behavior`, so a
15641
+ * consumer stylesheet saying `smooth` animates these scrolls too. Only `"instant"`
15642
+ * overrides that CSS, which is why reduced motion and the `pinOnConnect` jump name it.
15643
+ */
14869
15644
  #behavior() {
14870
15645
  if (prefersReducedMotion()) return "instant";
14871
15646
  return this.behaviorValue === "smooth" ? "smooth" : "auto";