stimeo-ui 0.1.0.pre.beta.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -0
- data/README.md +6 -6
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +256 -39
- data/dist/controllers/confirm_controller.js +107 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/dialog_controller.js +107 -8
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/focus_controller.js +107 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/sortable_controller.js +1 -1
- data/dist/controllers/toast_controller.js +108 -29
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/index.js +847 -349
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +2 -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}. */
|
|
@@ -118,6 +210,9 @@ var FocusTrap = class {
|
|
|
118
210
|
}
|
|
119
211
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
120
212
|
document.addEventListener("keydown", this.#onKeydown);
|
|
213
|
+
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
214
|
+
const onEscape = this.#options.onEscape;
|
|
215
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
121
216
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
122
217
|
}
|
|
123
218
|
/**
|
|
@@ -130,7 +225,9 @@ var FocusTrap = class {
|
|
|
130
225
|
deactivate({ restoreFocus = true } = {}) {
|
|
131
226
|
if (!this.#activeState) return;
|
|
132
227
|
this.#activeState = false;
|
|
228
|
+
this.#escapeLayer.deactivate();
|
|
133
229
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
230
|
+
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
134
231
|
if (this.#scrollLocked) {
|
|
135
232
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
136
233
|
this.#scrollLocked = false;
|
|
@@ -146,15 +243,23 @@ var FocusTrap = class {
|
|
|
146
243
|
if (option === void 0) return fallback;
|
|
147
244
|
return typeof option === "function" ? option() : option;
|
|
148
245
|
}
|
|
149
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* Reverts the side effects just before Turbo caches the page snapshot, so an
|
|
248
|
+
* overlay left open does not bake the scroll lock into `body[style]` — a
|
|
249
|
+
* restored page would feed that locked value back into {@link activate} as the
|
|
250
|
+
* baseline, and closing would then never unlock the page. Markup state stays
|
|
251
|
+
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
252
|
+
* is left alone mid-navigation. The listener lives only while active.
|
|
253
|
+
*/
|
|
254
|
+
#onBeforeCache = () => {
|
|
255
|
+
this.deactivate({ restoreFocus: false });
|
|
256
|
+
};
|
|
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
|
+
*/
|
|
150
262
|
#onKeydown = (event) => {
|
|
151
|
-
if (event.key === "Escape") {
|
|
152
|
-
if (this.#options.onEscape) {
|
|
153
|
-
event.preventDefault();
|
|
154
|
-
this.#options.onEscape();
|
|
155
|
-
}
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
263
|
if (event.key === "Tab") this.#trapTab(event);
|
|
159
264
|
};
|
|
160
265
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
@@ -502,6 +607,55 @@ var AspectRatioController = class extends Controller {
|
|
|
502
607
|
return value !== void 0 && Number.isFinite(value) && value > 0;
|
|
503
608
|
}
|
|
504
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
|
|
505
659
|
var AutoSubmitController = class extends Controller {
|
|
506
660
|
static targets = ["form"];
|
|
507
661
|
static values = {
|
|
@@ -525,34 +679,22 @@ var AutoSubmitController = class extends Controller {
|
|
|
525
679
|
window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
|
|
526
680
|
}
|
|
527
681
|
};
|
|
528
|
-
/**
|
|
529
|
-
#
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
};
|
|
534
|
-
/**
|
|
535
|
-
* Composition finished (the IME conversion is confirmed): clear the flag and
|
|
536
|
-
* schedule a submit as if `input` fired, so the settled text triggers a submit
|
|
537
|
-
* even on browsers whose post-composition `input` still reads `isComposing`.
|
|
538
|
-
*/
|
|
539
|
-
#onCompositionEnd = (event) => {
|
|
540
|
-
this.#composing = false;
|
|
541
|
-
if (this.#triggers("input")) this.#schedule(event.target ?? null);
|
|
542
|
-
};
|
|
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
|
+
});
|
|
543
688
|
connect() {
|
|
544
689
|
this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
545
|
-
this.#
|
|
546
|
-
this.#form.addEventListener("compositionend", this.#onCompositionEnd);
|
|
690
|
+
this.#composition.observe(this.#form);
|
|
547
691
|
}
|
|
548
692
|
disconnect() {
|
|
549
693
|
this.#timers.clearAll();
|
|
550
694
|
this.#pendingId = 0;
|
|
551
|
-
this.#
|
|
695
|
+
this.#composition.disconnect();
|
|
552
696
|
this.#form.removeAttribute("data-auto-submit-pending");
|
|
553
697
|
this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
554
|
-
this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
|
|
555
|
-
this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
556
698
|
}
|
|
557
699
|
/**
|
|
558
700
|
* Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
|
|
@@ -561,7 +703,7 @@ var AutoSubmitController = class extends Controller {
|
|
|
561
703
|
*/
|
|
562
704
|
submit(event) {
|
|
563
705
|
if (!this.#triggers(event.type)) return;
|
|
564
|
-
if (event.type === "input" &&
|
|
706
|
+
if (event.type === "input" && this.#composition.isComposing(event)) return;
|
|
565
707
|
this.#schedule(event.target ?? null);
|
|
566
708
|
}
|
|
567
709
|
/** Schedules (and coalesces) the debounced submit for the given trigger. */
|
|
@@ -1430,35 +1572,25 @@ var CharacterCounterController = class _CharacterCounterController extends Contr
|
|
|
1430
1572
|
static #announceDelay = 200;
|
|
1431
1573
|
#timeouts = new SafeTimeout();
|
|
1432
1574
|
#announceId = null;
|
|
1433
|
-
/**
|
|
1434
|
-
#
|
|
1575
|
+
/** Owns IME lifecycle state and applies the confirmed count once. */
|
|
1576
|
+
#composition = new CompositionTracker({ onEnd: () => this.#update() });
|
|
1435
1577
|
#onInput = (event) => {
|
|
1436
|
-
if (this.#
|
|
1437
|
-
this.#update();
|
|
1438
|
-
};
|
|
1439
|
-
#onCompositionStart = () => {
|
|
1440
|
-
this.#composing = true;
|
|
1441
|
-
};
|
|
1442
|
-
#onCompositionEnd = () => {
|
|
1443
|
-
this.#composing = false;
|
|
1578
|
+
if (this.#composition.isComposing(event)) return;
|
|
1444
1579
|
this.#update();
|
|
1445
1580
|
};
|
|
1446
1581
|
connect() {
|
|
1447
1582
|
const field = this.#field;
|
|
1448
1583
|
if (!field) return;
|
|
1449
1584
|
field.addEventListener("input", this.#onInput);
|
|
1450
|
-
|
|
1451
|
-
field.addEventListener("compositionend", this.#onCompositionEnd);
|
|
1585
|
+
this.#composition.observe(field);
|
|
1452
1586
|
this.#update({ announce: false });
|
|
1453
1587
|
}
|
|
1454
1588
|
disconnect() {
|
|
1455
1589
|
const field = this.#field;
|
|
1456
1590
|
field?.removeEventListener("input", this.#onInput);
|
|
1457
|
-
|
|
1458
|
-
field?.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
1591
|
+
this.#composition.disconnect();
|
|
1459
1592
|
this.#timeouts.clearAll();
|
|
1460
1593
|
this.#announceId = null;
|
|
1461
|
-
this.#composing = false;
|
|
1462
1594
|
}
|
|
1463
1595
|
/**
|
|
1464
1596
|
* Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)
|
|
@@ -2032,17 +2164,30 @@ var ComboboxController = class extends Controller {
|
|
|
2032
2164
|
* does not immediately re-open the listbox via a `focus`-bound action.
|
|
2033
2165
|
*/
|
|
2034
2166
|
#suppressOpen = false;
|
|
2167
|
+
/** Owns IME lifecycle state; confirmed text re-filters the list once. */
|
|
2168
|
+
#composition = new CompositionTracker({ onEnd: () => this.filter() });
|
|
2035
2169
|
/** Starts closed with no active option and registers the outside-click listener. */
|
|
2036
2170
|
connect() {
|
|
2171
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
2037
2172
|
this.close();
|
|
2038
2173
|
document.addEventListener("click", this.#onOutsideClick);
|
|
2039
2174
|
}
|
|
2040
2175
|
/** Removes the document-level listener registered in {@link connect}. */
|
|
2041
2176
|
disconnect() {
|
|
2177
|
+
this.#composition.disconnect();
|
|
2042
2178
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
2043
2179
|
}
|
|
2044
|
-
/**
|
|
2045
|
-
|
|
2180
|
+
/** Tracks an input added initially or after connect. */
|
|
2181
|
+
inputTargetConnected(input) {
|
|
2182
|
+
this.#composition.observe(input);
|
|
2183
|
+
}
|
|
2184
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
2185
|
+
inputTargetDisconnected(input) {
|
|
2186
|
+
this.#composition.unobserve(input);
|
|
2187
|
+
}
|
|
2188
|
+
/** Filters confirmed input text and opens the listbox. */
|
|
2189
|
+
filter(event) {
|
|
2190
|
+
if (this.#composition.isComposing(event)) return;
|
|
2046
2191
|
this.open();
|
|
2047
2192
|
}
|
|
2048
2193
|
/**
|
|
@@ -2079,7 +2224,7 @@ var ComboboxController = class extends Controller {
|
|
|
2079
2224
|
}
|
|
2080
2225
|
/** Routes keyboard interaction per the APG combobox model. */
|
|
2081
2226
|
onKeydown(event) {
|
|
2082
|
-
if (
|
|
2227
|
+
if (this.#composition.isComposing(event)) return;
|
|
2083
2228
|
switch (event.key) {
|
|
2084
2229
|
case "ArrowDown": {
|
|
2085
2230
|
event.preventDefault();
|
|
@@ -2125,6 +2270,7 @@ var ComboboxController = class extends Controller {
|
|
|
2125
2270
|
break;
|
|
2126
2271
|
}
|
|
2127
2272
|
case "Escape":
|
|
2273
|
+
if (event.defaultPrevented || this.#isClosed) break;
|
|
2128
2274
|
event.preventDefault();
|
|
2129
2275
|
this.close();
|
|
2130
2276
|
break;
|
|
@@ -2201,7 +2347,9 @@ var ComboboxController = class extends Controller {
|
|
|
2201
2347
|
return !this.hasListTarget || this.listTarget.hidden !== false;
|
|
2202
2348
|
}
|
|
2203
2349
|
};
|
|
2204
|
-
var CommandPaletteController = class extends Controller {
|
|
2350
|
+
var CommandPaletteController = class _CommandPaletteController extends Controller {
|
|
2351
|
+
static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
|
|
2352
|
+
static #ABSENT_ARIA_DISABLED = "absent";
|
|
2205
2353
|
static targets = ["dialog", "input", "list", "option", "empty"];
|
|
2206
2354
|
static values = {
|
|
2207
2355
|
hotkey: { type: String, default: "mod+k" },
|
|
@@ -2219,6 +2367,12 @@ var CommandPaletteController = class extends Controller {
|
|
|
2219
2367
|
static events = ["select"];
|
|
2220
2368
|
/** The index of the currently active option within the visible subset. */
|
|
2221
2369
|
#activeIndex = -1;
|
|
2370
|
+
/** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
|
|
2371
|
+
#composition = new CompositionTracker({
|
|
2372
|
+
onEnd: () => {
|
|
2373
|
+
if (this.#isOpen) this.filter();
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2222
2376
|
/**
|
|
2223
2377
|
* Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
|
|
2224
2378
|
* restore). Escape closes; focus on open goes to the input, and is restored to
|
|
@@ -2241,6 +2395,8 @@ var CommandPaletteController = class extends Controller {
|
|
|
2241
2395
|
*/
|
|
2242
2396
|
connect() {
|
|
2243
2397
|
document.addEventListener("keydown", this.#onGlobalKeydown);
|
|
2398
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
2399
|
+
this.#syncOptionSemantics();
|
|
2244
2400
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
2245
2401
|
this.#resetToClosedState();
|
|
2246
2402
|
if (shouldOpen) this.open();
|
|
@@ -2248,9 +2404,22 @@ var CommandPaletteController = class extends Controller {
|
|
|
2248
2404
|
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
2249
2405
|
disconnect() {
|
|
2250
2406
|
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
2407
|
+
this.#composition.disconnect();
|
|
2251
2408
|
this.#trap.deactivate({ restoreFocus: false });
|
|
2252
2409
|
this.#resetToClosedState();
|
|
2253
2410
|
}
|
|
2411
|
+
/** Initializes semantics for an option added before or after the controller connects. */
|
|
2412
|
+
optionTargetConnected(option) {
|
|
2413
|
+
this.#syncOptionSemanticsFor(option);
|
|
2414
|
+
}
|
|
2415
|
+
/** Tracks an input added initially or after connect without extra consumer actions. */
|
|
2416
|
+
inputTargetConnected(input) {
|
|
2417
|
+
this.#composition.observe(input);
|
|
2418
|
+
}
|
|
2419
|
+
/** Removes controller-owned listeners when the input target is replaced or removed. */
|
|
2420
|
+
inputTargetDisconnected(input) {
|
|
2421
|
+
this.#composition.unobserve(input);
|
|
2422
|
+
}
|
|
2254
2423
|
/** Toggles the open state of the command palette. */
|
|
2255
2424
|
toggle() {
|
|
2256
2425
|
if (this.#isOpen) {
|
|
@@ -2275,8 +2444,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
2275
2444
|
this.#trap.deactivate();
|
|
2276
2445
|
}
|
|
2277
2446
|
/** Filters option elements in-memory matching the input value. Bound to input target. */
|
|
2278
|
-
filter() {
|
|
2279
|
-
if (!this.hasInputTarget) return;
|
|
2447
|
+
filter(event) {
|
|
2448
|
+
if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
|
|
2449
|
+
this.#syncOptionSemantics();
|
|
2280
2450
|
const query = this.inputTarget.value.trim().toLowerCase();
|
|
2281
2451
|
let hasSelectableMatch = false;
|
|
2282
2452
|
for (const option of this.optionTargets) {
|
|
@@ -2299,6 +2469,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
2299
2469
|
const target = event.currentTarget;
|
|
2300
2470
|
if (!target) return;
|
|
2301
2471
|
const option = target.closest("[role='option']");
|
|
2472
|
+
if (option) this.#syncOptionSemanticsFor(option);
|
|
2302
2473
|
if (option && !this.#isDisabled(option)) {
|
|
2303
2474
|
this.#confirmSelection(option);
|
|
2304
2475
|
}
|
|
@@ -2314,7 +2485,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
2314
2485
|
* focus — not only the input.
|
|
2315
2486
|
*/
|
|
2316
2487
|
onKeydown(event) {
|
|
2317
|
-
if (!this.#isOpen) return;
|
|
2488
|
+
if (this.#composition.isComposing(event) || !this.#isOpen) return;
|
|
2318
2489
|
switch (event.key) {
|
|
2319
2490
|
case "ArrowDown":
|
|
2320
2491
|
event.preventDefault();
|
|
@@ -2349,23 +2520,25 @@ var CommandPaletteController = class extends Controller {
|
|
|
2349
2520
|
this.#setActiveIndex(newIndex);
|
|
2350
2521
|
}
|
|
2351
2522
|
#setActiveIndex(index) {
|
|
2523
|
+
this.#syncOptionSemantics();
|
|
2352
2524
|
const visible = this.#visibleOptions;
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2525
|
+
const activeOption = visible[index] ?? null;
|
|
2526
|
+
this.#activeIndex = activeOption ? index : -1;
|
|
2527
|
+
for (const option of this.optionTargets) {
|
|
2528
|
+
option.setAttribute("aria-selected", "false");
|
|
2529
|
+
option.removeAttribute("data-active");
|
|
2530
|
+
}
|
|
2531
|
+
if (activeOption) {
|
|
2532
|
+
activeOption.setAttribute("aria-selected", "true");
|
|
2533
|
+
activeOption.setAttribute("data-active", "true");
|
|
2534
|
+
activeOption.scrollIntoView({ block: "nearest" });
|
|
2535
|
+
}
|
|
2536
|
+
if (this.hasInputTarget) {
|
|
2537
|
+
if (activeOption?.id) {
|
|
2538
|
+
this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
|
|
2362
2539
|
} else {
|
|
2363
|
-
|
|
2364
|
-
option.removeAttribute("data-active");
|
|
2540
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
2365
2541
|
}
|
|
2366
|
-
});
|
|
2367
|
-
if (index === -1 && this.hasInputTarget) {
|
|
2368
|
-
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
2369
2542
|
}
|
|
2370
2543
|
}
|
|
2371
2544
|
#confirmSelection(option) {
|
|
@@ -2378,8 +2551,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
2378
2551
|
for (const option of this.optionTargets) {
|
|
2379
2552
|
option.removeAttribute("hidden");
|
|
2380
2553
|
}
|
|
2381
|
-
|
|
2382
|
-
this
|
|
2554
|
+
const hasSelectableOption = this.#visibleOptions.length > 0;
|
|
2555
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
|
|
2556
|
+
this.#setActiveIndex(hasSelectableOption ? 0 : -1);
|
|
2383
2557
|
}
|
|
2384
2558
|
get #visibleOptions() {
|
|
2385
2559
|
return this.optionTargets.filter(
|
|
@@ -2387,23 +2561,66 @@ var CommandPaletteController = class extends Controller {
|
|
|
2387
2561
|
);
|
|
2388
2562
|
}
|
|
2389
2563
|
#isDisabled(option) {
|
|
2390
|
-
return option.dataset.disabled === "true";
|
|
2564
|
+
return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
|
|
2565
|
+
}
|
|
2566
|
+
/** Synchronizes every option's controller-owned selection and disabled semantics. */
|
|
2567
|
+
#syncOptionSemantics() {
|
|
2568
|
+
for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
|
|
2569
|
+
}
|
|
2570
|
+
/** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
|
|
2571
|
+
#syncOptionSemanticsFor(option) {
|
|
2572
|
+
if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
|
|
2573
|
+
const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
|
|
2574
|
+
const originalValue = option.getAttribute(originalAttribute);
|
|
2575
|
+
if (option.dataset.disabled === "true") {
|
|
2576
|
+
if (originalValue === null) {
|
|
2577
|
+
option.setAttribute(
|
|
2578
|
+
originalAttribute,
|
|
2579
|
+
option.getAttribute("aria-disabled") ?? _CommandPaletteController.#ABSENT_ARIA_DISABLED
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2582
|
+
option.setAttribute("aria-disabled", "true");
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
if (originalValue === null) return;
|
|
2586
|
+
if (originalValue === _CommandPaletteController.#ABSENT_ARIA_DISABLED) {
|
|
2587
|
+
option.removeAttribute("aria-disabled");
|
|
2588
|
+
} else {
|
|
2589
|
+
option.setAttribute("aria-disabled", originalValue);
|
|
2590
|
+
}
|
|
2591
|
+
option.removeAttribute(originalAttribute);
|
|
2391
2592
|
}
|
|
2392
2593
|
get #isOpen() {
|
|
2393
2594
|
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
2394
2595
|
}
|
|
2395
2596
|
#onGlobalKeydown = (event) => {
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
if (!key) return;
|
|
2400
|
-
const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
|
|
2401
|
-
const keyMatch = event.key.toLowerCase() === key;
|
|
2402
|
-
if (modPressed && keyMatch) {
|
|
2403
|
-
event.preventDefault();
|
|
2404
|
-
this.toggle();
|
|
2405
|
-
}
|
|
2597
|
+
if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
|
|
2598
|
+
event.preventDefault();
|
|
2599
|
+
this.toggle();
|
|
2406
2600
|
};
|
|
2601
|
+
/** Matches the configured `mod+key` or bare-key hotkey without extra modifiers. */
|
|
2602
|
+
#matchesHotkey(event) {
|
|
2603
|
+
const hotkey = this.#parseHotkey();
|
|
2604
|
+
if (!hotkey || event.altKey || event.shiftKey) return false;
|
|
2605
|
+
if (hotkey.requiresMod) {
|
|
2606
|
+
const hasExactlyOneModKey = event.metaKey !== event.ctrlKey;
|
|
2607
|
+
if (!hasExactlyOneModKey) return false;
|
|
2608
|
+
} else if (event.metaKey || event.ctrlKey) {
|
|
2609
|
+
return false;
|
|
2610
|
+
}
|
|
2611
|
+
return event.key.toLowerCase() === hotkey.key;
|
|
2612
|
+
}
|
|
2613
|
+
/** Parses the intentionally small public hotkey grammar. */
|
|
2614
|
+
#parseHotkey() {
|
|
2615
|
+
const parts = this.hotkeyValue.toLowerCase().split("+");
|
|
2616
|
+
if (parts.length === 1 && parts[0] && parts[0] !== "mod") {
|
|
2617
|
+
return { key: parts[0], requiresMod: false };
|
|
2618
|
+
}
|
|
2619
|
+
if (parts.length === 2 && parts[0] === "mod" && parts[1]) {
|
|
2620
|
+
return { key: parts[1], requiresMod: true };
|
|
2621
|
+
}
|
|
2622
|
+
return null;
|
|
2623
|
+
}
|
|
2407
2624
|
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
2408
2625
|
#resetToClosedState() {
|
|
2409
2626
|
this.#activeIndex = -1;
|
|
@@ -2636,14 +2853,23 @@ var ConfirmController = class extends Controller {
|
|
|
2636
2853
|
var ContextMenuController = class extends Controller {
|
|
2637
2854
|
static targets = ["region", "menu", "item"];
|
|
2638
2855
|
static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
|
|
2639
|
-
|
|
2856
|
+
#timers = new SafeTimeout();
|
|
2857
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
2858
|
+
#escapeLayer = new EscapeLayer();
|
|
2859
|
+
/** Starts closed and registers delegated activation and outside-pointer listeners. */
|
|
2640
2860
|
connect() {
|
|
2641
2861
|
this.#closeMenu();
|
|
2642
|
-
|
|
2862
|
+
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
2863
|
+
document.addEventListener("click", this.#onOutsidePointer);
|
|
2864
|
+
document.addEventListener("contextmenu", this.#onOutsidePointer);
|
|
2643
2865
|
}
|
|
2644
|
-
/**
|
|
2866
|
+
/** Releases the listeners, stack membership, and pending Tab-close task. */
|
|
2645
2867
|
disconnect() {
|
|
2646
|
-
|
|
2868
|
+
this.#timers.clearAll();
|
|
2869
|
+
this.#escapeLayer.deactivate();
|
|
2870
|
+
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
2871
|
+
document.removeEventListener("click", this.#onOutsidePointer);
|
|
2872
|
+
document.removeEventListener("contextmenu", this.#onOutsidePointer);
|
|
2647
2873
|
}
|
|
2648
2874
|
/**
|
|
2649
2875
|
* Opens the menu from a `contextmenu` event: suppresses the native menu and
|
|
@@ -2668,11 +2894,17 @@ var ContextMenuController = class extends Controller {
|
|
|
2668
2894
|
switch (event.key) {
|
|
2669
2895
|
case "ArrowDown":
|
|
2670
2896
|
event.preventDefault();
|
|
2671
|
-
if (items.length > 0)
|
|
2897
|
+
if (items.length > 0) {
|
|
2898
|
+
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
|
|
2899
|
+
items[nextIndex]?.focus();
|
|
2900
|
+
}
|
|
2672
2901
|
break;
|
|
2673
2902
|
case "ArrowUp":
|
|
2674
2903
|
event.preventDefault();
|
|
2675
|
-
if (items.length > 0)
|
|
2904
|
+
if (items.length > 0) {
|
|
2905
|
+
const previousIndex = currentIndex < 0 ? items.length - 1 : currentIndex - 1;
|
|
2906
|
+
items[(previousIndex + items.length) % items.length]?.focus();
|
|
2907
|
+
}
|
|
2676
2908
|
break;
|
|
2677
2909
|
case "Home":
|
|
2678
2910
|
event.preventDefault();
|
|
@@ -2682,12 +2914,9 @@ var ContextMenuController = class extends Controller {
|
|
|
2682
2914
|
event.preventDefault();
|
|
2683
2915
|
items[items.length - 1]?.focus();
|
|
2684
2916
|
break;
|
|
2685
|
-
case "Escape":
|
|
2686
|
-
event.preventDefault();
|
|
2687
|
-
this.#closeAndRestore();
|
|
2688
|
-
break;
|
|
2689
2917
|
case "Tab":
|
|
2690
|
-
this.#
|
|
2918
|
+
this.#timers.clearAll();
|
|
2919
|
+
this.#timers.set(() => this.#closeMenu(), 0);
|
|
2691
2920
|
break;
|
|
2692
2921
|
}
|
|
2693
2922
|
}
|
|
@@ -2698,6 +2927,11 @@ var ContextMenuController = class extends Controller {
|
|
|
2698
2927
|
/** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
|
|
2699
2928
|
#openAt(x, y) {
|
|
2700
2929
|
if (!this.hasMenuTarget) return;
|
|
2930
|
+
this.#timers.clearAll();
|
|
2931
|
+
this.#escapeLayer.activate(document, {
|
|
2932
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
2933
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
2934
|
+
});
|
|
2701
2935
|
this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
|
|
2702
2936
|
this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
|
|
2703
2937
|
this.menuTarget.hidden = false;
|
|
@@ -2706,6 +2940,8 @@ var ContextMenuController = class extends Controller {
|
|
|
2706
2940
|
}
|
|
2707
2941
|
/** Hides the menu and reflects the collapsed state on the region. */
|
|
2708
2942
|
#closeMenu() {
|
|
2943
|
+
this.#timers.clearAll();
|
|
2944
|
+
this.#escapeLayer.deactivate();
|
|
2709
2945
|
if (!this.hasMenuTarget) return;
|
|
2710
2946
|
this.menuTarget.hidden = true;
|
|
2711
2947
|
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
|
|
@@ -2715,10 +2951,24 @@ var ContextMenuController = class extends Controller {
|
|
|
2715
2951
|
this.#closeMenu();
|
|
2716
2952
|
if (this.hasRegionTarget) this.regionTarget.focus();
|
|
2717
2953
|
}
|
|
2718
|
-
/** Closes
|
|
2719
|
-
#
|
|
2954
|
+
/** Closes when a click or context-menu invocation lands outside this instance. */
|
|
2955
|
+
#onOutsidePointer = (event) => {
|
|
2720
2956
|
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
|
|
2721
2957
|
};
|
|
2958
|
+
/**
|
|
2959
|
+
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|
|
2960
|
+
* Native Enter/Space activation also synthesizes a click and is blocked here.
|
|
2961
|
+
*/
|
|
2962
|
+
#onItemClickCapture = (event) => {
|
|
2963
|
+
const target = event.target;
|
|
2964
|
+
if (!(target instanceof Node)) return;
|
|
2965
|
+
const disabled = this.itemTargets.some(
|
|
2966
|
+
(item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
|
|
2967
|
+
);
|
|
2968
|
+
if (!disabled) return;
|
|
2969
|
+
event.preventDefault();
|
|
2970
|
+
event.stopImmediatePropagation();
|
|
2971
|
+
};
|
|
2722
2972
|
/** Menu items eligible for roving focus (excludes disabled / hidden). */
|
|
2723
2973
|
get #navigableItems() {
|
|
2724
2974
|
return this.itemTargets.filter((item) => this.#isNavigable(item));
|
|
@@ -2876,8 +3126,8 @@ var CountdownController = class extends Controller {
|
|
|
2876
3126
|
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
2877
3127
|
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
2878
3128
|
* not re-derived from the declarative `autostart` Value (which only governs the
|
|
2879
|
-
* initial state on connect); re-deriving it would override a user's pause
|
|
2880
|
-
*
|
|
3129
|
+
* initial state on connect); re-deriving it would override a user's pause —
|
|
3130
|
+
* the DOM, not a re-run of declarative config, is the source of truth.
|
|
2881
3131
|
*/
|
|
2882
3132
|
reset() {
|
|
2883
3133
|
const wasRunning = this.#state === "running";
|
|
@@ -3371,7 +3621,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
3371
3621
|
return;
|
|
3372
3622
|
}
|
|
3373
3623
|
if (event.key === "Escape") {
|
|
3374
|
-
if (this.#pendingStart) {
|
|
3624
|
+
if (this.#pendingStart && !event.defaultPrevented && !event.isComposing) {
|
|
3375
3625
|
event.preventDefault();
|
|
3376
3626
|
this.#pendingStart = "";
|
|
3377
3627
|
this.#previewDate = "";
|
|
@@ -3900,14 +4150,6 @@ var DirtyFormController = class extends Controller {
|
|
|
3900
4150
|
return null;
|
|
3901
4151
|
}
|
|
3902
4152
|
};
|
|
3903
|
-
var FOCUSABLE_SELECTOR = [
|
|
3904
|
-
"a[href]",
|
|
3905
|
-
"button:not([disabled])",
|
|
3906
|
-
"input:not([disabled])",
|
|
3907
|
-
"select:not([disabled])",
|
|
3908
|
-
"textarea:not([disabled])",
|
|
3909
|
-
'[tabindex]:not([tabindex="-1"])'
|
|
3910
|
-
].join(",");
|
|
3911
4153
|
var DismissibleController = class extends Controller {
|
|
3912
4154
|
static targets = ["root", "fallback"];
|
|
3913
4155
|
static values = {
|
|
@@ -3921,25 +4163,35 @@ var DismissibleController = class extends Controller {
|
|
|
3921
4163
|
if (!root.hasAttribute("data-state")) {
|
|
3922
4164
|
root.setAttribute("data-state", "open");
|
|
3923
4165
|
}
|
|
3924
|
-
|
|
3925
|
-
this.element.addEventListener("keydown", this.#onKeydown);
|
|
3926
|
-
}
|
|
4166
|
+
this.#syncEscapeListener();
|
|
3927
4167
|
}
|
|
3928
4168
|
disconnect() {
|
|
3929
4169
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
3930
4170
|
}
|
|
4171
|
+
/** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */
|
|
4172
|
+
closeOnEscapeValueChanged() {
|
|
4173
|
+
this.#syncEscapeListener();
|
|
4174
|
+
}
|
|
3931
4175
|
/** Dismisses the element. Bound via `data-action` (click on the close button). */
|
|
3932
4176
|
dismiss() {
|
|
3933
4177
|
this.#performDismiss();
|
|
3934
4178
|
}
|
|
3935
4179
|
/** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */
|
|
3936
4180
|
#onKeydown = (event) => {
|
|
3937
|
-
if (event.key !== "Escape") return;
|
|
4181
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
4182
|
+
if (!this.closeOnEscapeValue) return;
|
|
3938
4183
|
const active = document.activeElement;
|
|
3939
4184
|
if (!active || !this.element.contains(active)) return;
|
|
3940
4185
|
event.preventDefault();
|
|
3941
4186
|
this.#performDismiss();
|
|
3942
4187
|
};
|
|
4188
|
+
/** Adds or removes the one stable listener reference without duplicating it. */
|
|
4189
|
+
#syncEscapeListener() {
|
|
4190
|
+
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
4191
|
+
if (this.closeOnEscapeValue) {
|
|
4192
|
+
this.element.addEventListener("keydown", this.#onKeydown);
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
3943
4195
|
/** The element to dismiss: the explicit `root` target, or the host element. */
|
|
3944
4196
|
get #root() {
|
|
3945
4197
|
return this.hasRootTarget ? this.rootTarget : this.element;
|
|
@@ -3963,23 +4215,44 @@ var DismissibleController = class extends Controller {
|
|
|
3963
4215
|
#retreatFocus(root) {
|
|
3964
4216
|
const active = document.activeElement;
|
|
3965
4217
|
if (!(active instanceof HTMLElement) || !root.contains(active)) return;
|
|
3966
|
-
this.#
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
);
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
4218
|
+
for (const candidate of this.#focusFallbacks(root)) {
|
|
4219
|
+
candidate.focus();
|
|
4220
|
+
if (document.activeElement === candidate) return;
|
|
4221
|
+
}
|
|
4222
|
+
document.body.focus();
|
|
4223
|
+
}
|
|
4224
|
+
/** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */
|
|
4225
|
+
*#focusFallbacks(root) {
|
|
4226
|
+
const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;
|
|
4227
|
+
if (explicitFallback && !root.contains(explicitFallback) && this.#isFocusAvailable(explicitFallback)) {
|
|
4228
|
+
yield explicitFallback;
|
|
4229
|
+
}
|
|
4230
|
+
const candidates = document.querySelectorAll(FOCUSABLE);
|
|
4231
|
+
for (const element of candidates) {
|
|
4232
|
+
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
4233
|
+
continue;
|
|
4234
|
+
}
|
|
4235
|
+
if (this.#isFocusAvailable(element)) yield element;
|
|
4236
|
+
}
|
|
4237
|
+
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
|
4238
|
+
const element = candidates.item(index);
|
|
4239
|
+
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
|
4240
|
+
continue;
|
|
4241
|
+
}
|
|
4242
|
+
if (this.#isFocusAvailable(element)) yield element;
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
/** Whether focus is allowed by the element and its semantic ancestors. */
|
|
4246
|
+
#isFocusAvailable(element) {
|
|
4247
|
+
if (!element.matches(FOCUSABLE) && !element.hasAttribute("tabindex") && !element.isContentEditable) {
|
|
4248
|
+
return false;
|
|
4249
|
+
}
|
|
4250
|
+
if (element.matches(":disabled")) return false;
|
|
4251
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
4252
|
+
for (let current = element; current; current = current.parentElement) {
|
|
4253
|
+
if (current.hidden || current.inert) return false;
|
|
4254
|
+
}
|
|
4255
|
+
return true;
|
|
3983
4256
|
}
|
|
3984
4257
|
};
|
|
3985
4258
|
function maxTransitionMs(value) {
|
|
@@ -4123,35 +4396,46 @@ var DrawerController = class extends Controller {
|
|
|
4123
4396
|
return this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
|
|
4124
4397
|
}
|
|
4125
4398
|
};
|
|
4399
|
+
|
|
4400
|
+
// src/utils/aria_ids.ts
|
|
4401
|
+
var counter = 0;
|
|
4402
|
+
function uniqueId(prefix = "stimeo") {
|
|
4403
|
+
let candidate;
|
|
4404
|
+
do {
|
|
4405
|
+
counter += 1;
|
|
4406
|
+
candidate = `${prefix}-${counter}`;
|
|
4407
|
+
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
4408
|
+
return candidate;
|
|
4409
|
+
}
|
|
4410
|
+
function ensureId(element, prefix = "stimeo") {
|
|
4411
|
+
if (element.id) return element.id;
|
|
4412
|
+
const id = uniqueId(prefix);
|
|
4413
|
+
element.id = id;
|
|
4414
|
+
return id;
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4417
|
+
// src/controllers/dropdown_controller.ts
|
|
4126
4418
|
var DropdownController = class extends Controller {
|
|
4127
4419
|
static targets = ["trigger", "menu"];
|
|
4128
4420
|
static actions = ["close", "open", "toggle"];
|
|
4421
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
4422
|
+
#escapeLayer = new EscapeLayer();
|
|
4129
4423
|
/** Closes the menu when a click lands outside the controller's element. */
|
|
4130
4424
|
#onOutsideClick = (event) => {
|
|
4131
4425
|
if (!this.element.contains(event.target)) {
|
|
4132
4426
|
this.close();
|
|
4133
4427
|
}
|
|
4134
4428
|
};
|
|
4135
|
-
/**
|
|
4136
|
-
#onKeydown = (event) => {
|
|
4137
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
4138
|
-
this.close();
|
|
4139
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
4140
|
-
}
|
|
4141
|
-
};
|
|
4142
|
-
/**
|
|
4143
|
-
* Starts in the closed state and registers the document-level listeners that
|
|
4144
|
-
* power outside-click and `Escape` handling.
|
|
4145
|
-
*/
|
|
4429
|
+
/** Starts in the closed state and registers outside-click handling. */
|
|
4146
4430
|
connect() {
|
|
4431
|
+
this.#associateTriggerWithMenu();
|
|
4147
4432
|
this.close();
|
|
4148
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
4149
|
-
document.addEventListener("keydown", this.#onKeydown);
|
|
4433
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
4150
4434
|
}
|
|
4151
|
-
/** Removes the
|
|
4435
|
+
/** Removes the listeners registered in {@link connect}. */
|
|
4152
4436
|
disconnect() {
|
|
4153
|
-
|
|
4154
|
-
document.removeEventListener("
|
|
4437
|
+
this.#escapeLayer.deactivate();
|
|
4438
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
4155
4439
|
}
|
|
4156
4440
|
/** Toggles the menu between open and closed. Bound via `data-action`. */
|
|
4157
4441
|
toggle() {
|
|
@@ -4164,6 +4448,10 @@ var DropdownController = class extends Controller {
|
|
|
4164
4448
|
/** Reveals the menu and reflects the open state on the trigger. */
|
|
4165
4449
|
open() {
|
|
4166
4450
|
if (!this.hasMenuTarget) return;
|
|
4451
|
+
this.#escapeLayer.activate(document, {
|
|
4452
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
4453
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
4454
|
+
});
|
|
4167
4455
|
this.menuTarget.hidden = false;
|
|
4168
4456
|
if (this.hasTriggerTarget) {
|
|
4169
4457
|
this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
@@ -4171,16 +4459,34 @@ var DropdownController = class extends Controller {
|
|
|
4171
4459
|
}
|
|
4172
4460
|
/** Hides the menu and reflects the closed state on the trigger. */
|
|
4173
4461
|
close() {
|
|
4462
|
+
this.#escapeLayer.deactivate();
|
|
4174
4463
|
if (!this.hasMenuTarget) return;
|
|
4175
4464
|
this.menuTarget.hidden = true;
|
|
4176
4465
|
if (this.hasTriggerTarget) {
|
|
4177
4466
|
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
4178
4467
|
}
|
|
4179
4468
|
}
|
|
4469
|
+
/** Closes and restores focus to the trigger (the keyboard-dismissal path). */
|
|
4470
|
+
#closeAndRestore() {
|
|
4471
|
+
this.close();
|
|
4472
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
4473
|
+
}
|
|
4180
4474
|
/** Whether the menu is currently visible. */
|
|
4181
4475
|
get #isOpen() {
|
|
4182
4476
|
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
4183
4477
|
}
|
|
4478
|
+
/**
|
|
4479
|
+
* Associates the disclosure trigger and controlled region without clobbering
|
|
4480
|
+
* authored markup.
|
|
4481
|
+
*/
|
|
4482
|
+
#associateTriggerWithMenu() {
|
|
4483
|
+
if (!this.hasTriggerTarget || !this.hasMenuTarget) return;
|
|
4484
|
+
if (this.triggerTarget.hasAttribute("aria-controls")) return;
|
|
4485
|
+
this.triggerTarget.setAttribute(
|
|
4486
|
+
"aria-controls",
|
|
4487
|
+
ensureId(this.menuTarget, "stimeo--dropdown-menu")
|
|
4488
|
+
);
|
|
4489
|
+
}
|
|
4184
4490
|
};
|
|
4185
4491
|
var EditableController = class extends Controller {
|
|
4186
4492
|
static targets = ["display", "input"];
|
|
@@ -4191,10 +4497,28 @@ var EditableController = class extends Controller {
|
|
|
4191
4497
|
static events = ["cancel", "change"];
|
|
4192
4498
|
/** The value captured when edit mode began, used to detect real changes. */
|
|
4193
4499
|
#previousValue = "";
|
|
4500
|
+
/**
|
|
4501
|
+
* Owns IME lifecycle state for the edit surface, so a keydown that belongs to
|
|
4502
|
+
* a composition (cancel or confirm) is never treated as an edit command.
|
|
4503
|
+
*/
|
|
4504
|
+
#composition = new CompositionTracker();
|
|
4194
4505
|
/** Establishes the initial display mode (display shown, input hidden). */
|
|
4195
4506
|
connect() {
|
|
4507
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
4196
4508
|
this.#setMode("display");
|
|
4197
4509
|
}
|
|
4510
|
+
/** Releases the composition listeners so nothing outlives the element. */
|
|
4511
|
+
disconnect() {
|
|
4512
|
+
this.#composition.disconnect();
|
|
4513
|
+
}
|
|
4514
|
+
/** Tracks an input added initially or after connect (e.g. a Turbo swap). */
|
|
4515
|
+
inputTargetConnected(input) {
|
|
4516
|
+
this.#composition.observe(input);
|
|
4517
|
+
}
|
|
4518
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
4519
|
+
inputTargetDisconnected(input) {
|
|
4520
|
+
this.#composition.unobserve(input);
|
|
4521
|
+
}
|
|
4198
4522
|
/** Enters edit mode: seeds the input from the display text, focuses, selects. */
|
|
4199
4523
|
edit() {
|
|
4200
4524
|
if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
|
|
@@ -4213,7 +4537,9 @@ var EditableController = class extends Controller {
|
|
|
4213
4537
|
}
|
|
4214
4538
|
/** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */
|
|
4215
4539
|
onKeydown(event) {
|
|
4540
|
+
if (this.#composition.isComposing(event)) return;
|
|
4216
4541
|
if (event.key === "Escape") {
|
|
4542
|
+
if (event.defaultPrevented) return;
|
|
4217
4543
|
event.preventDefault();
|
|
4218
4544
|
this.#cancel();
|
|
4219
4545
|
return;
|
|
@@ -4781,25 +5107,6 @@ var FocusController = class extends Controller {
|
|
|
4781
5107
|
this.dispatch("deactivate", { detail: {} });
|
|
4782
5108
|
}
|
|
4783
5109
|
};
|
|
4784
|
-
|
|
4785
|
-
// src/utils/aria_ids.ts
|
|
4786
|
-
var counter = 0;
|
|
4787
|
-
function uniqueId(prefix = "stimeo") {
|
|
4788
|
-
let candidate;
|
|
4789
|
-
do {
|
|
4790
|
-
counter += 1;
|
|
4791
|
-
candidate = `${prefix}-${counter}`;
|
|
4792
|
-
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
4793
|
-
return candidate;
|
|
4794
|
-
}
|
|
4795
|
-
function ensureId(element, prefix = "stimeo") {
|
|
4796
|
-
if (element.id) return element.id;
|
|
4797
|
-
const id = uniqueId(prefix);
|
|
4798
|
-
element.id = id;
|
|
4799
|
-
return id;
|
|
4800
|
-
}
|
|
4801
|
-
|
|
4802
|
-
// src/controllers/form_field_controller.ts
|
|
4803
5110
|
var FormFieldController = class _FormFieldController extends Controller {
|
|
4804
5111
|
static targets = ["control", "description", "error"];
|
|
4805
5112
|
static values = {
|
|
@@ -5103,8 +5410,8 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
5103
5410
|
}
|
|
5104
5411
|
/**
|
|
5105
5412
|
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
5106
|
-
* for controls that opt in with `data-stimeo--form-field-disallow`. The
|
|
5107
|
-
*
|
|
5413
|
+
* for controls that opt in with `data-stimeo--form-field-disallow`. The supported
|
|
5414
|
+
* rule is `"whitespace"` — a value that is non-empty but blank
|
|
5108
5415
|
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
5109
5416
|
* the per-constraint (`value-missing`) → generic → default chain.
|
|
5110
5417
|
*
|
|
@@ -5373,21 +5680,26 @@ var HoverCardController = class extends Controller {
|
|
|
5373
5680
|
closeDelay: { type: Number, default: 200 },
|
|
5374
5681
|
closeOnScroll: { type: Boolean, default: false }
|
|
5375
5682
|
};
|
|
5376
|
-
static actions = ["close", "
|
|
5377
|
-
/** Pending open/close timers,
|
|
5683
|
+
static actions = ["close", "open"];
|
|
5684
|
+
/** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
|
|
5378
5685
|
#timers = new SafeTimeout();
|
|
5686
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
5687
|
+
#escapeLayer = new EscapeLayer();
|
|
5379
5688
|
#pendingOpen = null;
|
|
5380
5689
|
#pendingClose = null;
|
|
5381
5690
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
5382
5691
|
#stopScrollDismiss = null;
|
|
5383
|
-
/** Starts closed. */
|
|
5692
|
+
/** Starts closed and discards any stale pending state from a prior connection. */
|
|
5384
5693
|
connect() {
|
|
5694
|
+
this.#cancelOpen();
|
|
5695
|
+
this.#cancelClose();
|
|
5385
5696
|
this.#conceal();
|
|
5386
5697
|
}
|
|
5387
|
-
/** Clears timers
|
|
5698
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
5388
5699
|
disconnect() {
|
|
5389
|
-
this.#
|
|
5390
|
-
|
|
5700
|
+
this.#cancelOpen();
|
|
5701
|
+
this.#cancelClose();
|
|
5702
|
+
this.#escapeLayer.deactivate();
|
|
5391
5703
|
this.#stopScrollDismiss?.();
|
|
5392
5704
|
this.#stopScrollDismiss = null;
|
|
5393
5705
|
}
|
|
@@ -5419,32 +5731,26 @@ var HoverCardController = class extends Controller {
|
|
|
5419
5731
|
this.#conceal();
|
|
5420
5732
|
}, this.closeDelayValue);
|
|
5421
5733
|
}
|
|
5422
|
-
/**
|
|
5423
|
-
onKeydown(event) {
|
|
5424
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
5425
|
-
event.preventDefault();
|
|
5426
|
-
this.#dismiss();
|
|
5427
|
-
}
|
|
5428
|
-
}
|
|
5429
|
-
/** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
|
|
5734
|
+
/** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
|
|
5430
5735
|
#reveal() {
|
|
5431
5736
|
if (!this.hasCardTarget) return;
|
|
5432
5737
|
this.cardTarget.hidden = false;
|
|
5433
5738
|
this.cardTarget.setAttribute("data-state", "open");
|
|
5434
5739
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
5435
|
-
|
|
5740
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
|
|
5436
5741
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
5437
5742
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
5438
5743
|
}
|
|
5439
5744
|
}
|
|
5440
|
-
/** Hides the card, reflects state, and
|
|
5745
|
+
/** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
|
|
5441
5746
|
#conceal() {
|
|
5442
|
-
|
|
5747
|
+
this.#escapeLayer.deactivate();
|
|
5443
5748
|
this.#stopScrollDismiss?.();
|
|
5444
5749
|
this.#stopScrollDismiss = null;
|
|
5445
|
-
if (
|
|
5446
|
-
|
|
5447
|
-
|
|
5750
|
+
if (this.hasCardTarget) {
|
|
5751
|
+
this.cardTarget.hidden = true;
|
|
5752
|
+
this.cardTarget.setAttribute("data-state", "closed");
|
|
5753
|
+
}
|
|
5448
5754
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
5449
5755
|
}
|
|
5450
5756
|
/** Cancels pending timers and conceals immediately (shared Escape path). */
|
|
@@ -5453,13 +5759,6 @@ var HoverCardController = class extends Controller {
|
|
|
5453
5759
|
this.#cancelClose();
|
|
5454
5760
|
this.#conceal();
|
|
5455
5761
|
}
|
|
5456
|
-
/** Document-level `Escape` watcher (active only while open). */
|
|
5457
|
-
#onDocumentKeydown = (event) => {
|
|
5458
|
-
if (event.key === "Escape") {
|
|
5459
|
-
event.preventDefault();
|
|
5460
|
-
this.#dismiss();
|
|
5461
|
-
}
|
|
5462
|
-
};
|
|
5463
5762
|
/** Cancels any pending open timer. */
|
|
5464
5763
|
#cancelOpen() {
|
|
5465
5764
|
if (this.#pendingOpen !== null) {
|
|
@@ -6016,6 +6315,7 @@ var ListboxController = class extends Controller {
|
|
|
6016
6315
|
this.#commitActive();
|
|
6017
6316
|
break;
|
|
6018
6317
|
case "Escape":
|
|
6318
|
+
if (event.defaultPrevented || event.isComposing) break;
|
|
6019
6319
|
event.preventDefault();
|
|
6020
6320
|
this.close();
|
|
6021
6321
|
this.triggerTarget.focus();
|
|
@@ -6295,13 +6595,20 @@ var MenuController = class extends Controller {
|
|
|
6295
6595
|
"open",
|
|
6296
6596
|
"toggle"
|
|
6297
6597
|
];
|
|
6298
|
-
|
|
6598
|
+
#timers = new SafeTimeout();
|
|
6599
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
6600
|
+
#escapeLayer = new EscapeLayer();
|
|
6601
|
+
/** Starts closed and registers delegated listeners. */
|
|
6299
6602
|
connect() {
|
|
6300
6603
|
this.close();
|
|
6604
|
+
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
6301
6605
|
document.addEventListener("click", this.#onOutsideClick);
|
|
6302
6606
|
}
|
|
6303
|
-
/**
|
|
6607
|
+
/** Releases the listeners, stack membership, and any pending Tab-close task. */
|
|
6304
6608
|
disconnect() {
|
|
6609
|
+
this.#timers.clearAll();
|
|
6610
|
+
this.#escapeLayer.deactivate();
|
|
6611
|
+
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
6305
6612
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
6306
6613
|
}
|
|
6307
6614
|
/** Toggles the menu open/closed. Bound via `data-action` (click). */
|
|
@@ -6315,12 +6622,19 @@ var MenuController = class extends Controller {
|
|
|
6315
6622
|
}
|
|
6316
6623
|
/** Opens the menu and reflects the expanded state on the trigger. */
|
|
6317
6624
|
open() {
|
|
6625
|
+
this.#timers.clearAll();
|
|
6318
6626
|
if (!this.hasMenuTarget) return;
|
|
6627
|
+
this.#escapeLayer.activate(document, {
|
|
6628
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
6629
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
6630
|
+
});
|
|
6319
6631
|
this.menuTarget.hidden = false;
|
|
6320
6632
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
6321
6633
|
}
|
|
6322
6634
|
/** Closes the menu and reflects the collapsed state on the trigger. */
|
|
6323
6635
|
close() {
|
|
6636
|
+
this.#timers.clearAll();
|
|
6637
|
+
this.#escapeLayer.deactivate();
|
|
6324
6638
|
if (!this.hasMenuTarget) return;
|
|
6325
6639
|
this.menuTarget.hidden = true;
|
|
6326
6640
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
@@ -6365,18 +6679,18 @@ var MenuController = class extends Controller {
|
|
|
6365
6679
|
event.preventDefault();
|
|
6366
6680
|
items[items.length - 1]?.focus();
|
|
6367
6681
|
break;
|
|
6368
|
-
case "Escape":
|
|
6369
|
-
event.preventDefault();
|
|
6370
|
-
this.close();
|
|
6371
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
6372
|
-
break;
|
|
6373
6682
|
case "Tab":
|
|
6374
|
-
this.
|
|
6683
|
+
this.#timers.clearAll();
|
|
6684
|
+
this.#timers.set(() => this.close(), 0);
|
|
6375
6685
|
break;
|
|
6376
6686
|
}
|
|
6377
6687
|
}
|
|
6378
6688
|
/** Closes the menu after an item is activated. Bound via `data-action`. */
|
|
6379
6689
|
activate() {
|
|
6690
|
+
this.#closeAndRestore();
|
|
6691
|
+
}
|
|
6692
|
+
/** Closes and returns focus to the trigger (Escape / item-activation path). */
|
|
6693
|
+
#closeAndRestore() {
|
|
6380
6694
|
this.close();
|
|
6381
6695
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
6382
6696
|
}
|
|
@@ -6384,6 +6698,20 @@ var MenuController = class extends Controller {
|
|
|
6384
6698
|
#onOutsideClick = (event) => {
|
|
6385
6699
|
if (this.#isOpen && !this.element.contains(event.target)) this.close();
|
|
6386
6700
|
};
|
|
6701
|
+
/**
|
|
6702
|
+
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|
|
6703
|
+
* Native Enter/Space activation also synthesizes a click and is blocked here.
|
|
6704
|
+
*/
|
|
6705
|
+
#onItemClickCapture = (event) => {
|
|
6706
|
+
const target = event.target;
|
|
6707
|
+
if (!(target instanceof Node)) return;
|
|
6708
|
+
const disabled = this.itemTargets.some(
|
|
6709
|
+
(item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
|
|
6710
|
+
);
|
|
6711
|
+
if (!disabled) return;
|
|
6712
|
+
event.preventDefault();
|
|
6713
|
+
event.stopImmediatePropagation();
|
|
6714
|
+
};
|
|
6387
6715
|
/** Moves focus to the first navigable item (no-op if none). */
|
|
6388
6716
|
#focusFirst() {
|
|
6389
6717
|
this.#navigableItems[0]?.focus();
|
|
@@ -6422,6 +6750,8 @@ var MenubarController = class extends Controller {
|
|
|
6422
6750
|
#typeahead = "";
|
|
6423
6751
|
#typeaheadId = 0;
|
|
6424
6752
|
#timers = new SafeTimeout();
|
|
6753
|
+
/** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
|
|
6754
|
+
#escapeLayer = new EscapeLayer();
|
|
6425
6755
|
/** Establishes the single tab stop and the closed baseline. */
|
|
6426
6756
|
connect() {
|
|
6427
6757
|
const active = this.#roving.activeIndex;
|
|
@@ -6429,8 +6759,9 @@ var MenubarController = class extends Controller {
|
|
|
6429
6759
|
this.#closeAllMenus();
|
|
6430
6760
|
document.addEventListener("click", this.#onOutsideClick);
|
|
6431
6761
|
}
|
|
6432
|
-
/** Removes the document listener and any pending typeahead timer. */
|
|
6762
|
+
/** Removes the document listener, stack membership, and any pending typeahead timer. */
|
|
6433
6763
|
disconnect() {
|
|
6764
|
+
this.#escapeLayer.deactivate();
|
|
6434
6765
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
6435
6766
|
this.#timers.clearAll();
|
|
6436
6767
|
}
|
|
@@ -6475,12 +6806,6 @@ var MenubarController = class extends Controller {
|
|
|
6475
6806
|
event.preventDefault();
|
|
6476
6807
|
this.#gotoTop(length - 1, anyOpen);
|
|
6477
6808
|
break;
|
|
6478
|
-
case "Escape":
|
|
6479
|
-
if (anyOpen) {
|
|
6480
|
-
event.preventDefault();
|
|
6481
|
-
this.#closeAllMenus();
|
|
6482
|
-
}
|
|
6483
|
-
break;
|
|
6484
6809
|
}
|
|
6485
6810
|
}
|
|
6486
6811
|
/** Keyboard handling while focus is on a menu item. */
|
|
@@ -6516,11 +6841,6 @@ var MenubarController = class extends Controller {
|
|
|
6516
6841
|
event.preventDefault();
|
|
6517
6842
|
this.#moveToAdjacentMenu(menu, -1);
|
|
6518
6843
|
break;
|
|
6519
|
-
case "Escape":
|
|
6520
|
-
event.preventDefault();
|
|
6521
|
-
this.#closeMenu(this.#topFor(menu));
|
|
6522
|
-
this.#focusTop(this.#topFor(menu));
|
|
6523
|
-
break;
|
|
6524
6844
|
case "Tab":
|
|
6525
6845
|
this.#closeAllMenus();
|
|
6526
6846
|
break;
|
|
@@ -6558,6 +6878,10 @@ var MenubarController = class extends Controller {
|
|
|
6558
6878
|
if (!menu) return;
|
|
6559
6879
|
menu.hidden = false;
|
|
6560
6880
|
top.setAttribute("aria-expanded", "true");
|
|
6881
|
+
this.#escapeLayer.activate(document, {
|
|
6882
|
+
onDismiss: () => this.#dismissOpenMenu(),
|
|
6883
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
6884
|
+
});
|
|
6561
6885
|
this.#roving.setActive(this.topTargets.indexOf(top));
|
|
6562
6886
|
const items = this.#itemsIn(menu);
|
|
6563
6887
|
this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
|
|
@@ -6568,12 +6892,30 @@ var MenubarController = class extends Controller {
|
|
|
6568
6892
|
const menu = this.#menuFor(top);
|
|
6569
6893
|
if (menu) menu.hidden = true;
|
|
6570
6894
|
top.setAttribute("aria-expanded", "false");
|
|
6895
|
+
if (!this.#isAnyOpen) this.#escapeLayer.deactivate();
|
|
6571
6896
|
}
|
|
6572
6897
|
/** Closes every menu and resets the typeahead buffer. */
|
|
6573
6898
|
#closeAllMenus() {
|
|
6574
6899
|
for (const top of this.topTargets) this.#closeMenu(top);
|
|
6575
6900
|
this.#resetTypeahead();
|
|
6576
6901
|
}
|
|
6902
|
+
/**
|
|
6903
|
+
* Escape path, invoked by the shared resolver: pressed inside an open menu it
|
|
6904
|
+
* closes that menu and returns focus to its top item (the APG behavior);
|
|
6905
|
+
* pressed while focus is on a top item (or fell to the body) it closes every
|
|
6906
|
+
* menu without moving focus.
|
|
6907
|
+
*/
|
|
6908
|
+
#dismissOpenMenu() {
|
|
6909
|
+
const active = document.activeElement;
|
|
6910
|
+
const menu = this.menuTargets.find((candidate) => candidate.contains(active));
|
|
6911
|
+
if (menu) {
|
|
6912
|
+
const top = this.#topFor(menu);
|
|
6913
|
+
this.#closeMenu(top);
|
|
6914
|
+
this.#focusTop(top);
|
|
6915
|
+
return;
|
|
6916
|
+
}
|
|
6917
|
+
this.#closeAllMenus();
|
|
6918
|
+
}
|
|
6577
6919
|
/** Opens the menu of the top item `delta` steps from the one owning `menu`. */
|
|
6578
6920
|
#moveToAdjacentMenu(menu, delta) {
|
|
6579
6921
|
const top = this.#topFor(menu);
|
|
@@ -6755,9 +7097,25 @@ var MultiSelectController = class extends Controller {
|
|
|
6755
7097
|
static events = ["change", "filter"];
|
|
6756
7098
|
/** The active option (tracked via `aria-activedescendant`), or null. */
|
|
6757
7099
|
#activeOption = null;
|
|
7100
|
+
/** Absorbs the browser's redundant final input after compositionend. */
|
|
7101
|
+
#ignorePostCompositionInput = false;
|
|
7102
|
+
/** Owns IME lifecycle state; confirmed text emits one filter result. */
|
|
7103
|
+
#composition = new CompositionTracker({
|
|
7104
|
+
onStart: () => {
|
|
7105
|
+
this.#ignorePostCompositionInput = false;
|
|
7106
|
+
},
|
|
7107
|
+
onEnd: () => {
|
|
7108
|
+
this.#ignorePostCompositionInput = true;
|
|
7109
|
+
queueMicrotask(() => {
|
|
7110
|
+
this.#ignorePostCompositionInput = false;
|
|
7111
|
+
});
|
|
7112
|
+
this.filter();
|
|
7113
|
+
}
|
|
7114
|
+
});
|
|
6758
7115
|
#roving = new RovingTabindex(() => this.#removeButtons);
|
|
6759
7116
|
/** Starts closed, syncs chips for any pre-selected options, and listens out. */
|
|
6760
7117
|
connect() {
|
|
7118
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
6761
7119
|
this.close();
|
|
6762
7120
|
if (this.hasTagsTarget) {
|
|
6763
7121
|
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
@@ -6771,14 +7129,30 @@ var MultiSelectController = class extends Controller {
|
|
|
6771
7129
|
}
|
|
6772
7130
|
/** Tears down document and chip listeners on disconnect (Turbo included). */
|
|
6773
7131
|
disconnect() {
|
|
7132
|
+
this.#composition.disconnect();
|
|
7133
|
+
this.#ignorePostCompositionInput = false;
|
|
6774
7134
|
if (this.hasTagsTarget) {
|
|
6775
7135
|
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
6776
7136
|
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
6777
7137
|
}
|
|
6778
7138
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
6779
7139
|
}
|
|
6780
|
-
/**
|
|
6781
|
-
|
|
7140
|
+
/** Tracks an input added initially or after connect. */
|
|
7141
|
+
inputTargetConnected(input) {
|
|
7142
|
+
this.#composition.observe(input);
|
|
7143
|
+
}
|
|
7144
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
7145
|
+
inputTargetDisconnected(input) {
|
|
7146
|
+
this.#composition.unobserve(input);
|
|
7147
|
+
this.#ignorePostCompositionInput = false;
|
|
7148
|
+
}
|
|
7149
|
+
/** Filters confirmed input text, opens, and re-seeds the active option. */
|
|
7150
|
+
filter(event) {
|
|
7151
|
+
if (event && this.#ignorePostCompositionInput) {
|
|
7152
|
+
this.#ignorePostCompositionInput = false;
|
|
7153
|
+
return;
|
|
7154
|
+
}
|
|
7155
|
+
if (this.#composition.isComposing(event)) return;
|
|
6782
7156
|
const query = this.inputTarget.value.trim().toLowerCase();
|
|
6783
7157
|
for (const option of this.optionTargets) {
|
|
6784
7158
|
const label = (option.textContent ?? "").trim().toLowerCase();
|
|
@@ -6806,7 +7180,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6806
7180
|
}
|
|
6807
7181
|
/** Routes input keyboard interaction per the multi-select combobox model. */
|
|
6808
7182
|
onKeydown(event) {
|
|
6809
|
-
if (
|
|
7183
|
+
if (this.#composition.isComposing(event)) return;
|
|
6810
7184
|
switch (event.key) {
|
|
6811
7185
|
case "ArrowDown":
|
|
6812
7186
|
event.preventDefault();
|
|
@@ -6839,6 +7213,7 @@ var MultiSelectController = class extends Controller {
|
|
|
6839
7213
|
}
|
|
6840
7214
|
break;
|
|
6841
7215
|
case "Escape":
|
|
7216
|
+
if (event.defaultPrevented || this.#isClosed) break;
|
|
6842
7217
|
event.preventDefault();
|
|
6843
7218
|
this.close();
|
|
6844
7219
|
break;
|
|
@@ -7071,18 +7446,19 @@ var NavigationMenuController = class extends Controller {
|
|
|
7071
7446
|
* when targets churn between connect and disconnect.
|
|
7072
7447
|
*/
|
|
7073
7448
|
#hoverWired = /* @__PURE__ */ new Set();
|
|
7449
|
+
/** Escape-stack membership while any panel is open; the shared resolver dismisses via it. */
|
|
7450
|
+
#escapeLayer = new EscapeLayer();
|
|
7074
7451
|
/** Establishes the closed baseline and the dismissal listeners. */
|
|
7075
7452
|
connect() {
|
|
7076
7453
|
this.#closeAll();
|
|
7077
7454
|
document.addEventListener("click", this.#onOutsideClick);
|
|
7078
|
-
document.addEventListener("keydown", this.#onKeydown);
|
|
7079
7455
|
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
7080
7456
|
if (this.openOnHoverValue) this.#addHoverListeners();
|
|
7081
7457
|
}
|
|
7082
|
-
/** Removes every listener
|
|
7458
|
+
/** Removes every listener, pending hover timer, and stack membership registered while connected. */
|
|
7083
7459
|
disconnect() {
|
|
7460
|
+
this.#escapeLayer.deactivate();
|
|
7084
7461
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
7085
|
-
document.removeEventListener("keydown", this.#onKeydown);
|
|
7086
7462
|
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
7087
7463
|
this.#removeHoverListeners();
|
|
7088
7464
|
this.#hoverTimers.clearAll();
|
|
@@ -7141,17 +7517,34 @@ var NavigationMenuController = class extends Controller {
|
|
|
7141
7517
|
if (!panel) return;
|
|
7142
7518
|
panel.hidden = false;
|
|
7143
7519
|
trigger.setAttribute("aria-expanded", "true");
|
|
7520
|
+
this.#syncEscapeLayer();
|
|
7144
7521
|
}
|
|
7145
7522
|
/** Closes `trigger`'s panel and reflects the collapsed state. */
|
|
7146
7523
|
#closePanel(trigger) {
|
|
7147
7524
|
const panel = this.#panelFor(trigger);
|
|
7148
7525
|
if (panel) panel.hidden = true;
|
|
7149
7526
|
trigger.setAttribute("aria-expanded", "false");
|
|
7527
|
+
this.#syncEscapeLayer();
|
|
7150
7528
|
}
|
|
7151
7529
|
/** Closes every open panel. */
|
|
7152
7530
|
#closeAll() {
|
|
7153
7531
|
for (const trigger of this.triggerTargets) this.#closePanel(trigger);
|
|
7154
7532
|
}
|
|
7533
|
+
/**
|
|
7534
|
+
* Aligns Escape-stack membership with the open state: joins (or re-asserts to
|
|
7535
|
+
* the top) while a panel is open, leaves once none is. Re-asserting when the
|
|
7536
|
+
* user switches panels is deliberate — the nav is again the newest layer.
|
|
7537
|
+
*/
|
|
7538
|
+
#syncEscapeLayer() {
|
|
7539
|
+
if (this.#isAnyOpen) {
|
|
7540
|
+
this.#escapeLayer.activate(document, {
|
|
7541
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
7542
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
7543
|
+
});
|
|
7544
|
+
} else {
|
|
7545
|
+
this.#escapeLayer.deactivate();
|
|
7546
|
+
}
|
|
7547
|
+
}
|
|
7155
7548
|
/** Closes any open panel and returns focus to its trigger (Escape path). */
|
|
7156
7549
|
#closeAndRestore() {
|
|
7157
7550
|
const open = this.#openTrigger;
|
|
@@ -7163,17 +7556,17 @@ var NavigationMenuController = class extends Controller {
|
|
|
7163
7556
|
#onOutsideClick = (event) => {
|
|
7164
7557
|
if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAll();
|
|
7165
7558
|
};
|
|
7166
|
-
/**
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7559
|
+
/**
|
|
7560
|
+
* Closes (without restoring focus) when focus leaves the nav for a known
|
|
7561
|
+
* external destination. A null/non-Node destination is indeterminate:
|
|
7562
|
+
* browsers use it for clicks on non-focusable content and for window
|
|
7563
|
+
* deactivation, so those never close the nav here (matching the popover
|
|
7564
|
+
* convention) — the outside-click handler decides pointer dismissal, and the
|
|
7565
|
+
* Escape stack's body-focus claim keeps keyboard dismissal working.
|
|
7566
|
+
*/
|
|
7174
7567
|
#onFocusOut = (event) => {
|
|
7175
7568
|
const next = event.relatedTarget;
|
|
7176
|
-
if (next
|
|
7569
|
+
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
7177
7570
|
this.#closeAll();
|
|
7178
7571
|
};
|
|
7179
7572
|
/** Opens a trigger's panel after the hover delay (hover mode). */
|
|
@@ -7747,21 +8140,24 @@ var OtpController = class extends Controller {
|
|
|
7747
8140
|
};
|
|
7748
8141
|
static actions = ["onInput", "onKeydown", "onPaste"];
|
|
7749
8142
|
static events = ["change", "complete", "invalid"];
|
|
7750
|
-
|
|
8143
|
+
/** Owns IME lifecycle state across every digit field. */
|
|
8144
|
+
#composition = new CompositionTracker({
|
|
8145
|
+
onEnd: (event) => {
|
|
8146
|
+
const input = event.currentTarget;
|
|
8147
|
+
if (input) this.#handleInputValidation(input);
|
|
8148
|
+
}
|
|
8149
|
+
});
|
|
7751
8150
|
connect() {
|
|
7752
8151
|
for (const field of this.fieldTargets) {
|
|
7753
8152
|
field.addEventListener("focus", this.#onFieldFocus);
|
|
7754
|
-
|
|
7755
|
-
field.addEventListener("compositionend", this.#onCompositionEnd);
|
|
8153
|
+
this.#composition.observe(field);
|
|
7756
8154
|
}
|
|
7757
8155
|
}
|
|
7758
8156
|
disconnect() {
|
|
7759
8157
|
for (const field of this.fieldTargets) {
|
|
7760
8158
|
field.removeEventListener("focus", this.#onFieldFocus);
|
|
7761
|
-
field.removeEventListener("compositionstart", this.#onCompositionStart);
|
|
7762
|
-
field.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
7763
8159
|
}
|
|
7764
|
-
this.#
|
|
8160
|
+
this.#composition.disconnect();
|
|
7765
8161
|
}
|
|
7766
8162
|
/**
|
|
7767
8163
|
* Stimulus lifecycle callback when a new field target enters the DOM.
|
|
@@ -7769,21 +8165,18 @@ var OtpController = class extends Controller {
|
|
|
7769
8165
|
*/
|
|
7770
8166
|
fieldTargetConnected(element) {
|
|
7771
8167
|
element.addEventListener("focus", this.#onFieldFocus);
|
|
7772
|
-
|
|
7773
|
-
element.addEventListener("compositionend", this.#onCompositionEnd);
|
|
8168
|
+
this.#composition.observe(element);
|
|
7774
8169
|
}
|
|
7775
8170
|
/** Removes focus listeners when fields are dropped. */
|
|
7776
8171
|
fieldTargetDisconnected(element) {
|
|
7777
8172
|
element.removeEventListener("focus", this.#onFieldFocus);
|
|
7778
|
-
|
|
7779
|
-
element.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
7780
|
-
this.#isComposing.delete(element);
|
|
8173
|
+
this.#composition.unobserve(element);
|
|
7781
8174
|
}
|
|
7782
8175
|
/** Handles keystroke inputs and advances focus to the next field. */
|
|
7783
8176
|
onInput(event) {
|
|
7784
8177
|
const input = event.currentTarget;
|
|
7785
8178
|
if (!input) return;
|
|
7786
|
-
if (this.#isComposing
|
|
8179
|
+
if (this.#composition.isComposing(event)) return;
|
|
7787
8180
|
this.#handleInputValidation(input);
|
|
7788
8181
|
}
|
|
7789
8182
|
/** Handles Backspace retreating, arrows, and home/end navigation. */
|
|
@@ -7792,7 +8185,7 @@ var OtpController = class extends Controller {
|
|
|
7792
8185
|
if (!input) return;
|
|
7793
8186
|
const index = this.fieldTargets.indexOf(input);
|
|
7794
8187
|
if (index === -1) return;
|
|
7795
|
-
if (this.#isComposing
|
|
8188
|
+
if (this.#composition.isComposing(event)) return;
|
|
7796
8189
|
switch (event.key) {
|
|
7797
8190
|
case "Backspace":
|
|
7798
8191
|
if (!input.value) {
|
|
@@ -7874,15 +8267,6 @@ var OtpController = class extends Controller {
|
|
|
7874
8267
|
input.select();
|
|
7875
8268
|
}
|
|
7876
8269
|
};
|
|
7877
|
-
#onCompositionStart = (event) => {
|
|
7878
|
-
const input = event.currentTarget;
|
|
7879
|
-
this.#isComposing.set(input, true);
|
|
7880
|
-
};
|
|
7881
|
-
#onCompositionEnd = (event) => {
|
|
7882
|
-
const input = event.currentTarget;
|
|
7883
|
-
this.#isComposing.set(input, false);
|
|
7884
|
-
this.#handleInputValidation(input);
|
|
7885
|
-
};
|
|
7886
8270
|
#handleInputValidation(input) {
|
|
7887
8271
|
const index = this.fieldTargets.indexOf(input);
|
|
7888
8272
|
if (index === -1) return;
|
|
@@ -8826,6 +9210,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
8826
9210
|
const handle = this.#handleFor(event.target);
|
|
8827
9211
|
if (!handle) return;
|
|
8828
9212
|
if (event.key === "Escape") {
|
|
9213
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
8829
9214
|
if (this.#pointer?.started) {
|
|
8830
9215
|
const { pointerType } = this.#pointer;
|
|
8831
9216
|
this.#teardownSessions();
|
|
@@ -8938,9 +9323,8 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
8938
9323
|
}
|
|
8939
9324
|
/**
|
|
8940
9325
|
* Silently tears down whatever session is live (disconnect / disabled /
|
|
8941
|
-
* Escape). Composed from the two single-session teardowns so
|
|
8942
|
-
*
|
|
8943
|
-
* the capture-release fix).
|
|
9326
|
+
* Escape). Composed from the two single-session teardowns so pointer and
|
|
9327
|
+
* keyboard cleanup cannot diverge as either path evolves.
|
|
8944
9328
|
*/
|
|
8945
9329
|
#teardownSessions() {
|
|
8946
9330
|
this.#endPointerSession();
|
|
@@ -9047,24 +9431,26 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9047
9431
|
static actions = ["close", "open", "toggle"];
|
|
9048
9432
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
9049
9433
|
#stopScrollDismiss = null;
|
|
9434
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
9435
|
+
#escapeLayer = new EscapeLayer();
|
|
9050
9436
|
/** Selector for natively focusable elements used to find the first one. */
|
|
9051
9437
|
static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
9052
|
-
/** Starts closed and registers the
|
|
9438
|
+
/** Starts closed and registers the standing dismissal listeners. */
|
|
9053
9439
|
connect() {
|
|
9054
9440
|
this.close();
|
|
9055
9441
|
document.addEventListener("click", this.#onOutsideClick);
|
|
9056
|
-
|
|
9442
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
9057
9443
|
}
|
|
9058
9444
|
/**
|
|
9059
|
-
* Removes
|
|
9060
|
-
*
|
|
9061
|
-
*
|
|
9062
|
-
*
|
|
9445
|
+
* Removes every standing listener registered in {@link connect} plus any active
|
|
9446
|
+
* dismiss-on-scroll observers. `removeEventListener` is a no-op when it was
|
|
9447
|
+
* never added, so this is safe in the closed state too — no listener outlives
|
|
9448
|
+
* the element after a Turbo navigation.
|
|
9063
9449
|
*/
|
|
9064
9450
|
disconnect() {
|
|
9451
|
+
this.#escapeLayer.deactivate();
|
|
9065
9452
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
9066
|
-
|
|
9067
|
-
if (this.hasPanelTarget) this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
9453
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
9068
9454
|
this.#stopScrollDismiss?.();
|
|
9069
9455
|
this.#stopScrollDismiss = null;
|
|
9070
9456
|
}
|
|
@@ -9081,7 +9467,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9081
9467
|
if (!this.hasPanelTarget || this.#isOpen) return;
|
|
9082
9468
|
this.panelTarget.hidden = false;
|
|
9083
9469
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
9084
|
-
this.
|
|
9470
|
+
this.#escapeLayer.activate(document, {
|
|
9471
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
9472
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
9473
|
+
});
|
|
9085
9474
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
9086
9475
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
|
|
9087
9476
|
}
|
|
@@ -9089,11 +9478,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9089
9478
|
}
|
|
9090
9479
|
/** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
|
|
9091
9480
|
close() {
|
|
9092
|
-
|
|
9093
|
-
this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
9481
|
+
this.#escapeLayer.deactivate();
|
|
9094
9482
|
this.#stopScrollDismiss?.();
|
|
9095
9483
|
this.#stopScrollDismiss = null;
|
|
9096
|
-
this.panelTarget.hidden = true;
|
|
9484
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = true;
|
|
9097
9485
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
9098
9486
|
}
|
|
9099
9487
|
/** Moves focus to the first focusable element in the panel, or the panel itself. */
|
|
@@ -9106,31 +9494,28 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
9106
9494
|
if (!this.panelTarget.hasAttribute("tabindex")) this.panelTarget.tabIndex = -1;
|
|
9107
9495
|
this.panelTarget.focus();
|
|
9108
9496
|
}
|
|
9109
|
-
/** Closes and restores focus to the trigger
|
|
9497
|
+
/** Closes and restores focus to the trigger for explicit keyboard dismissal. */
|
|
9110
9498
|
#closeAndRestore() {
|
|
9111
9499
|
this.close();
|
|
9112
9500
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
9113
9501
|
}
|
|
9114
|
-
/** Closes
|
|
9502
|
+
/** Closes without moving focus when a click lands outside the controller element. */
|
|
9115
9503
|
#onOutsideClick = (event) => {
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
/** Closes (restoring focus) on `Escape` while open. */
|
|
9119
|
-
#onKeydown = (event) => {
|
|
9120
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
9121
|
-
event.preventDefault();
|
|
9122
|
-
this.#closeAndRestore();
|
|
9123
|
-
}
|
|
9504
|
+
const target = event.target;
|
|
9505
|
+
if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();
|
|
9124
9506
|
};
|
|
9125
9507
|
/**
|
|
9126
|
-
* Closes when focus leaves the
|
|
9127
|
-
* (e.g.
|
|
9128
|
-
* destination is kept, which is the modeless
|
|
9129
|
-
*
|
|
9508
|
+
* Closes when focus leaves the controller for a known external destination
|
|
9509
|
+
* (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is
|
|
9510
|
+
* not restored — the natural destination is kept, which is the modeless
|
|
9511
|
+
* contract. A null/non-Node destination is indeterminate: browsers use it for
|
|
9512
|
+
* clicks on non-focusable content and window deactivation, so the later outside
|
|
9513
|
+
* click handler decides pointer dismissal instead.
|
|
9130
9514
|
*/
|
|
9131
9515
|
#onFocusOut = (event) => {
|
|
9516
|
+
if (!this.#isOpen) return;
|
|
9132
9517
|
const next = event.relatedTarget;
|
|
9133
|
-
if (next
|
|
9518
|
+
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
9134
9519
|
this.close();
|
|
9135
9520
|
};
|
|
9136
9521
|
/** Whether the panel is currently visible. */
|
|
@@ -10170,7 +10555,7 @@ var RovingController = class extends Controller {
|
|
|
10170
10555
|
}
|
|
10171
10556
|
}
|
|
10172
10557
|
};
|
|
10173
|
-
var
|
|
10558
|
+
var FOCUSABLE_SELECTOR = [
|
|
10174
10559
|
"a[href]",
|
|
10175
10560
|
"button:not([disabled])",
|
|
10176
10561
|
"input:not([disabled])",
|
|
@@ -10282,7 +10667,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
10282
10667
|
}
|
|
10283
10668
|
}
|
|
10284
10669
|
#hasFocusableContent(vp) {
|
|
10285
|
-
return vp.querySelector(
|
|
10670
|
+
return vp.querySelector(FOCUSABLE_SELECTOR) !== null;
|
|
10286
10671
|
}
|
|
10287
10672
|
#hasAccessibleName(vp) {
|
|
10288
10673
|
return vp.hasAttribute("aria-label") || vp.hasAttribute("aria-labelledby");
|
|
@@ -11273,7 +11658,7 @@ var SortableController = class extends Controller {
|
|
|
11273
11658
|
* Mirrors a step into the `status` live region. Copy is localizable through
|
|
11274
11659
|
* `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on
|
|
11275
11660
|
* the status element (`%{name}` / `%{position}` / `%{total}` placeholders);
|
|
11276
|
-
* terse English is the fallback (
|
|
11661
|
+
* terse English is the fallback (the library's shared status-channel design).
|
|
11277
11662
|
*/
|
|
11278
11663
|
#announce(key, item) {
|
|
11279
11664
|
if (!this.hasStatusTarget) return;
|
|
@@ -11943,20 +12328,32 @@ var TagsInputController = class extends Controller {
|
|
|
11943
12328
|
static actions = ["onKeydown"];
|
|
11944
12329
|
static events = ["change", "reject"];
|
|
11945
12330
|
#roving = new RovingTabindex(() => this.#removeButtons);
|
|
12331
|
+
/** Owns IME lifecycle state for commit-key guarding. */
|
|
12332
|
+
#composition = new CompositionTracker();
|
|
11946
12333
|
/** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
|
|
11947
12334
|
connect() {
|
|
12335
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
11948
12336
|
this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
|
|
11949
12337
|
this.tagsTarget.addEventListener("click", this.#onTagClick);
|
|
11950
12338
|
this.#syncState();
|
|
11951
12339
|
}
|
|
11952
12340
|
/** Releases the delegated listeners so no handler outlives the element. */
|
|
11953
12341
|
disconnect() {
|
|
12342
|
+
this.#composition.disconnect();
|
|
11954
12343
|
this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
|
|
11955
12344
|
this.tagsTarget.removeEventListener("click", this.#onTagClick);
|
|
11956
12345
|
}
|
|
12346
|
+
/** Tracks an input added initially or after connect. */
|
|
12347
|
+
inputTargetConnected(input) {
|
|
12348
|
+
this.#composition.observe(input);
|
|
12349
|
+
}
|
|
12350
|
+
/** Removes composition listeners when the active input is replaced or removed. */
|
|
12351
|
+
inputTargetDisconnected(input) {
|
|
12352
|
+
this.#composition.unobserve(input);
|
|
12353
|
+
}
|
|
11957
12354
|
/** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
|
|
11958
12355
|
onKeydown(event) {
|
|
11959
|
-
if (
|
|
12356
|
+
if (this.#composition.isComposing(event)) return;
|
|
11960
12357
|
if (event.key === "Enter" || event.key === this.delimiterValue) {
|
|
11961
12358
|
event.preventDefault();
|
|
11962
12359
|
this.#commitInput();
|
|
@@ -12523,6 +12920,7 @@ var TimePickerController = class extends Controller {
|
|
|
12523
12920
|
function pad(value) {
|
|
12524
12921
|
return String(value).padStart(2, "0");
|
|
12525
12922
|
}
|
|
12923
|
+
var DELEGATED_EVENTS = ["click", "focusin", "focusout", "keydown", "mouseover", "mouseout"];
|
|
12526
12924
|
var ToastController = class extends Controller {
|
|
12527
12925
|
static targets = ["list", "template", "item"];
|
|
12528
12926
|
static values = {
|
|
@@ -12543,34 +12941,57 @@ var ToastController = class extends Controller {
|
|
|
12543
12941
|
* Tracked so {@link disconnect} can cancel any that have not fired, preventing a
|
|
12544
12942
|
* detached element from being mutated after it leaves the DOM (Turbo).
|
|
12545
12943
|
*/
|
|
12546
|
-
#rafHandles = /* @__PURE__ */ new
|
|
12944
|
+
#rafHandles = /* @__PURE__ */ new Map();
|
|
12547
12945
|
/** Track active timeouts mapped by each toast element for safe cancellation. */
|
|
12548
12946
|
#activeTimeouts = /* @__PURE__ */ new Map();
|
|
12549
12947
|
/** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
|
|
12550
12948
|
#pauseReasons = /* @__PURE__ */ new Map();
|
|
12949
|
+
/** The stable list that owns delegated listeners for dynamically added items. */
|
|
12950
|
+
#delegatedList = null;
|
|
12551
12951
|
connect() {
|
|
12952
|
+
this.#connectDelegatedEvents();
|
|
12552
12953
|
this.enforceMaxLimit();
|
|
12553
12954
|
for (const item of this.itemTargets) {
|
|
12554
|
-
if (!this.#activeTimeouts.has(item)) {
|
|
12955
|
+
if (!this.#activeTimeouts.has(item) && item.dataset.state !== "leaving") {
|
|
12555
12956
|
this.#startTimer(item);
|
|
12556
12957
|
}
|
|
12557
12958
|
}
|
|
12558
12959
|
}
|
|
12559
12960
|
disconnect() {
|
|
12961
|
+
this.#disconnectDelegatedEvents();
|
|
12560
12962
|
this.#timers.clearAll();
|
|
12561
|
-
for (const handle of this.#rafHandles) {
|
|
12963
|
+
for (const handle of this.#rafHandles.values()) {
|
|
12562
12964
|
window.cancelAnimationFrame(handle);
|
|
12563
12965
|
}
|
|
12564
12966
|
this.#rafHandles.clear();
|
|
12565
12967
|
this.#activeTimeouts.clear();
|
|
12566
12968
|
this.#pauseReasons.clear();
|
|
12567
12969
|
}
|
|
12970
|
+
/** Rebinds delegated interaction when Turbo replaces the list target in place. */
|
|
12971
|
+
listTargetConnected(element) {
|
|
12972
|
+
if (this.#delegatedList !== element) this.#connectDelegatedEvents(element);
|
|
12973
|
+
}
|
|
12974
|
+
/** Releases delegation only when the removed target is its current owner. */
|
|
12975
|
+
listTargetDisconnected(element) {
|
|
12976
|
+
if (this.#delegatedList === element) this.#disconnectDelegatedEvents();
|
|
12977
|
+
}
|
|
12568
12978
|
durationValueChanged() {
|
|
12569
|
-
|
|
12570
|
-
|
|
12571
|
-
|
|
12572
|
-
|
|
12573
|
-
|
|
12979
|
+
for (const item of this.itemTargets) {
|
|
12980
|
+
if (item.dataset.state === "leaving") continue;
|
|
12981
|
+
const pauseReasons = this.#pauseReasons.get(item);
|
|
12982
|
+
this.#clearTimer(item);
|
|
12983
|
+
if (this.durationValue <= 0) {
|
|
12984
|
+
item.removeAttribute("data-paused");
|
|
12985
|
+
} else if (pauseReasons && pauseReasons.size > 0) {
|
|
12986
|
+
this.#pauseReasons.set(item, pauseReasons);
|
|
12987
|
+
this.#activeTimeouts.set(item, {
|
|
12988
|
+
id: 0,
|
|
12989
|
+
startedAt: 0,
|
|
12990
|
+
remaining: this.durationValue
|
|
12991
|
+
});
|
|
12992
|
+
item.setAttribute("data-paused", "true");
|
|
12993
|
+
} else {
|
|
12994
|
+
this.#startTimer(item);
|
|
12574
12995
|
}
|
|
12575
12996
|
}
|
|
12576
12997
|
}
|
|
@@ -12584,17 +13005,21 @@ var ToastController = class extends Controller {
|
|
|
12584
13005
|
*/
|
|
12585
13006
|
itemTargetConnected(element) {
|
|
12586
13007
|
this.enforceMaxLimit();
|
|
13008
|
+
if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
|
|
12587
13009
|
this.#startTimer(element);
|
|
12588
13010
|
element.setAttribute("data-state", "entering");
|
|
13011
|
+
this.#cancelAnimation(element);
|
|
12589
13012
|
const handle = window.requestAnimationFrame(() => {
|
|
12590
|
-
this.#rafHandles.delete(
|
|
13013
|
+
this.#rafHandles.delete(element);
|
|
13014
|
+
if (element.parentNode !== this.listTarget || element.dataset.state === "leaving") return;
|
|
12591
13015
|
element.setAttribute("data-state", "visible");
|
|
12592
13016
|
});
|
|
12593
|
-
this.#rafHandles.
|
|
13017
|
+
this.#rafHandles.set(element, handle);
|
|
12594
13018
|
}
|
|
12595
13019
|
/** Clears any active timer when a toast is removed from the DOM. */
|
|
12596
13020
|
itemTargetDisconnected(element) {
|
|
12597
13021
|
this.#clearTimer(element);
|
|
13022
|
+
this.#cancelAnimation(element);
|
|
12598
13023
|
}
|
|
12599
13024
|
/**
|
|
12600
13025
|
* Shows a new toast. Accepts its content from either a Stimulus action param
|
|
@@ -12617,10 +13042,9 @@ var ToastController = class extends Controller {
|
|
|
12617
13042
|
const item = clone.querySelector("[data-stimeo--toast-target='item']");
|
|
12618
13043
|
if (!item) return;
|
|
12619
13044
|
const bodySlot = item.querySelector("[data-toast-slot='body']");
|
|
12620
|
-
if (bodySlot)
|
|
12621
|
-
|
|
12622
|
-
|
|
12623
|
-
item.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
|
|
13045
|
+
if (!bodySlot) return;
|
|
13046
|
+
bodySlot.textContent = body;
|
|
13047
|
+
bodySlot.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
|
|
12624
13048
|
this.listTarget.appendChild(item);
|
|
12625
13049
|
this.dispatch("show", { detail: { item } });
|
|
12626
13050
|
}
|
|
@@ -12642,18 +13066,15 @@ var ToastController = class extends Controller {
|
|
|
12642
13066
|
}
|
|
12643
13067
|
/** Dismisses the toast that contained the trigger. */
|
|
12644
13068
|
dismiss(event) {
|
|
12645
|
-
const
|
|
12646
|
-
if (!target) return;
|
|
12647
|
-
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
13069
|
+
const item = this.#itemFromEvent(event);
|
|
12648
13070
|
if (!item) return;
|
|
12649
13071
|
this.#removeWithTransition(item, "user");
|
|
12650
13072
|
}
|
|
12651
13073
|
/** Dismisses the focused toast when Escape is pressed. */
|
|
12652
13074
|
onKeydown(event) {
|
|
12653
13075
|
if (event.key === "Escape") {
|
|
12654
|
-
|
|
12655
|
-
|
|
12656
|
-
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
13076
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
13077
|
+
const item = this.#itemFromEvent(event);
|
|
12657
13078
|
if (!item) return;
|
|
12658
13079
|
event.preventDefault();
|
|
12659
13080
|
this.#removeWithTransition(item, "user");
|
|
@@ -12679,6 +13100,10 @@ var ToastController = class extends Controller {
|
|
|
12679
13100
|
this.#timers.clear(timeout.id);
|
|
12680
13101
|
const elapsed = Date.now() - timeout.startedAt;
|
|
12681
13102
|
const remaining = Math.max(0, timeout.remaining - elapsed);
|
|
13103
|
+
if (remaining <= 0) {
|
|
13104
|
+
this.#removeWithTransition(item, "timeout");
|
|
13105
|
+
return;
|
|
13106
|
+
}
|
|
12682
13107
|
this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
|
|
12683
13108
|
item.setAttribute("data-paused", "true");
|
|
12684
13109
|
}
|
|
@@ -12690,14 +13115,17 @@ var ToastController = class extends Controller {
|
|
|
12690
13115
|
reasons.delete(this.#pauseReason(event));
|
|
12691
13116
|
if (reasons.size > 0) return;
|
|
12692
13117
|
const timeout = this.#activeTimeouts.get(item);
|
|
12693
|
-
if (!timeout
|
|
13118
|
+
if (!timeout) return;
|
|
13119
|
+
if (timeout.id !== 0 || timeout.remaining <= 0) return;
|
|
12694
13120
|
item.removeAttribute("data-paused");
|
|
12695
13121
|
this.#startTimer(item, timeout.remaining);
|
|
12696
13122
|
}
|
|
12697
13123
|
/** Resolves the toast item element a pause/resume event targets. */
|
|
12698
13124
|
#itemFromEvent(event) {
|
|
12699
|
-
const target = event.
|
|
12700
|
-
|
|
13125
|
+
const target = event.target instanceof Element ? event.target : event.currentTarget;
|
|
13126
|
+
if (!(target instanceof Element) || !this.hasListTarget) return null;
|
|
13127
|
+
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
13128
|
+
return item && this.listTarget.contains(item) ? item : null;
|
|
12701
13129
|
}
|
|
12702
13130
|
/** Classifies a pause/resume event as a hover or focus reason. */
|
|
12703
13131
|
#pauseReason(event) {
|
|
@@ -12713,7 +13141,9 @@ var ToastController = class extends Controller {
|
|
|
12713
13141
|
return reasons;
|
|
12714
13142
|
}
|
|
12715
13143
|
#startTimer(element, duration = this.durationValue) {
|
|
12716
|
-
if (duration <= 0)
|
|
13144
|
+
if (duration <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
|
|
13145
|
+
return;
|
|
13146
|
+
}
|
|
12717
13147
|
this.#clearTimer(element);
|
|
12718
13148
|
const id = this.#timers.set(() => {
|
|
12719
13149
|
this.#removeWithTransition(element, "timeout");
|
|
@@ -12729,12 +13159,13 @@ var ToastController = class extends Controller {
|
|
|
12729
13159
|
this.#pauseReasons.delete(element);
|
|
12730
13160
|
}
|
|
12731
13161
|
#removeWithTransition(element, reason) {
|
|
13162
|
+
if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
|
|
12732
13163
|
this.#clearTimer(element);
|
|
13164
|
+
this.#cancelAnimation(element);
|
|
12733
13165
|
element.setAttribute("data-state", "leaving");
|
|
12734
13166
|
const finalize = () => {
|
|
12735
|
-
if (element.parentNode
|
|
12736
|
-
|
|
12737
|
-
}
|
|
13167
|
+
if (element.parentNode !== this.listTarget) return;
|
|
13168
|
+
this.listTarget.removeChild(element);
|
|
12738
13169
|
this.dispatch("dismiss", { detail: { item: element, reason } });
|
|
12739
13170
|
};
|
|
12740
13171
|
const transitions = window.getComputedStyle(element).transitionDuration;
|
|
@@ -12756,14 +13187,59 @@ var ToastController = class extends Controller {
|
|
|
12756
13187
|
*/
|
|
12757
13188
|
enforceMaxLimit() {
|
|
12758
13189
|
const currentItems = this.itemTargets;
|
|
12759
|
-
|
|
12760
|
-
|
|
13190
|
+
const max = Math.max(0, this.maxValue);
|
|
13191
|
+
if (currentItems.length > max) {
|
|
13192
|
+
const excessCount = currentItems.length - max;
|
|
12761
13193
|
for (let i = 0; i < excessCount; i++) {
|
|
12762
13194
|
const oldest = currentItems[i];
|
|
12763
13195
|
if (oldest) this.#removeWithTransition(oldest, "timeout");
|
|
12764
13196
|
}
|
|
12765
13197
|
}
|
|
12766
13198
|
}
|
|
13199
|
+
/** Wires stable-container delegation so newly appended items work immediately. */
|
|
13200
|
+
#connectDelegatedEvents(list = this.hasListTarget ? this.listTarget : null) {
|
|
13201
|
+
this.#disconnectDelegatedEvents();
|
|
13202
|
+
if (!list) return;
|
|
13203
|
+
this.#delegatedList = list;
|
|
13204
|
+
for (const type of DELEGATED_EVENTS) {
|
|
13205
|
+
this.#delegatedList.addEventListener(type, this.#onListEvent);
|
|
13206
|
+
}
|
|
13207
|
+
}
|
|
13208
|
+
/** Releases every delegated listener from the exact list that owns it. */
|
|
13209
|
+
#disconnectDelegatedEvents() {
|
|
13210
|
+
if (!this.#delegatedList) return;
|
|
13211
|
+
for (const type of DELEGATED_EVENTS) {
|
|
13212
|
+
this.#delegatedList.removeEventListener(type, this.#onListEvent);
|
|
13213
|
+
}
|
|
13214
|
+
this.#delegatedList = null;
|
|
13215
|
+
}
|
|
13216
|
+
/** Routes every delegated item interaction from the stable list. */
|
|
13217
|
+
#onListEvent = (event) => {
|
|
13218
|
+
if (event.type === "click") {
|
|
13219
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
13220
|
+
const trigger = target?.closest("[data-toast-dismiss]");
|
|
13221
|
+
if (trigger && this.#delegatedList?.contains(trigger)) this.dismiss(event);
|
|
13222
|
+
} else if (event.type === "keydown") {
|
|
13223
|
+
this.onKeydown(event);
|
|
13224
|
+
} else if (this.#crossesItemBoundary(event)) {
|
|
13225
|
+
if (event.type === "focusin" || event.type === "mouseover") this.pause(event);
|
|
13226
|
+
else this.resume(event);
|
|
13227
|
+
}
|
|
13228
|
+
};
|
|
13229
|
+
/** Whether a bubbling focus/pointer event enters or leaves a toast boundary. */
|
|
13230
|
+
#crossesItemBoundary(event) {
|
|
13231
|
+
const item = this.#itemFromEvent(event);
|
|
13232
|
+
if (!item) return false;
|
|
13233
|
+
const related = "relatedTarget" in event ? event.relatedTarget : null;
|
|
13234
|
+
return !(related instanceof Node && item.contains(related));
|
|
13235
|
+
}
|
|
13236
|
+
/** Cancels the pending entering-to-visible frame owned by one item. */
|
|
13237
|
+
#cancelAnimation(element) {
|
|
13238
|
+
const handle = this.#rafHandles.get(element);
|
|
13239
|
+
if (handle === void 0) return;
|
|
13240
|
+
window.cancelAnimationFrame(handle);
|
|
13241
|
+
this.#rafHandles.delete(element);
|
|
13242
|
+
}
|
|
12767
13243
|
};
|
|
12768
13244
|
function cssTimeToMs(value) {
|
|
12769
13245
|
const first = value.split(",")[0]?.trim() ?? "";
|
|
@@ -12926,27 +13402,39 @@ var TooltipController = class extends Controller {
|
|
|
12926
13402
|
hideDelay: { type: Number, default: 0 },
|
|
12927
13403
|
closeOnScroll: { type: Boolean, default: false }
|
|
12928
13404
|
};
|
|
12929
|
-
static actions = ["hide", "
|
|
12930
|
-
/**
|
|
13405
|
+
static actions = ["hide", "show"];
|
|
13406
|
+
/** Registry whose pending timers are cancelled individually with their guard ids. */
|
|
12931
13407
|
#timers = new SafeTimeout();
|
|
12932
|
-
/**
|
|
13408
|
+
/** Escape-stack membership while shown; the shared resolver dismisses via it. */
|
|
13409
|
+
#escapeLayer = new EscapeLayer();
|
|
13410
|
+
/** The id of the currently pending show timer, if any. */
|
|
12933
13411
|
#pendingShow = null;
|
|
13412
|
+
/** The id of the currently pending hide timer, if any. */
|
|
12934
13413
|
#pendingHide = null;
|
|
13414
|
+
/** Whether focus or the pointer currently requires the tooltip to persist. */
|
|
13415
|
+
#focusActive = false;
|
|
13416
|
+
#pointerActive = false;
|
|
12935
13417
|
/** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
|
|
12936
13418
|
#stopScrollDismiss = null;
|
|
12937
|
-
/** Starts hidden. */
|
|
13419
|
+
/** Starts hidden with no stale timer or interaction state from a prior connection. */
|
|
12938
13420
|
connect() {
|
|
13421
|
+
this.#cancelShow();
|
|
13422
|
+
this.#cancelHide();
|
|
13423
|
+
this.#resetInteractionState();
|
|
12939
13424
|
this.#conceal();
|
|
12940
13425
|
}
|
|
12941
|
-
/** Clears timers
|
|
13426
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
12942
13427
|
disconnect() {
|
|
12943
|
-
this.#
|
|
12944
|
-
|
|
13428
|
+
this.#cancelShow();
|
|
13429
|
+
this.#cancelHide();
|
|
13430
|
+
this.#resetInteractionState();
|
|
13431
|
+
this.#escapeLayer.deactivate();
|
|
12945
13432
|
this.#stopScrollDismiss?.();
|
|
12946
13433
|
this.#stopScrollDismiss = null;
|
|
12947
13434
|
}
|
|
12948
|
-
/** Shows
|
|
12949
|
-
show() {
|
|
13435
|
+
/** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */
|
|
13436
|
+
show(event) {
|
|
13437
|
+
this.#activateInteraction(event);
|
|
12950
13438
|
this.#cancelHide();
|
|
12951
13439
|
if (this.#isVisible || this.#pendingShow !== null) return;
|
|
12952
13440
|
if (this.showDelayValue <= 0) {
|
|
@@ -12958,8 +13446,13 @@ var TooltipController = class extends Controller {
|
|
|
12958
13446
|
this.#reveal();
|
|
12959
13447
|
}, this.showDelayValue);
|
|
12960
13448
|
}
|
|
12961
|
-
/** Hides
|
|
12962
|
-
hide() {
|
|
13449
|
+
/** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */
|
|
13450
|
+
hide(event) {
|
|
13451
|
+
const interactionEnded = this.#deactivateInteraction(event);
|
|
13452
|
+
if (interactionEnded && this.#hasActiveInteraction) {
|
|
13453
|
+
this.#cancelHide();
|
|
13454
|
+
return;
|
|
13455
|
+
}
|
|
12963
13456
|
this.#cancelShow();
|
|
12964
13457
|
if (!this.#isVisible || this.#pendingHide !== null) return;
|
|
12965
13458
|
if (this.hideDelayValue <= 0) {
|
|
@@ -12971,52 +13464,57 @@ var TooltipController = class extends Controller {
|
|
|
12971
13464
|
this.#conceal();
|
|
12972
13465
|
}, this.hideDelayValue);
|
|
12973
13466
|
}
|
|
12974
|
-
/**
|
|
12975
|
-
* Dismisses the tooltip on `Escape` from the trigger. The authoritative
|
|
12976
|
-
* dismissal path is the document-level listener (added while shown) so a
|
|
12977
|
-
* hover-triggered tooltip is dismissible regardless of focus; this handler
|
|
12978
|
-
* keeps the documented `keydown->#onKeydown` binding meaningful too.
|
|
12979
|
-
*/
|
|
12980
|
-
onKeydown(event) {
|
|
12981
|
-
if (event.key === "Escape" && this.#isVisible) {
|
|
12982
|
-
event.preventDefault();
|
|
12983
|
-
this.#cancelShow();
|
|
12984
|
-
this.#cancelHide();
|
|
12985
|
-
this.#conceal();
|
|
12986
|
-
}
|
|
12987
|
-
}
|
|
12988
|
-
/** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
|
|
13467
|
+
/** Reveals the content and joins the Escape stack / starts the scroll watcher. */
|
|
12989
13468
|
#reveal() {
|
|
12990
13469
|
if (!this.hasContentTarget) return;
|
|
12991
13470
|
this.contentTarget.hidden = false;
|
|
12992
13471
|
this.contentTarget.setAttribute("data-state", "open");
|
|
12993
|
-
|
|
13472
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
|
|
12994
13473
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
12995
|
-
this.#stopScrollDismiss = observeScrollDismiss(this.element, () =>
|
|
12996
|
-
this.#cancelShow();
|
|
12997
|
-
this.#cancelHide();
|
|
12998
|
-
this.#conceal();
|
|
12999
|
-
});
|
|
13474
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
13000
13475
|
}
|
|
13001
13476
|
}
|
|
13002
|
-
/** Hides the content and stops
|
|
13477
|
+
/** Hides the content and leaves the Escape stack / stops the scroll watcher. */
|
|
13003
13478
|
#conceal() {
|
|
13004
|
-
|
|
13479
|
+
this.#escapeLayer.deactivate();
|
|
13005
13480
|
this.#stopScrollDismiss?.();
|
|
13006
13481
|
this.#stopScrollDismiss = null;
|
|
13007
13482
|
if (!this.hasContentTarget) return;
|
|
13008
13483
|
this.contentTarget.hidden = true;
|
|
13009
13484
|
this.contentTarget.setAttribute("data-state", "closed");
|
|
13010
13485
|
}
|
|
13011
|
-
/**
|
|
13012
|
-
#
|
|
13013
|
-
|
|
13014
|
-
|
|
13015
|
-
|
|
13016
|
-
|
|
13017
|
-
|
|
13486
|
+
/** Cancels pending timers and conceals immediately (shared Escape / scroll path). */
|
|
13487
|
+
#dismiss() {
|
|
13488
|
+
this.#cancelShow();
|
|
13489
|
+
this.#cancelHide();
|
|
13490
|
+
this.#conceal();
|
|
13491
|
+
}
|
|
13492
|
+
/** Records the modality whose enter/focus event requires the tooltip to stay visible. */
|
|
13493
|
+
#activateInteraction(event) {
|
|
13494
|
+
if (event?.type === "mouseenter") this.#pointerActive = true;
|
|
13495
|
+
if (event?.type === "focusin") this.#focusActive = true;
|
|
13496
|
+
}
|
|
13497
|
+
/** Clears a modality on leave/blur and reports whether the event represented such a change. */
|
|
13498
|
+
#deactivateInteraction(event) {
|
|
13499
|
+
if (event?.type === "mouseleave") {
|
|
13500
|
+
this.#pointerActive = false;
|
|
13501
|
+
return true;
|
|
13018
13502
|
}
|
|
13019
|
-
|
|
13503
|
+
if (event?.type === "focusout") {
|
|
13504
|
+
this.#focusActive = false;
|
|
13505
|
+
return true;
|
|
13506
|
+
}
|
|
13507
|
+
return false;
|
|
13508
|
+
}
|
|
13509
|
+
/** Discards interaction reasons at a lifecycle boundary. */
|
|
13510
|
+
#resetInteractionState() {
|
|
13511
|
+
this.#focusActive = false;
|
|
13512
|
+
this.#pointerActive = false;
|
|
13513
|
+
}
|
|
13514
|
+
/** Whether focus or pointer presence still requires a persistent tooltip. */
|
|
13515
|
+
get #hasActiveInteraction() {
|
|
13516
|
+
return this.#focusActive || this.#pointerActive;
|
|
13517
|
+
}
|
|
13020
13518
|
/** Cancels any pending show timer. */
|
|
13021
13519
|
#cancelShow() {
|
|
13022
13520
|
if (this.#pendingShow !== null) {
|