stimeo-ui 0.1.0.pre.beta.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -0
- data/README.md +6 -6
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dialog_controller.js +94 -8
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/editable_controller.js +168 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +144 -38
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +1644 -643
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +28 -2
data/dist/index.js
CHANGED
|
@@ -71,6 +71,96 @@ var AccordionController = class extends Controller {
|
|
|
71
71
|
}
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
// src/utils/escape_layer.ts
|
|
75
|
+
function claimsWhileFocusWithin(element) {
|
|
76
|
+
return () => {
|
|
77
|
+
const active = element.ownerDocument.activeElement;
|
|
78
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
var EscapeLayer = class _EscapeLayer {
|
|
82
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
83
|
+
#ownerDocument = null;
|
|
84
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
85
|
+
#onDismiss = null;
|
|
86
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
87
|
+
#claims = null;
|
|
88
|
+
/**
|
|
89
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
90
|
+
* the document's shared resolver listener if this is its first layer.
|
|
91
|
+
* Re-activating an already-active layer moves it to the top.
|
|
92
|
+
*/
|
|
93
|
+
activate(ownerDocument = document, options) {
|
|
94
|
+
this.deactivate();
|
|
95
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
96
|
+
if (!registry) {
|
|
97
|
+
registry = _EscapeLayer.#createRegistry();
|
|
98
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
99
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
100
|
+
}
|
|
101
|
+
registry.stack.push(this);
|
|
102
|
+
this.#ownerDocument = ownerDocument;
|
|
103
|
+
this.#onDismiss = options.onDismiss;
|
|
104
|
+
this.#claims = options.claims ?? null;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
108
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
109
|
+
*/
|
|
110
|
+
deactivate() {
|
|
111
|
+
const ownerDocument = this.#ownerDocument;
|
|
112
|
+
if (!ownerDocument) return;
|
|
113
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
114
|
+
if (registry) {
|
|
115
|
+
const index = registry.stack.lastIndexOf(this);
|
|
116
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
117
|
+
if (registry.stack.length === 0) {
|
|
118
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
119
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
this.#ownerDocument = null;
|
|
123
|
+
this.#onDismiss = null;
|
|
124
|
+
this.#claims = null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
128
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
129
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
130
|
+
*/
|
|
131
|
+
get ownsEscape() {
|
|
132
|
+
const ownerDocument = this.#ownerDocument;
|
|
133
|
+
if (!ownerDocument) return false;
|
|
134
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
135
|
+
if (!registry) return false;
|
|
136
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
137
|
+
}
|
|
138
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
139
|
+
static #createRegistry() {
|
|
140
|
+
const registry = {
|
|
141
|
+
stack: [],
|
|
142
|
+
onKeydown: (event) => {
|
|
143
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
144
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
145
|
+
if (!owner) return;
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
owner.#onDismiss?.();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
return registry;
|
|
151
|
+
}
|
|
152
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
153
|
+
static #resolveOwner(stack) {
|
|
154
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
155
|
+
const layer = stack[index];
|
|
156
|
+
if (!layer) continue;
|
|
157
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
158
|
+
return layer;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
74
164
|
// src/utils/focus_trap.ts
|
|
75
165
|
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
76
166
|
var FocusTrap = class {
|
|
@@ -84,6 +174,8 @@ var FocusTrap = class {
|
|
|
84
174
|
#inertedSiblings = [];
|
|
85
175
|
/** Whether the modal side effects are currently applied. */
|
|
86
176
|
#activeState = false;
|
|
177
|
+
/** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
|
|
178
|
+
#escapeLayer = new EscapeLayer();
|
|
87
179
|
/** Returns the trapped element; called on every operation for the live target. */
|
|
88
180
|
#getContainer;
|
|
89
181
|
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
@@ -119,6 +211,8 @@ var FocusTrap = class {
|
|
|
119
211
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
120
212
|
document.addEventListener("keydown", this.#onKeydown);
|
|
121
213
|
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
214
|
+
const onEscape = this.#options.onEscape;
|
|
215
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
122
216
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
123
217
|
}
|
|
124
218
|
/**
|
|
@@ -131,6 +225,7 @@ var FocusTrap = class {
|
|
|
131
225
|
deactivate({ restoreFocus = true } = {}) {
|
|
132
226
|
if (!this.#activeState) return;
|
|
133
227
|
this.#activeState = false;
|
|
228
|
+
this.#escapeLayer.deactivate();
|
|
134
229
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
135
230
|
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
136
231
|
if (this.#scrollLocked) {
|
|
@@ -159,15 +254,12 @@ var FocusTrap = class {
|
|
|
159
254
|
#onBeforeCache = () => {
|
|
160
255
|
this.deactivate({ restoreFocus: false });
|
|
161
256
|
};
|
|
162
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
259
|
+
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
260
|
+
* which layer currently owns Escape.
|
|
261
|
+
*/
|
|
163
262
|
#onKeydown = (event) => {
|
|
164
|
-
if (event.key === "Escape") {
|
|
165
|
-
if (this.#options.onEscape) {
|
|
166
|
-
event.preventDefault();
|
|
167
|
-
this.#options.onEscape();
|
|
168
|
-
}
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
263
|
if (event.key === "Tab") this.#trapTab(event);
|
|
172
264
|
};
|
|
173
265
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
@@ -515,6 +607,55 @@ var AspectRatioController = class extends Controller {
|
|
|
515
607
|
return value !== void 0 && Number.isFinite(value) && value > 0;
|
|
516
608
|
}
|
|
517
609
|
};
|
|
610
|
+
|
|
611
|
+
// src/utils/composition_tracker.ts
|
|
612
|
+
var CompositionTracker = class {
|
|
613
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
614
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
615
|
+
#onStart;
|
|
616
|
+
#onEnd;
|
|
617
|
+
constructor(options = {}) {
|
|
618
|
+
this.#onStart = options.onStart;
|
|
619
|
+
this.#onEnd = options.onEnd;
|
|
620
|
+
}
|
|
621
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
622
|
+
observe(target) {
|
|
623
|
+
if (this.#observedTargets.has(target)) return;
|
|
624
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
625
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
626
|
+
this.#observedTargets.add(target);
|
|
627
|
+
}
|
|
628
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
629
|
+
unobserve(target) {
|
|
630
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
631
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
632
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
633
|
+
this.#activeTargets.delete(target);
|
|
634
|
+
}
|
|
635
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
636
|
+
disconnect() {
|
|
637
|
+
for (const target of this.#observedTargets) {
|
|
638
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
639
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
640
|
+
}
|
|
641
|
+
this.#observedTargets.clear();
|
|
642
|
+
this.#activeTargets.clear();
|
|
643
|
+
}
|
|
644
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
645
|
+
isComposing(event) {
|
|
646
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
647
|
+
}
|
|
648
|
+
#handleStart = (event) => {
|
|
649
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
650
|
+
this.#onStart?.(event);
|
|
651
|
+
};
|
|
652
|
+
#handleEnd = (event) => {
|
|
653
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
654
|
+
this.#onEnd?.(event);
|
|
655
|
+
};
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// src/controllers/auto_submit_controller.ts
|
|
518
659
|
var AutoSubmitController = class extends Controller {
|
|
519
660
|
static targets = ["form"];
|
|
520
661
|
static values = {
|
|
@@ -538,34 +679,22 @@ var AutoSubmitController = class extends Controller {
|
|
|
538
679
|
window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
|
|
539
680
|
}
|
|
540
681
|
};
|
|
541
|
-
/**
|
|
542
|
-
#
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
};
|
|
547
|
-
/**
|
|
548
|
-
* Composition finished (the IME conversion is confirmed): clear the flag and
|
|
549
|
-
* schedule a submit as if `input` fired, so the settled text triggers a submit
|
|
550
|
-
* even on browsers whose post-composition `input` still reads `isComposing`.
|
|
551
|
-
*/
|
|
552
|
-
#onCompositionEnd = (event) => {
|
|
553
|
-
this.#composing = false;
|
|
554
|
-
if (this.#triggers("input")) this.#schedule(event.target ?? null);
|
|
555
|
-
};
|
|
682
|
+
/** Owns delegated IME lifecycle state and submits confirmed input text. */
|
|
683
|
+
#composition = new CompositionTracker({
|
|
684
|
+
onEnd: (event) => {
|
|
685
|
+
if (this.#triggers("input")) this.#schedule(event.target ?? null);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
556
688
|
connect() {
|
|
557
689
|
this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
558
|
-
this.#
|
|
559
|
-
this.#form.addEventListener("compositionend", this.#onCompositionEnd);
|
|
690
|
+
this.#composition.observe(this.#form);
|
|
560
691
|
}
|
|
561
692
|
disconnect() {
|
|
562
693
|
this.#timers.clearAll();
|
|
563
694
|
this.#pendingId = 0;
|
|
564
|
-
this.#
|
|
695
|
+
this.#composition.disconnect();
|
|
565
696
|
this.#form.removeAttribute("data-auto-submit-pending");
|
|
566
697
|
this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
567
|
-
this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
|
|
568
|
-
this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
569
698
|
}
|
|
570
699
|
/**
|
|
571
700
|
* Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
|
|
@@ -574,7 +703,7 @@ var AutoSubmitController = class extends Controller {
|
|
|
574
703
|
*/
|
|
575
704
|
submit(event) {
|
|
576
705
|
if (!this.#triggers(event.type)) return;
|
|
577
|
-
if (event.type === "input" &&
|
|
706
|
+
if (event.type === "input" && this.#composition.isComposing(event)) return;
|
|
578
707
|
this.#schedule(event.target ?? null);
|
|
579
708
|
}
|
|
580
709
|
/** Schedules (and coalesces) the debounced submit for the given trigger. */
|
|
@@ -1443,35 +1572,25 @@ var CharacterCounterController = class _CharacterCounterController extends Contr
|
|
|
1443
1572
|
static #announceDelay = 200;
|
|
1444
1573
|
#timeouts = new SafeTimeout();
|
|
1445
1574
|
#announceId = null;
|
|
1446
|
-
/**
|
|
1447
|
-
#
|
|
1575
|
+
/** Owns IME lifecycle state and applies the confirmed count once. */
|
|
1576
|
+
#composition = new CompositionTracker({ onEnd: () => this.#update() });
|
|
1448
1577
|
#onInput = (event) => {
|
|
1449
|
-
if (this.#
|
|
1450
|
-
this.#update();
|
|
1451
|
-
};
|
|
1452
|
-
#onCompositionStart = () => {
|
|
1453
|
-
this.#composing = true;
|
|
1454
|
-
};
|
|
1455
|
-
#onCompositionEnd = () => {
|
|
1456
|
-
this.#composing = false;
|
|
1578
|
+
if (this.#composition.isComposing(event)) return;
|
|
1457
1579
|
this.#update();
|
|
1458
1580
|
};
|
|
1459
1581
|
connect() {
|
|
1460
1582
|
const field = this.#field;
|
|
1461
1583
|
if (!field) return;
|
|
1462
1584
|
field.addEventListener("input", this.#onInput);
|
|
1463
|
-
|
|
1464
|
-
field.addEventListener("compositionend", this.#onCompositionEnd);
|
|
1585
|
+
this.#composition.observe(field);
|
|
1465
1586
|
this.#update({ announce: false });
|
|
1466
1587
|
}
|
|
1467
1588
|
disconnect() {
|
|
1468
1589
|
const field = this.#field;
|
|
1469
1590
|
field?.removeEventListener("input", this.#onInput);
|
|
1470
|
-
|
|
1471
|
-
field?.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
1591
|
+
this.#composition.disconnect();
|
|
1472
1592
|
this.#timeouts.clearAll();
|
|
1473
1593
|
this.#announceId = null;
|
|
1474
|
-
this.#composing = false;
|
|
1475
1594
|
}
|
|
1476
1595
|
/**
|
|
1477
1596
|
* Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)
|
|
@@ -1695,28 +1814,176 @@ var ClipboardController = class extends Controller {
|
|
|
1695
1814
|
}
|
|
1696
1815
|
}
|
|
1697
1816
|
};
|
|
1817
|
+
|
|
1818
|
+
// src/utils/transition_completion.ts
|
|
1819
|
+
function timeMs(value) {
|
|
1820
|
+
const trimmed = value.trim();
|
|
1821
|
+
const amount = Number.parseFloat(trimmed);
|
|
1822
|
+
if (!Number.isFinite(amount)) return 0;
|
|
1823
|
+
if (trimmed.endsWith("ms")) return amount;
|
|
1824
|
+
if (trimmed.endsWith("s")) return amount * 1e3;
|
|
1825
|
+
return 0;
|
|
1826
|
+
}
|
|
1827
|
+
function cssList(value) {
|
|
1828
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1829
|
+
}
|
|
1830
|
+
function transitionTimings(style) {
|
|
1831
|
+
const properties = cssList(style.transitionProperty);
|
|
1832
|
+
const durations = cssList(style.transitionDuration).map(timeMs);
|
|
1833
|
+
const delays = cssList(style.transitionDelay).map(timeMs);
|
|
1834
|
+
const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
|
|
1835
|
+
const effectiveDurations = durations.length > 0 ? durations : [0];
|
|
1836
|
+
const effectiveDelays = delays.length > 0 ? delays : [0];
|
|
1837
|
+
return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
|
|
1838
|
+
property,
|
|
1839
|
+
totalMs: Math.max(
|
|
1840
|
+
0,
|
|
1841
|
+
(effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
|
|
1842
|
+
)
|
|
1843
|
+
}));
|
|
1844
|
+
}
|
|
1845
|
+
function maxTotalMs(timings) {
|
|
1846
|
+
return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
|
|
1847
|
+
}
|
|
1848
|
+
function maxTransitionTotalMs(style) {
|
|
1849
|
+
return maxTotalMs(transitionTimings(style));
|
|
1850
|
+
}
|
|
1851
|
+
var TransitionCompletion = class {
|
|
1852
|
+
#timers = new SafeTimeout();
|
|
1853
|
+
#element = null;
|
|
1854
|
+
#complete = null;
|
|
1855
|
+
#pendingProperties = null;
|
|
1856
|
+
#deadline = 0;
|
|
1857
|
+
/**
|
|
1858
|
+
* Replaces any prior wait and invokes `complete` synchronously for a 0ms
|
|
1859
|
+
* transition (including when `getComputedStyle` is unavailable).
|
|
1860
|
+
*
|
|
1861
|
+
* With a positive `options.timeoutMs` the synchronous fast-path is skipped and
|
|
1862
|
+
* the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
|
|
1863
|
+
*/
|
|
1864
|
+
wait(element, complete, options = {}) {
|
|
1865
|
+
this.cancel();
|
|
1866
|
+
const requested = options.timeoutMs ?? 0;
|
|
1867
|
+
const override = Number.isFinite(requested) && requested > 0 ? requested : 0;
|
|
1868
|
+
const timings = typeof window.getComputedStyle === "function" ? transitionTimings(window.getComputedStyle(element)) : [];
|
|
1869
|
+
const maximum = maxTotalMs(timings);
|
|
1870
|
+
if (maximum <= 0 && override <= 0) {
|
|
1871
|
+
complete();
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
this.#element = element;
|
|
1875
|
+
this.#complete = complete;
|
|
1876
|
+
this.#deadline = Date.now() + maximum;
|
|
1877
|
+
const activeProperties = this.#activeTransitionProperties(element);
|
|
1878
|
+
this.#pendingProperties = activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);
|
|
1879
|
+
element.addEventListener("transitionend", this.#onTerminal);
|
|
1880
|
+
element.addEventListener("transitioncancel", this.#onTerminal);
|
|
1881
|
+
this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
|
|
1882
|
+
}
|
|
1883
|
+
/** Cancels the pending wait without invoking its completion callback. */
|
|
1884
|
+
cancel() {
|
|
1885
|
+
this.#complete = null;
|
|
1886
|
+
this.#teardown();
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Handles terminal events from the observed element only.
|
|
1890
|
+
*
|
|
1891
|
+
* For explicit property lists, every declared positive-time property must
|
|
1892
|
+
* settle. For `all`, no reliable property set exists, so an event can finish
|
|
1893
|
+
* only after the computed maximum time; the safety timeout owns the usual path.
|
|
1894
|
+
*/
|
|
1895
|
+
#onTerminal = (event) => {
|
|
1896
|
+
if (event.target !== this.#element) return;
|
|
1897
|
+
const transitionEvent = event;
|
|
1898
|
+
if (transitionEvent.pseudoElement) return;
|
|
1899
|
+
if (this.#pendingProperties) {
|
|
1900
|
+
const propertyName = transitionEvent.propertyName;
|
|
1901
|
+
const active = this.#activeTransitionProperties(this.#element);
|
|
1902
|
+
if (active.has(propertyName)) return;
|
|
1903
|
+
if (!this.#pendingProperties.delete(propertyName)) return;
|
|
1904
|
+
if (this.#pendingProperties.size > 0) return;
|
|
1905
|
+
if (active.size > 0) {
|
|
1906
|
+
this.#pendingProperties = active;
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
this.#finish();
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
if (Date.now() >= this.#deadline) this.#finish();
|
|
1913
|
+
};
|
|
1914
|
+
/**
|
|
1915
|
+
* Returns active CSS transition properties expanded to the names reported by
|
|
1916
|
+
* terminal events. CSS animations and pseudo-element effects are excluded.
|
|
1917
|
+
*/
|
|
1918
|
+
#activeTransitionProperties(element) {
|
|
1919
|
+
if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
|
|
1920
|
+
try {
|
|
1921
|
+
const properties = element.getAnimations().flatMap((animation) => {
|
|
1922
|
+
if (animation.playState === "idle" || animation.playState === "finished") return [];
|
|
1923
|
+
const effect = animation.effect;
|
|
1924
|
+
if (effect?.pseudoElement) return [];
|
|
1925
|
+
const target = effect?.target;
|
|
1926
|
+
if (target && target !== element) return [];
|
|
1927
|
+
const property = animation.transitionProperty;
|
|
1928
|
+
return typeof property === "string" && property.length > 0 ? [property] : [];
|
|
1929
|
+
});
|
|
1930
|
+
return new Set(properties);
|
|
1931
|
+
} catch {
|
|
1932
|
+
return /* @__PURE__ */ new Set();
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
/** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
|
|
1936
|
+
#explicitPendingProperties(timings) {
|
|
1937
|
+
if (timings.some(({ property }) => property === "all")) return null;
|
|
1938
|
+
const pending = new Set(
|
|
1939
|
+
timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property)
|
|
1940
|
+
);
|
|
1941
|
+
return pending.size > 0 ? pending : null;
|
|
1942
|
+
}
|
|
1943
|
+
/** Completes exactly once, releasing listeners and the fallback before the callback. */
|
|
1944
|
+
#finish() {
|
|
1945
|
+
const complete = this.#complete;
|
|
1946
|
+
if (!complete) return;
|
|
1947
|
+
this.#complete = null;
|
|
1948
|
+
this.#teardown();
|
|
1949
|
+
complete();
|
|
1950
|
+
}
|
|
1951
|
+
/** Releases the exact element listeners and timer owned by the current wait. */
|
|
1952
|
+
#teardown() {
|
|
1953
|
+
this.#timers.clearAll();
|
|
1954
|
+
this.#element?.removeEventListener("transitionend", this.#onTerminal);
|
|
1955
|
+
this.#element?.removeEventListener("transitioncancel", this.#onTerminal);
|
|
1956
|
+
this.#element = null;
|
|
1957
|
+
this.#pendingProperties = null;
|
|
1958
|
+
this.#deadline = 0;
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
// src/controllers/collapsible_controller.ts
|
|
1698
1963
|
var CollapsibleController = class extends Controller {
|
|
1699
1964
|
static targets = ["trigger", "content"];
|
|
1700
1965
|
static values = {
|
|
1701
1966
|
open: { type: Boolean, default: false }
|
|
1702
1967
|
};
|
|
1703
1968
|
static actions = ["toggle"];
|
|
1969
|
+
/** Owns the cancellable close-transition wait and its bounded fallback. */
|
|
1970
|
+
#transition = new TransitionCompletion();
|
|
1971
|
+
/** Distinguishes dynamic target churn from the callbacks that precede `connect()`. */
|
|
1972
|
+
#connected = false;
|
|
1704
1973
|
/**
|
|
1705
|
-
*
|
|
1706
|
-
* Tracked so {@link disconnect} can detach it and a reopen can supersede it.
|
|
1707
|
-
*/
|
|
1708
|
-
#pendingTransitionEnd = null;
|
|
1709
|
-
/**
|
|
1710
|
-
* Establishes the initial open/closed state without animating.
|
|
1974
|
+
* Establishes the initial open/closed state without waiting for a close transition.
|
|
1711
1975
|
*
|
|
1712
1976
|
* The DOM is the source of truth on reconnect (Turbo cache restore / morph): an
|
|
1713
1977
|
* **explicit** state attribute — `aria-expanded="true"`/`"false"` (or, with no
|
|
1714
1978
|
* trigger, `data-state="open"`/`"closed"`) — is honored verbatim so a region the
|
|
1715
1979
|
* user opened *or* closed survives a back-navigation, even when the declarative
|
|
1716
|
-
* `open` Value disagrees.
|
|
1717
|
-
*
|
|
1980
|
+
* `open` Value disagrees. An already-open region remains open without a
|
|
1981
|
+
* close/reopen cycle. The Value only seeds a genuinely fresh render where no
|
|
1982
|
+
* state attribute is present yet; any opening animation in that case belongs to
|
|
1983
|
+
* the consumer's CSS. Mirrors `sidebar`'s `#restoreCollapsed`.
|
|
1718
1984
|
*/
|
|
1719
1985
|
connect() {
|
|
1986
|
+
this.#connected = true;
|
|
1720
1987
|
this.#apply(this.#initialOpen(), false);
|
|
1721
1988
|
}
|
|
1722
1989
|
/** Resolves the connect-time state: explicit DOM state wins, else the `open` Value. */
|
|
@@ -1733,7 +2000,27 @@ var CollapsibleController = class extends Controller {
|
|
|
1733
2000
|
return this.openValue;
|
|
1734
2001
|
}
|
|
1735
2002
|
disconnect() {
|
|
1736
|
-
this.#
|
|
2003
|
+
this.#connected = false;
|
|
2004
|
+
this.#transition.cancel();
|
|
2005
|
+
}
|
|
2006
|
+
/** Reconciles a replacement trigger target with the content's live state. */
|
|
2007
|
+
triggerTargetConnected(trigger) {
|
|
2008
|
+
if (!this.#connected || !this.hasContentTarget) return;
|
|
2009
|
+
trigger.setAttribute(
|
|
2010
|
+
"aria-expanded",
|
|
2011
|
+
this.contentTarget.getAttribute("data-state") === "open" ? "true" : "false"
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
/** Reconciles a replacement content target with the disclosure's live state. */
|
|
2015
|
+
contentTargetConnected(content) {
|
|
2016
|
+
if (!this.#connected) return;
|
|
2017
|
+
this.#transition.cancel();
|
|
2018
|
+
const open = this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-expanded") === "true" : content.getAttribute("data-state") === "open";
|
|
2019
|
+
this.#applyContent(content, open, false);
|
|
2020
|
+
}
|
|
2021
|
+
/** Cancels a wait tied to a content target that was removed or replaced. */
|
|
2022
|
+
contentTargetDisconnected() {
|
|
2023
|
+
this.#transition.cancel();
|
|
1737
2024
|
}
|
|
1738
2025
|
/** Toggles the region open/closed. Bound via `data-action` (click). */
|
|
1739
2026
|
toggle() {
|
|
@@ -1756,16 +2043,21 @@ var CollapsibleController = class extends Controller {
|
|
|
1756
2043
|
* spec requires.
|
|
1757
2044
|
*
|
|
1758
2045
|
* @param open - Target state.
|
|
1759
|
-
* @param
|
|
1760
|
-
* `hidden` immediately
|
|
2046
|
+
* @param waitForCloseTransition - When `false` (initial `connect`) the close
|
|
2047
|
+
* path applies `hidden` immediately. This flag does not suppress consumer CSS
|
|
2048
|
+
* on the open path.
|
|
1761
2049
|
*/
|
|
1762
|
-
#apply(open,
|
|
2050
|
+
#apply(open, waitForCloseTransition) {
|
|
1763
2051
|
if (this.hasTriggerTarget) {
|
|
1764
2052
|
this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1765
2053
|
}
|
|
1766
2054
|
if (!this.hasContentTarget) return;
|
|
1767
2055
|
const content = this.contentTarget;
|
|
1768
|
-
this.#
|
|
2056
|
+
this.#transition.cancel();
|
|
2057
|
+
this.#applyContent(content, open, waitForCloseTransition);
|
|
2058
|
+
}
|
|
2059
|
+
/** Reflects one content target without relying on a later target lookup. */
|
|
2060
|
+
#applyContent(content, open, waitForCloseTransition) {
|
|
1769
2061
|
if (open) {
|
|
1770
2062
|
content.hidden = false;
|
|
1771
2063
|
content.style.setProperty("--stimeo-collapsible-content-height", `${content.scrollHeight}px`);
|
|
@@ -1773,44 +2065,23 @@ var CollapsibleController = class extends Controller {
|
|
|
1773
2065
|
return;
|
|
1774
2066
|
}
|
|
1775
2067
|
content.setAttribute("data-state", "closed");
|
|
1776
|
-
if (
|
|
2068
|
+
if (waitForCloseTransition) {
|
|
1777
2069
|
this.#applyHiddenAfterTransition(content);
|
|
1778
2070
|
} else {
|
|
1779
2071
|
content.hidden = true;
|
|
1780
2072
|
}
|
|
1781
2073
|
}
|
|
1782
2074
|
/**
|
|
1783
|
-
* Re-applies `hidden` once the close transition
|
|
1784
|
-
* reopen mid-transition
|
|
1785
|
-
*
|
|
2075
|
+
* Re-applies `hidden` once the close transition settles. Guarded against a
|
|
2076
|
+
* reopen mid-transition; the shared waiter also guarantees a bounded fallback
|
|
2077
|
+
* when the browser emits no terminal transition event.
|
|
1786
2078
|
*/
|
|
1787
2079
|
#applyHiddenAfterTransition(content) {
|
|
1788
|
-
|
|
1789
|
-
if (event.target !== content) return;
|
|
1790
|
-
this.#detachTransitionEnd();
|
|
2080
|
+
this.#transition.wait(content, () => {
|
|
1791
2081
|
if (content.getAttribute("data-state") === "closed") {
|
|
1792
2082
|
content.hidden = true;
|
|
1793
2083
|
}
|
|
1794
|
-
};
|
|
1795
|
-
this.#pendingTransitionEnd = handler;
|
|
1796
|
-
content.addEventListener("transitionend", handler);
|
|
1797
|
-
}
|
|
1798
|
-
#detachTransitionEnd() {
|
|
1799
|
-
if (this.#pendingTransitionEnd && this.hasContentTarget) {
|
|
1800
|
-
this.contentTarget.removeEventListener("transitionend", this.#pendingTransitionEnd);
|
|
1801
|
-
}
|
|
1802
|
-
this.#pendingTransitionEnd = null;
|
|
1803
|
-
}
|
|
1804
|
-
/**
|
|
1805
|
-
* First `transition-duration` of `element` in milliseconds. Browsers normalize
|
|
1806
|
-
* computed `<time>` to seconds (`0.2s`), but `ms` is parsed defensively. A zero
|
|
1807
|
-
* here — including the consumer's reduced-motion CSS — takes the immediate path.
|
|
1808
|
-
*/
|
|
1809
|
-
#transitionMs(element) {
|
|
1810
|
-
const first = window.getComputedStyle(element).transitionDuration.split(",")[0]?.trim() ?? "";
|
|
1811
|
-
const amount = Number.parseFloat(first);
|
|
1812
|
-
if (Number.isNaN(amount)) return 0;
|
|
1813
|
-
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
2084
|
+
});
|
|
1814
2085
|
}
|
|
1815
2086
|
};
|
|
1816
2087
|
var COLOR_PROPERTY = "--stimeo-color";
|
|
@@ -2045,17 +2316,30 @@ var ComboboxController = class extends Controller {
|
|
|
2045
2316
|
* does not immediately re-open the listbox via a `focus`-bound action.
|
|
2046
2317
|
*/
|
|
2047
2318
|
#suppressOpen = false;
|
|
2319
|
+
/** Owns IME lifecycle state; confirmed text re-filters the list once. */
|
|
2320
|
+
#composition = new CompositionTracker({ onEnd: () => this.filter() });
|
|
2048
2321
|
/** Starts closed with no active option and registers the outside-click listener. */
|
|
2049
2322
|
connect() {
|
|
2323
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
2050
2324
|
this.close();
|
|
2051
2325
|
document.addEventListener("click", this.#onOutsideClick);
|
|
2052
2326
|
}
|
|
2053
2327
|
/** Removes the document-level listener registered in {@link connect}. */
|
|
2054
2328
|
disconnect() {
|
|
2329
|
+
this.#composition.disconnect();
|
|
2055
2330
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
2056
2331
|
}
|
|
2057
|
-
/**
|
|
2058
|
-
|
|
2332
|
+
/** Tracks an input added initially or after connect. */
|
|
2333
|
+
inputTargetConnected(input) {
|
|
2334
|
+
this.#composition.observe(input);
|
|
2335
|
+
}
|
|
2336
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
2337
|
+
inputTargetDisconnected(input) {
|
|
2338
|
+
this.#composition.unobserve(input);
|
|
2339
|
+
}
|
|
2340
|
+
/** Filters confirmed input text and opens the listbox. */
|
|
2341
|
+
filter(event) {
|
|
2342
|
+
if (this.#composition.isComposing(event)) return;
|
|
2059
2343
|
this.open();
|
|
2060
2344
|
}
|
|
2061
2345
|
/**
|
|
@@ -2092,7 +2376,7 @@ var ComboboxController = class extends Controller {
|
|
|
2092
2376
|
}
|
|
2093
2377
|
/** Routes keyboard interaction per the APG combobox model. */
|
|
2094
2378
|
onKeydown(event) {
|
|
2095
|
-
if (
|
|
2379
|
+
if (this.#composition.isComposing(event)) return;
|
|
2096
2380
|
switch (event.key) {
|
|
2097
2381
|
case "ArrowDown": {
|
|
2098
2382
|
event.preventDefault();
|
|
@@ -2138,6 +2422,7 @@ var ComboboxController = class extends Controller {
|
|
|
2138
2422
|
break;
|
|
2139
2423
|
}
|
|
2140
2424
|
case "Escape":
|
|
2425
|
+
if (event.defaultPrevented || this.#isClosed) break;
|
|
2141
2426
|
event.preventDefault();
|
|
2142
2427
|
this.close();
|
|
2143
2428
|
break;
|
|
@@ -2214,7 +2499,9 @@ var ComboboxController = class extends Controller {
|
|
|
2214
2499
|
return !this.hasListTarget || this.listTarget.hidden !== false;
|
|
2215
2500
|
}
|
|
2216
2501
|
};
|
|
2217
|
-
var CommandPaletteController = class extends Controller {
|
|
2502
|
+
var CommandPaletteController = class _CommandPaletteController extends Controller {
|
|
2503
|
+
static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
|
|
2504
|
+
static #ABSENT_ARIA_DISABLED = "absent";
|
|
2218
2505
|
static targets = ["dialog", "input", "list", "option", "empty"];
|
|
2219
2506
|
static values = {
|
|
2220
2507
|
hotkey: { type: String, default: "mod+k" },
|
|
@@ -2232,6 +2519,12 @@ var CommandPaletteController = class extends Controller {
|
|
|
2232
2519
|
static events = ["select"];
|
|
2233
2520
|
/** The index of the currently active option within the visible subset. */
|
|
2234
2521
|
#activeIndex = -1;
|
|
2522
|
+
/** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
|
|
2523
|
+
#composition = new CompositionTracker({
|
|
2524
|
+
onEnd: () => {
|
|
2525
|
+
if (this.#isOpen) this.filter();
|
|
2526
|
+
}
|
|
2527
|
+
});
|
|
2235
2528
|
/**
|
|
2236
2529
|
* Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
|
|
2237
2530
|
* restore). Escape closes; focus on open goes to the input, and is restored to
|
|
@@ -2254,6 +2547,8 @@ var CommandPaletteController = class extends Controller {
|
|
|
2254
2547
|
*/
|
|
2255
2548
|
connect() {
|
|
2256
2549
|
document.addEventListener("keydown", this.#onGlobalKeydown);
|
|
2550
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
2551
|
+
this.#syncOptionSemantics();
|
|
2257
2552
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
2258
2553
|
this.#resetToClosedState();
|
|
2259
2554
|
if (shouldOpen) this.open();
|
|
@@ -2261,9 +2556,22 @@ var CommandPaletteController = class extends Controller {
|
|
|
2261
2556
|
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
2262
2557
|
disconnect() {
|
|
2263
2558
|
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
2559
|
+
this.#composition.disconnect();
|
|
2264
2560
|
this.#trap.deactivate({ restoreFocus: false });
|
|
2265
2561
|
this.#resetToClosedState();
|
|
2266
2562
|
}
|
|
2563
|
+
/** Initializes semantics for an option added before or after the controller connects. */
|
|
2564
|
+
optionTargetConnected(option) {
|
|
2565
|
+
this.#syncOptionSemanticsFor(option);
|
|
2566
|
+
}
|
|
2567
|
+
/** Tracks an input added initially or after connect without extra consumer actions. */
|
|
2568
|
+
inputTargetConnected(input) {
|
|
2569
|
+
this.#composition.observe(input);
|
|
2570
|
+
}
|
|
2571
|
+
/** Removes controller-owned listeners when the input target is replaced or removed. */
|
|
2572
|
+
inputTargetDisconnected(input) {
|
|
2573
|
+
this.#composition.unobserve(input);
|
|
2574
|
+
}
|
|
2267
2575
|
/** Toggles the open state of the command palette. */
|
|
2268
2576
|
toggle() {
|
|
2269
2577
|
if (this.#isOpen) {
|
|
@@ -2288,8 +2596,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
2288
2596
|
this.#trap.deactivate();
|
|
2289
2597
|
}
|
|
2290
2598
|
/** Filters option elements in-memory matching the input value. Bound to input target. */
|
|
2291
|
-
filter() {
|
|
2292
|
-
if (!this.hasInputTarget) return;
|
|
2599
|
+
filter(event) {
|
|
2600
|
+
if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
|
|
2601
|
+
this.#syncOptionSemantics();
|
|
2293
2602
|
const query = this.inputTarget.value.trim().toLowerCase();
|
|
2294
2603
|
let hasSelectableMatch = false;
|
|
2295
2604
|
for (const option of this.optionTargets) {
|
|
@@ -2312,6 +2621,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
2312
2621
|
const target = event.currentTarget;
|
|
2313
2622
|
if (!target) return;
|
|
2314
2623
|
const option = target.closest("[role='option']");
|
|
2624
|
+
if (option) this.#syncOptionSemanticsFor(option);
|
|
2315
2625
|
if (option && !this.#isDisabled(option)) {
|
|
2316
2626
|
this.#confirmSelection(option);
|
|
2317
2627
|
}
|
|
@@ -2327,7 +2637,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
2327
2637
|
* focus — not only the input.
|
|
2328
2638
|
*/
|
|
2329
2639
|
onKeydown(event) {
|
|
2330
|
-
if (!this.#isOpen) return;
|
|
2640
|
+
if (this.#composition.isComposing(event) || !this.#isOpen) return;
|
|
2331
2641
|
switch (event.key) {
|
|
2332
2642
|
case "ArrowDown":
|
|
2333
2643
|
event.preventDefault();
|
|
@@ -2362,23 +2672,25 @@ var CommandPaletteController = class extends Controller {
|
|
|
2362
2672
|
this.#setActiveIndex(newIndex);
|
|
2363
2673
|
}
|
|
2364
2674
|
#setActiveIndex(index) {
|
|
2675
|
+
this.#syncOptionSemantics();
|
|
2365
2676
|
const visible = this.#visibleOptions;
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2677
|
+
const activeOption = visible[index] ?? null;
|
|
2678
|
+
this.#activeIndex = activeOption ? index : -1;
|
|
2679
|
+
for (const option of this.optionTargets) {
|
|
2680
|
+
option.setAttribute("aria-selected", "false");
|
|
2681
|
+
option.removeAttribute("data-active");
|
|
2682
|
+
}
|
|
2683
|
+
if (activeOption) {
|
|
2684
|
+
activeOption.setAttribute("aria-selected", "true");
|
|
2685
|
+
activeOption.setAttribute("data-active", "true");
|
|
2686
|
+
activeOption.scrollIntoView({ block: "nearest" });
|
|
2687
|
+
}
|
|
2688
|
+
if (this.hasInputTarget) {
|
|
2689
|
+
if (activeOption?.id) {
|
|
2690
|
+
this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
|
|
2375
2691
|
} else {
|
|
2376
|
-
|
|
2377
|
-
option.removeAttribute("data-active");
|
|
2692
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
2378
2693
|
}
|
|
2379
|
-
});
|
|
2380
|
-
if (index === -1 && this.hasInputTarget) {
|
|
2381
|
-
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
2382
2694
|
}
|
|
2383
2695
|
}
|
|
2384
2696
|
#confirmSelection(option) {
|
|
@@ -2391,8 +2703,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
2391
2703
|
for (const option of this.optionTargets) {
|
|
2392
2704
|
option.removeAttribute("hidden");
|
|
2393
2705
|
}
|
|
2394
|
-
|
|
2395
|
-
this
|
|
2706
|
+
const hasSelectableOption = this.#visibleOptions.length > 0;
|
|
2707
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
|
|
2708
|
+
this.#setActiveIndex(hasSelectableOption ? 0 : -1);
|
|
2396
2709
|
}
|
|
2397
2710
|
get #visibleOptions() {
|
|
2398
2711
|
return this.optionTargets.filter(
|
|
@@ -2400,23 +2713,66 @@ var CommandPaletteController = class extends Controller {
|
|
|
2400
2713
|
);
|
|
2401
2714
|
}
|
|
2402
2715
|
#isDisabled(option) {
|
|
2403
|
-
return option.dataset.disabled === "true";
|
|
2716
|
+
return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
|
|
2717
|
+
}
|
|
2718
|
+
/** Synchronizes every option's controller-owned selection and disabled semantics. */
|
|
2719
|
+
#syncOptionSemantics() {
|
|
2720
|
+
for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
|
|
2721
|
+
}
|
|
2722
|
+
/** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
|
|
2723
|
+
#syncOptionSemanticsFor(option) {
|
|
2724
|
+
if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
|
|
2725
|
+
const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
|
|
2726
|
+
const originalValue = option.getAttribute(originalAttribute);
|
|
2727
|
+
if (option.dataset.disabled === "true") {
|
|
2728
|
+
if (originalValue === null) {
|
|
2729
|
+
option.setAttribute(
|
|
2730
|
+
originalAttribute,
|
|
2731
|
+
option.getAttribute("aria-disabled") ?? _CommandPaletteController.#ABSENT_ARIA_DISABLED
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
option.setAttribute("aria-disabled", "true");
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
if (originalValue === null) return;
|
|
2738
|
+
if (originalValue === _CommandPaletteController.#ABSENT_ARIA_DISABLED) {
|
|
2739
|
+
option.removeAttribute("aria-disabled");
|
|
2740
|
+
} else {
|
|
2741
|
+
option.setAttribute("aria-disabled", originalValue);
|
|
2742
|
+
}
|
|
2743
|
+
option.removeAttribute(originalAttribute);
|
|
2404
2744
|
}
|
|
2405
2745
|
get #isOpen() {
|
|
2406
2746
|
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
2407
2747
|
}
|
|
2408
2748
|
#onGlobalKeydown = (event) => {
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
if (!key) return;
|
|
2413
|
-
const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
|
|
2414
|
-
const keyMatch = event.key.toLowerCase() === key;
|
|
2415
|
-
if (modPressed && keyMatch) {
|
|
2416
|
-
event.preventDefault();
|
|
2417
|
-
this.toggle();
|
|
2418
|
-
}
|
|
2749
|
+
if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
|
|
2750
|
+
event.preventDefault();
|
|
2751
|
+
this.toggle();
|
|
2419
2752
|
};
|
|
2753
|
+
/** Matches the configured `mod+key` or bare-key hotkey without extra modifiers. */
|
|
2754
|
+
#matchesHotkey(event) {
|
|
2755
|
+
const hotkey = this.#parseHotkey();
|
|
2756
|
+
if (!hotkey || event.altKey || event.shiftKey) return false;
|
|
2757
|
+
if (hotkey.requiresMod) {
|
|
2758
|
+
const hasExactlyOneModKey = event.metaKey !== event.ctrlKey;
|
|
2759
|
+
if (!hasExactlyOneModKey) return false;
|
|
2760
|
+
} else if (event.metaKey || event.ctrlKey) {
|
|
2761
|
+
return false;
|
|
2762
|
+
}
|
|
2763
|
+
return event.key.toLowerCase() === hotkey.key;
|
|
2764
|
+
}
|
|
2765
|
+
/** Parses the intentionally small public hotkey grammar. */
|
|
2766
|
+
#parseHotkey() {
|
|
2767
|
+
const parts = this.hotkeyValue.toLowerCase().split("+");
|
|
2768
|
+
if (parts.length === 1 && parts[0] && parts[0] !== "mod") {
|
|
2769
|
+
return { key: parts[0], requiresMod: false };
|
|
2770
|
+
}
|
|
2771
|
+
if (parts.length === 2 && parts[0] === "mod" && parts[1]) {
|
|
2772
|
+
return { key: parts[1], requiresMod: true };
|
|
2773
|
+
}
|
|
2774
|
+
return null;
|
|
2775
|
+
}
|
|
2420
2776
|
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
2421
2777
|
#resetToClosedState() {
|
|
2422
2778
|
this.#activeIndex = -1;
|
|
@@ -2649,14 +3005,23 @@ var ConfirmController = class extends Controller {
|
|
|
2649
3005
|
var ContextMenuController = class extends Controller {
|
|
2650
3006
|
static targets = ["region", "menu", "item"];
|
|
2651
3007
|
static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
|
|
2652
|
-
|
|
3008
|
+
#timers = new SafeTimeout();
|
|
3009
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
3010
|
+
#escapeLayer = new EscapeLayer();
|
|
3011
|
+
/** Starts closed and registers delegated activation and outside-pointer listeners. */
|
|
2653
3012
|
connect() {
|
|
2654
3013
|
this.#closeMenu();
|
|
2655
|
-
|
|
3014
|
+
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
3015
|
+
document.addEventListener("click", this.#onOutsidePointer);
|
|
3016
|
+
document.addEventListener("contextmenu", this.#onOutsidePointer);
|
|
2656
3017
|
}
|
|
2657
|
-
/**
|
|
3018
|
+
/** Releases the listeners, stack membership, and pending Tab-close task. */
|
|
2658
3019
|
disconnect() {
|
|
2659
|
-
|
|
3020
|
+
this.#timers.clearAll();
|
|
3021
|
+
this.#escapeLayer.deactivate();
|
|
3022
|
+
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
3023
|
+
document.removeEventListener("click", this.#onOutsidePointer);
|
|
3024
|
+
document.removeEventListener("contextmenu", this.#onOutsidePointer);
|
|
2660
3025
|
}
|
|
2661
3026
|
/**
|
|
2662
3027
|
* Opens the menu from a `contextmenu` event: suppresses the native menu and
|
|
@@ -2681,11 +3046,17 @@ var ContextMenuController = class extends Controller {
|
|
|
2681
3046
|
switch (event.key) {
|
|
2682
3047
|
case "ArrowDown":
|
|
2683
3048
|
event.preventDefault();
|
|
2684
|
-
if (items.length > 0)
|
|
3049
|
+
if (items.length > 0) {
|
|
3050
|
+
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
|
|
3051
|
+
items[nextIndex]?.focus();
|
|
3052
|
+
}
|
|
2685
3053
|
break;
|
|
2686
3054
|
case "ArrowUp":
|
|
2687
3055
|
event.preventDefault();
|
|
2688
|
-
if (items.length > 0)
|
|
3056
|
+
if (items.length > 0) {
|
|
3057
|
+
const previousIndex = currentIndex < 0 ? items.length - 1 : currentIndex - 1;
|
|
3058
|
+
items[(previousIndex + items.length) % items.length]?.focus();
|
|
3059
|
+
}
|
|
2689
3060
|
break;
|
|
2690
3061
|
case "Home":
|
|
2691
3062
|
event.preventDefault();
|
|
@@ -2695,12 +3066,9 @@ var ContextMenuController = class extends Controller {
|
|
|
2695
3066
|
event.preventDefault();
|
|
2696
3067
|
items[items.length - 1]?.focus();
|
|
2697
3068
|
break;
|
|
2698
|
-
case "Escape":
|
|
2699
|
-
event.preventDefault();
|
|
2700
|
-
this.#closeAndRestore();
|
|
2701
|
-
break;
|
|
2702
3069
|
case "Tab":
|
|
2703
|
-
this.#
|
|
3070
|
+
this.#timers.clearAll();
|
|
3071
|
+
this.#timers.set(() => this.#closeMenu(), 0);
|
|
2704
3072
|
break;
|
|
2705
3073
|
}
|
|
2706
3074
|
}
|
|
@@ -2711,6 +3079,11 @@ var ContextMenuController = class extends Controller {
|
|
|
2711
3079
|
/** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
|
|
2712
3080
|
#openAt(x, y) {
|
|
2713
3081
|
if (!this.hasMenuTarget) return;
|
|
3082
|
+
this.#timers.clearAll();
|
|
3083
|
+
this.#escapeLayer.activate(document, {
|
|
3084
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
3085
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
3086
|
+
});
|
|
2714
3087
|
this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
|
|
2715
3088
|
this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
|
|
2716
3089
|
this.menuTarget.hidden = false;
|
|
@@ -2719,6 +3092,8 @@ var ContextMenuController = class extends Controller {
|
|
|
2719
3092
|
}
|
|
2720
3093
|
/** Hides the menu and reflects the collapsed state on the region. */
|
|
2721
3094
|
#closeMenu() {
|
|
3095
|
+
this.#timers.clearAll();
|
|
3096
|
+
this.#escapeLayer.deactivate();
|
|
2722
3097
|
if (!this.hasMenuTarget) return;
|
|
2723
3098
|
this.menuTarget.hidden = true;
|
|
2724
3099
|
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
|
|
@@ -2728,10 +3103,24 @@ var ContextMenuController = class extends Controller {
|
|
|
2728
3103
|
this.#closeMenu();
|
|
2729
3104
|
if (this.hasRegionTarget) this.regionTarget.focus();
|
|
2730
3105
|
}
|
|
2731
|
-
/** Closes
|
|
2732
|
-
#
|
|
3106
|
+
/** Closes when a click or context-menu invocation lands outside this instance. */
|
|
3107
|
+
#onOutsidePointer = (event) => {
|
|
2733
3108
|
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
|
|
2734
3109
|
};
|
|
3110
|
+
/**
|
|
3111
|
+
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|
|
3112
|
+
* Native Enter/Space activation also synthesizes a click and is blocked here.
|
|
3113
|
+
*/
|
|
3114
|
+
#onItemClickCapture = (event) => {
|
|
3115
|
+
const target = event.target;
|
|
3116
|
+
if (!(target instanceof Node)) return;
|
|
3117
|
+
const disabled = this.itemTargets.some(
|
|
3118
|
+
(item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
|
|
3119
|
+
);
|
|
3120
|
+
if (!disabled) return;
|
|
3121
|
+
event.preventDefault();
|
|
3122
|
+
event.stopImmediatePropagation();
|
|
3123
|
+
};
|
|
2735
3124
|
/** Menu items eligible for roving focus (excludes disabled / hidden). */
|
|
2736
3125
|
get #navigableItems() {
|
|
2737
3126
|
return this.itemTargets.filter((item) => this.#isNavigable(item));
|
|
@@ -2751,6 +3140,13 @@ var ContextMenuController = class extends Controller {
|
|
|
2751
3140
|
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
2752
3141
|
}
|
|
2753
3142
|
};
|
|
3143
|
+
|
|
3144
|
+
// src/utils/reduced_motion.ts
|
|
3145
|
+
function prefersReducedMotion() {
|
|
3146
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
// src/controllers/count_up_controller.ts
|
|
2754
3150
|
var CountUpController = class extends Controller {
|
|
2755
3151
|
static values = {
|
|
2756
3152
|
duration: { type: Number, default: 1200 },
|
|
@@ -2782,7 +3178,7 @@ var CountUpController = class extends Controller {
|
|
|
2782
3178
|
this.#finalText = this.element.textContent ?? "";
|
|
2783
3179
|
const target = Number.parseInt(this.#finalText.replace(/[^0-9-]/g, ""), 10);
|
|
2784
3180
|
if (Number.isNaN(target)) return;
|
|
2785
|
-
if (
|
|
3181
|
+
if (prefersReducedMotion()) {
|
|
2786
3182
|
this.element.setAttribute("data-count-up-done", "true");
|
|
2787
3183
|
this.dispatch("end", { detail: { value: target } });
|
|
2788
3184
|
return;
|
|
@@ -2889,8 +3285,8 @@ var CountdownController = class extends Controller {
|
|
|
2889
3285
|
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
2890
3286
|
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
2891
3287
|
* not re-derived from the declarative `autostart` Value (which only governs the
|
|
2892
|
-
* initial state on connect); re-deriving it would override a user's pause
|
|
2893
|
-
*
|
|
3288
|
+
* initial state on connect); re-deriving it would override a user's pause —
|
|
3289
|
+
* the DOM, not a re-run of declarative config, is the source of truth.
|
|
2894
3290
|
*/
|
|
2895
3291
|
reset() {
|
|
2896
3292
|
const wasRunning = this.#state === "running";
|
|
@@ -3384,7 +3780,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
3384
3780
|
return;
|
|
3385
3781
|
}
|
|
3386
3782
|
if (event.key === "Escape") {
|
|
3387
|
-
if (this.#pendingStart) {
|
|
3783
|
+
if (this.#pendingStart && !event.defaultPrevented && !event.isComposing) {
|
|
3388
3784
|
event.preventDefault();
|
|
3389
3785
|
this.#pendingStart = "";
|
|
3390
3786
|
this.#previewDate = "";
|
|
@@ -3913,14 +4309,6 @@ var DirtyFormController = class extends Controller {
|
|
|
3913
4309
|
return null;
|
|
3914
4310
|
}
|
|
3915
4311
|
};
|
|
3916
|
-
var FOCUSABLE_SELECTOR = [
|
|
3917
|
-
"a[href]",
|
|
3918
|
-
"button:not([disabled])",
|
|
3919
|
-
"input:not([disabled])",
|
|
3920
|
-
"select:not([disabled])",
|
|
3921
|
-
"textarea:not([disabled])",
|
|
3922
|
-
'[tabindex]:not([tabindex="-1"])'
|
|
3923
|
-
].join(",");
|
|
3924
4312
|
var DismissibleController = class extends Controller {
|
|
3925
4313
|
static targets = ["root", "fallback"];
|
|
3926
4314
|
static values = {
|
|
@@ -3934,25 +4322,35 @@ var DismissibleController = class extends Controller {
|
|
|
3934
4322
|
if (!root.hasAttribute("data-state")) {
|
|
3935
4323
|
root.setAttribute("data-state", "open");
|
|
3936
4324
|
}
|
|
3937
|
-
|
|
3938
|
-
this.element.addEventListener("keydown", this.#onKeydown);
|
|
3939
|
-
}
|
|
4325
|
+
this.#syncEscapeListener();
|
|
3940
4326
|
}
|
|
3941
4327
|
disconnect() {
|
|
3942
4328
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
3943
4329
|
}
|
|
4330
|
+
/** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */
|
|
4331
|
+
closeOnEscapeValueChanged() {
|
|
4332
|
+
this.#syncEscapeListener();
|
|
4333
|
+
}
|
|
3944
4334
|
/** Dismisses the element. Bound via `data-action` (click on the close button). */
|
|
3945
4335
|
dismiss() {
|
|
3946
4336
|
this.#performDismiss();
|
|
3947
4337
|
}
|
|
3948
4338
|
/** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */
|
|
3949
4339
|
#onKeydown = (event) => {
|
|
3950
|
-
if (event.key !== "Escape") return;
|
|
4340
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
4341
|
+
if (!this.closeOnEscapeValue) return;
|
|
3951
4342
|
const active = document.activeElement;
|
|
3952
4343
|
if (!active || !this.element.contains(active)) return;
|
|
3953
4344
|
event.preventDefault();
|
|
3954
4345
|
this.#performDismiss();
|
|
3955
4346
|
};
|
|
4347
|
+
/** Adds or removes the one stable listener reference without duplicating it. */
|
|
4348
|
+
#syncEscapeListener() {
|
|
4349
|
+
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
4350
|
+
if (this.closeOnEscapeValue) {
|
|
4351
|
+
this.element.addEventListener("keydown", this.#onKeydown);
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
3956
4354
|
/** The element to dismiss: the explicit `root` target, or the host element. */
|
|
3957
4355
|
get #root() {
|
|
3958
4356
|
return this.hasRootTarget ? this.rootTarget : this.element;
|
|
@@ -3976,34 +4374,46 @@ var DismissibleController = class extends Controller {
|
|
|
3976
4374
|
#retreatFocus(root) {
|
|
3977
4375
|
const active = document.activeElement;
|
|
3978
4376
|
if (!(active instanceof HTMLElement) || !root.contains(active)) return;
|
|
3979
|
-
this.#
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
);
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
4377
|
+
for (const candidate of this.#focusFallbacks(root)) {
|
|
4378
|
+
candidate.focus();
|
|
4379
|
+
if (document.activeElement === candidate) return;
|
|
4380
|
+
}
|
|
4381
|
+
document.body.focus();
|
|
4382
|
+
}
|
|
4383
|
+
/** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */
|
|
4384
|
+
*#focusFallbacks(root) {
|
|
4385
|
+
const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;
|
|
4386
|
+
if (explicitFallback && !root.contains(explicitFallback) && this.#isFocusAvailable(explicitFallback)) {
|
|
4387
|
+
yield explicitFallback;
|
|
4388
|
+
}
|
|
4389
|
+
const candidates = document.querySelectorAll(FOCUSABLE);
|
|
4390
|
+
for (const element of candidates) {
|
|
4391
|
+
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
4392
|
+
continue;
|
|
4393
|
+
}
|
|
4394
|
+
if (this.#isFocusAvailable(element)) yield element;
|
|
4395
|
+
}
|
|
4396
|
+
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
|
4397
|
+
const element = candidates.item(index);
|
|
4398
|
+
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
|
4399
|
+
continue;
|
|
4400
|
+
}
|
|
4401
|
+
if (this.#isFocusAvailable(element)) yield element;
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
/** Whether focus is allowed by the element and its semantic ancestors. */
|
|
4405
|
+
#isFocusAvailable(element) {
|
|
4406
|
+
if (!element.matches(FOCUSABLE) && !element.hasAttribute("tabindex") && !element.isContentEditable) {
|
|
4407
|
+
return false;
|
|
4408
|
+
}
|
|
4409
|
+
if (element.matches(":disabled")) return false;
|
|
4410
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
4411
|
+
for (let current = element; current; current = current.parentElement) {
|
|
4412
|
+
if (current.hidden || current.inert) return false;
|
|
4413
|
+
}
|
|
4414
|
+
return true;
|
|
3996
4415
|
}
|
|
3997
4416
|
};
|
|
3998
|
-
function maxTransitionMs(value) {
|
|
3999
|
-
const durations = value.split(",").map((part) => {
|
|
4000
|
-
const trimmed = part.trim();
|
|
4001
|
-
if (trimmed.endsWith("ms")) return Number.parseFloat(trimmed);
|
|
4002
|
-
if (trimmed.endsWith("s")) return Number.parseFloat(trimmed) * 1e3;
|
|
4003
|
-
return 0;
|
|
4004
|
-
});
|
|
4005
|
-
return durations.length === 0 ? 0 : Math.max(...durations);
|
|
4006
|
-
}
|
|
4007
4417
|
var DrawerController = class extends Controller {
|
|
4008
4418
|
static targets = ["trigger", "overlay", "panel"];
|
|
4009
4419
|
static values = {
|
|
@@ -4011,13 +4421,17 @@ var DrawerController = class extends Controller {
|
|
|
4011
4421
|
open: { type: Boolean, default: false }
|
|
4012
4422
|
};
|
|
4013
4423
|
static actions = ["close", "closeOnBackdrop", "open"];
|
|
4424
|
+
/** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
|
|
4425
|
+
#activePanel = null;
|
|
4014
4426
|
/** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
|
|
4015
|
-
#trap = new FocusTrap(() => this.panelTarget, {
|
|
4427
|
+
#trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
|
|
4016
4428
|
onEscape: () => this.close(),
|
|
4017
4429
|
fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
|
|
4018
4430
|
});
|
|
4019
|
-
/**
|
|
4020
|
-
#
|
|
4431
|
+
/** Owns the cancellable close-transition wait and its bounded fallback. */
|
|
4432
|
+
#transition = new TransitionCompletion();
|
|
4433
|
+
/** Distinguishes dynamic target churn from callbacks around controller teardown. */
|
|
4434
|
+
#connected = false;
|
|
4021
4435
|
/**
|
|
4022
4436
|
* Reflects placement and establishes the initial open/closed state.
|
|
4023
4437
|
*
|
|
@@ -4030,6 +4444,8 @@ var DrawerController = class extends Controller {
|
|
|
4030
4444
|
* after a reconnect and must be re-activated.
|
|
4031
4445
|
*/
|
|
4032
4446
|
connect() {
|
|
4447
|
+
this.#connected = true;
|
|
4448
|
+
this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
|
|
4033
4449
|
this.#reflectPlacement();
|
|
4034
4450
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
4035
4451
|
this.#applyClosedState();
|
|
@@ -4037,8 +4453,31 @@ var DrawerController = class extends Controller {
|
|
|
4037
4453
|
}
|
|
4038
4454
|
/** Reverts the modal side effects and pending hide if torn down while open. */
|
|
4039
4455
|
disconnect() {
|
|
4040
|
-
this.#
|
|
4456
|
+
this.#connected = false;
|
|
4457
|
+
this.#transition.cancel();
|
|
4041
4458
|
this.#trap.deactivate({ restoreFocus: false });
|
|
4459
|
+
this.#activePanel = null;
|
|
4460
|
+
}
|
|
4461
|
+
/** Adopts a panel target added by a Turbo morph after the controller connected. */
|
|
4462
|
+
panelTargetConnected(panel) {
|
|
4463
|
+
if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
|
|
4464
|
+
this.#adoptPanel(panel);
|
|
4465
|
+
}
|
|
4466
|
+
/** Closes and releases modal side effects when the actively trapped panel disappears. */
|
|
4467
|
+
panelTargetDisconnected(panel) {
|
|
4468
|
+
if (panel !== this.#activePanel) return;
|
|
4469
|
+
this.#transition.cancel();
|
|
4470
|
+
this.#activePanel = null;
|
|
4471
|
+
if (!this.#connected) return;
|
|
4472
|
+
panel.setAttribute("data-state", "closed");
|
|
4473
|
+
panel.hidden = true;
|
|
4474
|
+
if (this.hasOverlayTarget) {
|
|
4475
|
+
this.overlayTarget.setAttribute("data-state", "closed");
|
|
4476
|
+
this.overlayTarget.hidden = true;
|
|
4477
|
+
}
|
|
4478
|
+
this.openValue = false;
|
|
4479
|
+
this.#trap.deactivate();
|
|
4480
|
+
if (this.hasPanelTarget) this.#adoptPanel(this.panelTarget);
|
|
4042
4481
|
}
|
|
4043
4482
|
/** Keeps `data-placement` in sync if the value changes at runtime. */
|
|
4044
4483
|
placementValueChanged() {
|
|
@@ -4047,7 +4486,8 @@ var DrawerController = class extends Controller {
|
|
|
4047
4486
|
/** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
|
|
4048
4487
|
open() {
|
|
4049
4488
|
if (!this.hasPanelTarget || this.#isOpen) return;
|
|
4050
|
-
this.#
|
|
4489
|
+
this.#transition.cancel();
|
|
4490
|
+
this.#activePanel = this.panelTarget;
|
|
4051
4491
|
this.panelTarget.hidden = false;
|
|
4052
4492
|
if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
|
|
4053
4493
|
void this.panelTarget.offsetWidth;
|
|
@@ -4077,6 +4517,32 @@ var DrawerController = class extends Controller {
|
|
|
4077
4517
|
#reflectPlacement() {
|
|
4078
4518
|
if (this.hasPanelTarget) this.panelTarget.setAttribute("data-placement", this.#placement);
|
|
4079
4519
|
}
|
|
4520
|
+
/** Reconciles a replacement panel and companion overlay from its explicit DOM state. */
|
|
4521
|
+
#adoptPanel(panel) {
|
|
4522
|
+
this.#transition.cancel();
|
|
4523
|
+
const trapWasActive = this.#trap.active;
|
|
4524
|
+
this.#activePanel = panel;
|
|
4525
|
+
panel.setAttribute("data-placement", this.#placement);
|
|
4526
|
+
if (panel.getAttribute("data-state") === "open") {
|
|
4527
|
+
panel.hidden = false;
|
|
4528
|
+
if (this.hasOverlayTarget) {
|
|
4529
|
+
this.overlayTarget.setAttribute("data-state", "open");
|
|
4530
|
+
this.overlayTarget.hidden = false;
|
|
4531
|
+
}
|
|
4532
|
+
this.openValue = true;
|
|
4533
|
+
if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
|
|
4534
|
+
this.#trap.activate();
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
panel.setAttribute("data-state", "closed");
|
|
4538
|
+
panel.hidden = true;
|
|
4539
|
+
if (this.hasOverlayTarget) {
|
|
4540
|
+
this.overlayTarget.setAttribute("data-state", "closed");
|
|
4541
|
+
this.overlayTarget.hidden = true;
|
|
4542
|
+
}
|
|
4543
|
+
this.openValue = false;
|
|
4544
|
+
this.#trap.deactivate();
|
|
4545
|
+
}
|
|
4080
4546
|
/** Validated placement (`left`/`right`/`top`/`bottom`), defaulting to `right`. */
|
|
4081
4547
|
get #placement() {
|
|
4082
4548
|
const value = this.placementValue;
|
|
@@ -4095,24 +4561,13 @@ var DrawerController = class extends Controller {
|
|
|
4095
4561
|
}
|
|
4096
4562
|
/**
|
|
4097
4563
|
* Applies `hidden` once the panel's close transition ends, so the exit slide
|
|
4098
|
-
* can play.
|
|
4099
|
-
*
|
|
4100
|
-
* that would never fire.
|
|
4564
|
+
* can play. The shared waiter hides synchronously for 0ms transitions and
|
|
4565
|
+
* supplies a bounded fallback when the browser emits no terminal event.
|
|
4101
4566
|
*/
|
|
4102
4567
|
#hideAfterTransition() {
|
|
4103
4568
|
const panel = this.panelTarget;
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
this.#applyHidden();
|
|
4107
|
-
return;
|
|
4108
|
-
}
|
|
4109
|
-
const onEnd = (event) => {
|
|
4110
|
-
if (event.target !== panel) return;
|
|
4111
|
-
this.#cancelPendingHide();
|
|
4112
|
-
this.#applyHidden();
|
|
4113
|
-
};
|
|
4114
|
-
this.#pendingHide = () => panel.removeEventListener("transitionend", onEnd);
|
|
4115
|
-
panel.addEventListener("transitionend", onEnd);
|
|
4569
|
+
this.#activePanel = panel;
|
|
4570
|
+
this.#transition.wait(panel, () => this.#applyHidden(panel));
|
|
4116
4571
|
}
|
|
4117
4572
|
/**
|
|
4118
4573
|
* Runs once the close transition has finished: applies `hidden` to the panel
|
|
@@ -4121,50 +4576,56 @@ var DrawerController = class extends Controller {
|
|
|
4121
4576
|
* {@link FocusTrap} teardown to here — rather than at {@link close} time — keeps
|
|
4122
4577
|
* the background unreachable and focus trapped for the whole exit animation.
|
|
4123
4578
|
*/
|
|
4124
|
-
#applyHidden() {
|
|
4125
|
-
|
|
4579
|
+
#applyHidden(panel) {
|
|
4580
|
+
panel.hidden = true;
|
|
4126
4581
|
if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
|
|
4127
4582
|
this.#trap.deactivate();
|
|
4128
4583
|
}
|
|
4129
|
-
/** Removes any pending `transitionend` hide listener. */
|
|
4130
|
-
#cancelPendingHide() {
|
|
4131
|
-
this.#pendingHide?.();
|
|
4132
|
-
this.#pendingHide = null;
|
|
4133
|
-
}
|
|
4134
4584
|
/** Whether the drawer is open (tracked via `data-state`, not `hidden`). */
|
|
4135
4585
|
get #isOpen() {
|
|
4136
4586
|
return this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
|
|
4137
4587
|
}
|
|
4138
4588
|
};
|
|
4589
|
+
|
|
4590
|
+
// src/utils/aria_ids.ts
|
|
4591
|
+
var counter = 0;
|
|
4592
|
+
function uniqueId(prefix = "stimeo") {
|
|
4593
|
+
let candidate;
|
|
4594
|
+
do {
|
|
4595
|
+
counter += 1;
|
|
4596
|
+
candidate = `${prefix}-${counter}`;
|
|
4597
|
+
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
4598
|
+
return candidate;
|
|
4599
|
+
}
|
|
4600
|
+
function ensureId(element, prefix = "stimeo") {
|
|
4601
|
+
if (element.id) return element.id;
|
|
4602
|
+
const id = uniqueId(prefix);
|
|
4603
|
+
element.id = id;
|
|
4604
|
+
return id;
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
// src/controllers/dropdown_controller.ts
|
|
4139
4608
|
var DropdownController = class extends Controller {
|
|
4140
4609
|
static targets = ["trigger", "menu"];
|
|
4141
4610
|
static actions = ["close", "open", "toggle"];
|
|
4611
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
4612
|
+
#escapeLayer = new EscapeLayer();
|
|
4142
4613
|
/** Closes the menu when a click lands outside the controller's element. */
|
|
4143
4614
|
#onOutsideClick = (event) => {
|
|
4144
4615
|
if (!this.element.contains(event.target)) {
|
|
4145
4616
|
this.close();
|
|
4146
4617
|
}
|
|
4147
4618
|
};
|
|
4148
|
-
/**
|
|
4149
|
-
#onKeydown = (event) => {
|
|
4150
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
4151
|
-
this.close();
|
|
4152
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
4153
|
-
}
|
|
4154
|
-
};
|
|
4155
|
-
/**
|
|
4156
|
-
* Starts in the closed state and registers the document-level listeners that
|
|
4157
|
-
* power outside-click and `Escape` handling.
|
|
4158
|
-
*/
|
|
4619
|
+
/** Starts in the closed state and registers outside-click handling. */
|
|
4159
4620
|
connect() {
|
|
4621
|
+
this.#associateTriggerWithMenu();
|
|
4160
4622
|
this.close();
|
|
4161
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
4162
|
-
document.addEventListener("keydown", this.#onKeydown);
|
|
4623
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
4163
4624
|
}
|
|
4164
|
-
/** Removes the
|
|
4625
|
+
/** Removes the listeners registered in {@link connect}. */
|
|
4165
4626
|
disconnect() {
|
|
4166
|
-
|
|
4167
|
-
document.removeEventListener("
|
|
4627
|
+
this.#escapeLayer.deactivate();
|
|
4628
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
4168
4629
|
}
|
|
4169
4630
|
/** Toggles the menu between open and closed. Bound via `data-action`. */
|
|
4170
4631
|
toggle() {
|
|
@@ -4177,6 +4638,10 @@ var DropdownController = class extends Controller {
|
|
|
4177
4638
|
/** Reveals the menu and reflects the open state on the trigger. */
|
|
4178
4639
|
open() {
|
|
4179
4640
|
if (!this.hasMenuTarget) return;
|
|
4641
|
+
this.#escapeLayer.activate(document, {
|
|
4642
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
4643
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
4644
|
+
});
|
|
4180
4645
|
this.menuTarget.hidden = false;
|
|
4181
4646
|
if (this.hasTriggerTarget) {
|
|
4182
4647
|
this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
@@ -4184,16 +4649,34 @@ var DropdownController = class extends Controller {
|
|
|
4184
4649
|
}
|
|
4185
4650
|
/** Hides the menu and reflects the closed state on the trigger. */
|
|
4186
4651
|
close() {
|
|
4652
|
+
this.#escapeLayer.deactivate();
|
|
4187
4653
|
if (!this.hasMenuTarget) return;
|
|
4188
4654
|
this.menuTarget.hidden = true;
|
|
4189
4655
|
if (this.hasTriggerTarget) {
|
|
4190
4656
|
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
4191
4657
|
}
|
|
4192
4658
|
}
|
|
4659
|
+
/** Closes and restores focus to the trigger (the keyboard-dismissal path). */
|
|
4660
|
+
#closeAndRestore() {
|
|
4661
|
+
this.close();
|
|
4662
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
4663
|
+
}
|
|
4193
4664
|
/** Whether the menu is currently visible. */
|
|
4194
4665
|
get #isOpen() {
|
|
4195
4666
|
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
4196
4667
|
}
|
|
4668
|
+
/**
|
|
4669
|
+
* Associates the disclosure trigger and controlled region without clobbering
|
|
4670
|
+
* authored markup.
|
|
4671
|
+
*/
|
|
4672
|
+
#associateTriggerWithMenu() {
|
|
4673
|
+
if (!this.hasTriggerTarget || !this.hasMenuTarget) return;
|
|
4674
|
+
if (this.triggerTarget.hasAttribute("aria-controls")) return;
|
|
4675
|
+
this.triggerTarget.setAttribute(
|
|
4676
|
+
"aria-controls",
|
|
4677
|
+
ensureId(this.menuTarget, "stimeo--dropdown-menu")
|
|
4678
|
+
);
|
|
4679
|
+
}
|
|
4197
4680
|
};
|
|
4198
4681
|
var EditableController = class extends Controller {
|
|
4199
4682
|
static targets = ["display", "input"];
|
|
@@ -4204,10 +4687,28 @@ var EditableController = class extends Controller {
|
|
|
4204
4687
|
static events = ["cancel", "change"];
|
|
4205
4688
|
/** The value captured when edit mode began, used to detect real changes. */
|
|
4206
4689
|
#previousValue = "";
|
|
4690
|
+
/**
|
|
4691
|
+
* Owns IME lifecycle state for the edit surface, so a keydown that belongs to
|
|
4692
|
+
* a composition (cancel or confirm) is never treated as an edit command.
|
|
4693
|
+
*/
|
|
4694
|
+
#composition = new CompositionTracker();
|
|
4207
4695
|
/** Establishes the initial display mode (display shown, input hidden). */
|
|
4208
4696
|
connect() {
|
|
4697
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
4209
4698
|
this.#setMode("display");
|
|
4210
4699
|
}
|
|
4700
|
+
/** Releases the composition listeners so nothing outlives the element. */
|
|
4701
|
+
disconnect() {
|
|
4702
|
+
this.#composition.disconnect();
|
|
4703
|
+
}
|
|
4704
|
+
/** Tracks an input added initially or after connect (e.g. a Turbo swap). */
|
|
4705
|
+
inputTargetConnected(input) {
|
|
4706
|
+
this.#composition.observe(input);
|
|
4707
|
+
}
|
|
4708
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
4709
|
+
inputTargetDisconnected(input) {
|
|
4710
|
+
this.#composition.unobserve(input);
|
|
4711
|
+
}
|
|
4211
4712
|
/** Enters edit mode: seeds the input from the display text, focuses, selects. */
|
|
4212
4713
|
edit() {
|
|
4213
4714
|
if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
|
|
@@ -4226,7 +4727,9 @@ var EditableController = class extends Controller {
|
|
|
4226
4727
|
}
|
|
4227
4728
|
/** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */
|
|
4228
4729
|
onKeydown(event) {
|
|
4730
|
+
if (this.#composition.isComposing(event)) return;
|
|
4229
4731
|
if (event.key === "Escape") {
|
|
4732
|
+
if (event.defaultPrevented) return;
|
|
4230
4733
|
event.preventDefault();
|
|
4231
4734
|
this.#cancel();
|
|
4232
4735
|
return;
|
|
@@ -4734,13 +5237,10 @@ var FlashController = class extends Controller {
|
|
|
4734
5237
|
finalize();
|
|
4735
5238
|
}
|
|
4736
5239
|
}
|
|
4737
|
-
/**
|
|
5240
|
+
/** Maximum transition total (duration + delay) of `el` in ms (0 when none / unsupported). */
|
|
4738
5241
|
#transitionMs(el) {
|
|
4739
5242
|
if (typeof window.getComputedStyle !== "function") return 0;
|
|
4740
|
-
|
|
4741
|
-
const amount = Number.parseFloat(first);
|
|
4742
|
-
if (Number.isNaN(amount)) return 0;
|
|
4743
|
-
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
5243
|
+
return maxTransitionTotalMs(window.getComputedStyle(el));
|
|
4744
5244
|
}
|
|
4745
5245
|
};
|
|
4746
5246
|
var FocusController = class extends Controller {
|
|
@@ -4794,25 +5294,6 @@ var FocusController = class extends Controller {
|
|
|
4794
5294
|
this.dispatch("deactivate", { detail: {} });
|
|
4795
5295
|
}
|
|
4796
5296
|
};
|
|
4797
|
-
|
|
4798
|
-
// src/utils/aria_ids.ts
|
|
4799
|
-
var counter = 0;
|
|
4800
|
-
function uniqueId(prefix = "stimeo") {
|
|
4801
|
-
let candidate;
|
|
4802
|
-
do {
|
|
4803
|
-
counter += 1;
|
|
4804
|
-
candidate = `${prefix}-${counter}`;
|
|
4805
|
-
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
4806
|
-
return candidate;
|
|
4807
|
-
}
|
|
4808
|
-
function ensureId(element, prefix = "stimeo") {
|
|
4809
|
-
if (element.id) return element.id;
|
|
4810
|
-
const id = uniqueId(prefix);
|
|
4811
|
-
element.id = id;
|
|
4812
|
-
return id;
|
|
4813
|
-
}
|
|
4814
|
-
|
|
4815
|
-
// src/controllers/form_field_controller.ts
|
|
4816
5297
|
var FormFieldController = class _FormFieldController extends Controller {
|
|
4817
5298
|
static targets = ["control", "description", "error"];
|
|
4818
5299
|
static values = {
|
|
@@ -5116,8 +5597,8 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
5116
5597
|
}
|
|
5117
5598
|
/**
|
|
5118
5599
|
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
5119
|
-
* for controls that opt in with `data-stimeo--form-field-disallow`. The
|
|
5120
|
-
*
|
|
5600
|
+
* for controls that opt in with `data-stimeo--form-field-disallow`. The supported
|
|
5601
|
+
* rule is `"whitespace"` — a value that is non-empty but blank
|
|
5121
5602
|
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
5122
5603
|
* the per-constraint (`value-missing`) → generic → default chain.
|
|
5123
5604
|
*
|
|
@@ -5340,7 +5821,7 @@ var HighlightController = class extends Controller {
|
|
|
5340
5821
|
}
|
|
5341
5822
|
/** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
|
|
5342
5823
|
#highlight(el) {
|
|
5343
|
-
if (
|
|
5824
|
+
if (prefersReducedMotion()) return;
|
|
5344
5825
|
el.setAttribute("data-highlight", "true");
|
|
5345
5826
|
this.dispatch("start", { target: el, detail: { element: el } });
|
|
5346
5827
|
this.#timeouts.set(() => {
|
|
@@ -5348,9 +5829,6 @@ var HighlightController = class extends Controller {
|
|
|
5348
5829
|
this.dispatch("end", { target: el, detail: { element: el } });
|
|
5349
5830
|
}, this.durationValue);
|
|
5350
5831
|
}
|
|
5351
|
-
#prefersReducedMotion() {
|
|
5352
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
5353
|
-
}
|
|
5354
5832
|
};
|
|
5355
5833
|
|
|
5356
5834
|
// src/utils/scroll_dismiss.ts
|
|
@@ -5386,21 +5864,26 @@ var HoverCardController = class extends Controller {
|
|
|
5386
5864
|
closeDelay: { type: Number, default: 200 },
|
|
5387
5865
|
closeOnScroll: { type: Boolean, default: false }
|
|
5388
5866
|
};
|
|
5389
|
-
static actions = ["close", "
|
|
5390
|
-
/** Pending open/close timers,
|
|
5867
|
+
static actions = ["close", "open"];
|
|
5868
|
+
/** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
|
|
5391
5869
|
#timers = new SafeTimeout();
|
|
5870
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
5871
|
+
#escapeLayer = new EscapeLayer();
|
|
5392
5872
|
#pendingOpen = null;
|
|
5393
5873
|
#pendingClose = null;
|
|
5394
5874
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
5395
5875
|
#stopScrollDismiss = null;
|
|
5396
|
-
/** Starts closed. */
|
|
5876
|
+
/** Starts closed and discards any stale pending state from a prior connection. */
|
|
5397
5877
|
connect() {
|
|
5878
|
+
this.#cancelOpen();
|
|
5879
|
+
this.#cancelClose();
|
|
5398
5880
|
this.#conceal();
|
|
5399
5881
|
}
|
|
5400
|
-
/** Clears timers
|
|
5882
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
5401
5883
|
disconnect() {
|
|
5402
|
-
this.#
|
|
5403
|
-
|
|
5884
|
+
this.#cancelOpen();
|
|
5885
|
+
this.#cancelClose();
|
|
5886
|
+
this.#escapeLayer.deactivate();
|
|
5404
5887
|
this.#stopScrollDismiss?.();
|
|
5405
5888
|
this.#stopScrollDismiss = null;
|
|
5406
5889
|
}
|
|
@@ -5432,32 +5915,26 @@ var HoverCardController = class extends Controller {
|
|
|
5432
5915
|
this.#conceal();
|
|
5433
5916
|
}, this.closeDelayValue);
|
|
5434
5917
|
}
|
|
5435
|
-
/**
|
|
5436
|
-
onKeydown(event) {
|
|
5437
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
5438
|
-
event.preventDefault();
|
|
5439
|
-
this.#dismiss();
|
|
5440
|
-
}
|
|
5441
|
-
}
|
|
5442
|
-
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
|
|
5918
|
+
/** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
|
|
5443
5919
|
#reveal() {
|
|
5444
5920
|
if (!this.hasCardTarget) return;
|
|
5445
5921
|
this.cardTarget.hidden = false;
|
|
5446
5922
|
this.cardTarget.setAttribute("data-state", "open");
|
|
5447
5923
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
5448
|
-
|
|
5924
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
|
|
5449
5925
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
5450
5926
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
5451
5927
|
}
|
|
5452
5928
|
}
|
|
5453
|
-
/** Hides the card, reflects state, and
|
|
5929
|
+
/** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
|
|
5454
5930
|
#conceal() {
|
|
5455
|
-
|
|
5931
|
+
this.#escapeLayer.deactivate();
|
|
5456
5932
|
this.#stopScrollDismiss?.();
|
|
5457
5933
|
this.#stopScrollDismiss = null;
|
|
5458
|
-
if (
|
|
5459
|
-
|
|
5460
|
-
|
|
5934
|
+
if (this.hasCardTarget) {
|
|
5935
|
+
this.cardTarget.hidden = true;
|
|
5936
|
+
this.cardTarget.setAttribute("data-state", "closed");
|
|
5937
|
+
}
|
|
5461
5938
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
5462
5939
|
}
|
|
5463
5940
|
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
@@ -5466,13 +5943,6 @@ var HoverCardController = class extends Controller {
|
|
|
5466
5943
|
this.#cancelClose();
|
|
5467
5944
|
this.#conceal();
|
|
5468
5945
|
}
|
|
5469
|
-
/** Document-level `Escape` watcher (active only while open). */
|
|
5470
|
-
#onDocumentKeydown = (event) => {
|
|
5471
|
-
if (event.key === "Escape") {
|
|
5472
|
-
event.preventDefault();
|
|
5473
|
-
this.#dismiss();
|
|
5474
|
-
}
|
|
5475
|
-
};
|
|
5476
5946
|
/** Cancels any pending open timer. */
|
|
5477
5947
|
#cancelOpen() {
|
|
5478
5948
|
if (this.#pendingOpen !== null) {
|
|
@@ -5732,6 +6202,12 @@ var InputMaskController = class extends Controller {
|
|
|
5732
6202
|
};
|
|
5733
6203
|
|
|
5734
6204
|
// src/utils/intersection_watcher.ts
|
|
6205
|
+
function isBeforeRootStart(entry) {
|
|
6206
|
+
const rect = entry.boundingClientRect;
|
|
6207
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
6208
|
+
const rootTop = entry.rootBounds?.top ?? 0;
|
|
6209
|
+
return rect.bottom <= rootTop;
|
|
6210
|
+
}
|
|
5735
6211
|
var IntersectionWatcher = class {
|
|
5736
6212
|
#onEntries;
|
|
5737
6213
|
#observer = null;
|
|
@@ -5747,6 +6223,11 @@ var IntersectionWatcher = class {
|
|
|
5747
6223
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
5748
6224
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
5749
6225
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
6226
|
+
*
|
|
6227
|
+
* @throws Whatever the platform throws for an invalid `rootMargin`/`threshold`
|
|
6228
|
+
* or a failing `observe()`. The exception is passed through unchanged, but
|
|
6229
|
+
* the watcher rolls back first: every target observed so far is released and
|
|
6230
|
+
* `active` stays `false`, so a caller that retries starts from a clean slate.
|
|
5750
6231
|
*/
|
|
5751
6232
|
start(targets, options = {}) {
|
|
5752
6233
|
this.stop();
|
|
@@ -5754,25 +6235,42 @@ var IntersectionWatcher = class {
|
|
|
5754
6235
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
5755
6236
|
if (list.length === 0) return false;
|
|
5756
6237
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
6238
|
+
let observer = null;
|
|
6239
|
+
try {
|
|
6240
|
+
observer = new IntersectionObserver(
|
|
6241
|
+
(entries) => {
|
|
6242
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
6243
|
+
},
|
|
6244
|
+
{ root, rootMargin: options.rootMargin, threshold: options.threshold }
|
|
6245
|
+
);
|
|
6246
|
+
for (const target of list) observer.observe(target);
|
|
6247
|
+
this.#observer = observer;
|
|
6248
|
+
this.#active = true;
|
|
6249
|
+
return true;
|
|
6250
|
+
} catch (error) {
|
|
6251
|
+
observer?.disconnect();
|
|
6252
|
+
this.#observer = null;
|
|
6253
|
+
this.#active = false;
|
|
6254
|
+
throw error;
|
|
6255
|
+
}
|
|
5766
6256
|
}
|
|
5767
6257
|
/**
|
|
5768
6258
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
5769
6259
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
5770
6260
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
6261
|
+
*
|
|
6262
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
6263
|
+
* first, so it never stays live with a half-rearmed target.
|
|
5771
6264
|
*/
|
|
5772
6265
|
rearm(target) {
|
|
5773
6266
|
if (!this.#observer) return;
|
|
5774
|
-
|
|
5775
|
-
|
|
6267
|
+
try {
|
|
6268
|
+
this.#observer.unobserve(target);
|
|
6269
|
+
this.#observer.observe(target);
|
|
6270
|
+
} catch (error) {
|
|
6271
|
+
this.stop();
|
|
6272
|
+
throw error;
|
|
6273
|
+
}
|
|
5776
6274
|
}
|
|
5777
6275
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
5778
6276
|
stop() {
|
|
@@ -5809,7 +6307,7 @@ var IntersectionController = class extends Controller {
|
|
|
5809
6307
|
this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
|
|
5810
6308
|
this.dispatch("change", { detail: { intersecting, ratio } });
|
|
5811
6309
|
this.#syncIntersecting(intersecting, ratio, entry);
|
|
5812
|
-
this.#syncPassed(!intersecting &&
|
|
6310
|
+
this.#syncPassed(!intersecting && isBeforeRootStart(entry));
|
|
5813
6311
|
}
|
|
5814
6312
|
}
|
|
5815
6313
|
connect() {
|
|
@@ -5857,7 +6355,7 @@ var IntersectionController = class extends Controller {
|
|
|
5857
6355
|
if (this.onceValue) this.#watcher.stop();
|
|
5858
6356
|
} else if (!intersecting && previous === "true") {
|
|
5859
6357
|
this.dispatch("exit", {
|
|
5860
|
-
detail: { ratio, position:
|
|
6358
|
+
detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
|
|
5861
6359
|
});
|
|
5862
6360
|
}
|
|
5863
6361
|
}
|
|
@@ -5873,11 +6371,6 @@ var IntersectionController = class extends Controller {
|
|
|
5873
6371
|
const changed = previous === null ? passed : previous === "true" !== passed;
|
|
5874
6372
|
if (changed) this.dispatch("passed", { detail: { passed } });
|
|
5875
6373
|
}
|
|
5876
|
-
/** True when the element sits entirely before the root's start (top) edge. */
|
|
5877
|
-
#isBefore(entry) {
|
|
5878
|
-
const rootTop = entry.rootBounds?.top ?? 0;
|
|
5879
|
-
return entry.boundingClientRect.bottom <= rootTop;
|
|
5880
|
-
}
|
|
5881
6374
|
/** The configured `threshold`, clamped to the 0..1 the observer accepts. */
|
|
5882
6375
|
#clampedThreshold() {
|
|
5883
6376
|
return Math.min(1, Math.max(0, this.thresholdValue));
|
|
@@ -6029,6 +6522,7 @@ var ListboxController = class extends Controller {
|
|
|
6029
6522
|
this.#commitActive();
|
|
6030
6523
|
break;
|
|
6031
6524
|
case "Escape":
|
|
6525
|
+
if (event.defaultPrevented || event.isComposing) break;
|
|
6032
6526
|
event.preventDefault();
|
|
6033
6527
|
this.close();
|
|
6034
6528
|
this.triggerTarget.focus();
|
|
@@ -6308,13 +6802,20 @@ var MenuController = class extends Controller {
|
|
|
6308
6802
|
"open",
|
|
6309
6803
|
"toggle"
|
|
6310
6804
|
];
|
|
6311
|
-
|
|
6805
|
+
#timers = new SafeTimeout();
|
|
6806
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
6807
|
+
#escapeLayer = new EscapeLayer();
|
|
6808
|
+
/** Starts closed and registers delegated listeners. */
|
|
6312
6809
|
connect() {
|
|
6313
6810
|
this.close();
|
|
6811
|
+
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
6314
6812
|
document.addEventListener("click", this.#onOutsideClick);
|
|
6315
6813
|
}
|
|
6316
|
-
/**
|
|
6814
|
+
/** Releases the listeners, stack membership, and any pending Tab-close task. */
|
|
6317
6815
|
disconnect() {
|
|
6816
|
+
this.#timers.clearAll();
|
|
6817
|
+
this.#escapeLayer.deactivate();
|
|
6818
|
+
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
6318
6819
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
6319
6820
|
}
|
|
6320
6821
|
/** Toggles the menu open/closed. Bound via `data-action` (click). */
|
|
@@ -6328,12 +6829,19 @@ var MenuController = class extends Controller {
|
|
|
6328
6829
|
}
|
|
6329
6830
|
/** Opens the menu and reflects the expanded state on the trigger. */
|
|
6330
6831
|
open() {
|
|
6832
|
+
this.#timers.clearAll();
|
|
6331
6833
|
if (!this.hasMenuTarget) return;
|
|
6834
|
+
this.#escapeLayer.activate(document, {
|
|
6835
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
6836
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
6837
|
+
});
|
|
6332
6838
|
this.menuTarget.hidden = false;
|
|
6333
6839
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
6334
6840
|
}
|
|
6335
6841
|
/** Closes the menu and reflects the collapsed state on the trigger. */
|
|
6336
6842
|
close() {
|
|
6843
|
+
this.#timers.clearAll();
|
|
6844
|
+
this.#escapeLayer.deactivate();
|
|
6337
6845
|
if (!this.hasMenuTarget) return;
|
|
6338
6846
|
this.menuTarget.hidden = true;
|
|
6339
6847
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
@@ -6378,18 +6886,18 @@ var MenuController = class extends Controller {
|
|
|
6378
6886
|
event.preventDefault();
|
|
6379
6887
|
items[items.length - 1]?.focus();
|
|
6380
6888
|
break;
|
|
6381
|
-
case "Escape":
|
|
6382
|
-
event.preventDefault();
|
|
6383
|
-
this.close();
|
|
6384
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
6385
|
-
break;
|
|
6386
6889
|
case "Tab":
|
|
6387
|
-
this.
|
|
6890
|
+
this.#timers.clearAll();
|
|
6891
|
+
this.#timers.set(() => this.close(), 0);
|
|
6388
6892
|
break;
|
|
6389
6893
|
}
|
|
6390
6894
|
}
|
|
6391
6895
|
/** Closes the menu after an item is activated. Bound via `data-action`. */
|
|
6392
6896
|
activate() {
|
|
6897
|
+
this.#closeAndRestore();
|
|
6898
|
+
}
|
|
6899
|
+
/** Closes and returns focus to the trigger (Escape / item-activation path). */
|
|
6900
|
+
#closeAndRestore() {
|
|
6393
6901
|
this.close();
|
|
6394
6902
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
6395
6903
|
}
|
|
@@ -6397,6 +6905,20 @@ var MenuController = class extends Controller {
|
|
|
6397
6905
|
#onOutsideClick = (event) => {
|
|
6398
6906
|
if (this.#isOpen && !this.element.contains(event.target)) this.close();
|
|
6399
6907
|
};
|
|
6908
|
+
/**
|
|
6909
|
+
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|
|
6910
|
+
* Native Enter/Space activation also synthesizes a click and is blocked here.
|
|
6911
|
+
*/
|
|
6912
|
+
#onItemClickCapture = (event) => {
|
|
6913
|
+
const target = event.target;
|
|
6914
|
+
if (!(target instanceof Node)) return;
|
|
6915
|
+
const disabled = this.itemTargets.some(
|
|
6916
|
+
(item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
|
|
6917
|
+
);
|
|
6918
|
+
if (!disabled) return;
|
|
6919
|
+
event.preventDefault();
|
|
6920
|
+
event.stopImmediatePropagation();
|
|
6921
|
+
};
|
|
6400
6922
|
/** Moves focus to the first navigable item (no-op if none). */
|
|
6401
6923
|
#focusFirst() {
|
|
6402
6924
|
this.#navigableItems[0]?.focus();
|
|
@@ -6435,6 +6957,8 @@ var MenubarController = class extends Controller {
|
|
|
6435
6957
|
#typeahead = "";
|
|
6436
6958
|
#typeaheadId = 0;
|
|
6437
6959
|
#timers = new SafeTimeout();
|
|
6960
|
+
/** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
|
|
6961
|
+
#escapeLayer = new EscapeLayer();
|
|
6438
6962
|
/** Establishes the single tab stop and the closed baseline. */
|
|
6439
6963
|
connect() {
|
|
6440
6964
|
const active = this.#roving.activeIndex;
|
|
@@ -6442,8 +6966,9 @@ var MenubarController = class extends Controller {
|
|
|
6442
6966
|
this.#closeAllMenus();
|
|
6443
6967
|
document.addEventListener("click", this.#onOutsideClick);
|
|
6444
6968
|
}
|
|
6445
|
-
/** Removes the document listener and any pending typeahead timer. */
|
|
6969
|
+
/** Removes the document listener, stack membership, and any pending typeahead timer. */
|
|
6446
6970
|
disconnect() {
|
|
6971
|
+
this.#escapeLayer.deactivate();
|
|
6447
6972
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
6448
6973
|
this.#timers.clearAll();
|
|
6449
6974
|
}
|
|
@@ -6488,12 +7013,6 @@ var MenubarController = class extends Controller {
|
|
|
6488
7013
|
event.preventDefault();
|
|
6489
7014
|
this.#gotoTop(length - 1, anyOpen);
|
|
6490
7015
|
break;
|
|
6491
|
-
case "Escape":
|
|
6492
|
-
if (anyOpen) {
|
|
6493
|
-
event.preventDefault();
|
|
6494
|
-
this.#closeAllMenus();
|
|
6495
|
-
}
|
|
6496
|
-
break;
|
|
6497
7016
|
}
|
|
6498
7017
|
}
|
|
6499
7018
|
/** Keyboard handling while focus is on a menu item. */
|
|
@@ -6529,11 +7048,6 @@ var MenubarController = class extends Controller {
|
|
|
6529
7048
|
event.preventDefault();
|
|
6530
7049
|
this.#moveToAdjacentMenu(menu, -1);
|
|
6531
7050
|
break;
|
|
6532
|
-
case "Escape":
|
|
6533
|
-
event.preventDefault();
|
|
6534
|
-
this.#closeMenu(this.#topFor(menu));
|
|
6535
|
-
this.#focusTop(this.#topFor(menu));
|
|
6536
|
-
break;
|
|
6537
7051
|
case "Tab":
|
|
6538
7052
|
this.#closeAllMenus();
|
|
6539
7053
|
break;
|
|
@@ -6571,6 +7085,10 @@ var MenubarController = class extends Controller {
|
|
|
6571
7085
|
if (!menu) return;
|
|
6572
7086
|
menu.hidden = false;
|
|
6573
7087
|
top.setAttribute("aria-expanded", "true");
|
|
7088
|
+
this.#escapeLayer.activate(document, {
|
|
7089
|
+
onDismiss: () => this.#dismissOpenMenu(),
|
|
7090
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
7091
|
+
});
|
|
6574
7092
|
this.#roving.setActive(this.topTargets.indexOf(top));
|
|
6575
7093
|
const items = this.#itemsIn(menu);
|
|
6576
7094
|
this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
|
|
@@ -6581,12 +7099,30 @@ var MenubarController = class extends Controller {
|
|
|
6581
7099
|
const menu = this.#menuFor(top);
|
|
6582
7100
|
if (menu) menu.hidden = true;
|
|
6583
7101
|
top.setAttribute("aria-expanded", "false");
|
|
7102
|
+
if (!this.#isAnyOpen) this.#escapeLayer.deactivate();
|
|
6584
7103
|
}
|
|
6585
7104
|
/** Closes every menu and resets the typeahead buffer. */
|
|
6586
7105
|
#closeAllMenus() {
|
|
6587
7106
|
for (const top of this.topTargets) this.#closeMenu(top);
|
|
6588
7107
|
this.#resetTypeahead();
|
|
6589
7108
|
}
|
|
7109
|
+
/**
|
|
7110
|
+
* Escape path, invoked by the shared resolver: pressed inside an open menu it
|
|
7111
|
+
* closes that menu and returns focus to its top item (the APG behavior);
|
|
7112
|
+
* pressed while focus is on a top item (or fell to the body) it closes every
|
|
7113
|
+
* menu without moving focus.
|
|
7114
|
+
*/
|
|
7115
|
+
#dismissOpenMenu() {
|
|
7116
|
+
const active = document.activeElement;
|
|
7117
|
+
const menu = this.menuTargets.find((candidate) => candidate.contains(active));
|
|
7118
|
+
if (menu) {
|
|
7119
|
+
const top = this.#topFor(menu);
|
|
7120
|
+
this.#closeMenu(top);
|
|
7121
|
+
this.#focusTop(top);
|
|
7122
|
+
return;
|
|
7123
|
+
}
|
|
7124
|
+
this.#closeAllMenus();
|
|
7125
|
+
}
|
|
6590
7126
|
/** Opens the menu of the top item `delta` steps from the one owning `menu`. */
|
|
6591
7127
|
#moveToAdjacentMenu(menu, delta) {
|
|
6592
7128
|
const top = this.#topFor(menu);
|
|
@@ -6768,9 +7304,25 @@ var MultiSelectController = class extends Controller {
|
|
|
6768
7304
|
static events = ["change", "filter"];
|
|
6769
7305
|
/** The active option (tracked via `aria-activedescendant`), or null. */
|
|
6770
7306
|
#activeOption = null;
|
|
7307
|
+
/** Absorbs the browser's redundant final input after compositionend. */
|
|
7308
|
+
#ignorePostCompositionInput = false;
|
|
7309
|
+
/** Owns IME lifecycle state; confirmed text emits one filter result. */
|
|
7310
|
+
#composition = new CompositionTracker({
|
|
7311
|
+
onStart: () => {
|
|
7312
|
+
this.#ignorePostCompositionInput = false;
|
|
7313
|
+
},
|
|
7314
|
+
onEnd: () => {
|
|
7315
|
+
this.#ignorePostCompositionInput = true;
|
|
7316
|
+
queueMicrotask(() => {
|
|
7317
|
+
this.#ignorePostCompositionInput = false;
|
|
7318
|
+
});
|
|
7319
|
+
this.filter();
|
|
7320
|
+
}
|
|
7321
|
+
});
|
|
6771
7322
|
#roving = new RovingTabindex(() => this.#removeButtons);
|
|
6772
7323
|
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
6773
7324
|
connect() {
|
|
7325
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
6774
7326
|
this.close();
|
|
6775
7327
|
if (this.hasTagsTarget) {
|
|
6776
7328
|
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
@@ -6784,14 +7336,30 @@ var MultiSelectController = class extends Controller {
|
|
|
6784
7336
|
}
|
|
6785
7337
|
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
6786
7338
|
disconnect() {
|
|
7339
|
+
this.#composition.disconnect();
|
|
7340
|
+
this.#ignorePostCompositionInput = false;
|
|
6787
7341
|
if (this.hasTagsTarget) {
|
|
6788
7342
|
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
6789
7343
|
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
6790
7344
|
}
|
|
6791
7345
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
6792
7346
|
}
|
|
6793
|
-
/**
|
|
6794
|
-
|
|
7347
|
+
/** Tracks an input added initially or after connect. */
|
|
7348
|
+
inputTargetConnected(input) {
|
|
7349
|
+
this.#composition.observe(input);
|
|
7350
|
+
}
|
|
7351
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
7352
|
+
inputTargetDisconnected(input) {
|
|
7353
|
+
this.#composition.unobserve(input);
|
|
7354
|
+
this.#ignorePostCompositionInput = false;
|
|
7355
|
+
}
|
|
7356
|
+
/** Filters confirmed input text, opens, and re-seeds the active option. */
|
|
7357
|
+
filter(event) {
|
|
7358
|
+
if (event && this.#ignorePostCompositionInput) {
|
|
7359
|
+
this.#ignorePostCompositionInput = false;
|
|
7360
|
+
return;
|
|
7361
|
+
}
|
|
7362
|
+
if (this.#composition.isComposing(event)) return;
|
|
6795
7363
|
const query = this.inputTarget.value.trim().toLowerCase();
|
|
6796
7364
|
for (const option of this.optionTargets) {
|
|
6797
7365
|
const label = (option.textContent ?? "").trim().toLowerCase();
|
|
@@ -6819,7 +7387,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6819
7387
|
}
|
|
6820
7388
|
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
6821
7389
|
onKeydown(event) {
|
|
6822
|
-
if (
|
|
7390
|
+
if (this.#composition.isComposing(event)) return;
|
|
6823
7391
|
switch (event.key) {
|
|
6824
7392
|
case "ArrowDown":
|
|
6825
7393
|
event.preventDefault();
|
|
@@ -6852,6 +7420,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6852
7420
|
}
|
|
6853
7421
|
break;
|
|
6854
7422
|
case "Escape":
|
|
7423
|
+
if (event.defaultPrevented || this.#isClosed) break;
|
|
6855
7424
|
event.preventDefault();
|
|
6856
7425
|
this.close();
|
|
6857
7426
|
break;
|
|
@@ -7084,18 +7653,19 @@ var NavigationMenuController = class extends Controller {
|
|
|
7084
7653
|
* when targets churn between connect and disconnect.
|
|
7085
7654
|
*/
|
|
7086
7655
|
#hoverWired = /* @__PURE__ */ new Set();
|
|
7656
|
+
/** Escape-stack membership while any panel is open; the shared resolver dismisses via it. */
|
|
7657
|
+
#escapeLayer = new EscapeLayer();
|
|
7087
7658
|
/** Establishes the closed baseline and the dismissal listeners. */
|
|
7088
7659
|
connect() {
|
|
7089
7660
|
this.#closeAll();
|
|
7090
7661
|
document.addEventListener("click", this.#onOutsideClick);
|
|
7091
|
-
document.addEventListener("keydown", this.#onKeydown);
|
|
7092
7662
|
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
7093
7663
|
if (this.openOnHoverValue) this.#addHoverListeners();
|
|
7094
7664
|
}
|
|
7095
|
-
/** Removes every listener
|
|
7665
|
+
/** Removes every listener, pending hover timer, and stack membership registered while connected. */
|
|
7096
7666
|
disconnect() {
|
|
7667
|
+
this.#escapeLayer.deactivate();
|
|
7097
7668
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
7098
|
-
document.removeEventListener("keydown", this.#onKeydown);
|
|
7099
7669
|
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
7100
7670
|
this.#removeHoverListeners();
|
|
7101
7671
|
this.#hoverTimers.clearAll();
|
|
@@ -7154,17 +7724,34 @@ var NavigationMenuController = class extends Controller {
|
|
|
7154
7724
|
if (!panel) return;
|
|
7155
7725
|
panel.hidden = false;
|
|
7156
7726
|
trigger.setAttribute("aria-expanded", "true");
|
|
7727
|
+
this.#syncEscapeLayer();
|
|
7157
7728
|
}
|
|
7158
7729
|
/** Closes `trigger`'s panel and reflects the collapsed state. */
|
|
7159
7730
|
#closePanel(trigger) {
|
|
7160
7731
|
const panel = this.#panelFor(trigger);
|
|
7161
7732
|
if (panel) panel.hidden = true;
|
|
7162
7733
|
trigger.setAttribute("aria-expanded", "false");
|
|
7734
|
+
this.#syncEscapeLayer();
|
|
7163
7735
|
}
|
|
7164
7736
|
/** Closes every open panel. */
|
|
7165
7737
|
#closeAll() {
|
|
7166
7738
|
for (const trigger of this.triggerTargets) this.#closePanel(trigger);
|
|
7167
7739
|
}
|
|
7740
|
+
/**
|
|
7741
|
+
* Aligns Escape-stack membership with the open state: joins (or re-asserts to
|
|
7742
|
+
* the top) while a panel is open, leaves once none is. Re-asserting when the
|
|
7743
|
+
* user switches panels is deliberate — the nav is again the newest layer.
|
|
7744
|
+
*/
|
|
7745
|
+
#syncEscapeLayer() {
|
|
7746
|
+
if (this.#isAnyOpen) {
|
|
7747
|
+
this.#escapeLayer.activate(document, {
|
|
7748
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
7749
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
7750
|
+
});
|
|
7751
|
+
} else {
|
|
7752
|
+
this.#escapeLayer.deactivate();
|
|
7753
|
+
}
|
|
7754
|
+
}
|
|
7168
7755
|
/** Closes any open panel and returns focus to its trigger (Escape path). */
|
|
7169
7756
|
#closeAndRestore() {
|
|
7170
7757
|
const open = this.#openTrigger;
|
|
@@ -7176,17 +7763,17 @@ var NavigationMenuController = class extends Controller {
|
|
|
7176
7763
|
#onOutsideClick = (event) => {
|
|
7177
7764
|
if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAll();
|
|
7178
7765
|
};
|
|
7179
|
-
/**
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7766
|
+
/**
|
|
7767
|
+
* Closes (without restoring focus) when focus leaves the nav for a known
|
|
7768
|
+
* external destination. A null/non-Node destination is indeterminate:
|
|
7769
|
+
* browsers use it for clicks on non-focusable content and for window
|
|
7770
|
+
* deactivation, so those never close the nav here (matching the popover
|
|
7771
|
+
* convention) — the outside-click handler decides pointer dismissal, and the
|
|
7772
|
+
* Escape stack's body-focus claim keeps keyboard dismissal working.
|
|
7773
|
+
*/
|
|
7187
7774
|
#onFocusOut = (event) => {
|
|
7188
7775
|
const next = event.relatedTarget;
|
|
7189
|
-
if (next
|
|
7776
|
+
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
7190
7777
|
this.#closeAll();
|
|
7191
7778
|
};
|
|
7192
7779
|
/** Opens a trigger's panel after the hover delay (hover mode). */
|
|
@@ -7760,21 +8347,24 @@ var OtpController = class extends Controller {
|
|
|
7760
8347
|
};
|
|
7761
8348
|
static actions = ["onInput", "onKeydown", "onPaste"];
|
|
7762
8349
|
static events = ["change", "complete", "invalid"];
|
|
7763
|
-
|
|
8350
|
+
/** Owns IME lifecycle state across every digit field. */
|
|
8351
|
+
#composition = new CompositionTracker({
|
|
8352
|
+
onEnd: (event) => {
|
|
8353
|
+
const input = event.currentTarget;
|
|
8354
|
+
if (input) this.#handleInputValidation(input);
|
|
8355
|
+
}
|
|
8356
|
+
});
|
|
7764
8357
|
connect() {
|
|
7765
8358
|
for (const field of this.fieldTargets) {
|
|
7766
8359
|
field.addEventListener("focus", this.#onFieldFocus);
|
|
7767
|
-
|
|
7768
|
-
field.addEventListener("compositionend", this.#onCompositionEnd);
|
|
8360
|
+
this.#composition.observe(field);
|
|
7769
8361
|
}
|
|
7770
8362
|
}
|
|
7771
8363
|
disconnect() {
|
|
7772
8364
|
for (const field of this.fieldTargets) {
|
|
7773
8365
|
field.removeEventListener("focus", this.#onFieldFocus);
|
|
7774
|
-
field.removeEventListener("compositionstart", this.#onCompositionStart);
|
|
7775
|
-
field.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
7776
8366
|
}
|
|
7777
|
-
this.#
|
|
8367
|
+
this.#composition.disconnect();
|
|
7778
8368
|
}
|
|
7779
8369
|
/**
|
|
7780
8370
|
* Stimulus lifecycle callback when a new field target enters the DOM.
|
|
@@ -7782,21 +8372,18 @@ var OtpController = class extends Controller {
|
|
|
7782
8372
|
*/
|
|
7783
8373
|
fieldTargetConnected(element) {
|
|
7784
8374
|
element.addEventListener("focus", this.#onFieldFocus);
|
|
7785
|
-
|
|
7786
|
-
element.addEventListener("compositionend", this.#onCompositionEnd);
|
|
8375
|
+
this.#composition.observe(element);
|
|
7787
8376
|
}
|
|
7788
8377
|
/** Removes focus listeners when fields are dropped. */
|
|
7789
8378
|
fieldTargetDisconnected(element) {
|
|
7790
8379
|
element.removeEventListener("focus", this.#onFieldFocus);
|
|
7791
|
-
|
|
7792
|
-
element.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
7793
|
-
this.#isComposing.delete(element);
|
|
8380
|
+
this.#composition.unobserve(element);
|
|
7794
8381
|
}
|
|
7795
8382
|
/** Handles keystroke inputs and advances focus to the next field. */
|
|
7796
8383
|
onInput(event) {
|
|
7797
8384
|
const input = event.currentTarget;
|
|
7798
8385
|
if (!input) return;
|
|
7799
|
-
if (this.#isComposing
|
|
8386
|
+
if (this.#composition.isComposing(event)) return;
|
|
7800
8387
|
this.#handleInputValidation(input);
|
|
7801
8388
|
}
|
|
7802
8389
|
/** Handles Backspace retreating, arrows, and home/end navigation. */
|
|
@@ -7805,7 +8392,7 @@ var OtpController = class extends Controller {
|
|
|
7805
8392
|
if (!input) return;
|
|
7806
8393
|
const index = this.fieldTargets.indexOf(input);
|
|
7807
8394
|
if (index === -1) return;
|
|
7808
|
-
if (this.#isComposing
|
|
8395
|
+
if (this.#composition.isComposing(event)) return;
|
|
7809
8396
|
switch (event.key) {
|
|
7810
8397
|
case "Backspace":
|
|
7811
8398
|
if (!input.value) {
|
|
@@ -7887,15 +8474,6 @@ var OtpController = class extends Controller {
|
|
|
7887
8474
|
input.select();
|
|
7888
8475
|
}
|
|
7889
8476
|
};
|
|
7890
|
-
#onCompositionStart = (event) => {
|
|
7891
|
-
const input = event.currentTarget;
|
|
7892
|
-
this.#isComposing.set(input, true);
|
|
7893
|
-
};
|
|
7894
|
-
#onCompositionEnd = (event) => {
|
|
7895
|
-
const input = event.currentTarget;
|
|
7896
|
-
this.#isComposing.set(input, false);
|
|
7897
|
-
this.#handleInputValidation(input);
|
|
7898
|
-
};
|
|
7899
8477
|
#handleInputValidation(input) {
|
|
7900
8478
|
const index = this.fieldTargets.indexOf(input);
|
|
7901
8479
|
if (index === -1) return;
|
|
@@ -7946,6 +8524,26 @@ var OtpController = class extends Controller {
|
|
|
7946
8524
|
}
|
|
7947
8525
|
}
|
|
7948
8526
|
};
|
|
8527
|
+
|
|
8528
|
+
// src/utils/logical_scroll.ts
|
|
8529
|
+
function isRtl(element) {
|
|
8530
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8531
|
+
}
|
|
8532
|
+
function logicalScrollMetrics(element, horizontal) {
|
|
8533
|
+
const max = Math.max(
|
|
8534
|
+
0,
|
|
8535
|
+
horizontal ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight
|
|
8536
|
+
);
|
|
8537
|
+
const raw = horizontal ? element.scrollLeft : element.scrollTop;
|
|
8538
|
+
const position = horizontal && isRtl(element) ? -raw : raw;
|
|
8539
|
+
return { position: Math.min(max, Math.max(0, position)), max };
|
|
8540
|
+
}
|
|
8541
|
+
function physicalScrollDelta(element, horizontal, logicalDelta) {
|
|
8542
|
+
return horizontal && isRtl(element) ? -logicalDelta : logicalDelta;
|
|
8543
|
+
}
|
|
8544
|
+
|
|
8545
|
+
// src/controllers/overflow_indicator_controller.ts
|
|
8546
|
+
var DIRECTION_BUTTON_SELECTOR = "[data-stimeo--overflow-indicator-direction-param]";
|
|
7949
8547
|
var OverflowIndicatorController = class extends Controller {
|
|
7950
8548
|
static targets = ["viewport"];
|
|
7951
8549
|
static values = {
|
|
@@ -7954,38 +8552,50 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
7954
8552
|
};
|
|
7955
8553
|
static actions = ["scrollByPage", "update"];
|
|
7956
8554
|
static events = ["change"];
|
|
7957
|
-
#layout = new LayoutObserver(() =>
|
|
8555
|
+
#layout = new LayoutObserver(() => {
|
|
8556
|
+
if (this.#connected) this.update();
|
|
8557
|
+
});
|
|
8558
|
+
#connected = false;
|
|
8559
|
+
#observedViewport = null;
|
|
8560
|
+
#observedContent = /* @__PURE__ */ new Set();
|
|
7958
8561
|
#mutationObserver = null;
|
|
8562
|
+
#pendingButtonDisables = /* @__PURE__ */ new Map();
|
|
7959
8563
|
/** Last reported room, so `change` fires only on transitions. */
|
|
7960
8564
|
#state = null;
|
|
7961
8565
|
connect() {
|
|
7962
|
-
|
|
7963
|
-
this.#
|
|
7964
|
-
this.#layout.observeViewport();
|
|
7965
|
-
if (typeof MutationObserver !== "undefined") {
|
|
7966
|
-
this.#mutationObserver = new MutationObserver(() => this.update());
|
|
7967
|
-
this.#mutationObserver.observe(this.viewportTarget, {
|
|
7968
|
-
childList: true,
|
|
7969
|
-
subtree: true,
|
|
7970
|
-
characterData: true
|
|
7971
|
-
});
|
|
7972
|
-
}
|
|
7973
|
-
this.update();
|
|
8566
|
+
this.#connected = true;
|
|
8567
|
+
this.#syncViewport();
|
|
7974
8568
|
}
|
|
7975
8569
|
disconnect() {
|
|
8570
|
+
this.#connected = false;
|
|
8571
|
+
this.#stopObservingViewport();
|
|
7976
8572
|
this.#layout.disconnect();
|
|
7977
|
-
this.#
|
|
7978
|
-
this.#mutationObserver = null;
|
|
8573
|
+
this.#clearPendingButtonDisables();
|
|
7979
8574
|
this.#state = null;
|
|
7980
8575
|
}
|
|
7981
|
-
|
|
8576
|
+
viewportTargetConnected() {
|
|
8577
|
+
this.#syncViewport();
|
|
8578
|
+
}
|
|
8579
|
+
viewportTargetDisconnected(viewport) {
|
|
8580
|
+
if (this.#observedViewport === viewport) this.#stopObservingViewport();
|
|
8581
|
+
this.#syncViewport();
|
|
8582
|
+
}
|
|
8583
|
+
orientationValueChanged() {
|
|
8584
|
+
if (this.#connected) this.update();
|
|
8585
|
+
}
|
|
8586
|
+
thresholdValueChanged() {
|
|
8587
|
+
if (this.#connected) this.update();
|
|
8588
|
+
}
|
|
8589
|
+
/**
|
|
8590
|
+
* Re-measures remaining scroll room and reflects the state hooks.
|
|
8591
|
+
* Public so it can be wired to the viewport's `scroll`.
|
|
8592
|
+
*/
|
|
7982
8593
|
update() {
|
|
7983
8594
|
if (!this.hasViewportTarget) return;
|
|
7984
8595
|
const vp = this.viewportTarget;
|
|
7985
8596
|
const horizontal = this.orientationValue !== "vertical";
|
|
7986
|
-
const t = this
|
|
7987
|
-
const scrollPos
|
|
7988
|
-
const maxScroll = horizontal ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
|
|
8597
|
+
const t = this.#threshold;
|
|
8598
|
+
const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontal);
|
|
7989
8599
|
const start = scrollPos > t;
|
|
7990
8600
|
const end = scrollPos < maxScroll - t;
|
|
7991
8601
|
vp.setAttribute("data-overflow-start", start ? "true" : "false");
|
|
@@ -8001,11 +8611,13 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
8001
8611
|
if (!this.hasViewportTarget) return;
|
|
8002
8612
|
const direction = this.#directionFromEvent(event);
|
|
8003
8613
|
if (!direction) return;
|
|
8614
|
+
if (this.#state && !this.#state[direction]) return;
|
|
8004
8615
|
const vp = this.viewportTarget;
|
|
8005
8616
|
const horizontal = this.orientationValue !== "vertical";
|
|
8006
8617
|
const page = horizontal ? vp.clientWidth : vp.clientHeight;
|
|
8007
|
-
const
|
|
8008
|
-
const
|
|
8618
|
+
const logicalDelta = direction === "start" ? -page : page;
|
|
8619
|
+
const delta = physicalScrollDelta(vp, horizontal, logicalDelta);
|
|
8620
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
8009
8621
|
if (horizontal) {
|
|
8010
8622
|
vp.scrollBy({ left: delta, behavior });
|
|
8011
8623
|
} else {
|
|
@@ -8014,10 +8626,16 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
8014
8626
|
}
|
|
8015
8627
|
/** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
|
|
8016
8628
|
#syncButtons(start, end) {
|
|
8017
|
-
const
|
|
8018
|
-
"[data-stimeo--overflow-indicator
|
|
8019
|
-
|
|
8629
|
+
for (const button of [...this.#pendingButtonDisables.keys()]) {
|
|
8630
|
+
if (!button.isConnected || button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
|
|
8631
|
+
this.#cancelPendingButtonDisable(button);
|
|
8632
|
+
}
|
|
8633
|
+
}
|
|
8634
|
+
const buttons = this.element.querySelectorAll(DIRECTION_BUTTON_SELECTOR);
|
|
8020
8635
|
for (const button of buttons) {
|
|
8636
|
+
if (button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
|
|
8637
|
+
continue;
|
|
8638
|
+
}
|
|
8021
8639
|
const direction = button.getAttribute("data-stimeo--overflow-indicator-direction-param");
|
|
8022
8640
|
if (direction === "start") this.#toggleButton(button, start);
|
|
8023
8641
|
else if (direction === "end") this.#toggleButton(button, end);
|
|
@@ -8030,7 +8648,11 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
8030
8648
|
* whole control disabled) is therefore never blindly re-enabled.
|
|
8031
8649
|
*/
|
|
8032
8650
|
#toggleButton(button, hasRoom) {
|
|
8651
|
+
if (!this.#pendingButtonDisables.has(button) && button.hasAttribute("data-overflow-indicator-pending-disabled")) {
|
|
8652
|
+
this.#cancelPendingButtonDisable(button);
|
|
8653
|
+
}
|
|
8033
8654
|
if (hasRoom) {
|
|
8655
|
+
this.#cancelPendingButtonDisable(button);
|
|
8034
8656
|
if (button.hasAttribute("data-overflow-indicator-disabled")) {
|
|
8035
8657
|
button.disabled = false;
|
|
8036
8658
|
button.removeAttribute("data-overflow-indicator-disabled");
|
|
@@ -8038,17 +8660,121 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
8038
8660
|
return;
|
|
8039
8661
|
}
|
|
8040
8662
|
if (button.disabled) return;
|
|
8663
|
+
if (document.activeElement === button) {
|
|
8664
|
+
this.#deferButtonDisable(button);
|
|
8665
|
+
} else {
|
|
8666
|
+
this.#disableButton(button);
|
|
8667
|
+
}
|
|
8668
|
+
}
|
|
8669
|
+
/**
|
|
8670
|
+
* Keeps a boundary button focusable until native blur, exposing its temporary
|
|
8671
|
+
* inoperability with `aria-disabled` and making its action a no-op meanwhile.
|
|
8672
|
+
*/
|
|
8673
|
+
#deferButtonDisable(button) {
|
|
8674
|
+
if (this.#pendingButtonDisables.has(button)) return;
|
|
8675
|
+
button.setAttribute("data-overflow-indicator-pending-disabled", "");
|
|
8676
|
+
button.setAttribute(
|
|
8677
|
+
"data-overflow-indicator-aria-disabled",
|
|
8678
|
+
button.getAttribute("aria-disabled") ?? ""
|
|
8679
|
+
);
|
|
8680
|
+
button.setAttribute("aria-disabled", "true");
|
|
8681
|
+
const onBlur = () => {
|
|
8682
|
+
this.#cancelPendingButtonDisable(button);
|
|
8683
|
+
if (!button.disabled) this.#disableButton(button);
|
|
8684
|
+
};
|
|
8685
|
+
this.#pendingButtonDisables.set(button, { onBlur });
|
|
8686
|
+
button.addEventListener("blur", onBlur);
|
|
8687
|
+
}
|
|
8688
|
+
#disableButton(button) {
|
|
8041
8689
|
button.disabled = true;
|
|
8042
8690
|
button.setAttribute("data-overflow-indicator-disabled", "");
|
|
8043
8691
|
}
|
|
8692
|
+
#cancelPendingButtonDisable(button) {
|
|
8693
|
+
const pending = this.#pendingButtonDisables.get(button);
|
|
8694
|
+
if (pending) button.removeEventListener("blur", pending.onBlur);
|
|
8695
|
+
this.#pendingButtonDisables.delete(button);
|
|
8696
|
+
button.removeAttribute("data-overflow-indicator-pending-disabled");
|
|
8697
|
+
const displaced = button.getAttribute("data-overflow-indicator-aria-disabled");
|
|
8698
|
+
if (displaced !== null) {
|
|
8699
|
+
button.removeAttribute("data-overflow-indicator-aria-disabled");
|
|
8700
|
+
if (button.getAttribute("aria-disabled") === "true") {
|
|
8701
|
+
if (displaced === "") button.removeAttribute("aria-disabled");
|
|
8702
|
+
else button.setAttribute("aria-disabled", displaced);
|
|
8703
|
+
}
|
|
8704
|
+
}
|
|
8705
|
+
}
|
|
8706
|
+
#clearPendingButtonDisables() {
|
|
8707
|
+
for (const button of [...this.#pendingButtonDisables.keys()]) {
|
|
8708
|
+
this.#cancelPendingButtonDisable(button);
|
|
8709
|
+
}
|
|
8710
|
+
}
|
|
8711
|
+
/** Moves resize/mutation/load observation to the current viewport target. */
|
|
8712
|
+
#syncViewport() {
|
|
8713
|
+
if (!this.#connected) return;
|
|
8714
|
+
const next = this.hasViewportTarget ? this.viewportTarget : null;
|
|
8715
|
+
if (next === this.#observedViewport) return;
|
|
8716
|
+
this.#stopObservingViewport();
|
|
8717
|
+
if (!next) return;
|
|
8718
|
+
this.#observedViewport = next;
|
|
8719
|
+
this.#layout.observe(next);
|
|
8720
|
+
this.#layout.observeViewport();
|
|
8721
|
+
next.addEventListener("load", this.#onContentLoad, true);
|
|
8722
|
+
this.#syncContentObservation();
|
|
8723
|
+
if (typeof MutationObserver !== "undefined") {
|
|
8724
|
+
this.#mutationObserver = new MutationObserver(() => {
|
|
8725
|
+
if (!this.#connected || this.#observedViewport !== next) return;
|
|
8726
|
+
this.#syncContentObservation();
|
|
8727
|
+
this.update();
|
|
8728
|
+
});
|
|
8729
|
+
this.#mutationObserver.observe(this.element, {
|
|
8730
|
+
childList: true,
|
|
8731
|
+
subtree: true,
|
|
8732
|
+
characterData: true,
|
|
8733
|
+
attributes: true,
|
|
8734
|
+
attributeFilter: ["class", "style", "hidden"]
|
|
8735
|
+
});
|
|
8736
|
+
}
|
|
8737
|
+
this.#state = null;
|
|
8738
|
+
this.update();
|
|
8739
|
+
}
|
|
8740
|
+
/** Observes direct content boxes whose resize can change the viewport's scroll extent. */
|
|
8741
|
+
#syncContentObservation() {
|
|
8742
|
+
const next = new Set(this.#observedViewport?.children ?? []);
|
|
8743
|
+
for (const content of this.#observedContent) {
|
|
8744
|
+
if (!next.has(content)) {
|
|
8745
|
+
this.#layout.unobserve(content);
|
|
8746
|
+
this.#observedContent.delete(content);
|
|
8747
|
+
}
|
|
8748
|
+
}
|
|
8749
|
+
for (const content of next) {
|
|
8750
|
+
if (!this.#observedContent.has(content)) {
|
|
8751
|
+
this.#observedContent.add(content);
|
|
8752
|
+
this.#layout.observe(content);
|
|
8753
|
+
}
|
|
8754
|
+
}
|
|
8755
|
+
}
|
|
8756
|
+
#stopObservingViewport() {
|
|
8757
|
+
this.#mutationObserver?.disconnect();
|
|
8758
|
+
this.#mutationObserver = null;
|
|
8759
|
+
this.#observedViewport?.removeEventListener("load", this.#onContentLoad, true);
|
|
8760
|
+
if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
|
|
8761
|
+
for (const content of this.#observedContent) this.#layout.unobserve(content);
|
|
8762
|
+
this.#observedContent.clear();
|
|
8763
|
+
this.#observedViewport = null;
|
|
8764
|
+
this.#layout.unobserveViewport();
|
|
8765
|
+
}
|
|
8766
|
+
#onContentLoad = () => {
|
|
8767
|
+
if (this.#connected) this.update();
|
|
8768
|
+
};
|
|
8769
|
+
get #threshold() {
|
|
8770
|
+
const value = this.thresholdValue;
|
|
8771
|
+
return Number.isFinite(value) ? Math.max(0, value) : 1;
|
|
8772
|
+
}
|
|
8044
8773
|
#directionFromEvent(event) {
|
|
8045
8774
|
const params = event.params;
|
|
8046
8775
|
const direction = params?.direction;
|
|
8047
8776
|
return direction === "start" || direction === "end" ? direction : null;
|
|
8048
8777
|
}
|
|
8049
|
-
#prefersReducedMotion() {
|
|
8050
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
8051
|
-
}
|
|
8052
8778
|
};
|
|
8053
8779
|
var OverflowMenuController = class extends Controller {
|
|
8054
8780
|
static targets = ["items", "more"];
|
|
@@ -8839,6 +9565,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
8839
9565
|
const handle = this.#handleFor(event.target);
|
|
8840
9566
|
if (!handle) return;
|
|
8841
9567
|
if (event.key === "Escape") {
|
|
9568
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
8842
9569
|
if (this.#pointer?.started) {
|
|
8843
9570
|
const { pointerType } = this.#pointer;
|
|
8844
9571
|
this.#teardownSessions();
|
|
@@ -8951,9 +9678,8 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
8951
9678
|
}
|
|
8952
9679
|
/**
|
|
8953
9680
|
* Silently tears down whatever session is live (disconnect / disabled /
|
|
8954
|
-
* Escape). Composed from the two single-session teardowns so
|
|
8955
|
-
*
|
|
8956
|
-
* the capture-release fix).
|
|
9681
|
+
* Escape). Composed from the two single-session teardowns so pointer and
|
|
9682
|
+
* keyboard cleanup cannot diverge as either path evolves.
|
|
8957
9683
|
*/
|
|
8958
9684
|
#teardownSessions() {
|
|
8959
9685
|
this.#endPointerSession();
|
|
@@ -9060,24 +9786,26 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9060
9786
|
static actions = ["close", "open", "toggle"];
|
|
9061
9787
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
9062
9788
|
#stopScrollDismiss = null;
|
|
9789
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
9790
|
+
#escapeLayer = new EscapeLayer();
|
|
9063
9791
|
/** Selector for natively focusable elements used to find the first one. */
|
|
9064
9792
|
static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
9065
|
-
/** Starts closed and registers the
|
|
9793
|
+
/** Starts closed and registers the standing dismissal listeners. */
|
|
9066
9794
|
connect() {
|
|
9067
9795
|
this.close();
|
|
9068
9796
|
document.addEventListener("click", this.#onOutsideClick);
|
|
9069
|
-
|
|
9797
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
9070
9798
|
}
|
|
9071
9799
|
/**
|
|
9072
|
-
* Removes
|
|
9073
|
-
*
|
|
9074
|
-
*
|
|
9075
|
-
*
|
|
9800
|
+
* Removes every standing listener registered in {@link connect} plus any active
|
|
9801
|
+
* dismiss-on-scroll observers. `removeEventListener` is a no-op when it was
|
|
9802
|
+
* never added, so this is safe in the closed state too — no listener outlives
|
|
9803
|
+
* the element after a Turbo navigation.
|
|
9076
9804
|
*/
|
|
9077
9805
|
disconnect() {
|
|
9806
|
+
this.#escapeLayer.deactivate();
|
|
9078
9807
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
9079
|
-
|
|
9080
|
-
if (this.hasPanelTarget) this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
9808
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
9081
9809
|
this.#stopScrollDismiss?.();
|
|
9082
9810
|
this.#stopScrollDismiss = null;
|
|
9083
9811
|
}
|
|
@@ -9094,7 +9822,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9094
9822
|
if (!this.hasPanelTarget || this.#isOpen) return;
|
|
9095
9823
|
this.panelTarget.hidden = false;
|
|
9096
9824
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
9097
|
-
this.
|
|
9825
|
+
this.#escapeLayer.activate(document, {
|
|
9826
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
9827
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
9828
|
+
});
|
|
9098
9829
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
9099
9830
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
|
|
9100
9831
|
}
|
|
@@ -9102,11 +9833,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9102
9833
|
}
|
|
9103
9834
|
/** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
|
|
9104
9835
|
close() {
|
|
9105
|
-
|
|
9106
|
-
this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
9836
|
+
this.#escapeLayer.deactivate();
|
|
9107
9837
|
this.#stopScrollDismiss?.();
|
|
9108
9838
|
this.#stopScrollDismiss = null;
|
|
9109
|
-
this.panelTarget.hidden = true;
|
|
9839
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = true;
|
|
9110
9840
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
9111
9841
|
}
|
|
9112
9842
|
/** Moves focus to the first focusable element in the panel, or the panel itself. */
|
|
@@ -9119,31 +9849,28 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9119
9849
|
if (!this.panelTarget.hasAttribute("tabindex")) this.panelTarget.tabIndex = -1;
|
|
9120
9850
|
this.panelTarget.focus();
|
|
9121
9851
|
}
|
|
9122
|
-
/** Closes and restores focus to the trigger
|
|
9852
|
+
/** Closes and restores focus to the trigger for explicit keyboard dismissal. */
|
|
9123
9853
|
#closeAndRestore() {
|
|
9124
9854
|
this.close();
|
|
9125
9855
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
9126
9856
|
}
|
|
9127
|
-
/** Closes
|
|
9857
|
+
/** Closes without moving focus when a click lands outside the controller element. */
|
|
9128
9858
|
#onOutsideClick = (event) => {
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
/** Closes (restoring focus) on `Escape` while open. */
|
|
9132
|
-
#onKeydown = (event) => {
|
|
9133
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
9134
|
-
event.preventDefault();
|
|
9135
|
-
this.#closeAndRestore();
|
|
9136
|
-
}
|
|
9859
|
+
const target = event.target;
|
|
9860
|
+
if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();
|
|
9137
9861
|
};
|
|
9138
9862
|
/**
|
|
9139
|
-
* Closes when focus leaves the
|
|
9140
|
-
* (e.g.
|
|
9141
|
-
* destination is kept, which is the modeless
|
|
9142
|
-
*
|
|
9863
|
+
* Closes when focus leaves the controller for a known external destination
|
|
9864
|
+
* (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is
|
|
9865
|
+
* not restored — the natural destination is kept, which is the modeless
|
|
9866
|
+
* contract. A null/non-Node destination is indeterminate: browsers use it for
|
|
9867
|
+
* clicks on non-focusable content and window deactivation, so the later outside
|
|
9868
|
+
* click handler decides pointer dismissal instead.
|
|
9143
9869
|
*/
|
|
9144
9870
|
#onFocusOut = (event) => {
|
|
9871
|
+
if (!this.#isOpen) return;
|
|
9145
9872
|
const next = event.relatedTarget;
|
|
9146
|
-
if (next
|
|
9873
|
+
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
9147
9874
|
this.close();
|
|
9148
9875
|
};
|
|
9149
9876
|
/** Whether the panel is currently visible. */
|
|
@@ -9738,29 +10465,49 @@ var ReadMoreController = class extends Controller {
|
|
|
9738
10465
|
collapsed: { type: Boolean, default: true }
|
|
9739
10466
|
};
|
|
9740
10467
|
static actions = ["toggle"];
|
|
9741
|
-
|
|
9742
|
-
#
|
|
10468
|
+
#connected = false;
|
|
10469
|
+
#collapsed = true;
|
|
10470
|
+
#observedContent = null;
|
|
10471
|
+
#contentMutationObserver = null;
|
|
10472
|
+
#deferredHideTrigger = null;
|
|
10473
|
+
#update = () => {
|
|
10474
|
+
if (this.#connected) this.#evaluateOverflow();
|
|
10475
|
+
};
|
|
10476
|
+
#layout = new LayoutObserver(this.#update);
|
|
10477
|
+
#onDeferredHideBlur = () => {
|
|
10478
|
+
this.#clearDeferredHide();
|
|
10479
|
+
this.#update();
|
|
10480
|
+
};
|
|
9743
10481
|
connect() {
|
|
9744
|
-
this.#
|
|
9745
|
-
this.#
|
|
9746
|
-
|
|
9747
|
-
this.#layout.observe(this.contentTarget);
|
|
9748
|
-
this.#layout.observeViewport();
|
|
9749
|
-
}
|
|
10482
|
+
this.#connected = true;
|
|
10483
|
+
this.#collapsed = this.#initialCollapsed();
|
|
10484
|
+
this.#syncTargets();
|
|
9750
10485
|
}
|
|
9751
10486
|
disconnect() {
|
|
10487
|
+
this.#connected = false;
|
|
10488
|
+
this.#stopObservingContent();
|
|
9752
10489
|
this.#layout.disconnect();
|
|
9753
10490
|
}
|
|
10491
|
+
contentTargetConnected() {
|
|
10492
|
+
this.#syncTargets();
|
|
10493
|
+
}
|
|
10494
|
+
contentTargetDisconnected() {
|
|
10495
|
+
this.#syncTargets();
|
|
10496
|
+
}
|
|
10497
|
+
triggerTargetConnected() {
|
|
10498
|
+
this.#syncTargets();
|
|
10499
|
+
}
|
|
10500
|
+
triggerTargetDisconnected(trigger) {
|
|
10501
|
+
if (this.#deferredHideTrigger === trigger) this.#clearDeferredHide();
|
|
10502
|
+
this.#syncTargets();
|
|
10503
|
+
}
|
|
9754
10504
|
/** Toggles between the collapsed (clamped) and expanded states. */
|
|
9755
10505
|
toggle() {
|
|
9756
|
-
|
|
10506
|
+
if (!this.#connected) return;
|
|
10507
|
+
this.#collapsed = !this.#collapsed;
|
|
10508
|
+
this.#reflect();
|
|
9757
10509
|
this.#evaluateOverflow();
|
|
9758
10510
|
}
|
|
9759
|
-
/** Whether the content is currently collapsed (clamped). */
|
|
9760
|
-
get #isCollapsed() {
|
|
9761
|
-
return this.hasContentTarget ? this.contentTarget.getAttribute("data-state") !== "expanded" : this.collapsedValue;
|
|
9762
|
-
}
|
|
9763
|
-
/** Connect-time state: an explicit `data-state` wins, else the `collapsed` Value. */
|
|
9764
10511
|
#initialCollapsed() {
|
|
9765
10512
|
if (this.hasContentTarget) {
|
|
9766
10513
|
const state = this.contentTarget.getAttribute("data-state");
|
|
@@ -9769,29 +10516,76 @@ var ReadMoreController = class extends Controller {
|
|
|
9769
10516
|
}
|
|
9770
10517
|
return this.collapsedValue;
|
|
9771
10518
|
}
|
|
9772
|
-
|
|
9773
|
-
#reflect(collapsed) {
|
|
10519
|
+
#reflect() {
|
|
9774
10520
|
if (this.hasContentTarget) {
|
|
9775
|
-
this.contentTarget.setAttribute("data-state", collapsed ? "collapsed" : "expanded");
|
|
10521
|
+
this.contentTarget.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
|
|
9776
10522
|
}
|
|
9777
10523
|
if (this.hasTriggerTarget) {
|
|
9778
|
-
this.triggerTarget.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
10524
|
+
this.triggerTarget.setAttribute("aria-expanded", this.#collapsed ? "false" : "true");
|
|
9779
10525
|
}
|
|
9780
10526
|
}
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
10527
|
+
#syncTargets() {
|
|
10528
|
+
if (!this.#connected) return;
|
|
10529
|
+
this.#syncContentObservation();
|
|
10530
|
+
this.#reflect();
|
|
10531
|
+
this.#evaluateOverflow();
|
|
10532
|
+
}
|
|
10533
|
+
#syncContentObservation() {
|
|
10534
|
+
const next = this.hasContentTarget ? this.contentTarget : null;
|
|
10535
|
+
if (next === this.#observedContent) return;
|
|
10536
|
+
this.#stopObservingContent();
|
|
10537
|
+
if (!next) return;
|
|
10538
|
+
this.#observedContent = next;
|
|
10539
|
+
this.#layout.observe(next);
|
|
10540
|
+
this.#layout.observeViewport();
|
|
10541
|
+
next.addEventListener("load", this.#update, true);
|
|
10542
|
+
if (typeof MutationObserver !== "undefined") {
|
|
10543
|
+
this.#contentMutationObserver = new MutationObserver(this.#update);
|
|
10544
|
+
this.#contentMutationObserver.observe(next, {
|
|
10545
|
+
childList: true,
|
|
10546
|
+
subtree: true,
|
|
10547
|
+
characterData: true
|
|
10548
|
+
});
|
|
10549
|
+
}
|
|
10550
|
+
}
|
|
10551
|
+
#stopObservingContent() {
|
|
10552
|
+
this.#clearDeferredHide();
|
|
10553
|
+
if (this.#observedContent) {
|
|
10554
|
+
this.#layout.unobserve(this.#observedContent);
|
|
10555
|
+
this.#observedContent.removeEventListener("load", this.#update, true);
|
|
10556
|
+
}
|
|
10557
|
+
this.#observedContent = null;
|
|
10558
|
+
this.#contentMutationObserver?.disconnect();
|
|
10559
|
+
this.#contentMutationObserver = null;
|
|
10560
|
+
this.#layout.unobserveViewport();
|
|
10561
|
+
}
|
|
10562
|
+
#deferHide(trigger) {
|
|
10563
|
+
if (this.#deferredHideTrigger === trigger) return;
|
|
10564
|
+
this.#clearDeferredHide();
|
|
10565
|
+
this.#deferredHideTrigger = trigger;
|
|
10566
|
+
trigger.addEventListener("blur", this.#onDeferredHideBlur);
|
|
10567
|
+
}
|
|
10568
|
+
#clearDeferredHide() {
|
|
10569
|
+
this.#deferredHideTrigger?.removeEventListener("blur", this.#onDeferredHideBlur);
|
|
10570
|
+
this.#deferredHideTrigger = null;
|
|
10571
|
+
}
|
|
9786
10572
|
#evaluateOverflow() {
|
|
9787
10573
|
if (!this.hasTriggerTarget || !this.hasContentTarget) return;
|
|
9788
|
-
|
|
9789
|
-
|
|
10574
|
+
const trigger = this.triggerTarget;
|
|
10575
|
+
const content = this.contentTarget;
|
|
10576
|
+
const useful = !this.#collapsed || content.scrollHeight > content.clientHeight;
|
|
10577
|
+
if (useful) {
|
|
10578
|
+
this.#clearDeferredHide();
|
|
10579
|
+
trigger.hidden = false;
|
|
9790
10580
|
return;
|
|
9791
10581
|
}
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
10582
|
+
if (document.activeElement === trigger) {
|
|
10583
|
+
trigger.hidden = false;
|
|
10584
|
+
this.#deferHide(trigger);
|
|
10585
|
+
return;
|
|
10586
|
+
}
|
|
10587
|
+
this.#clearDeferredHide();
|
|
10588
|
+
trigger.hidden = true;
|
|
9795
10589
|
}
|
|
9796
10590
|
};
|
|
9797
10591
|
var PROGRESS_PROPERTY = "--stimeo--reading-progress";
|
|
@@ -10183,7 +10977,7 @@ var RovingController = class extends Controller {
|
|
|
10183
10977
|
}
|
|
10184
10978
|
}
|
|
10185
10979
|
};
|
|
10186
|
-
var
|
|
10980
|
+
var FOCUSABLE_SELECTOR = [
|
|
10187
10981
|
"a[href]",
|
|
10188
10982
|
"button:not([disabled])",
|
|
10189
10983
|
"input:not([disabled])",
|
|
@@ -10255,8 +11049,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
10255
11049
|
*/
|
|
10256
11050
|
#measurePosition(vp) {
|
|
10257
11051
|
const horizontalPrimary = this.orientationValue === "horizontal" || this.orientationValue === "both" && vp.scrollHeight <= vp.clientHeight + EDGE_EPSILON;
|
|
10258
|
-
const scrollPos
|
|
10259
|
-
const maxScroll = horizontalPrimary ? vp.scrollWidth - vp.clientWidth : vp.scrollHeight - vp.clientHeight;
|
|
11052
|
+
const { position: scrollPos, max: maxScroll } = logicalScrollMetrics(vp, horizontalPrimary);
|
|
10260
11053
|
if (maxScroll <= EDGE_EPSILON) return { position: "start", progress: 0 };
|
|
10261
11054
|
const progress = Math.min(1, Math.max(0, scrollPos / maxScroll));
|
|
10262
11055
|
if (scrollPos <= EDGE_EPSILON) return { position: "start", progress };
|
|
@@ -10295,7 +11088,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
10295
11088
|
}
|
|
10296
11089
|
}
|
|
10297
11090
|
#hasFocusableContent(vp) {
|
|
10298
|
-
return vp.querySelector(
|
|
11091
|
+
return vp.querySelector(FOCUSABLE_SELECTOR) !== null;
|
|
10299
11092
|
}
|
|
10300
11093
|
#hasAccessibleName(vp) {
|
|
10301
11094
|
return vp.hasAttribute("aria-label") || vp.hasAttribute("aria-labelledby");
|
|
@@ -10431,7 +11224,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
10431
11224
|
}
|
|
10432
11225
|
/** Scrolls the source to the top and, optionally, moves focus to a safe target. */
|
|
10433
11226
|
toTop() {
|
|
10434
|
-
const behavior =
|
|
11227
|
+
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
10435
11228
|
this.#scrollSource.scrollTo({ top: 0, behavior });
|
|
10436
11229
|
if (this.focusSelectorValue) {
|
|
10437
11230
|
const target = document.querySelector(this.focusSelectorValue);
|
|
@@ -10479,9 +11272,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
10479
11272
|
}
|
|
10480
11273
|
return this.#scrollSource.scrollTop;
|
|
10481
11274
|
}
|
|
10482
|
-
#prefersReducedMotion() {
|
|
10483
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
10484
|
-
}
|
|
10485
11275
|
};
|
|
10486
11276
|
var ScrollspyController = class extends Controller {
|
|
10487
11277
|
static targets = ["link"];
|
|
@@ -10720,15 +11510,23 @@ var SeparatorController = class extends Controller {
|
|
|
10720
11510
|
}
|
|
10721
11511
|
}
|
|
10722
11512
|
};
|
|
10723
|
-
|
|
10724
|
-
|
|
10725
|
-
|
|
10726
|
-
|
|
10727
|
-
|
|
10728
|
-
|
|
10729
|
-
|
|
10730
|
-
|
|
11513
|
+
|
|
11514
|
+
// src/utils/safe_storage.ts
|
|
11515
|
+
function readLocalStorage(key) {
|
|
11516
|
+
try {
|
|
11517
|
+
return window.localStorage.getItem(key);
|
|
11518
|
+
} catch {
|
|
11519
|
+
return null;
|
|
11520
|
+
}
|
|
11521
|
+
}
|
|
11522
|
+
function writeLocalStorage(key, value) {
|
|
11523
|
+
try {
|
|
11524
|
+
window.localStorage.setItem(key, value);
|
|
11525
|
+
} catch {
|
|
11526
|
+
}
|
|
10731
11527
|
}
|
|
11528
|
+
|
|
11529
|
+
// src/controllers/sidebar_controller.ts
|
|
10732
11530
|
var SidebarController = class extends Controller {
|
|
10733
11531
|
static targets = ["trigger", "panel", "backdrop"];
|
|
10734
11532
|
static values = {
|
|
@@ -10736,9 +11534,11 @@ var SidebarController = class extends Controller {
|
|
|
10736
11534
|
key: { type: String, default: "" },
|
|
10737
11535
|
collapsed: { type: Boolean, default: false }
|
|
10738
11536
|
};
|
|
10739
|
-
static actions = ["close", "open", "toggle"];
|
|
11537
|
+
static actions = ["beforeCache", "close", "open", "toggle"];
|
|
11538
|
+
/** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
|
|
11539
|
+
#activePanel = null;
|
|
10740
11540
|
/** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
|
|
10741
|
-
#trap = new FocusTrap(() => this.panelTarget, {
|
|
11541
|
+
#trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
|
|
10742
11542
|
onEscape: () => this.close(),
|
|
10743
11543
|
fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
|
|
10744
11544
|
});
|
|
@@ -10748,18 +11548,79 @@ var SidebarController = class extends Controller {
|
|
|
10748
11548
|
#collapsed = false;
|
|
10749
11549
|
/** The matched media query (`min-width: breakpoint`), watched for mode changes. */
|
|
10750
11550
|
#mql = null;
|
|
10751
|
-
/**
|
|
10752
|
-
#
|
|
11551
|
+
/** Exact normalized query currently represented by the active media-query listener. */
|
|
11552
|
+
#mqlQuery = null;
|
|
11553
|
+
/** Owns the cancellable close-transition wait and its bounded fallback. */
|
|
11554
|
+
#transition = new TransitionCompletion();
|
|
11555
|
+
/** Distinguishes dynamic target churn from callbacks around controller teardown. */
|
|
11556
|
+
#connected = false;
|
|
10753
11557
|
connect() {
|
|
11558
|
+
this.#connected = true;
|
|
11559
|
+
this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
|
|
10754
11560
|
this.#collapsed = this.#restoreCollapsed();
|
|
10755
|
-
this.#
|
|
11561
|
+
this.#mqlQuery = this.#breakpointQuery;
|
|
11562
|
+
this.#mql = this.#matchBreakpoint(this.#mqlQuery);
|
|
10756
11563
|
this.#mql?.addEventListener("change", this.#onMediaChange);
|
|
10757
11564
|
this.#applyMode(this.#computeMode());
|
|
10758
11565
|
}
|
|
10759
11566
|
disconnect() {
|
|
11567
|
+
this.#connected = false;
|
|
11568
|
+
this.#mql?.removeEventListener("change", this.#onMediaChange);
|
|
11569
|
+
this.#mql = null;
|
|
11570
|
+
this.#mqlQuery = null;
|
|
11571
|
+
this.#transition.cancel();
|
|
11572
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
11573
|
+
this.#activePanel = null;
|
|
11574
|
+
}
|
|
11575
|
+
/** Adopts a panel target added by a Turbo morph after the controller connected. */
|
|
11576
|
+
panelTargetConnected(panel) {
|
|
11577
|
+
if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
|
|
11578
|
+
this.#adoptPanel(panel);
|
|
11579
|
+
}
|
|
11580
|
+
/** Closes and releases overlay side effects when the actively trapped panel disappears. */
|
|
11581
|
+
panelTargetDisconnected(panel) {
|
|
11582
|
+
if (panel !== this.#activePanel) return;
|
|
11583
|
+
this.#transition.cancel();
|
|
11584
|
+
this.#activePanel = null;
|
|
11585
|
+
if (!this.#connected) return;
|
|
11586
|
+
panel.setAttribute("data-state", "closed");
|
|
11587
|
+
panel.hidden = true;
|
|
11588
|
+
this.#hideBackdrop();
|
|
11589
|
+
this.#setExpandedAttr(false);
|
|
11590
|
+
this.#trap.deactivate();
|
|
11591
|
+
if (this.hasPanelTarget) this.#adoptPanel(this.panelTarget);
|
|
11592
|
+
}
|
|
11593
|
+
/**
|
|
11594
|
+
* Rebinds responsive observation when `breakpoint` changes at runtime.
|
|
11595
|
+
*
|
|
11596
|
+
* Stimulus calls value callbacks before `connect()`, so the connected guard
|
|
11597
|
+
* prevents an eager subscription. Equivalent normalized queries retain the
|
|
11598
|
+
* existing listener instead of allocating duplicate `MediaQueryList` objects.
|
|
11599
|
+
*/
|
|
11600
|
+
breakpointValueChanged() {
|
|
11601
|
+
if (!this.#connected) return;
|
|
11602
|
+
const query = this.#breakpointQuery;
|
|
11603
|
+
if (this.#mqlQuery === query) return;
|
|
10760
11604
|
this.#mql?.removeEventListener("change", this.#onMediaChange);
|
|
10761
|
-
this.#
|
|
11605
|
+
this.#mqlQuery = query;
|
|
11606
|
+
this.#mql = this.#matchBreakpoint(query);
|
|
11607
|
+
this.#mql?.addEventListener("change", this.#onMediaChange);
|
|
11608
|
+
const next = this.#computeMode();
|
|
11609
|
+
if (next !== this.#mode) this.#applyMode(next);
|
|
11610
|
+
}
|
|
11611
|
+
/**
|
|
11612
|
+
* Sanitizes overlay markup before Turbo snapshots the page.
|
|
11613
|
+
*
|
|
11614
|
+
* Inline state is durable and remains untouched. Overlay state is transient:
|
|
11615
|
+
* pending transitions are cancelled, controller-owned open attributes are
|
|
11616
|
+
* closed immediately, and modal side effects are released without moving
|
|
11617
|
+
* focus during navigation.
|
|
11618
|
+
*/
|
|
11619
|
+
beforeCache() {
|
|
11620
|
+
if (!this.#connected || !this.#isOverlay) return;
|
|
11621
|
+
this.#transition.cancel();
|
|
10762
11622
|
this.#trap.deactivate({ restoreFocus: false });
|
|
11623
|
+
this.#setOverlayClosedImmediate();
|
|
10763
11624
|
}
|
|
10764
11625
|
/** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
|
|
10765
11626
|
toggle() {
|
|
@@ -10785,17 +11646,48 @@ var SidebarController = class extends Controller {
|
|
|
10785
11646
|
this.#mode = mode;
|
|
10786
11647
|
if (this.hasPanelTarget) this.panelTarget.setAttribute("data-mode", mode);
|
|
10787
11648
|
if (mode === "inline") {
|
|
10788
|
-
this.#
|
|
11649
|
+
this.#transition.cancel();
|
|
10789
11650
|
this.#trap.deactivate({ restoreFocus: false });
|
|
10790
11651
|
if (this.hasPanelTarget) this.panelTarget.hidden = false;
|
|
10791
11652
|
this.#hideBackdrop();
|
|
10792
11653
|
this.#applyInlineState(this.#collapsed);
|
|
10793
11654
|
} else {
|
|
10794
|
-
this.#
|
|
11655
|
+
this.#transition.cancel();
|
|
10795
11656
|
this.#trap.deactivate({ restoreFocus: false });
|
|
10796
11657
|
this.#setOverlayClosedImmediate();
|
|
10797
11658
|
}
|
|
10798
11659
|
}
|
|
11660
|
+
/** Reconciles a replacement panel with the current responsive mode and DOM state. */
|
|
11661
|
+
#adoptPanel(panel) {
|
|
11662
|
+
this.#transition.cancel();
|
|
11663
|
+
const trapWasActive = this.#trap.active;
|
|
11664
|
+
this.#activePanel = panel;
|
|
11665
|
+
panel.setAttribute("data-mode", this.#mode);
|
|
11666
|
+
if (this.#mode === "inline") {
|
|
11667
|
+
panel.hidden = false;
|
|
11668
|
+
panel.setAttribute("data-state", this.#collapsed ? "collapsed" : "expanded");
|
|
11669
|
+
this.#hideBackdrop();
|
|
11670
|
+
this.#setExpandedAttr(!this.#collapsed);
|
|
11671
|
+
this.#trap.deactivate({ restoreFocus: false });
|
|
11672
|
+
return;
|
|
11673
|
+
}
|
|
11674
|
+
if (panel.getAttribute("data-state") === "open") {
|
|
11675
|
+
panel.hidden = false;
|
|
11676
|
+
if (this.hasBackdropTarget) {
|
|
11677
|
+
this.backdropTarget.setAttribute("data-state", "open");
|
|
11678
|
+
this.backdropTarget.hidden = false;
|
|
11679
|
+
}
|
|
11680
|
+
this.#setExpandedAttr(true);
|
|
11681
|
+
if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
|
|
11682
|
+
this.#trap.activate();
|
|
11683
|
+
return;
|
|
11684
|
+
}
|
|
11685
|
+
panel.setAttribute("data-state", "closed");
|
|
11686
|
+
panel.hidden = true;
|
|
11687
|
+
this.#hideBackdrop();
|
|
11688
|
+
this.#setExpandedAttr(false);
|
|
11689
|
+
this.#trap.deactivate();
|
|
11690
|
+
}
|
|
10799
11691
|
#onMediaChange = (event) => {
|
|
10800
11692
|
const next = event.matches ? "inline" : "overlay";
|
|
10801
11693
|
if (next !== this.#mode) this.#applyMode(next);
|
|
@@ -10803,13 +11695,14 @@ var SidebarController = class extends Controller {
|
|
|
10803
11695
|
#computeMode() {
|
|
10804
11696
|
return this.#mql?.matches ?? true ? "inline" : "overlay";
|
|
10805
11697
|
}
|
|
10806
|
-
#matchBreakpoint() {
|
|
11698
|
+
#matchBreakpoint(query = this.#breakpointQuery) {
|
|
10807
11699
|
if (typeof window.matchMedia !== "function") return null;
|
|
10808
|
-
return window.matchMedia(
|
|
11700
|
+
return window.matchMedia(query);
|
|
10809
11701
|
}
|
|
10810
11702
|
// --- Inline (rail) ---------------------------------------------------------
|
|
10811
11703
|
/** Sets, reflects, and persists the inline collapsed preference. */
|
|
10812
11704
|
#setCollapsed(collapsed) {
|
|
11705
|
+
if (collapsed === this.#collapsed) return;
|
|
10813
11706
|
this.#collapsed = collapsed;
|
|
10814
11707
|
this.#applyInlineState(collapsed);
|
|
10815
11708
|
this.#persistCollapsed(collapsed);
|
|
@@ -10825,7 +11718,8 @@ var SidebarController = class extends Controller {
|
|
|
10825
11718
|
/** Opens the overlay: reveal it, commit a starting frame, then trap focus. */
|
|
10826
11719
|
#openOverlay() {
|
|
10827
11720
|
if (!this.hasPanelTarget || this.#isOverlayOpen) return;
|
|
10828
|
-
this.#
|
|
11721
|
+
this.#transition.cancel();
|
|
11722
|
+
this.#activePanel = this.panelTarget;
|
|
10829
11723
|
this.panelTarget.hidden = false;
|
|
10830
11724
|
if (this.hasBackdropTarget) this.backdropTarget.hidden = false;
|
|
10831
11725
|
void this.panelTarget.offsetWidth;
|
|
@@ -10854,28 +11748,18 @@ var SidebarController = class extends Controller {
|
|
|
10854
11748
|
}
|
|
10855
11749
|
/**
|
|
10856
11750
|
* Applies `hidden` once the close transition ends so the exit slide can play,
|
|
10857
|
-
* then reverts the modal side effects.
|
|
10858
|
-
*
|
|
10859
|
-
*
|
|
11751
|
+
* then reverts the modal side effects. The shared waiter completes
|
|
11752
|
+
* synchronously for 0ms transitions and supplies a bounded fallback when the
|
|
11753
|
+
* browser emits no terminal event.
|
|
10860
11754
|
*/
|
|
10861
11755
|
#hideAfterTransition() {
|
|
10862
11756
|
const panel = this.panelTarget;
|
|
10863
|
-
|
|
10864
|
-
|
|
10865
|
-
this.#applyOverlayHidden();
|
|
10866
|
-
return;
|
|
10867
|
-
}
|
|
10868
|
-
const onEnd = (event) => {
|
|
10869
|
-
if (event.target !== panel) return;
|
|
10870
|
-
this.#cancelPendingHide();
|
|
10871
|
-
this.#applyOverlayHidden();
|
|
10872
|
-
};
|
|
10873
|
-
this.#pendingHide = () => panel.removeEventListener("transitionend", onEnd);
|
|
10874
|
-
panel.addEventListener("transitionend", onEnd);
|
|
11757
|
+
this.#activePanel = panel;
|
|
11758
|
+
this.#transition.wait(panel, () => this.#applyOverlayHidden(panel));
|
|
10875
11759
|
}
|
|
10876
11760
|
/** Hides the panel/backdrop and tears down the trap after the exit transition. */
|
|
10877
|
-
#applyOverlayHidden() {
|
|
10878
|
-
|
|
11761
|
+
#applyOverlayHidden(panel) {
|
|
11762
|
+
panel.hidden = true;
|
|
10879
11763
|
this.#hideBackdrop();
|
|
10880
11764
|
this.#trap.deactivate();
|
|
10881
11765
|
}
|
|
@@ -10884,10 +11768,6 @@ var SidebarController = class extends Controller {
|
|
|
10884
11768
|
this.backdropTarget.setAttribute("data-state", "closed");
|
|
10885
11769
|
this.backdropTarget.hidden = true;
|
|
10886
11770
|
}
|
|
10887
|
-
#cancelPendingHide() {
|
|
10888
|
-
this.#pendingHide?.();
|
|
10889
|
-
this.#pendingHide = null;
|
|
10890
|
-
}
|
|
10891
11771
|
// --- Shared helpers --------------------------------------------------------
|
|
10892
11772
|
#setExpandedAttr(expanded) {
|
|
10893
11773
|
if (this.hasTriggerTarget) {
|
|
@@ -10905,11 +11785,8 @@ var SidebarController = class extends Controller {
|
|
|
10905
11785
|
#restoreCollapsed() {
|
|
10906
11786
|
const key = this.#storageKey;
|
|
10907
11787
|
if (key) {
|
|
10908
|
-
|
|
10909
|
-
|
|
10910
|
-
if (stored !== null) return stored === "1";
|
|
10911
|
-
} catch {
|
|
10912
|
-
}
|
|
11788
|
+
const stored = readLocalStorage(key);
|
|
11789
|
+
if (stored !== null) return stored === "1";
|
|
10913
11790
|
}
|
|
10914
11791
|
const domState = this.hasPanelTarget ? this.panelTarget.getAttribute("data-state") : null;
|
|
10915
11792
|
if (domState === "collapsed") return true;
|
|
@@ -10920,14 +11797,17 @@ var SidebarController = class extends Controller {
|
|
|
10920
11797
|
#persistCollapsed(collapsed) {
|
|
10921
11798
|
const key = this.#storageKey;
|
|
10922
11799
|
if (!key) return;
|
|
10923
|
-
|
|
10924
|
-
localStorage.setItem(key, collapsed ? "1" : "0");
|
|
10925
|
-
} catch {
|
|
10926
|
-
}
|
|
11800
|
+
writeLocalStorage(key, collapsed ? "1" : "0");
|
|
10927
11801
|
}
|
|
10928
11802
|
get #storageKey() {
|
|
10929
11803
|
return this.keyValue ? `stimeo--sidebar:${this.keyValue}` : "";
|
|
10930
11804
|
}
|
|
11805
|
+
/** Valid breakpoint CSS query, defaulting malformed or negative values to 768px. */
|
|
11806
|
+
get #breakpointQuery() {
|
|
11807
|
+
const value = this.breakpointValue;
|
|
11808
|
+
const breakpoint = Number.isFinite(value) && value >= 0 ? value : 768;
|
|
11809
|
+
return `(min-width: ${breakpoint}px)`;
|
|
11810
|
+
}
|
|
10931
11811
|
get #isOverlay() {
|
|
10932
11812
|
return this.#mode === "overlay";
|
|
10933
11813
|
}
|
|
@@ -11487,23 +12367,32 @@ var StepperController = class extends Controller {
|
|
|
11487
12367
|
};
|
|
11488
12368
|
static actions = ["goto", "next", "prev"];
|
|
11489
12369
|
static events = ["change"];
|
|
12370
|
+
#isConnected = false;
|
|
11490
12371
|
/** Normalizes an out-of-range initial `index` and renders the initial state. */
|
|
11491
12372
|
connect() {
|
|
11492
|
-
this
|
|
11493
|
-
this.#
|
|
12373
|
+
this.#isConnected = true;
|
|
12374
|
+
this.#normalizeAndRender();
|
|
12375
|
+
}
|
|
12376
|
+
disconnect() {
|
|
12377
|
+
this.#isConnected = false;
|
|
12378
|
+
}
|
|
12379
|
+
/** Re-renders when Turbo Morph or application code changes `index` at runtime. */
|
|
12380
|
+
indexValueChanged() {
|
|
12381
|
+
if (!this.#isConnected) return;
|
|
12382
|
+
this.#normalizeAndRender();
|
|
11494
12383
|
}
|
|
11495
12384
|
/** Advances to the next step (ignored at the last step). */
|
|
11496
12385
|
next() {
|
|
11497
|
-
this.#moveTo(this.indexValue + 1);
|
|
12386
|
+
this.#moveTo(this.#clampIndex(this.indexValue) + 1);
|
|
11498
12387
|
}
|
|
11499
12388
|
/** Returns to the previous step (ignored at the first step). */
|
|
11500
12389
|
prev() {
|
|
11501
|
-
this.#moveTo(this.indexValue - 1);
|
|
12390
|
+
this.#moveTo(this.#clampIndex(this.indexValue) - 1);
|
|
11502
12391
|
}
|
|
11503
12392
|
/** Jumps to the step carried in the action's `index` param. */
|
|
11504
12393
|
goto(event) {
|
|
11505
12394
|
const target = Number(event.params.index);
|
|
11506
|
-
if (!Number.isFinite(target)) return;
|
|
12395
|
+
if (!Number.isFinite(target) || !Number.isInteger(target)) return;
|
|
11507
12396
|
this.#moveTo(target);
|
|
11508
12397
|
}
|
|
11509
12398
|
/**
|
|
@@ -11513,16 +12402,24 @@ var StepperController = class extends Controller {
|
|
|
11513
12402
|
*/
|
|
11514
12403
|
#moveTo(target) {
|
|
11515
12404
|
const total = this.stepTargets.length;
|
|
12405
|
+
if (!Number.isFinite(target) || !Number.isInteger(target)) return;
|
|
11516
12406
|
if (target < 0 || target >= total) return;
|
|
11517
|
-
|
|
11518
|
-
if (
|
|
11519
|
-
|
|
12407
|
+
const current = this.#clampIndex(this.indexValue);
|
|
12408
|
+
if (target === current) return;
|
|
12409
|
+
if (this.linearValue && target > current + 1) return;
|
|
12410
|
+
const previous = current;
|
|
11520
12411
|
this.indexValue = target;
|
|
11521
|
-
this.#render();
|
|
12412
|
+
this.#render(target);
|
|
11522
12413
|
this.dispatch("change", {
|
|
11523
12414
|
detail: { index: target, previous, step: this.stepTargets[target] }
|
|
11524
12415
|
});
|
|
11525
12416
|
}
|
|
12417
|
+
/** Normalizes the public Value and reflects it without dispatching an action event. */
|
|
12418
|
+
#normalizeAndRender() {
|
|
12419
|
+
const normalized = this.#clampIndex(this.indexValue);
|
|
12420
|
+
if (!Object.is(normalized, this.indexValue)) this.indexValue = normalized;
|
|
12421
|
+
this.#render(normalized);
|
|
12422
|
+
}
|
|
11526
12423
|
/**
|
|
11527
12424
|
* Derives each step's `data-state` and the current button's `aria-current`.
|
|
11528
12425
|
*
|
|
@@ -11530,8 +12427,7 @@ var StepperController = class extends Controller {
|
|
|
11530
12427
|
* contract assumes one operable button per step. If a step needs multiple
|
|
11531
12428
|
* buttons, mark the navigational one first (or this would target the wrong one).
|
|
11532
12429
|
*/
|
|
11533
|
-
#render() {
|
|
11534
|
-
const current = this.indexValue;
|
|
12430
|
+
#render(current) {
|
|
11535
12431
|
this.stepTargets.forEach((step, index) => {
|
|
11536
12432
|
step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
|
|
11537
12433
|
const button = step.querySelector("button");
|
|
@@ -11546,7 +12442,7 @@ var StepperController = class extends Controller {
|
|
|
11546
12442
|
/** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
|
|
11547
12443
|
#clampIndex(index) {
|
|
11548
12444
|
const last = this.stepTargets.length - 1;
|
|
11549
|
-
if (last < 0) return 0;
|
|
12445
|
+
if (last < 0 || !Number.isFinite(index)) return 0;
|
|
11550
12446
|
return Math.min(last, Math.max(0, Math.trunc(index)));
|
|
11551
12447
|
}
|
|
11552
12448
|
};
|
|
@@ -11636,12 +12532,9 @@ var StickToBottomController = class extends Controller {
|
|
|
11636
12532
|
return this.hasContentTarget ? this.contentTarget : this.element;
|
|
11637
12533
|
}
|
|
11638
12534
|
#behavior() {
|
|
11639
|
-
if (
|
|
12535
|
+
if (prefersReducedMotion()) return "auto";
|
|
11640
12536
|
return this.behaviorValue === "smooth" ? "smooth" : "auto";
|
|
11641
12537
|
}
|
|
11642
|
-
#prefersReducedMotion() {
|
|
11643
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
11644
|
-
}
|
|
11645
12538
|
};
|
|
11646
12539
|
var StickyObserverController = class extends Controller {
|
|
11647
12540
|
static targets = ["sentinel", "element"];
|
|
@@ -11654,23 +12547,63 @@ var StickyObserverController = class extends Controller {
|
|
|
11654
12547
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
11655
12548
|
/** Last reported stuck state, so `change` fires only on transitions. */
|
|
11656
12549
|
#stuck = null;
|
|
12550
|
+
/** Target currently owned by the watcher, used to avoid duplicate restarts. */
|
|
12551
|
+
#observedSentinel = null;
|
|
12552
|
+
#connected = false;
|
|
11657
12553
|
#onIntersect(entries) {
|
|
11658
|
-
const entry
|
|
11659
|
-
|
|
11660
|
-
|
|
12554
|
+
for (const entry of entries) {
|
|
12555
|
+
if (!this.#connected || !this.#watcher.active) return;
|
|
12556
|
+
this.#setStuck(!entry.isIntersecting && isBeforeRootStart(entry));
|
|
12557
|
+
}
|
|
11661
12558
|
}
|
|
11662
12559
|
connect() {
|
|
11663
|
-
|
|
11664
|
-
this.#
|
|
11665
|
-
|
|
11666
|
-
rootMargin: `-${this.offsetValue}px 0px 0px 0px`,
|
|
11667
|
-
threshold: [0]
|
|
11668
|
-
});
|
|
12560
|
+
this.#connected = true;
|
|
12561
|
+
this.#stuck = null;
|
|
12562
|
+
this.#syncObserver();
|
|
11669
12563
|
}
|
|
11670
12564
|
disconnect() {
|
|
12565
|
+
this.#connected = false;
|
|
11671
12566
|
this.#watcher.stop();
|
|
12567
|
+
this.#observedSentinel = null;
|
|
11672
12568
|
this.#stuck = null;
|
|
11673
12569
|
}
|
|
12570
|
+
/** Starts observation when a sentinel is inserted after connection. */
|
|
12571
|
+
sentinelTargetConnected() {
|
|
12572
|
+
if (this.#connected) this.#syncObserver();
|
|
12573
|
+
}
|
|
12574
|
+
/** Stops or transfers observation when the current sentinel is removed. */
|
|
12575
|
+
sentinelTargetDisconnected() {
|
|
12576
|
+
if (this.#connected) this.#syncObserver();
|
|
12577
|
+
}
|
|
12578
|
+
/** Reflects the last snapshot onto an element inserted after that snapshot. */
|
|
12579
|
+
elementTargetConnected(element) {
|
|
12580
|
+
if (this.#stuck !== null) {
|
|
12581
|
+
element.setAttribute("data-stuck", this.#stuck ? "true" : "false");
|
|
12582
|
+
}
|
|
12583
|
+
}
|
|
12584
|
+
/** Rebuilds the observer when Turbo morphs the configured root. */
|
|
12585
|
+
rootSelectorValueChanged() {
|
|
12586
|
+
if (this.#connected) this.#syncObserver(true);
|
|
12587
|
+
}
|
|
12588
|
+
/** Rebuilds the observer when Turbo morphs the configured top offset. */
|
|
12589
|
+
offsetValueChanged() {
|
|
12590
|
+
if (this.#connected) this.#syncObserver(true);
|
|
12591
|
+
}
|
|
12592
|
+
#syncObserver(force = false) {
|
|
12593
|
+
const sentinel = this.hasSentinelTarget ? this.sentinelTarget : null;
|
|
12594
|
+
if (!force && sentinel === this.#observedSentinel && this.#watcher.active) return;
|
|
12595
|
+
this.#watcher.stop();
|
|
12596
|
+
this.#observedSentinel = null;
|
|
12597
|
+
if (!sentinel) return;
|
|
12598
|
+
const configuredOffset = this.offsetValue;
|
|
12599
|
+
const offset = Number.isFinite(configuredOffset) ? configuredOffset : 0;
|
|
12600
|
+
const started = this.#watcher.start(sentinel, {
|
|
12601
|
+
rootSelector: this.rootSelectorValue,
|
|
12602
|
+
rootMargin: `${-offset}px 0px 0px 0px`,
|
|
12603
|
+
threshold: [0]
|
|
12604
|
+
});
|
|
12605
|
+
if (started) this.#observedSentinel = sentinel;
|
|
12606
|
+
}
|
|
11674
12607
|
/** Reflects the stuck state onto the sticky element and emits `change`. */
|
|
11675
12608
|
#setStuck(next) {
|
|
11676
12609
|
if (next === this.#stuck) return;
|
|
@@ -11956,20 +12889,32 @@ var TagsInputController = class extends Controller {
|
|
|
11956
12889
|
static actions = ["onKeydown"];
|
|
11957
12890
|
static events = ["change", "reject"];
|
|
11958
12891
|
#roving = new RovingTabindex(() => this.#removeButtons);
|
|
12892
|
+
/** Owns IME lifecycle state for commit-key guarding. */
|
|
12893
|
+
#composition = new CompositionTracker();
|
|
11959
12894
|
/** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
|
|
11960
12895
|
connect() {
|
|
12896
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
11961
12897
|
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
11962
12898
|
this.tagsTarget.addEventListener("click", this.#onTagClick);
|
|
11963
12899
|
this.#syncState();
|
|
11964
12900
|
}
|
|
11965
12901
|
/** Releases the delegated listeners so no handler outlives the element. */
|
|
11966
12902
|
disconnect() {
|
|
12903
|
+
this.#composition.disconnect();
|
|
11967
12904
|
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
11968
12905
|
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
11969
12906
|
}
|
|
12907
|
+
/** Tracks an input added initially or after connect. */
|
|
12908
|
+
inputTargetConnected(input) {
|
|
12909
|
+
this.#composition.observe(input);
|
|
12910
|
+
}
|
|
12911
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
12912
|
+
inputTargetDisconnected(input) {
|
|
12913
|
+
this.#composition.unobserve(input);
|
|
12914
|
+
}
|
|
11970
12915
|
/** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
|
|
11971
12916
|
onKeydown(event) {
|
|
11972
|
-
if (
|
|
12917
|
+
if (this.#composition.isComposing(event)) return;
|
|
11973
12918
|
if (event.key === "Enter" || event.key === this.delimiterValue) {
|
|
11974
12919
|
event.preventDefault();
|
|
11975
12920
|
this.#commitInput();
|
|
@@ -12316,19 +13261,12 @@ var ThemeController = class extends Controller {
|
|
|
12316
13261
|
}
|
|
12317
13262
|
/** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
|
|
12318
13263
|
#readStored() {
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
return isMode(value) ? value : null;
|
|
12322
|
-
} catch {
|
|
12323
|
-
return null;
|
|
12324
|
-
}
|
|
13264
|
+
const value = readLocalStorage(this.storageKeyValue);
|
|
13265
|
+
return isMode(value) ? value : null;
|
|
12325
13266
|
}
|
|
12326
13267
|
/** Persists the mode, swallowing storage errors (private mode / quota). */
|
|
12327
13268
|
#writeStored(mode) {
|
|
12328
|
-
|
|
12329
|
-
window.localStorage.setItem(this.storageKeyValue, mode);
|
|
12330
|
-
} catch {
|
|
12331
|
-
}
|
|
13269
|
+
writeLocalStorage(this.storageKeyValue, mode);
|
|
12332
13270
|
}
|
|
12333
13271
|
};
|
|
12334
13272
|
var AM = 0;
|
|
@@ -12536,6 +13474,7 @@ var TimePickerController = class extends Controller {
|
|
|
12536
13474
|
function pad(value) {
|
|
12537
13475
|
return String(value).padStart(2, "0");
|
|
12538
13476
|
}
|
|
13477
|
+
var DELEGATED_EVENTS = ["click", "focusin", "focusout", "keydown", "mouseover", "mouseout"];
|
|
12539
13478
|
var ToastController = class extends Controller {
|
|
12540
13479
|
static targets = ["list", "template", "item"];
|
|
12541
13480
|
static values = {
|
|
@@ -12556,34 +13495,57 @@ var ToastController = class extends Controller {
|
|
|
12556
13495
|
* Tracked so {@link disconnect} can cancel any that have not fired, preventing a
|
|
12557
13496
|
* detached element from being mutated after it leaves the DOM (Turbo).
|
|
12558
13497
|
*/
|
|
12559
|
-
#rafHandles = /* @__PURE__ */ new
|
|
13498
|
+
#rafHandles = /* @__PURE__ */ new Map();
|
|
12560
13499
|
/** Track active timeouts mapped by each toast element for safe cancellation. */
|
|
12561
13500
|
#activeTimeouts = /* @__PURE__ */ new Map();
|
|
12562
13501
|
/** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
|
|
12563
13502
|
#pauseReasons = /* @__PURE__ */ new Map();
|
|
13503
|
+
/** The stable list that owns delegated listeners for dynamically added items. */
|
|
13504
|
+
#delegatedList = null;
|
|
12564
13505
|
connect() {
|
|
13506
|
+
this.#connectDelegatedEvents();
|
|
12565
13507
|
this.enforceMaxLimit();
|
|
12566
13508
|
for (const item of this.itemTargets) {
|
|
12567
|
-
if (!this.#activeTimeouts.has(item)) {
|
|
13509
|
+
if (!this.#activeTimeouts.has(item) && item.dataset.state !== "leaving") {
|
|
12568
13510
|
this.#startTimer(item);
|
|
12569
13511
|
}
|
|
12570
13512
|
}
|
|
12571
13513
|
}
|
|
12572
13514
|
disconnect() {
|
|
13515
|
+
this.#disconnectDelegatedEvents();
|
|
12573
13516
|
this.#timers.clearAll();
|
|
12574
|
-
for (const handle of this.#rafHandles) {
|
|
13517
|
+
for (const handle of this.#rafHandles.values()) {
|
|
12575
13518
|
window.cancelAnimationFrame(handle);
|
|
12576
13519
|
}
|
|
12577
13520
|
this.#rafHandles.clear();
|
|
12578
13521
|
this.#activeTimeouts.clear();
|
|
12579
13522
|
this.#pauseReasons.clear();
|
|
12580
13523
|
}
|
|
13524
|
+
/** Rebinds delegated interaction when Turbo replaces the list target in place. */
|
|
13525
|
+
listTargetConnected(element) {
|
|
13526
|
+
if (this.#delegatedList !== element) this.#connectDelegatedEvents(element);
|
|
13527
|
+
}
|
|
13528
|
+
/** Releases delegation only when the removed target is its current owner. */
|
|
13529
|
+
listTargetDisconnected(element) {
|
|
13530
|
+
if (this.#delegatedList === element) this.#disconnectDelegatedEvents();
|
|
13531
|
+
}
|
|
12581
13532
|
durationValueChanged() {
|
|
12582
|
-
|
|
12583
|
-
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
13533
|
+
for (const item of this.itemTargets) {
|
|
13534
|
+
if (item.dataset.state === "leaving") continue;
|
|
13535
|
+
const pauseReasons = this.#pauseReasons.get(item);
|
|
13536
|
+
this.#clearTimer(item);
|
|
13537
|
+
if (this.durationValue <= 0) {
|
|
13538
|
+
item.removeAttribute("data-paused");
|
|
13539
|
+
} else if (pauseReasons && pauseReasons.size > 0) {
|
|
13540
|
+
this.#pauseReasons.set(item, pauseReasons);
|
|
13541
|
+
this.#activeTimeouts.set(item, {
|
|
13542
|
+
id: 0,
|
|
13543
|
+
startedAt: 0,
|
|
13544
|
+
remaining: this.durationValue
|
|
13545
|
+
});
|
|
13546
|
+
item.setAttribute("data-paused", "true");
|
|
13547
|
+
} else {
|
|
13548
|
+
this.#startTimer(item);
|
|
12587
13549
|
}
|
|
12588
13550
|
}
|
|
12589
13551
|
}
|
|
@@ -12597,17 +13559,21 @@ var ToastController = class extends Controller {
|
|
|
12597
13559
|
*/
|
|
12598
13560
|
itemTargetConnected(element) {
|
|
12599
13561
|
this.enforceMaxLimit();
|
|
13562
|
+
if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
|
|
12600
13563
|
this.#startTimer(element);
|
|
12601
13564
|
element.setAttribute("data-state", "entering");
|
|
13565
|
+
this.#cancelAnimation(element);
|
|
12602
13566
|
const handle = window.requestAnimationFrame(() => {
|
|
12603
|
-
this.#rafHandles.delete(
|
|
13567
|
+
this.#rafHandles.delete(element);
|
|
13568
|
+
if (element.parentNode !== this.listTarget || element.dataset.state === "leaving") return;
|
|
12604
13569
|
element.setAttribute("data-state", "visible");
|
|
12605
13570
|
});
|
|
12606
|
-
this.#rafHandles.
|
|
13571
|
+
this.#rafHandles.set(element, handle);
|
|
12607
13572
|
}
|
|
12608
13573
|
/** Clears any active timer when a toast is removed from the DOM. */
|
|
12609
13574
|
itemTargetDisconnected(element) {
|
|
12610
13575
|
this.#clearTimer(element);
|
|
13576
|
+
this.#cancelAnimation(element);
|
|
12611
13577
|
}
|
|
12612
13578
|
/**
|
|
12613
13579
|
* Shows a new toast. Accepts its content from either a Stimulus action param
|
|
@@ -12630,10 +13596,9 @@ var ToastController = class extends Controller {
|
|
|
12630
13596
|
const item = clone.querySelector("[data-stimeo--toast-target='item']");
|
|
12631
13597
|
if (!item) return;
|
|
12632
13598
|
const bodySlot = item.querySelector("[data-toast-slot='body']");
|
|
12633
|
-
if (bodySlot)
|
|
12634
|
-
|
|
12635
|
-
|
|
12636
|
-
item.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
|
|
13599
|
+
if (!bodySlot) return;
|
|
13600
|
+
bodySlot.textContent = body;
|
|
13601
|
+
bodySlot.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
|
|
12637
13602
|
this.listTarget.appendChild(item);
|
|
12638
13603
|
this.dispatch("show", { detail: { item } });
|
|
12639
13604
|
}
|
|
@@ -12655,18 +13620,15 @@ var ToastController = class extends Controller {
|
|
|
12655
13620
|
}
|
|
12656
13621
|
/** Dismisses the toast that contained the trigger. */
|
|
12657
13622
|
dismiss(event) {
|
|
12658
|
-
const
|
|
12659
|
-
if (!target) return;
|
|
12660
|
-
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
13623
|
+
const item = this.#itemFromEvent(event);
|
|
12661
13624
|
if (!item) return;
|
|
12662
13625
|
this.#removeWithTransition(item, "user");
|
|
12663
13626
|
}
|
|
12664
13627
|
/** Dismisses the focused toast when Escape is pressed. */
|
|
12665
13628
|
onKeydown(event) {
|
|
12666
13629
|
if (event.key === "Escape") {
|
|
12667
|
-
|
|
12668
|
-
|
|
12669
|
-
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
13630
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
13631
|
+
const item = this.#itemFromEvent(event);
|
|
12670
13632
|
if (!item) return;
|
|
12671
13633
|
event.preventDefault();
|
|
12672
13634
|
this.#removeWithTransition(item, "user");
|
|
@@ -12692,6 +13654,10 @@ var ToastController = class extends Controller {
|
|
|
12692
13654
|
this.#timers.clear(timeout.id);
|
|
12693
13655
|
const elapsed = Date.now() - timeout.startedAt;
|
|
12694
13656
|
const remaining = Math.max(0, timeout.remaining - elapsed);
|
|
13657
|
+
if (remaining <= 0) {
|
|
13658
|
+
this.#removeWithTransition(item, "timeout");
|
|
13659
|
+
return;
|
|
13660
|
+
}
|
|
12695
13661
|
this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
|
|
12696
13662
|
item.setAttribute("data-paused", "true");
|
|
12697
13663
|
}
|
|
@@ -12703,14 +13669,17 @@ var ToastController = class extends Controller {
|
|
|
12703
13669
|
reasons.delete(this.#pauseReason(event));
|
|
12704
13670
|
if (reasons.size > 0) return;
|
|
12705
13671
|
const timeout = this.#activeTimeouts.get(item);
|
|
12706
|
-
if (!timeout
|
|
13672
|
+
if (!timeout) return;
|
|
13673
|
+
if (timeout.id !== 0 || timeout.remaining <= 0) return;
|
|
12707
13674
|
item.removeAttribute("data-paused");
|
|
12708
13675
|
this.#startTimer(item, timeout.remaining);
|
|
12709
13676
|
}
|
|
12710
13677
|
/** Resolves the toast item element a pause/resume event targets. */
|
|
12711
13678
|
#itemFromEvent(event) {
|
|
12712
|
-
const target = event.
|
|
12713
|
-
|
|
13679
|
+
const target = event.target instanceof Element ? event.target : event.currentTarget;
|
|
13680
|
+
if (!(target instanceof Element) || !this.hasListTarget) return null;
|
|
13681
|
+
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
13682
|
+
return item && this.listTarget.contains(item) ? item : null;
|
|
12714
13683
|
}
|
|
12715
13684
|
/** Classifies a pause/resume event as a hover or focus reason. */
|
|
12716
13685
|
#pauseReason(event) {
|
|
@@ -12726,7 +13695,9 @@ var ToastController = class extends Controller {
|
|
|
12726
13695
|
return reasons;
|
|
12727
13696
|
}
|
|
12728
13697
|
#startTimer(element, duration = this.durationValue) {
|
|
12729
|
-
if (duration <= 0)
|
|
13698
|
+
if (duration <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
|
|
13699
|
+
return;
|
|
13700
|
+
}
|
|
12730
13701
|
this.#clearTimer(element);
|
|
12731
13702
|
const id = this.#timers.set(() => {
|
|
12732
13703
|
this.#removeWithTransition(element, "timeout");
|
|
@@ -12742,16 +13713,16 @@ var ToastController = class extends Controller {
|
|
|
12742
13713
|
this.#pauseReasons.delete(element);
|
|
12743
13714
|
}
|
|
12744
13715
|
#removeWithTransition(element, reason) {
|
|
13716
|
+
if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
|
|
12745
13717
|
this.#clearTimer(element);
|
|
13718
|
+
this.#cancelAnimation(element);
|
|
12746
13719
|
element.setAttribute("data-state", "leaving");
|
|
12747
13720
|
const finalize = () => {
|
|
12748
|
-
if (element.parentNode
|
|
12749
|
-
|
|
12750
|
-
}
|
|
13721
|
+
if (element.parentNode !== this.listTarget) return;
|
|
13722
|
+
this.listTarget.removeChild(element);
|
|
12751
13723
|
this.dispatch("dismiss", { detail: { item: element, reason } });
|
|
12752
13724
|
};
|
|
12753
|
-
const
|
|
12754
|
-
const duration = cssTimeToMs(transitions);
|
|
13725
|
+
const duration = maxTransitionTotalMs(window.getComputedStyle(element));
|
|
12755
13726
|
if (duration > 0) {
|
|
12756
13727
|
this.#timers.set(finalize, duration);
|
|
12757
13728
|
} else {
|
|
@@ -12769,21 +13740,60 @@ var ToastController = class extends Controller {
|
|
|
12769
13740
|
*/
|
|
12770
13741
|
enforceMaxLimit() {
|
|
12771
13742
|
const currentItems = this.itemTargets;
|
|
12772
|
-
|
|
12773
|
-
|
|
13743
|
+
const max = Math.max(0, this.maxValue);
|
|
13744
|
+
if (currentItems.length > max) {
|
|
13745
|
+
const excessCount = currentItems.length - max;
|
|
12774
13746
|
for (let i = 0; i < excessCount; i++) {
|
|
12775
13747
|
const oldest = currentItems[i];
|
|
12776
13748
|
if (oldest) this.#removeWithTransition(oldest, "timeout");
|
|
12777
13749
|
}
|
|
12778
13750
|
}
|
|
12779
13751
|
}
|
|
13752
|
+
/** Wires stable-container delegation so newly appended items work immediately. */
|
|
13753
|
+
#connectDelegatedEvents(list = this.hasListTarget ? this.listTarget : null) {
|
|
13754
|
+
this.#disconnectDelegatedEvents();
|
|
13755
|
+
if (!list) return;
|
|
13756
|
+
this.#delegatedList = list;
|
|
13757
|
+
for (const type of DELEGATED_EVENTS) {
|
|
13758
|
+
this.#delegatedList.addEventListener(type, this.#onListEvent);
|
|
13759
|
+
}
|
|
13760
|
+
}
|
|
13761
|
+
/** Releases every delegated listener from the exact list that owns it. */
|
|
13762
|
+
#disconnectDelegatedEvents() {
|
|
13763
|
+
if (!this.#delegatedList) return;
|
|
13764
|
+
for (const type of DELEGATED_EVENTS) {
|
|
13765
|
+
this.#delegatedList.removeEventListener(type, this.#onListEvent);
|
|
13766
|
+
}
|
|
13767
|
+
this.#delegatedList = null;
|
|
13768
|
+
}
|
|
13769
|
+
/** Routes every delegated item interaction from the stable list. */
|
|
13770
|
+
#onListEvent = (event) => {
|
|
13771
|
+
if (event.type === "click") {
|
|
13772
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
13773
|
+
const trigger = target?.closest("[data-toast-dismiss]");
|
|
13774
|
+
if (trigger && this.#delegatedList?.contains(trigger)) this.dismiss(event);
|
|
13775
|
+
} else if (event.type === "keydown") {
|
|
13776
|
+
this.onKeydown(event);
|
|
13777
|
+
} else if (this.#crossesItemBoundary(event)) {
|
|
13778
|
+
if (event.type === "focusin" || event.type === "mouseover") this.pause(event);
|
|
13779
|
+
else this.resume(event);
|
|
13780
|
+
}
|
|
13781
|
+
};
|
|
13782
|
+
/** Whether a bubbling focus/pointer event enters or leaves a toast boundary. */
|
|
13783
|
+
#crossesItemBoundary(event) {
|
|
13784
|
+
const item = this.#itemFromEvent(event);
|
|
13785
|
+
if (!item) return false;
|
|
13786
|
+
const related = "relatedTarget" in event ? event.relatedTarget : null;
|
|
13787
|
+
return !(related instanceof Node && item.contains(related));
|
|
13788
|
+
}
|
|
13789
|
+
/** Cancels the pending entering-to-visible frame owned by one item. */
|
|
13790
|
+
#cancelAnimation(element) {
|
|
13791
|
+
const handle = this.#rafHandles.get(element);
|
|
13792
|
+
if (handle === void 0) return;
|
|
13793
|
+
window.cancelAnimationFrame(handle);
|
|
13794
|
+
this.#rafHandles.delete(element);
|
|
13795
|
+
}
|
|
12780
13796
|
};
|
|
12781
|
-
function cssTimeToMs(value) {
|
|
12782
|
-
const first = value.split(",")[0]?.trim() ?? "";
|
|
12783
|
-
const amount = Number.parseFloat(first);
|
|
12784
|
-
if (Number.isNaN(amount)) return 0;
|
|
12785
|
-
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
12786
|
-
}
|
|
12787
13797
|
var ToggleGroupController = class extends Controller {
|
|
12788
13798
|
static targets = ["item"];
|
|
12789
13799
|
static values = {
|
|
@@ -12939,27 +13949,39 @@ var TooltipController = class extends Controller {
|
|
|
12939
13949
|
hideDelay: { type: Number, default: 0 },
|
|
12940
13950
|
closeOnScroll: { type: Boolean, default: false }
|
|
12941
13951
|
};
|
|
12942
|
-
static actions = ["hide", "
|
|
12943
|
-
/**
|
|
13952
|
+
static actions = ["hide", "show"];
|
|
13953
|
+
/** Registry whose pending timers are cancelled individually with their guard ids. */
|
|
12944
13954
|
#timers = new SafeTimeout();
|
|
12945
|
-
/**
|
|
13955
|
+
/** Escape-stack membership while shown; the shared resolver dismisses via it. */
|
|
13956
|
+
#escapeLayer = new EscapeLayer();
|
|
13957
|
+
/** The id of the currently pending show timer, if any. */
|
|
12946
13958
|
#pendingShow = null;
|
|
13959
|
+
/** The id of the currently pending hide timer, if any. */
|
|
12947
13960
|
#pendingHide = null;
|
|
13961
|
+
/** Whether focus or the pointer currently requires the tooltip to persist. */
|
|
13962
|
+
#focusActive = false;
|
|
13963
|
+
#pointerActive = false;
|
|
12948
13964
|
/** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
|
|
12949
13965
|
#stopScrollDismiss = null;
|
|
12950
|
-
/** Starts hidden. */
|
|
13966
|
+
/** Starts hidden with no stale timer or interaction state from a prior connection. */
|
|
12951
13967
|
connect() {
|
|
13968
|
+
this.#cancelShow();
|
|
13969
|
+
this.#cancelHide();
|
|
13970
|
+
this.#resetInteractionState();
|
|
12952
13971
|
this.#conceal();
|
|
12953
13972
|
}
|
|
12954
|
-
/** Clears timers
|
|
13973
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
12955
13974
|
disconnect() {
|
|
12956
|
-
this.#
|
|
12957
|
-
|
|
13975
|
+
this.#cancelShow();
|
|
13976
|
+
this.#cancelHide();
|
|
13977
|
+
this.#resetInteractionState();
|
|
13978
|
+
this.#escapeLayer.deactivate();
|
|
12958
13979
|
this.#stopScrollDismiss?.();
|
|
12959
13980
|
this.#stopScrollDismiss = null;
|
|
12960
13981
|
}
|
|
12961
|
-
/** Shows
|
|
12962
|
-
show() {
|
|
13982
|
+
/** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */
|
|
13983
|
+
show(event) {
|
|
13984
|
+
this.#activateInteraction(event);
|
|
12963
13985
|
this.#cancelHide();
|
|
12964
13986
|
if (this.#isVisible || this.#pendingShow !== null) return;
|
|
12965
13987
|
if (this.showDelayValue <= 0) {
|
|
@@ -12971,8 +13993,13 @@ var TooltipController = class extends Controller {
|
|
|
12971
13993
|
this.#reveal();
|
|
12972
13994
|
}, this.showDelayValue);
|
|
12973
13995
|
}
|
|
12974
|
-
/** Hides
|
|
12975
|
-
hide() {
|
|
13996
|
+
/** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */
|
|
13997
|
+
hide(event) {
|
|
13998
|
+
const interactionEnded = this.#deactivateInteraction(event);
|
|
13999
|
+
if (interactionEnded && this.#hasActiveInteraction) {
|
|
14000
|
+
this.#cancelHide();
|
|
14001
|
+
return;
|
|
14002
|
+
}
|
|
12976
14003
|
this.#cancelShow();
|
|
12977
14004
|
if (!this.#isVisible || this.#pendingHide !== null) return;
|
|
12978
14005
|
if (this.hideDelayValue <= 0) {
|
|
@@ -12984,52 +14011,57 @@ var TooltipController = class extends Controller {
|
|
|
12984
14011
|
this.#conceal();
|
|
12985
14012
|
}, this.hideDelayValue);
|
|
12986
14013
|
}
|
|
12987
|
-
/**
|
|
12988
|
-
* Dismisses the tooltip on `Escape` from the trigger. The authoritative
|
|
12989
|
-
* dismissal path is the document-level listener (added while shown) so a
|
|
12990
|
-
* hover-triggered tooltip is dismissible regardless of focus; this handler
|
|
12991
|
-
* keeps the documented `keydown->#onKeydown` binding meaningful too.
|
|
12992
|
-
*/
|
|
12993
|
-
onKeydown(event) {
|
|
12994
|
-
if (event.key === "Escape" && this.#isVisible) {
|
|
12995
|
-
event.preventDefault();
|
|
12996
|
-
this.#cancelShow();
|
|
12997
|
-
this.#cancelHide();
|
|
12998
|
-
this.#conceal();
|
|
12999
|
-
}
|
|
13000
|
-
}
|
|
13001
|
-
/** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
|
|
14014
|
+
/** Reveals the content and joins the Escape stack / starts the scroll watcher. */
|
|
13002
14015
|
#reveal() {
|
|
13003
14016
|
if (!this.hasContentTarget) return;
|
|
13004
14017
|
this.contentTarget.hidden = false;
|
|
13005
14018
|
this.contentTarget.setAttribute("data-state", "open");
|
|
13006
|
-
|
|
14019
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
|
|
13007
14020
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
13008
|
-
this.#stopScrollDismiss = observeScrollDismiss(this.element, () =>
|
|
13009
|
-
this.#cancelShow();
|
|
13010
|
-
this.#cancelHide();
|
|
13011
|
-
this.#conceal();
|
|
13012
|
-
});
|
|
14021
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
13013
14022
|
}
|
|
13014
14023
|
}
|
|
13015
|
-
/** Hides the content and stops
|
|
14024
|
+
/** Hides the content and leaves the Escape stack / stops the scroll watcher. */
|
|
13016
14025
|
#conceal() {
|
|
13017
|
-
|
|
14026
|
+
this.#escapeLayer.deactivate();
|
|
13018
14027
|
this.#stopScrollDismiss?.();
|
|
13019
14028
|
this.#stopScrollDismiss = null;
|
|
13020
14029
|
if (!this.hasContentTarget) return;
|
|
13021
14030
|
this.contentTarget.hidden = true;
|
|
13022
14031
|
this.contentTarget.setAttribute("data-state", "closed");
|
|
13023
14032
|
}
|
|
13024
|
-
/**
|
|
13025
|
-
#
|
|
13026
|
-
|
|
13027
|
-
|
|
13028
|
-
|
|
13029
|
-
|
|
13030
|
-
|
|
14033
|
+
/** Cancels pending timers and conceals immediately (shared Escape / scroll path). */
|
|
14034
|
+
#dismiss() {
|
|
14035
|
+
this.#cancelShow();
|
|
14036
|
+
this.#cancelHide();
|
|
14037
|
+
this.#conceal();
|
|
14038
|
+
}
|
|
14039
|
+
/** Records the modality whose enter/focus event requires the tooltip to stay visible. */
|
|
14040
|
+
#activateInteraction(event) {
|
|
14041
|
+
if (event?.type === "mouseenter") this.#pointerActive = true;
|
|
14042
|
+
if (event?.type === "focusin") this.#focusActive = true;
|
|
14043
|
+
}
|
|
14044
|
+
/** Clears a modality on leave/blur and reports whether the event represented such a change. */
|
|
14045
|
+
#deactivateInteraction(event) {
|
|
14046
|
+
if (event?.type === "mouseleave") {
|
|
14047
|
+
this.#pointerActive = false;
|
|
14048
|
+
return true;
|
|
13031
14049
|
}
|
|
13032
|
-
|
|
14050
|
+
if (event?.type === "focusout") {
|
|
14051
|
+
this.#focusActive = false;
|
|
14052
|
+
return true;
|
|
14053
|
+
}
|
|
14054
|
+
return false;
|
|
14055
|
+
}
|
|
14056
|
+
/** Discards interaction reasons at a lifecycle boundary. */
|
|
14057
|
+
#resetInteractionState() {
|
|
14058
|
+
this.#focusActive = false;
|
|
14059
|
+
this.#pointerActive = false;
|
|
14060
|
+
}
|
|
14061
|
+
/** Whether focus or pointer presence still requires a persistent tooltip. */
|
|
14062
|
+
get #hasActiveInteraction() {
|
|
14063
|
+
return this.#focusActive || this.#pointerActive;
|
|
14064
|
+
}
|
|
13033
14065
|
/** Cancels any pending show timer. */
|
|
13034
14066
|
#cancelShow() {
|
|
13035
14067
|
if (this.#pendingShow !== null) {
|
|
@@ -13050,12 +14082,6 @@ var TooltipController = class extends Controller {
|
|
|
13050
14082
|
}
|
|
13051
14083
|
};
|
|
13052
14084
|
var tokensOf = (value) => value.split(/\s+/).filter(Boolean);
|
|
13053
|
-
var firstTimeMs = (value) => {
|
|
13054
|
-
const first = value.split(",")[0]?.trim() ?? "";
|
|
13055
|
-
const amount = Number.parseFloat(first);
|
|
13056
|
-
if (Number.isNaN(amount)) return 0;
|
|
13057
|
-
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
13058
|
-
};
|
|
13059
14085
|
var TransitionController = class extends Controller {
|
|
13060
14086
|
static values = {
|
|
13061
14087
|
enter: { type: String, default: "" },
|
|
@@ -13068,9 +14094,9 @@ var TransitionController = class extends Controller {
|
|
|
13068
14094
|
};
|
|
13069
14095
|
static actions = ["enter", "leave", "toggle"];
|
|
13070
14096
|
static events = ["entered", "left"];
|
|
13071
|
-
|
|
14097
|
+
/** Owns the cancellable completion wait (terminal events + bounded fallback). */
|
|
14098
|
+
#transition = new TransitionCompletion();
|
|
13072
14099
|
#rafId = null;
|
|
13073
|
-
#endListener = null;
|
|
13074
14100
|
connect() {
|
|
13075
14101
|
this.#strip();
|
|
13076
14102
|
this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
|
|
@@ -13097,7 +14123,7 @@ var TransitionController = class extends Controller {
|
|
|
13097
14123
|
const isEnter = kind === "enter";
|
|
13098
14124
|
if (isEnter) this.element.hidden = false;
|
|
13099
14125
|
this.element.setAttribute("data-transition-state", isEnter ? "entering" : "leaving");
|
|
13100
|
-
if (
|
|
14126
|
+
if (prefersReducedMotion()) {
|
|
13101
14127
|
this.#finish(kind);
|
|
13102
14128
|
return;
|
|
13103
14129
|
}
|
|
@@ -13109,12 +14135,13 @@ var TransitionController = class extends Controller {
|
|
|
13109
14135
|
this.#rafId = null;
|
|
13110
14136
|
this.#remove(from);
|
|
13111
14137
|
this.#add(to);
|
|
13112
|
-
this.#
|
|
14138
|
+
this.#transition.wait(this.element, () => this.#finish(kind), {
|
|
14139
|
+
timeoutMs: this.timeoutValue
|
|
14140
|
+
});
|
|
13113
14141
|
});
|
|
13114
14142
|
}
|
|
13115
14143
|
/** Settles the element into the completed state, clearing the stage classes. */
|
|
13116
14144
|
#finish(kind) {
|
|
13117
|
-
this.#cleanupEnd();
|
|
13118
14145
|
this.#strip();
|
|
13119
14146
|
if (kind === "enter") {
|
|
13120
14147
|
this.element.setAttribute("data-transition-state", "entered");
|
|
@@ -13125,29 +14152,13 @@ var TransitionController = class extends Controller {
|
|
|
13125
14152
|
this.dispatch("left", { detail: {} });
|
|
13126
14153
|
}
|
|
13127
14154
|
}
|
|
13128
|
-
/** Resolves on the element's own `transitionend`, with a safety timeout fallback. */
|
|
13129
|
-
#awaitEnd(done) {
|
|
13130
|
-
this.#endListener = (event) => {
|
|
13131
|
-
if (event.target === this.element) done();
|
|
13132
|
-
};
|
|
13133
|
-
this.element.addEventListener("transitionend", this.#endListener);
|
|
13134
|
-
const ms = this.timeoutValue > 0 ? this.timeoutValue : this.#duration();
|
|
13135
|
-
this.#timers.set(done, ms);
|
|
13136
|
-
}
|
|
13137
|
-
#cleanupEnd() {
|
|
13138
|
-
if (this.#endListener) {
|
|
13139
|
-
this.element.removeEventListener("transitionend", this.#endListener);
|
|
13140
|
-
this.#endListener = null;
|
|
13141
|
-
}
|
|
13142
|
-
this.#timers.clearAll();
|
|
13143
|
-
}
|
|
13144
14155
|
/** Cancels any in-flight transition (interruption / teardown). */
|
|
13145
14156
|
#cancel() {
|
|
13146
14157
|
if (this.#rafId !== null) {
|
|
13147
14158
|
this.#cancelRaf(this.#rafId);
|
|
13148
14159
|
this.#rafId = null;
|
|
13149
14160
|
}
|
|
13150
|
-
this.#
|
|
14161
|
+
this.#transition.cancel();
|
|
13151
14162
|
this.#strip();
|
|
13152
14163
|
}
|
|
13153
14164
|
#add(...lists) {
|
|
@@ -13169,13 +14180,6 @@ var TransitionController = class extends Controller {
|
|
|
13169
14180
|
this.leaveToValue
|
|
13170
14181
|
);
|
|
13171
14182
|
}
|
|
13172
|
-
/** Auto-computed safety duration (transition time + delay, with a small buffer). */
|
|
13173
|
-
#duration() {
|
|
13174
|
-
if (typeof window.getComputedStyle !== "function") return 0;
|
|
13175
|
-
const style = window.getComputedStyle(this.element);
|
|
13176
|
-
const total = firstTimeMs(style.transitionDuration) + firstTimeMs(style.transitionDelay);
|
|
13177
|
-
return total > 0 ? total + 50 : 0;
|
|
13178
|
-
}
|
|
13179
14183
|
#raf(callback) {
|
|
13180
14184
|
if (typeof window.requestAnimationFrame === "function") {
|
|
13181
14185
|
return window.requestAnimationFrame(() => callback());
|
|
@@ -13186,9 +14190,6 @@ var TransitionController = class extends Controller {
|
|
|
13186
14190
|
if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
|
|
13187
14191
|
else window.clearTimeout(id);
|
|
13188
14192
|
}
|
|
13189
|
-
#prefersReducedMotion() {
|
|
13190
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
13191
|
-
}
|
|
13192
14193
|
};
|
|
13193
14194
|
var TYPEAHEAD_TIMEOUT2 = 500;
|
|
13194
14195
|
var TreeViewController = class extends Controller {
|