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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/color_picker_controller.js +46 -0
- data/dist/controllers/command_palette_controller.js +32 -5
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/date_range_picker_controller.js +50 -0
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/local_time_controller.js +100 -6
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/overflow_menu_controller.js +33 -6
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/range_slider_controller.js +68 -3
- data/dist/controllers/rating_controller.js +53 -0
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +60 -10
- data/dist/index.js +1056 -281
- data/lib/stimeo/ui/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
*
|
|
711
|
+
* Queues `message` for its politeness and arms the drain.
|
|
554
712
|
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
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
|
|
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.#
|
|
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.#
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
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.#
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
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.#
|
|
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
|
|
3908
|
-
*
|
|
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
|
|
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 =
|
|
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 /
|
|
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
|
-
|
|
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.#
|
|
6773
|
+
this.#floor.cancel();
|
|
6381
6774
|
if (!this.#loading) this.#begin();
|
|
6382
6775
|
};
|
|
6383
6776
|
#onEnd = () => {
|
|
6384
6777
|
if (!this.#loading) return;
|
|
6385
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
/**
|
|
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
|
|
7564
|
-
|
|
7565
|
-
|
|
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
|
|
8065
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
7595
8066
|
get #locale() {
|
|
7596
|
-
return this.localeValue || this.element.lang
|
|
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
|
-
|
|
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
|
|
8489
|
-
*
|
|
8490
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
8505
|
-
this.element.setAttribute("data-state",
|
|
8506
|
-
this.#applyValueText(
|
|
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
|
-
*
|
|
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.
|
|
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(
|
|
8519
|
-
const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{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
|
-
/**
|
|
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
|
|
10410
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
12523
|
+
/** Repaints when application code (or a Turbo morph) changes `indeterminate` at runtime. */
|
|
12010
12524
|
indeterminateValueChanged() {
|
|
12011
|
-
this.#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
12839
|
+
String(rangeFraction(start, this.minValue, this.maxValue))
|
|
12287
12840
|
);
|
|
12288
12841
|
this.element.style.setProperty(
|
|
12289
12842
|
END_PROPERTY,
|
|
12290
|
-
String(
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
13280
|
+
const formatter = this.#formatter;
|
|
13281
|
+
if (formatter === null) return null;
|
|
12656
13282
|
const value = Math.round(deltaMs / scale.ms);
|
|
12657
|
-
this.element.textContent =
|
|
13283
|
+
this.element.textContent = formatter.format(value, scale.unit);
|
|
12658
13284
|
this.element.setAttribute("data-state", "relative");
|
|
12659
|
-
|
|
12660
|
-
return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
|
|
13285
|
+
return nextDelay;
|
|
12661
13286
|
}
|
|
12662
|
-
/**
|
|
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
|
-
|
|
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
|
|
13298
|
+
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
12667
13299
|
get #locale() {
|
|
12668
|
-
return this.localeValue || this.element.lang
|
|
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(
|
|
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 (!
|
|
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 = ["
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
14144
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
|
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
|
|
14558
|
-
#
|
|
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.#
|
|
14568
|
-
this.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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";
|