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