stimeo-ui 0.1.0.pre.beta.3 → 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.
data/dist/index.js CHANGED
@@ -71,6 +71,96 @@ var AccordionController = class extends Controller {
71
71
  }
72
72
  };
73
73
 
74
+ // src/utils/escape_layer.ts
75
+ function claimsWhileFocusWithin(element) {
76
+ return () => {
77
+ const active = element.ownerDocument.activeElement;
78
+ return active === null || active === element.ownerDocument.body || element.contains(active);
79
+ };
80
+ }
81
+ var EscapeLayer = class _EscapeLayer {
82
+ static #registries = /* @__PURE__ */ new WeakMap();
83
+ #ownerDocument = null;
84
+ /** Dismissal callback while active; `null` when inactive. */
85
+ #onDismiss = null;
86
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
87
+ #claims = null;
88
+ /**
89
+ * Activates this layer at the top of its document's Escape stack, installing
90
+ * the document's shared resolver listener if this is its first layer.
91
+ * Re-activating an already-active layer moves it to the top.
92
+ */
93
+ activate(ownerDocument = document, options) {
94
+ this.deactivate();
95
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
96
+ if (!registry) {
97
+ registry = _EscapeLayer.#createRegistry();
98
+ _EscapeLayer.#registries.set(ownerDocument, registry);
99
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
100
+ }
101
+ registry.stack.push(this);
102
+ this.#ownerDocument = ownerDocument;
103
+ this.#onDismiss = options.onDismiss;
104
+ this.#claims = options.claims ?? null;
105
+ }
106
+ /**
107
+ * Removes this layer from its document's Escape stack, uninstalling the
108
+ * shared listener when the stack empties. Safe to call when inactive.
109
+ */
110
+ deactivate() {
111
+ const ownerDocument = this.#ownerDocument;
112
+ if (!ownerDocument) return;
113
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
114
+ if (registry) {
115
+ const index = registry.stack.lastIndexOf(this);
116
+ if (index >= 0) registry.stack.splice(index, 1);
117
+ if (registry.stack.length === 0) {
118
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
119
+ _EscapeLayer.#registries.delete(ownerDocument);
120
+ }
121
+ }
122
+ this.#ownerDocument = null;
123
+ this.#onDismiss = null;
124
+ this.#claims = null;
125
+ }
126
+ /**
127
+ * Whether this active layer would own a press right now: it is the topmost
128
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
129
+ * and diagnostics — production dismissal goes through the shared listener.
130
+ */
131
+ get ownsEscape() {
132
+ const ownerDocument = this.#ownerDocument;
133
+ if (!ownerDocument) return false;
134
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
135
+ if (!registry) return false;
136
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
137
+ }
138
+ /** Builds a document's registry with its shared resolver listener. */
139
+ static #createRegistry() {
140
+ const registry = {
141
+ stack: [],
142
+ onKeydown: (event) => {
143
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
144
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
145
+ if (!owner) return;
146
+ event.preventDefault();
147
+ owner.#onDismiss?.();
148
+ }
149
+ };
150
+ return registry;
151
+ }
152
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
153
+ static #resolveOwner(stack) {
154
+ for (let index = stack.length - 1; index >= 0; index--) {
155
+ const layer = stack[index];
156
+ if (!layer) continue;
157
+ if (layer.#claims && !layer.#claims()) continue;
158
+ return layer;
159
+ }
160
+ return null;
161
+ }
162
+ };
163
+
74
164
  // src/utils/focus_trap.ts
75
165
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
76
166
  var FocusTrap = class {
@@ -84,6 +174,8 @@ var FocusTrap = class {
84
174
  #inertedSiblings = [];
85
175
  /** Whether the modal side effects are currently applied. */
86
176
  #activeState = false;
177
+ /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
178
+ #escapeLayer = new EscapeLayer();
87
179
  /** Returns the trapped element; called on every operation for the live target. */
88
180
  #getContainer;
89
181
  /** Closing/focus hooks; see {@link FocusTrapOptions}. */
@@ -119,6 +211,8 @@ var FocusTrap = class {
119
211
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
120
212
  document.addEventListener("keydown", this.#onKeydown);
121
213
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
214
+ const onEscape = this.#options.onEscape;
215
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
122
216
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
123
217
  }
124
218
  /**
@@ -131,6 +225,7 @@ var FocusTrap = class {
131
225
  deactivate({ restoreFocus = true } = {}) {
132
226
  if (!this.#activeState) return;
133
227
  this.#activeState = false;
228
+ this.#escapeLayer.deactivate();
134
229
  document.removeEventListener("keydown", this.#onKeydown);
135
230
  document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
136
231
  if (this.#scrollLocked) {
@@ -159,15 +254,12 @@ var FocusTrap = class {
159
254
  #onBeforeCache = () => {
160
255
  this.deactivate({ restoreFocus: false });
161
256
  };
162
- /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
257
+ /**
258
+ * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
259
+ * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
260
+ * which layer currently owns Escape.
261
+ */
163
262
  #onKeydown = (event) => {
164
- if (event.key === "Escape") {
165
- if (this.#options.onEscape) {
166
- event.preventDefault();
167
- this.#options.onEscape();
168
- }
169
- return;
170
- }
171
263
  if (event.key === "Tab") this.#trapTab(event);
172
264
  };
173
265
  /** Keeps `Tab` focus cycling within the container's focusable elements. */
@@ -515,6 +607,55 @@ var AspectRatioController = class extends Controller {
515
607
  return value !== void 0 && Number.isFinite(value) && value > 0;
516
608
  }
517
609
  };
610
+
611
+ // src/utils/composition_tracker.ts
612
+ var CompositionTracker = class {
613
+ #observedTargets = /* @__PURE__ */ new Set();
614
+ #activeTargets = /* @__PURE__ */ new Set();
615
+ #onStart;
616
+ #onEnd;
617
+ constructor(options = {}) {
618
+ this.#onStart = options.onStart;
619
+ this.#onEnd = options.onEnd;
620
+ }
621
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
622
+ observe(target) {
623
+ if (this.#observedTargets.has(target)) return;
624
+ target.addEventListener("compositionstart", this.#handleStart);
625
+ target.addEventListener("compositionend", this.#handleEnd);
626
+ this.#observedTargets.add(target);
627
+ }
628
+ /** Stops tracking one target and clears any active composition it owned. */
629
+ unobserve(target) {
630
+ if (!this.#observedTargets.delete(target)) return;
631
+ target.removeEventListener("compositionstart", this.#handleStart);
632
+ target.removeEventListener("compositionend", this.#handleEnd);
633
+ this.#activeTargets.delete(target);
634
+ }
635
+ /** Releases every listener and clears state so reconnect starts cleanly. */
636
+ disconnect() {
637
+ for (const target of this.#observedTargets) {
638
+ target.removeEventListener("compositionstart", this.#handleStart);
639
+ target.removeEventListener("compositionend", this.#handleEnd);
640
+ }
641
+ this.#observedTargets.clear();
642
+ this.#activeTargets.clear();
643
+ }
644
+ /** True when lifecycle tracking or the current event reports composition. */
645
+ isComposing(event) {
646
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
647
+ }
648
+ #handleStart = (event) => {
649
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
650
+ this.#onStart?.(event);
651
+ };
652
+ #handleEnd = (event) => {
653
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
654
+ this.#onEnd?.(event);
655
+ };
656
+ };
657
+
658
+ // src/controllers/auto_submit_controller.ts
518
659
  var AutoSubmitController = class extends Controller {
519
660
  static targets = ["form"];
520
661
  static values = {
@@ -538,34 +679,22 @@ var AutoSubmitController = class extends Controller {
538
679
  window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
539
680
  }
540
681
  };
541
- /** True while an IME composition is in progress on one of the form's fields. */
542
- #composing = false;
543
- /** Marks composition active so `input` events mid-conversion don't submit. */
544
- #onCompositionStart = () => {
545
- this.#composing = true;
546
- };
547
- /**
548
- * Composition finished (the IME conversion is confirmed): clear the flag and
549
- * schedule a submit as if `input` fired, so the settled text triggers a submit
550
- * even on browsers whose post-composition `input` still reads `isComposing`.
551
- */
552
- #onCompositionEnd = (event) => {
553
- this.#composing = false;
554
- if (this.#triggers("input")) this.#schedule(event.target ?? null);
555
- };
682
+ /** Owns delegated IME lifecycle state and submits confirmed input text. */
683
+ #composition = new CompositionTracker({
684
+ onEnd: (event) => {
685
+ if (this.#triggers("input")) this.#schedule(event.target ?? null);
686
+ }
687
+ });
556
688
  connect() {
557
689
  this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
558
- this.#form.addEventListener("compositionstart", this.#onCompositionStart);
559
- this.#form.addEventListener("compositionend", this.#onCompositionEnd);
690
+ this.#composition.observe(this.#form);
560
691
  }
561
692
  disconnect() {
562
693
  this.#timers.clearAll();
563
694
  this.#pendingId = 0;
564
- this.#composing = false;
695
+ this.#composition.disconnect();
565
696
  this.#form.removeAttribute("data-auto-submit-pending");
566
697
  this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
567
- this.#form.removeEventListener("compositionstart", this.#onCompositionStart);
568
- this.#form.removeEventListener("compositionend", this.#onCompositionEnd);
569
698
  }
570
699
  /**
571
700
  * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
@@ -574,7 +703,7 @@ var AutoSubmitController = class extends Controller {
574
703
  */
575
704
  submit(event) {
576
705
  if (!this.#triggers(event.type)) return;
577
- if (event.type === "input" && (this.#composing || event.isComposing)) return;
706
+ if (event.type === "input" && this.#composition.isComposing(event)) return;
578
707
  this.#schedule(event.target ?? null);
579
708
  }
580
709
  /** Schedules (and coalesces) the debounced submit for the given trigger. */
@@ -1443,35 +1572,25 @@ var CharacterCounterController = class _CharacterCounterController extends Contr
1443
1572
  static #announceDelay = 200;
1444
1573
  #timeouts = new SafeTimeout();
1445
1574
  #announceId = null;
1446
- /** True while an IME composition is active; intermediate input is skipped. */
1447
- #composing = false;
1575
+ /** Owns IME lifecycle state and applies the confirmed count once. */
1576
+ #composition = new CompositionTracker({ onEnd: () => this.#update() });
1448
1577
  #onInput = (event) => {
1449
- if (this.#composing || event.isComposing) return;
1450
- this.#update();
1451
- };
1452
- #onCompositionStart = () => {
1453
- this.#composing = true;
1454
- };
1455
- #onCompositionEnd = () => {
1456
- this.#composing = false;
1578
+ if (this.#composition.isComposing(event)) return;
1457
1579
  this.#update();
1458
1580
  };
1459
1581
  connect() {
1460
1582
  const field = this.#field;
1461
1583
  if (!field) return;
1462
1584
  field.addEventListener("input", this.#onInput);
1463
- field.addEventListener("compositionstart", this.#onCompositionStart);
1464
- field.addEventListener("compositionend", this.#onCompositionEnd);
1585
+ this.#composition.observe(field);
1465
1586
  this.#update({ announce: false });
1466
1587
  }
1467
1588
  disconnect() {
1468
1589
  const field = this.#field;
1469
1590
  field?.removeEventListener("input", this.#onInput);
1470
- field?.removeEventListener("compositionstart", this.#onCompositionStart);
1471
- field?.removeEventListener("compositionend", this.#onCompositionEnd);
1591
+ this.#composition.disconnect();
1472
1592
  this.#timeouts.clearAll();
1473
1593
  this.#announceId = null;
1474
- this.#composing = false;
1475
1594
  }
1476
1595
  /**
1477
1596
  * Recomputes length-derived state. Non-text state (data hooks, `aria-invalid`)
@@ -2045,17 +2164,30 @@ var ComboboxController = class extends Controller {
2045
2164
  * does not immediately re-open the listbox via a `focus`-bound action.
2046
2165
  */
2047
2166
  #suppressOpen = false;
2167
+ /** Owns IME lifecycle state; confirmed text re-filters the list once. */
2168
+ #composition = new CompositionTracker({ onEnd: () => this.filter() });
2048
2169
  /** Starts closed with no active option and registers the outside-click listener. */
2049
2170
  connect() {
2171
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
2050
2172
  this.close();
2051
2173
  document.addEventListener("click", this.#onOutsideClick);
2052
2174
  }
2053
2175
  /** Removes the document-level listener registered in {@link connect}. */
2054
2176
  disconnect() {
2177
+ this.#composition.disconnect();
2055
2178
  document.removeEventListener("click", this.#onOutsideClick);
2056
2179
  }
2057
- /** Filters options by the current input value and opens the listbox. */
2058
- filter() {
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;
2059
2191
  this.open();
2060
2192
  }
2061
2193
  /**
@@ -2092,7 +2224,7 @@ var ComboboxController = class extends Controller {
2092
2224
  }
2093
2225
  /** Routes keyboard interaction per the APG combobox model. */
2094
2226
  onKeydown(event) {
2095
- if (event.isComposing || event.keyCode === 229) return;
2227
+ if (this.#composition.isComposing(event)) return;
2096
2228
  switch (event.key) {
2097
2229
  case "ArrowDown": {
2098
2230
  event.preventDefault();
@@ -2138,6 +2270,7 @@ var ComboboxController = class extends Controller {
2138
2270
  break;
2139
2271
  }
2140
2272
  case "Escape":
2273
+ if (event.defaultPrevented || this.#isClosed) break;
2141
2274
  event.preventDefault();
2142
2275
  this.close();
2143
2276
  break;
@@ -2214,7 +2347,9 @@ var ComboboxController = class extends Controller {
2214
2347
  return !this.hasListTarget || this.listTarget.hidden !== false;
2215
2348
  }
2216
2349
  };
2217
- 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";
2218
2353
  static targets = ["dialog", "input", "list", "option", "empty"];
2219
2354
  static values = {
2220
2355
  hotkey: { type: String, default: "mod+k" },
@@ -2232,6 +2367,12 @@ var CommandPaletteController = class extends Controller {
2232
2367
  static events = ["select"];
2233
2368
  /** The index of the currently active option within the visible subset. */
2234
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
+ });
2235
2376
  /**
2236
2377
  * Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
2237
2378
  * restore). Escape closes; focus on open goes to the input, and is restored to
@@ -2254,6 +2395,8 @@ var CommandPaletteController = class extends Controller {
2254
2395
  */
2255
2396
  connect() {
2256
2397
  document.addEventListener("keydown", this.#onGlobalKeydown);
2398
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
2399
+ this.#syncOptionSemantics();
2257
2400
  const shouldOpen = this.#isOpen || this.openValue;
2258
2401
  this.#resetToClosedState();
2259
2402
  if (shouldOpen) this.open();
@@ -2261,9 +2404,22 @@ var CommandPaletteController = class extends Controller {
2261
2404
  /** Tears down the global hotkey listener and reverts the modal side effects. */
2262
2405
  disconnect() {
2263
2406
  document.removeEventListener("keydown", this.#onGlobalKeydown);
2407
+ this.#composition.disconnect();
2264
2408
  this.#trap.deactivate({ restoreFocus: false });
2265
2409
  this.#resetToClosedState();
2266
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
+ }
2267
2423
  /** Toggles the open state of the command palette. */
2268
2424
  toggle() {
2269
2425
  if (this.#isOpen) {
@@ -2288,8 +2444,9 @@ var CommandPaletteController = class extends Controller {
2288
2444
  this.#trap.deactivate();
2289
2445
  }
2290
2446
  /** Filters option elements in-memory matching the input value. Bound to input target. */
2291
- filter() {
2292
- if (!this.hasInputTarget) return;
2447
+ filter(event) {
2448
+ if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
2449
+ this.#syncOptionSemantics();
2293
2450
  const query = this.inputTarget.value.trim().toLowerCase();
2294
2451
  let hasSelectableMatch = false;
2295
2452
  for (const option of this.optionTargets) {
@@ -2312,6 +2469,7 @@ var CommandPaletteController = class extends Controller {
2312
2469
  const target = event.currentTarget;
2313
2470
  if (!target) return;
2314
2471
  const option = target.closest("[role='option']");
2472
+ if (option) this.#syncOptionSemanticsFor(option);
2315
2473
  if (option && !this.#isDisabled(option)) {
2316
2474
  this.#confirmSelection(option);
2317
2475
  }
@@ -2327,7 +2485,7 @@ var CommandPaletteController = class extends Controller {
2327
2485
  * focus — not only the input.
2328
2486
  */
2329
2487
  onKeydown(event) {
2330
- if (!this.#isOpen) return;
2488
+ if (this.#composition.isComposing(event) || !this.#isOpen) return;
2331
2489
  switch (event.key) {
2332
2490
  case "ArrowDown":
2333
2491
  event.preventDefault();
@@ -2362,23 +2520,25 @@ var CommandPaletteController = class extends Controller {
2362
2520
  this.#setActiveIndex(newIndex);
2363
2521
  }
2364
2522
  #setActiveIndex(index) {
2523
+ this.#syncOptionSemantics();
2365
2524
  const visible = this.#visibleOptions;
2366
- this.#activeIndex = index;
2367
- visible.forEach((option, i) => {
2368
- if (i === index) {
2369
- option.setAttribute("aria-selected", "true");
2370
- option.setAttribute("data-active", "true");
2371
- if (this.hasInputTarget) {
2372
- this.inputTarget.setAttribute("aria-activedescendant", option.id || "");
2373
- }
2374
- option.scrollIntoView({ block: "nearest" });
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);
2375
2539
  } else {
2376
- option.setAttribute("aria-selected", "false");
2377
- option.removeAttribute("data-active");
2540
+ this.inputTarget.removeAttribute("aria-activedescendant");
2378
2541
  }
2379
- });
2380
- if (index === -1 && this.hasInputTarget) {
2381
- this.inputTarget.removeAttribute("aria-activedescendant");
2382
2542
  }
2383
2543
  }
2384
2544
  #confirmSelection(option) {
@@ -2391,8 +2551,9 @@ var CommandPaletteController = class extends Controller {
2391
2551
  for (const option of this.optionTargets) {
2392
2552
  option.removeAttribute("hidden");
2393
2553
  }
2394
- if (this.hasEmptyTarget) this.emptyTarget.hidden = true;
2395
- this.#setActiveIndex(0);
2554
+ const hasSelectableOption = this.#visibleOptions.length > 0;
2555
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
2556
+ this.#setActiveIndex(hasSelectableOption ? 0 : -1);
2396
2557
  }
2397
2558
  get #visibleOptions() {
2398
2559
  return this.optionTargets.filter(
@@ -2400,23 +2561,66 @@ var CommandPaletteController = class extends Controller {
2400
2561
  );
2401
2562
  }
2402
2563
  #isDisabled(option) {
2403
- 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);
2404
2592
  }
2405
2593
  get #isOpen() {
2406
2594
  return this.hasDialogTarget && !this.dialogTarget.hidden;
2407
2595
  }
2408
2596
  #onGlobalKeydown = (event) => {
2409
- const hotkey = this.hotkeyValue.toLowerCase();
2410
- const isMod = hotkey.includes("mod+");
2411
- const key = hotkey.split("+").pop();
2412
- if (!key) return;
2413
- const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
2414
- const keyMatch = event.key.toLowerCase() === key;
2415
- if (modPressed && keyMatch) {
2416
- event.preventDefault();
2417
- this.toggle();
2418
- }
2597
+ if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
2598
+ event.preventDefault();
2599
+ this.toggle();
2419
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
+ }
2420
2624
  /** Resets transient open state so reconnect starts from a predictable closed snapshot. */
2421
2625
  #resetToClosedState() {
2422
2626
  this.#activeIndex = -1;
@@ -2649,14 +2853,23 @@ var ConfirmController = class extends Controller {
2649
2853
  var ContextMenuController = class extends Controller {
2650
2854
  static targets = ["region", "menu", "item"];
2651
2855
  static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
2652
- /** Starts closed and registers the outside-click listener. */
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. */
2653
2860
  connect() {
2654
2861
  this.#closeMenu();
2655
- document.addEventListener("click", this.#onOutsideClick);
2862
+ this.element.addEventListener("click", this.#onItemClickCapture, true);
2863
+ document.addEventListener("click", this.#onOutsidePointer);
2864
+ document.addEventListener("contextmenu", this.#onOutsidePointer);
2656
2865
  }
2657
- /** Removes the document-level listener registered in {@link connect}. */
2866
+ /** Releases the listeners, stack membership, and pending Tab-close task. */
2658
2867
  disconnect() {
2659
- document.removeEventListener("click", this.#onOutsideClick);
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);
2660
2873
  }
2661
2874
  /**
2662
2875
  * Opens the menu from a `contextmenu` event: suppresses the native menu and
@@ -2681,11 +2894,17 @@ var ContextMenuController = class extends Controller {
2681
2894
  switch (event.key) {
2682
2895
  case "ArrowDown":
2683
2896
  event.preventDefault();
2684
- if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();
2897
+ if (items.length > 0) {
2898
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
2899
+ items[nextIndex]?.focus();
2900
+ }
2685
2901
  break;
2686
2902
  case "ArrowUp":
2687
2903
  event.preventDefault();
2688
- if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();
2904
+ if (items.length > 0) {
2905
+ const previousIndex = currentIndex < 0 ? items.length - 1 : currentIndex - 1;
2906
+ items[(previousIndex + items.length) % items.length]?.focus();
2907
+ }
2689
2908
  break;
2690
2909
  case "Home":
2691
2910
  event.preventDefault();
@@ -2695,12 +2914,9 @@ var ContextMenuController = class extends Controller {
2695
2914
  event.preventDefault();
2696
2915
  items[items.length - 1]?.focus();
2697
2916
  break;
2698
- case "Escape":
2699
- event.preventDefault();
2700
- this.#closeAndRestore();
2701
- break;
2702
2917
  case "Tab":
2703
- this.#closeMenu();
2918
+ this.#timers.clearAll();
2919
+ this.#timers.set(() => this.#closeMenu(), 0);
2704
2920
  break;
2705
2921
  }
2706
2922
  }
@@ -2711,6 +2927,11 @@ var ContextMenuController = class extends Controller {
2711
2927
  /** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
2712
2928
  #openAt(x, y) {
2713
2929
  if (!this.hasMenuTarget) return;
2930
+ this.#timers.clearAll();
2931
+ this.#escapeLayer.activate(document, {
2932
+ onDismiss: () => this.#closeAndRestore(),
2933
+ claims: claimsWhileFocusWithin(this.element)
2934
+ });
2714
2935
  this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
2715
2936
  this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
2716
2937
  this.menuTarget.hidden = false;
@@ -2719,6 +2940,8 @@ var ContextMenuController = class extends Controller {
2719
2940
  }
2720
2941
  /** Hides the menu and reflects the collapsed state on the region. */
2721
2942
  #closeMenu() {
2943
+ this.#timers.clearAll();
2944
+ this.#escapeLayer.deactivate();
2722
2945
  if (!this.hasMenuTarget) return;
2723
2946
  this.menuTarget.hidden = true;
2724
2947
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
@@ -2728,10 +2951,24 @@ var ContextMenuController = class extends Controller {
2728
2951
  this.#closeMenu();
2729
2952
  if (this.hasRegionTarget) this.regionTarget.focus();
2730
2953
  }
2731
- /** Closes the menu when a click lands outside the controller's element. */
2732
- #onOutsideClick = (event) => {
2954
+ /** Closes when a click or context-menu invocation lands outside this instance. */
2955
+ #onOutsidePointer = (event) => {
2733
2956
  if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
2734
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
+ };
2735
2972
  /** Menu items eligible for roving focus (excludes disabled / hidden). */
2736
2973
  get #navigableItems() {
2737
2974
  return this.itemTargets.filter((item) => this.#isNavigable(item));
@@ -2889,8 +3126,8 @@ var CountdownController = class extends Controller {
2889
3126
  * paused (or completed) one resets the displayed amount but stays paused until the
2890
3127
  * user resumes — it never silently restarts. The run state is read from the DOM,
2891
3128
  * not re-derived from the declarative `autostart` Value (which only governs the
2892
- * initial state on connect); re-deriving it would override a user's pause, the same
2893
- * anti-pattern the Turbo-lifecycle guide warns about.
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.
2894
3131
  */
2895
3132
  reset() {
2896
3133
  const wasRunning = this.#state === "running";
@@ -3384,7 +3621,7 @@ var DateRangePickerController = class extends Controller {
3384
3621
  return;
3385
3622
  }
3386
3623
  if (event.key === "Escape") {
3387
- if (this.#pendingStart) {
3624
+ if (this.#pendingStart && !event.defaultPrevented && !event.isComposing) {
3388
3625
  event.preventDefault();
3389
3626
  this.#pendingStart = "";
3390
3627
  this.#previewDate = "";
@@ -3913,14 +4150,6 @@ var DirtyFormController = class extends Controller {
3913
4150
  return null;
3914
4151
  }
3915
4152
  };
3916
- var FOCUSABLE_SELECTOR = [
3917
- "a[href]",
3918
- "button:not([disabled])",
3919
- "input:not([disabled])",
3920
- "select:not([disabled])",
3921
- "textarea:not([disabled])",
3922
- '[tabindex]:not([tabindex="-1"])'
3923
- ].join(",");
3924
4153
  var DismissibleController = class extends Controller {
3925
4154
  static targets = ["root", "fallback"];
3926
4155
  static values = {
@@ -3934,25 +4163,35 @@ var DismissibleController = class extends Controller {
3934
4163
  if (!root.hasAttribute("data-state")) {
3935
4164
  root.setAttribute("data-state", "open");
3936
4165
  }
3937
- if (this.closeOnEscapeValue) {
3938
- this.element.addEventListener("keydown", this.#onKeydown);
3939
- }
4166
+ this.#syncEscapeListener();
3940
4167
  }
3941
4168
  disconnect() {
3942
4169
  this.element.removeEventListener("keydown", this.#onKeydown);
3943
4170
  }
4171
+ /** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */
4172
+ closeOnEscapeValueChanged() {
4173
+ this.#syncEscapeListener();
4174
+ }
3944
4175
  /** Dismisses the element. Bound via `data-action` (click on the close button). */
3945
4176
  dismiss() {
3946
4177
  this.#performDismiss();
3947
4178
  }
3948
4179
  /** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */
3949
4180
  #onKeydown = (event) => {
3950
- if (event.key !== "Escape") return;
4181
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
4182
+ if (!this.closeOnEscapeValue) return;
3951
4183
  const active = document.activeElement;
3952
4184
  if (!active || !this.element.contains(active)) return;
3953
4185
  event.preventDefault();
3954
4186
  this.#performDismiss();
3955
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
+ }
3956
4195
  /** The element to dismiss: the explicit `root` target, or the host element. */
3957
4196
  get #root() {
3958
4197
  return this.hasRootTarget ? this.rootTarget : this.element;
@@ -3976,23 +4215,44 @@ var DismissibleController = class extends Controller {
3976
4215
  #retreatFocus(root) {
3977
4216
  const active = document.activeElement;
3978
4217
  if (!(active instanceof HTMLElement) || !root.contains(active)) return;
3979
- this.#focusFallback(root).focus();
3980
- }
3981
- /** Resolves the best focus fallback per the documented precedence. */
3982
- #focusFallback(root) {
3983
- if (this.hasFallbackTarget && !root.contains(this.fallbackTarget)) {
3984
- return this.fallbackTarget;
3985
- }
3986
- const candidates = Array.from(
3987
- document.querySelectorAll(FOCUSABLE_SELECTOR)
3988
- ).filter((element) => !root.contains(element));
3989
- const after = candidates.find(
3990
- (element) => root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING
3991
- );
3992
- if (after) return after;
3993
- const before = candidates.reverse().find((element) => root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING);
3994
- if (before) return before;
3995
- return document.body;
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;
3996
4256
  }
3997
4257
  };
3998
4258
  function maxTransitionMs(value) {
@@ -4136,35 +4396,46 @@ var DrawerController = class extends Controller {
4136
4396
  return this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
4137
4397
  }
4138
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
4139
4418
  var DropdownController = class extends Controller {
4140
4419
  static targets = ["trigger", "menu"];
4141
4420
  static actions = ["close", "open", "toggle"];
4421
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
4422
+ #escapeLayer = new EscapeLayer();
4142
4423
  /** Closes the menu when a click lands outside the controller's element. */
4143
4424
  #onOutsideClick = (event) => {
4144
4425
  if (!this.element.contains(event.target)) {
4145
4426
  this.close();
4146
4427
  }
4147
4428
  };
4148
- /** Closes the menu on `Escape` and restores focus to the trigger. */
4149
- #onKeydown = (event) => {
4150
- if (event.key === "Escape" && this.#isOpen) {
4151
- this.close();
4152
- if (this.hasTriggerTarget) this.triggerTarget.focus();
4153
- }
4154
- };
4155
- /**
4156
- * Starts in the closed state and registers the document-level listeners that
4157
- * power outside-click and `Escape` handling.
4158
- */
4429
+ /** Starts in the closed state and registers outside-click handling. */
4159
4430
  connect() {
4431
+ this.#associateTriggerWithMenu();
4160
4432
  this.close();
4161
- document.addEventListener("click", this.#onOutsideClick);
4162
- document.addEventListener("keydown", this.#onKeydown);
4433
+ document.addEventListener("click", this.#onOutsideClick, true);
4163
4434
  }
4164
- /** Removes the document-level listeners registered in {@link connect}. */
4435
+ /** Removes the listeners registered in {@link connect}. */
4165
4436
  disconnect() {
4166
- document.removeEventListener("click", this.#onOutsideClick);
4167
- document.removeEventListener("keydown", this.#onKeydown);
4437
+ this.#escapeLayer.deactivate();
4438
+ document.removeEventListener("click", this.#onOutsideClick, true);
4168
4439
  }
4169
4440
  /** Toggles the menu between open and closed. Bound via `data-action`. */
4170
4441
  toggle() {
@@ -4177,6 +4448,10 @@ var DropdownController = class extends Controller {
4177
4448
  /** Reveals the menu and reflects the open state on the trigger. */
4178
4449
  open() {
4179
4450
  if (!this.hasMenuTarget) return;
4451
+ this.#escapeLayer.activate(document, {
4452
+ onDismiss: () => this.#closeAndRestore(),
4453
+ claims: claimsWhileFocusWithin(this.element)
4454
+ });
4180
4455
  this.menuTarget.hidden = false;
4181
4456
  if (this.hasTriggerTarget) {
4182
4457
  this.triggerTarget.setAttribute("aria-expanded", "true");
@@ -4184,16 +4459,34 @@ var DropdownController = class extends Controller {
4184
4459
  }
4185
4460
  /** Hides the menu and reflects the closed state on the trigger. */
4186
4461
  close() {
4462
+ this.#escapeLayer.deactivate();
4187
4463
  if (!this.hasMenuTarget) return;
4188
4464
  this.menuTarget.hidden = true;
4189
4465
  if (this.hasTriggerTarget) {
4190
4466
  this.triggerTarget.setAttribute("aria-expanded", "false");
4191
4467
  }
4192
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
+ }
4193
4474
  /** Whether the menu is currently visible. */
4194
4475
  get #isOpen() {
4195
4476
  return this.hasMenuTarget && !this.menuTarget.hidden;
4196
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
+ }
4197
4490
  };
4198
4491
  var EditableController = class extends Controller {
4199
4492
  static targets = ["display", "input"];
@@ -4204,10 +4497,28 @@ var EditableController = class extends Controller {
4204
4497
  static events = ["cancel", "change"];
4205
4498
  /** The value captured when edit mode began, used to detect real changes. */
4206
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();
4207
4505
  /** Establishes the initial display mode (display shown, input hidden). */
4208
4506
  connect() {
4507
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
4209
4508
  this.#setMode("display");
4210
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
+ }
4211
4522
  /** Enters edit mode: seeds the input from the display text, focuses, selects. */
4212
4523
  edit() {
4213
4524
  if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
@@ -4226,7 +4537,9 @@ var EditableController = class extends Controller {
4226
4537
  }
4227
4538
  /** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */
4228
4539
  onKeydown(event) {
4540
+ if (this.#composition.isComposing(event)) return;
4229
4541
  if (event.key === "Escape") {
4542
+ if (event.defaultPrevented) return;
4230
4543
  event.preventDefault();
4231
4544
  this.#cancel();
4232
4545
  return;
@@ -4794,25 +5107,6 @@ var FocusController = class extends Controller {
4794
5107
  this.dispatch("deactivate", { detail: {} });
4795
5108
  }
4796
5109
  };
4797
-
4798
- // src/utils/aria_ids.ts
4799
- var counter = 0;
4800
- function uniqueId(prefix = "stimeo") {
4801
- let candidate;
4802
- do {
4803
- counter += 1;
4804
- candidate = `${prefix}-${counter}`;
4805
- } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
4806
- return candidate;
4807
- }
4808
- function ensureId(element, prefix = "stimeo") {
4809
- if (element.id) return element.id;
4810
- const id = uniqueId(prefix);
4811
- element.id = id;
4812
- return id;
4813
- }
4814
-
4815
- // src/controllers/form_field_controller.ts
4816
5110
  var FormFieldController = class _FormFieldController extends Controller {
4817
5111
  static targets = ["control", "description", "error"];
4818
5112
  static values = {
@@ -5116,8 +5410,8 @@ var FormValidationController = class _FormValidationController extends Controlle
5116
5410
  }
5117
5411
  /**
5118
5412
  * Applies (or clears) a declarative custom constraint via `setCustomValidity`,
5119
- * for controls that opt in with `data-stimeo--form-field-disallow`. The one
5120
- * supported rule today is `"whitespace"` — a value that is non-empty but blank
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
5121
5415
  * after trimming (which slips past `required` / `minlength`); its message follows
5122
5416
  * the per-constraint (`value-missing`) → generic → default chain.
5123
5417
  *
@@ -5386,21 +5680,26 @@ var HoverCardController = class extends Controller {
5386
5680
  closeDelay: { type: Number, default: 200 },
5387
5681
  closeOnScroll: { type: Boolean, default: false }
5388
5682
  };
5389
- static actions = ["close", "onKeydown", "open"];
5390
- /** Pending open/close timers, torn down together on disconnect. */
5683
+ static actions = ["close", "open"];
5684
+ /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
5391
5685
  #timers = new SafeTimeout();
5686
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
5687
+ #escapeLayer = new EscapeLayer();
5392
5688
  #pendingOpen = null;
5393
5689
  #pendingClose = null;
5394
5690
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
5395
5691
  #stopScrollDismiss = null;
5396
- /** Starts closed. */
5692
+ /** Starts closed and discards any stale pending state from a prior connection. */
5397
5693
  connect() {
5694
+ this.#cancelOpen();
5695
+ this.#cancelClose();
5398
5696
  this.#conceal();
5399
5697
  }
5400
- /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
5698
+ /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
5401
5699
  disconnect() {
5402
- this.#timers.clearAll();
5403
- document.removeEventListener("keydown", this.#onDocumentKeydown);
5700
+ this.#cancelOpen();
5701
+ this.#cancelClose();
5702
+ this.#escapeLayer.deactivate();
5404
5703
  this.#stopScrollDismiss?.();
5405
5704
  this.#stopScrollDismiss = null;
5406
5705
  }
@@ -5432,32 +5731,26 @@ var HoverCardController = class extends Controller {
5432
5731
  this.#conceal();
5433
5732
  }, this.closeDelayValue);
5434
5733
  }
5435
- /** Closes immediately on `Escape` while open (keyboard dismissal from the trigger). */
5436
- onKeydown(event) {
5437
- if (event.key === "Escape" && this.#isOpen) {
5438
- event.preventDefault();
5439
- this.#dismiss();
5440
- }
5441
- }
5442
- /** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
5734
+ /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
5443
5735
  #reveal() {
5444
5736
  if (!this.hasCardTarget) return;
5445
5737
  this.cardTarget.hidden = false;
5446
5738
  this.cardTarget.setAttribute("data-state", "open");
5447
5739
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
5448
- document.addEventListener("keydown", this.#onDocumentKeydown);
5740
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
5449
5741
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
5450
5742
  this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
5451
5743
  }
5452
5744
  }
5453
- /** Hides the card, reflects state, and stops watching for `Escape`/scroll. */
5745
+ /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
5454
5746
  #conceal() {
5455
- document.removeEventListener("keydown", this.#onDocumentKeydown);
5747
+ this.#escapeLayer.deactivate();
5456
5748
  this.#stopScrollDismiss?.();
5457
5749
  this.#stopScrollDismiss = null;
5458
- if (!this.hasCardTarget) return;
5459
- this.cardTarget.hidden = true;
5460
- this.cardTarget.setAttribute("data-state", "closed");
5750
+ if (this.hasCardTarget) {
5751
+ this.cardTarget.hidden = true;
5752
+ this.cardTarget.setAttribute("data-state", "closed");
5753
+ }
5461
5754
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
5462
5755
  }
5463
5756
  /** Cancels pending timers and conceals immediately (shared Escape path). */
@@ -5466,13 +5759,6 @@ var HoverCardController = class extends Controller {
5466
5759
  this.#cancelClose();
5467
5760
  this.#conceal();
5468
5761
  }
5469
- /** Document-level `Escape` watcher (active only while open). */
5470
- #onDocumentKeydown = (event) => {
5471
- if (event.key === "Escape") {
5472
- event.preventDefault();
5473
- this.#dismiss();
5474
- }
5475
- };
5476
5762
  /** Cancels any pending open timer. */
5477
5763
  #cancelOpen() {
5478
5764
  if (this.#pendingOpen !== null) {
@@ -6029,6 +6315,7 @@ var ListboxController = class extends Controller {
6029
6315
  this.#commitActive();
6030
6316
  break;
6031
6317
  case "Escape":
6318
+ if (event.defaultPrevented || event.isComposing) break;
6032
6319
  event.preventDefault();
6033
6320
  this.close();
6034
6321
  this.triggerTarget.focus();
@@ -6308,13 +6595,20 @@ var MenuController = class extends Controller {
6308
6595
  "open",
6309
6596
  "toggle"
6310
6597
  ];
6311
- /** Starts closed and registers the outside-click listener. */
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. */
6312
6602
  connect() {
6313
6603
  this.close();
6604
+ this.element.addEventListener("click", this.#onItemClickCapture, true);
6314
6605
  document.addEventListener("click", this.#onOutsideClick);
6315
6606
  }
6316
- /** Removes the document-level listener registered in {@link connect}. */
6607
+ /** Releases the listeners, stack membership, and any pending Tab-close task. */
6317
6608
  disconnect() {
6609
+ this.#timers.clearAll();
6610
+ this.#escapeLayer.deactivate();
6611
+ this.element.removeEventListener("click", this.#onItemClickCapture, true);
6318
6612
  document.removeEventListener("click", this.#onOutsideClick);
6319
6613
  }
6320
6614
  /** Toggles the menu open/closed. Bound via `data-action` (click). */
@@ -6328,12 +6622,19 @@ var MenuController = class extends Controller {
6328
6622
  }
6329
6623
  /** Opens the menu and reflects the expanded state on the trigger. */
6330
6624
  open() {
6625
+ this.#timers.clearAll();
6331
6626
  if (!this.hasMenuTarget) return;
6627
+ this.#escapeLayer.activate(document, {
6628
+ onDismiss: () => this.#closeAndRestore(),
6629
+ claims: claimsWhileFocusWithin(this.element)
6630
+ });
6332
6631
  this.menuTarget.hidden = false;
6333
6632
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
6334
6633
  }
6335
6634
  /** Closes the menu and reflects the collapsed state on the trigger. */
6336
6635
  close() {
6636
+ this.#timers.clearAll();
6637
+ this.#escapeLayer.deactivate();
6337
6638
  if (!this.hasMenuTarget) return;
6338
6639
  this.menuTarget.hidden = true;
6339
6640
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
@@ -6378,18 +6679,18 @@ var MenuController = class extends Controller {
6378
6679
  event.preventDefault();
6379
6680
  items[items.length - 1]?.focus();
6380
6681
  break;
6381
- case "Escape":
6382
- event.preventDefault();
6383
- this.close();
6384
- if (this.hasTriggerTarget) this.triggerTarget.focus();
6385
- break;
6386
6682
  case "Tab":
6387
- this.close();
6683
+ this.#timers.clearAll();
6684
+ this.#timers.set(() => this.close(), 0);
6388
6685
  break;
6389
6686
  }
6390
6687
  }
6391
6688
  /** Closes the menu after an item is activated. Bound via `data-action`. */
6392
6689
  activate() {
6690
+ this.#closeAndRestore();
6691
+ }
6692
+ /** Closes and returns focus to the trigger (Escape / item-activation path). */
6693
+ #closeAndRestore() {
6393
6694
  this.close();
6394
6695
  if (this.hasTriggerTarget) this.triggerTarget.focus();
6395
6696
  }
@@ -6397,6 +6698,20 @@ var MenuController = class extends Controller {
6397
6698
  #onOutsideClick = (event) => {
6398
6699
  if (this.#isOpen && !this.element.contains(event.target)) this.close();
6399
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
+ };
6400
6715
  /** Moves focus to the first navigable item (no-op if none). */
6401
6716
  #focusFirst() {
6402
6717
  this.#navigableItems[0]?.focus();
@@ -6435,6 +6750,8 @@ var MenubarController = class extends Controller {
6435
6750
  #typeahead = "";
6436
6751
  #typeaheadId = 0;
6437
6752
  #timers = new SafeTimeout();
6753
+ /** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
6754
+ #escapeLayer = new EscapeLayer();
6438
6755
  /** Establishes the single tab stop and the closed baseline. */
6439
6756
  connect() {
6440
6757
  const active = this.#roving.activeIndex;
@@ -6442,8 +6759,9 @@ var MenubarController = class extends Controller {
6442
6759
  this.#closeAllMenus();
6443
6760
  document.addEventListener("click", this.#onOutsideClick);
6444
6761
  }
6445
- /** Removes the document listener and any pending typeahead timer. */
6762
+ /** Removes the document listener, stack membership, and any pending typeahead timer. */
6446
6763
  disconnect() {
6764
+ this.#escapeLayer.deactivate();
6447
6765
  document.removeEventListener("click", this.#onOutsideClick);
6448
6766
  this.#timers.clearAll();
6449
6767
  }
@@ -6488,12 +6806,6 @@ var MenubarController = class extends Controller {
6488
6806
  event.preventDefault();
6489
6807
  this.#gotoTop(length - 1, anyOpen);
6490
6808
  break;
6491
- case "Escape":
6492
- if (anyOpen) {
6493
- event.preventDefault();
6494
- this.#closeAllMenus();
6495
- }
6496
- break;
6497
6809
  }
6498
6810
  }
6499
6811
  /** Keyboard handling while focus is on a menu item. */
@@ -6529,11 +6841,6 @@ var MenubarController = class extends Controller {
6529
6841
  event.preventDefault();
6530
6842
  this.#moveToAdjacentMenu(menu, -1);
6531
6843
  break;
6532
- case "Escape":
6533
- event.preventDefault();
6534
- this.#closeMenu(this.#topFor(menu));
6535
- this.#focusTop(this.#topFor(menu));
6536
- break;
6537
6844
  case "Tab":
6538
6845
  this.#closeAllMenus();
6539
6846
  break;
@@ -6571,6 +6878,10 @@ var MenubarController = class extends Controller {
6571
6878
  if (!menu) return;
6572
6879
  menu.hidden = false;
6573
6880
  top.setAttribute("aria-expanded", "true");
6881
+ this.#escapeLayer.activate(document, {
6882
+ onDismiss: () => this.#dismissOpenMenu(),
6883
+ claims: claimsWhileFocusWithin(this.element)
6884
+ });
6574
6885
  this.#roving.setActive(this.topTargets.indexOf(top));
6575
6886
  const items = this.#itemsIn(menu);
6576
6887
  this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
@@ -6581,12 +6892,30 @@ var MenubarController = class extends Controller {
6581
6892
  const menu = this.#menuFor(top);
6582
6893
  if (menu) menu.hidden = true;
6583
6894
  top.setAttribute("aria-expanded", "false");
6895
+ if (!this.#isAnyOpen) this.#escapeLayer.deactivate();
6584
6896
  }
6585
6897
  /** Closes every menu and resets the typeahead buffer. */
6586
6898
  #closeAllMenus() {
6587
6899
  for (const top of this.topTargets) this.#closeMenu(top);
6588
6900
  this.#resetTypeahead();
6589
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
+ }
6590
6919
  /** Opens the menu of the top item `delta` steps from the one owning `menu`. */
6591
6920
  #moveToAdjacentMenu(menu, delta) {
6592
6921
  const top = this.#topFor(menu);
@@ -6768,9 +7097,25 @@ var MultiSelectController = class extends Controller {
6768
7097
  static events = ["change", "filter"];
6769
7098
  /** The active option (tracked via `aria-activedescendant`), or null. */
6770
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
+ });
6771
7115
  #roving = new RovingTabindex(() => this.#removeButtons);
6772
7116
  /** Starts closed, syncs chips for any pre-selected options, and listens out. */
6773
7117
  connect() {
7118
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
6774
7119
  this.close();
6775
7120
  if (this.hasTagsTarget) {
6776
7121
  this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
@@ -6784,14 +7129,30 @@ var MultiSelectController = class extends Controller {
6784
7129
  }
6785
7130
  /** Tears down document and chip listeners on disconnect (Turbo included). */
6786
7131
  disconnect() {
7132
+ this.#composition.disconnect();
7133
+ this.#ignorePostCompositionInput = false;
6787
7134
  if (this.hasTagsTarget) {
6788
7135
  this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
6789
7136
  this.tagsTarget.removeEventListener("click", this.#onTagClick);
6790
7137
  }
6791
7138
  document.removeEventListener("click", this.#onOutsideClick);
6792
7139
  }
6793
- /** Filters options by the input substring, opens, and re-seeds the active one. */
6794
- filter() {
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;
6795
7156
  const query = this.inputTarget.value.trim().toLowerCase();
6796
7157
  for (const option of this.optionTargets) {
6797
7158
  const label = (option.textContent ?? "").trim().toLowerCase();
@@ -6819,7 +7180,7 @@ var MultiSelectController = class extends Controller {
6819
7180
  }
6820
7181
  /** Routes input keyboard interaction per the multi-select combobox model. */
6821
7182
  onKeydown(event) {
6822
- if (event.isComposing || event.keyCode === 229) return;
7183
+ if (this.#composition.isComposing(event)) return;
6823
7184
  switch (event.key) {
6824
7185
  case "ArrowDown":
6825
7186
  event.preventDefault();
@@ -6852,6 +7213,7 @@ var MultiSelectController = class extends Controller {
6852
7213
  }
6853
7214
  break;
6854
7215
  case "Escape":
7216
+ if (event.defaultPrevented || this.#isClosed) break;
6855
7217
  event.preventDefault();
6856
7218
  this.close();
6857
7219
  break;
@@ -7084,18 +7446,19 @@ var NavigationMenuController = class extends Controller {
7084
7446
  * when targets churn between connect and disconnect.
7085
7447
  */
7086
7448
  #hoverWired = /* @__PURE__ */ new Set();
7449
+ /** Escape-stack membership while any panel is open; the shared resolver dismisses via it. */
7450
+ #escapeLayer = new EscapeLayer();
7087
7451
  /** Establishes the closed baseline and the dismissal listeners. */
7088
7452
  connect() {
7089
7453
  this.#closeAll();
7090
7454
  document.addEventListener("click", this.#onOutsideClick);
7091
- document.addEventListener("keydown", this.#onKeydown);
7092
7455
  this.element.addEventListener("focusout", this.#onFocusOut);
7093
7456
  if (this.openOnHoverValue) this.#addHoverListeners();
7094
7457
  }
7095
- /** Removes every listener and pending hover timer registered in {@link connect}. */
7458
+ /** Removes every listener, pending hover timer, and stack membership registered while connected. */
7096
7459
  disconnect() {
7460
+ this.#escapeLayer.deactivate();
7097
7461
  document.removeEventListener("click", this.#onOutsideClick);
7098
- document.removeEventListener("keydown", this.#onKeydown);
7099
7462
  this.element.removeEventListener("focusout", this.#onFocusOut);
7100
7463
  this.#removeHoverListeners();
7101
7464
  this.#hoverTimers.clearAll();
@@ -7154,17 +7517,34 @@ var NavigationMenuController = class extends Controller {
7154
7517
  if (!panel) return;
7155
7518
  panel.hidden = false;
7156
7519
  trigger.setAttribute("aria-expanded", "true");
7520
+ this.#syncEscapeLayer();
7157
7521
  }
7158
7522
  /** Closes `trigger`'s panel and reflects the collapsed state. */
7159
7523
  #closePanel(trigger) {
7160
7524
  const panel = this.#panelFor(trigger);
7161
7525
  if (panel) panel.hidden = true;
7162
7526
  trigger.setAttribute("aria-expanded", "false");
7527
+ this.#syncEscapeLayer();
7163
7528
  }
7164
7529
  /** Closes every open panel. */
7165
7530
  #closeAll() {
7166
7531
  for (const trigger of this.triggerTargets) this.#closePanel(trigger);
7167
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
+ }
7168
7548
  /** Closes any open panel and returns focus to its trigger (Escape path). */
7169
7549
  #closeAndRestore() {
7170
7550
  const open = this.#openTrigger;
@@ -7176,17 +7556,17 @@ var NavigationMenuController = class extends Controller {
7176
7556
  #onOutsideClick = (event) => {
7177
7557
  if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAll();
7178
7558
  };
7179
- /** Closes (restoring focus) on `Escape` while a panel is open. */
7180
- #onKeydown = (event) => {
7181
- if (event.key !== "Escape" || !this.#isAnyOpen) return;
7182
- if (!this.element.contains(document.activeElement)) return;
7183
- event.preventDefault();
7184
- this.#closeAndRestore();
7185
- };
7186
- /** Closes (without restoring focus) when focus leaves the nav entirely. */
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
+ */
7187
7567
  #onFocusOut = (event) => {
7188
7568
  const next = event.relatedTarget;
7189
- if (next && this.element.contains(next)) return;
7569
+ if (!(next instanceof Node) || this.element.contains(next)) return;
7190
7570
  this.#closeAll();
7191
7571
  };
7192
7572
  /** Opens a trigger's panel after the hover delay (hover mode). */
@@ -7760,21 +8140,24 @@ var OtpController = class extends Controller {
7760
8140
  };
7761
8141
  static actions = ["onInput", "onKeydown", "onPaste"];
7762
8142
  static events = ["change", "complete", "invalid"];
7763
- #isComposing = /* @__PURE__ */ new Map();
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
+ });
7764
8150
  connect() {
7765
8151
  for (const field of this.fieldTargets) {
7766
8152
  field.addEventListener("focus", this.#onFieldFocus);
7767
- field.addEventListener("compositionstart", this.#onCompositionStart);
7768
- field.addEventListener("compositionend", this.#onCompositionEnd);
8153
+ this.#composition.observe(field);
7769
8154
  }
7770
8155
  }
7771
8156
  disconnect() {
7772
8157
  for (const field of this.fieldTargets) {
7773
8158
  field.removeEventListener("focus", this.#onFieldFocus);
7774
- field.removeEventListener("compositionstart", this.#onCompositionStart);
7775
- field.removeEventListener("compositionend", this.#onCompositionEnd);
7776
8159
  }
7777
- this.#isComposing.clear();
8160
+ this.#composition.disconnect();
7778
8161
  }
7779
8162
  /**
7780
8163
  * Stimulus lifecycle callback when a new field target enters the DOM.
@@ -7782,21 +8165,18 @@ var OtpController = class extends Controller {
7782
8165
  */
7783
8166
  fieldTargetConnected(element) {
7784
8167
  element.addEventListener("focus", this.#onFieldFocus);
7785
- element.addEventListener("compositionstart", this.#onCompositionStart);
7786
- element.addEventListener("compositionend", this.#onCompositionEnd);
8168
+ this.#composition.observe(element);
7787
8169
  }
7788
8170
  /** Removes focus listeners when fields are dropped. */
7789
8171
  fieldTargetDisconnected(element) {
7790
8172
  element.removeEventListener("focus", this.#onFieldFocus);
7791
- element.removeEventListener("compositionstart", this.#onCompositionStart);
7792
- element.removeEventListener("compositionend", this.#onCompositionEnd);
7793
- this.#isComposing.delete(element);
8173
+ this.#composition.unobserve(element);
7794
8174
  }
7795
8175
  /** Handles keystroke inputs and advances focus to the next field. */
7796
8176
  onInput(event) {
7797
8177
  const input = event.currentTarget;
7798
8178
  if (!input) return;
7799
- if (this.#isComposing.get(input)) return;
8179
+ if (this.#composition.isComposing(event)) return;
7800
8180
  this.#handleInputValidation(input);
7801
8181
  }
7802
8182
  /** Handles Backspace retreating, arrows, and home/end navigation. */
@@ -7805,7 +8185,7 @@ var OtpController = class extends Controller {
7805
8185
  if (!input) return;
7806
8186
  const index = this.fieldTargets.indexOf(input);
7807
8187
  if (index === -1) return;
7808
- if (this.#isComposing.get(input)) return;
8188
+ if (this.#composition.isComposing(event)) return;
7809
8189
  switch (event.key) {
7810
8190
  case "Backspace":
7811
8191
  if (!input.value) {
@@ -7887,15 +8267,6 @@ var OtpController = class extends Controller {
7887
8267
  input.select();
7888
8268
  }
7889
8269
  };
7890
- #onCompositionStart = (event) => {
7891
- const input = event.currentTarget;
7892
- this.#isComposing.set(input, true);
7893
- };
7894
- #onCompositionEnd = (event) => {
7895
- const input = event.currentTarget;
7896
- this.#isComposing.set(input, false);
7897
- this.#handleInputValidation(input);
7898
- };
7899
8270
  #handleInputValidation(input) {
7900
8271
  const index = this.fieldTargets.indexOf(input);
7901
8272
  if (index === -1) return;
@@ -8839,6 +9210,7 @@ var PointerDragController = class _PointerDragController extends Controller {
8839
9210
  const handle = this.#handleFor(event.target);
8840
9211
  if (!handle) return;
8841
9212
  if (event.key === "Escape") {
9213
+ if (event.defaultPrevented || event.isComposing) return;
8842
9214
  if (this.#pointer?.started) {
8843
9215
  const { pointerType } = this.#pointer;
8844
9216
  this.#teardownSessions();
@@ -8951,9 +9323,8 @@ var PointerDragController = class _PointerDragController extends Controller {
8951
9323
  }
8952
9324
  /**
8953
9325
  * Silently tears down whatever session is live (disconnect / disabled /
8954
- * Escape). Composed from the two single-session teardowns so a cleanup step
8955
- * added to one path can never be missed on the other (the bug-shape behind
8956
- * 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.
8957
9328
  */
8958
9329
  #teardownSessions() {
8959
9330
  this.#endPointerSession();
@@ -9060,24 +9431,26 @@ var PopoverController = class _PopoverController extends Controller {
9060
9431
  static actions = ["close", "open", "toggle"];
9061
9432
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
9062
9433
  #stopScrollDismiss = null;
9434
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
9435
+ #escapeLayer = new EscapeLayer();
9063
9436
  /** Selector for natively focusable elements used to find the first one. */
9064
9437
  static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
9065
- /** Starts closed and registers the document-level dismissal listeners. */
9438
+ /** Starts closed and registers the standing dismissal listeners. */
9066
9439
  connect() {
9067
9440
  this.close();
9068
9441
  document.addEventListener("click", this.#onOutsideClick);
9069
- document.addEventListener("keydown", this.#onKeydown);
9442
+ this.element.addEventListener("focusout", this.#onFocusOut);
9070
9443
  }
9071
9444
  /**
9072
- * Removes the document-level listeners registered in {@link connect}, plus the
9073
- * panel's `focusout` listener if the popover is torn down while open (e.g. a
9074
- * Turbo navigation). `removeEventListener` is a no-op when it was never added,
9075
- * so this is safe in the closed state too no listener outlives the element.
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.
9076
9449
  */
9077
9450
  disconnect() {
9451
+ this.#escapeLayer.deactivate();
9078
9452
  document.removeEventListener("click", this.#onOutsideClick);
9079
- document.removeEventListener("keydown", this.#onKeydown);
9080
- if (this.hasPanelTarget) this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
9453
+ this.element.removeEventListener("focusout", this.#onFocusOut);
9081
9454
  this.#stopScrollDismiss?.();
9082
9455
  this.#stopScrollDismiss = null;
9083
9456
  }
@@ -9094,7 +9467,10 @@ var PopoverController = class _PopoverController extends Controller {
9094
9467
  if (!this.hasPanelTarget || this.#isOpen) return;
9095
9468
  this.panelTarget.hidden = false;
9096
9469
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
9097
- this.panelTarget.addEventListener("focusout", this.#onFocusOut);
9470
+ this.#escapeLayer.activate(document, {
9471
+ onDismiss: () => this.#closeAndRestore(),
9472
+ claims: claimsWhileFocusWithin(this.element)
9473
+ });
9098
9474
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
9099
9475
  this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
9100
9476
  }
@@ -9102,11 +9478,10 @@ var PopoverController = class _PopoverController extends Controller {
9102
9478
  }
9103
9479
  /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
9104
9480
  close() {
9105
- if (!this.hasPanelTarget) return;
9106
- this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
9481
+ this.#escapeLayer.deactivate();
9107
9482
  this.#stopScrollDismiss?.();
9108
9483
  this.#stopScrollDismiss = null;
9109
- this.panelTarget.hidden = true;
9484
+ if (this.hasPanelTarget) this.panelTarget.hidden = true;
9110
9485
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
9111
9486
  }
9112
9487
  /** Moves focus to the first focusable element in the panel, or the panel itself. */
@@ -9119,31 +9494,28 @@ var PopoverController = class _PopoverController extends Controller {
9119
9494
  if (!this.panelTarget.hasAttribute("tabindex")) this.panelTarget.tabIndex = -1;
9120
9495
  this.panelTarget.focus();
9121
9496
  }
9122
- /** Closes and restores focus to the trigger (shared by Escape / outside click). */
9497
+ /** Closes and restores focus to the trigger for explicit keyboard dismissal. */
9123
9498
  #closeAndRestore() {
9124
9499
  this.close();
9125
9500
  if (this.hasTriggerTarget) this.triggerTarget.focus();
9126
9501
  }
9127
- /** Closes (restoring focus) when a click lands outside the controller element. */
9502
+ /** Closes without moving focus when a click lands outside the controller element. */
9128
9503
  #onOutsideClick = (event) => {
9129
- if (this.#isOpen && !this.element.contains(event.target)) this.#closeAndRestore();
9130
- };
9131
- /** Closes (restoring focus) on `Escape` while open. */
9132
- #onKeydown = (event) => {
9133
- if (event.key === "Escape" && this.#isOpen) {
9134
- event.preventDefault();
9135
- this.#closeAndRestore();
9136
- }
9504
+ const target = event.target;
9505
+ if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();
9137
9506
  };
9138
9507
  /**
9139
- * Closes when focus leaves the panel for an element outside the controller
9140
- * (e.g. `Tab` past the last field). Focus is *not* restored the natural
9141
- * destination is kept, which is the modeless contract. Moves within the
9142
- * controller (panel trigger) keep it open.
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.
9143
9514
  */
9144
9515
  #onFocusOut = (event) => {
9516
+ if (!this.#isOpen) return;
9145
9517
  const next = event.relatedTarget;
9146
- if (next && this.element.contains(next)) return;
9518
+ if (!(next instanceof Node) || this.element.contains(next)) return;
9147
9519
  this.close();
9148
9520
  };
9149
9521
  /** Whether the panel is currently visible. */
@@ -10183,7 +10555,7 @@ var RovingController = class extends Controller {
10183
10555
  }
10184
10556
  }
10185
10557
  };
10186
- var FOCUSABLE_SELECTOR2 = [
10558
+ var FOCUSABLE_SELECTOR = [
10187
10559
  "a[href]",
10188
10560
  "button:not([disabled])",
10189
10561
  "input:not([disabled])",
@@ -10295,7 +10667,7 @@ var ScrollAreaController = class extends Controller {
10295
10667
  }
10296
10668
  }
10297
10669
  #hasFocusableContent(vp) {
10298
- return vp.querySelector(FOCUSABLE_SELECTOR2) !== null;
10670
+ return vp.querySelector(FOCUSABLE_SELECTOR) !== null;
10299
10671
  }
10300
10672
  #hasAccessibleName(vp) {
10301
10673
  return vp.hasAttribute("aria-label") || vp.hasAttribute("aria-labelledby");
@@ -11956,20 +12328,32 @@ var TagsInputController = class extends Controller {
11956
12328
  static actions = ["onKeydown"];
11957
12329
  static events = ["change", "reject"];
11958
12330
  #roving = new RovingTabindex(() => this.#removeButtons);
12331
+ /** Owns IME lifecycle state for commit-key guarding. */
12332
+ #composition = new CompositionTracker();
11959
12333
  /** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
11960
12334
  connect() {
12335
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
11961
12336
  this.tagsTarget.addEventListener("keydown", this.#onTagKeydown);
11962
12337
  this.tagsTarget.addEventListener("click", this.#onTagClick);
11963
12338
  this.#syncState();
11964
12339
  }
11965
12340
  /** Releases the delegated listeners so no handler outlives the element. */
11966
12341
  disconnect() {
12342
+ this.#composition.disconnect();
11967
12343
  this.tagsTarget.removeEventListener("keydown", this.#onTagKeydown);
11968
12344
  this.tagsTarget.removeEventListener("click", this.#onTagClick);
11969
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
+ }
11970
12354
  /** Commits on `Enter`/delimiter and deletes the last tag on empty `Backspace`. */
11971
12355
  onKeydown(event) {
11972
- if (event.isComposing || event.keyCode === 229) return;
12356
+ if (this.#composition.isComposing(event)) return;
11973
12357
  if (event.key === "Enter" || event.key === this.delimiterValue) {
11974
12358
  event.preventDefault();
11975
12359
  this.#commitInput();
@@ -12536,6 +12920,7 @@ var TimePickerController = class extends Controller {
12536
12920
  function pad(value) {
12537
12921
  return String(value).padStart(2, "0");
12538
12922
  }
12923
+ var DELEGATED_EVENTS = ["click", "focusin", "focusout", "keydown", "mouseover", "mouseout"];
12539
12924
  var ToastController = class extends Controller {
12540
12925
  static targets = ["list", "template", "item"];
12541
12926
  static values = {
@@ -12556,34 +12941,57 @@ var ToastController = class extends Controller {
12556
12941
  * Tracked so {@link disconnect} can cancel any that have not fired, preventing a
12557
12942
  * detached element from being mutated after it leaves the DOM (Turbo).
12558
12943
  */
12559
- #rafHandles = /* @__PURE__ */ new Set();
12944
+ #rafHandles = /* @__PURE__ */ new Map();
12560
12945
  /** Track active timeouts mapped by each toast element for safe cancellation. */
12561
12946
  #activeTimeouts = /* @__PURE__ */ new Map();
12562
12947
  /** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
12563
12948
  #pauseReasons = /* @__PURE__ */ new Map();
12949
+ /** The stable list that owns delegated listeners for dynamically added items. */
12950
+ #delegatedList = null;
12564
12951
  connect() {
12952
+ this.#connectDelegatedEvents();
12565
12953
  this.enforceMaxLimit();
12566
12954
  for (const item of this.itemTargets) {
12567
- if (!this.#activeTimeouts.has(item)) {
12955
+ if (!this.#activeTimeouts.has(item) && item.dataset.state !== "leaving") {
12568
12956
  this.#startTimer(item);
12569
12957
  }
12570
12958
  }
12571
12959
  }
12572
12960
  disconnect() {
12961
+ this.#disconnectDelegatedEvents();
12573
12962
  this.#timers.clearAll();
12574
- for (const handle of this.#rafHandles) {
12963
+ for (const handle of this.#rafHandles.values()) {
12575
12964
  window.cancelAnimationFrame(handle);
12576
12965
  }
12577
12966
  this.#rafHandles.clear();
12578
12967
  this.#activeTimeouts.clear();
12579
12968
  this.#pauseReasons.clear();
12580
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
+ }
12581
12978
  durationValueChanged() {
12582
- if (this.durationValue > 0) {
12583
- for (const item of this.itemTargets) {
12584
- if (!this.#activeTimeouts.has(item)) {
12585
- this.#startTimer(item);
12586
- }
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);
12587
12995
  }
12588
12996
  }
12589
12997
  }
@@ -12597,17 +13005,21 @@ var ToastController = class extends Controller {
12597
13005
  */
12598
13006
  itemTargetConnected(element) {
12599
13007
  this.enforceMaxLimit();
13008
+ if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
12600
13009
  this.#startTimer(element);
12601
13010
  element.setAttribute("data-state", "entering");
13011
+ this.#cancelAnimation(element);
12602
13012
  const handle = window.requestAnimationFrame(() => {
12603
- this.#rafHandles.delete(handle);
13013
+ this.#rafHandles.delete(element);
13014
+ if (element.parentNode !== this.listTarget || element.dataset.state === "leaving") return;
12604
13015
  element.setAttribute("data-state", "visible");
12605
13016
  });
12606
- this.#rafHandles.add(handle);
13017
+ this.#rafHandles.set(element, handle);
12607
13018
  }
12608
13019
  /** Clears any active timer when a toast is removed from the DOM. */
12609
13020
  itemTargetDisconnected(element) {
12610
13021
  this.#clearTimer(element);
13022
+ this.#cancelAnimation(element);
12611
13023
  }
12612
13024
  /**
12613
13025
  * Shows a new toast. Accepts its content from either a Stimulus action param
@@ -12630,10 +13042,9 @@ var ToastController = class extends Controller {
12630
13042
  const item = clone.querySelector("[data-stimeo--toast-target='item']");
12631
13043
  if (!item) return;
12632
13044
  const bodySlot = item.querySelector("[data-toast-slot='body']");
12633
- if (bodySlot) {
12634
- bodySlot.textContent = body;
12635
- }
12636
- 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");
12637
13048
  this.listTarget.appendChild(item);
12638
13049
  this.dispatch("show", { detail: { item } });
12639
13050
  }
@@ -12655,18 +13066,15 @@ var ToastController = class extends Controller {
12655
13066
  }
12656
13067
  /** Dismisses the toast that contained the trigger. */
12657
13068
  dismiss(event) {
12658
- const target = event.currentTarget || event.target;
12659
- if (!target) return;
12660
- const item = target.closest("[data-stimeo--toast-target='item']");
13069
+ const item = this.#itemFromEvent(event);
12661
13070
  if (!item) return;
12662
13071
  this.#removeWithTransition(item, "user");
12663
13072
  }
12664
13073
  /** Dismisses the focused toast when Escape is pressed. */
12665
13074
  onKeydown(event) {
12666
13075
  if (event.key === "Escape") {
12667
- const target = event.currentTarget || event.target;
12668
- if (!target) return;
12669
- const item = target.closest("[data-stimeo--toast-target='item']");
13076
+ if (event.defaultPrevented || event.isComposing) return;
13077
+ const item = this.#itemFromEvent(event);
12670
13078
  if (!item) return;
12671
13079
  event.preventDefault();
12672
13080
  this.#removeWithTransition(item, "user");
@@ -12692,6 +13100,10 @@ var ToastController = class extends Controller {
12692
13100
  this.#timers.clear(timeout.id);
12693
13101
  const elapsed = Date.now() - timeout.startedAt;
12694
13102
  const remaining = Math.max(0, timeout.remaining - elapsed);
13103
+ if (remaining <= 0) {
13104
+ this.#removeWithTransition(item, "timeout");
13105
+ return;
13106
+ }
12695
13107
  this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
12696
13108
  item.setAttribute("data-paused", "true");
12697
13109
  }
@@ -12703,14 +13115,17 @@ var ToastController = class extends Controller {
12703
13115
  reasons.delete(this.#pauseReason(event));
12704
13116
  if (reasons.size > 0) return;
12705
13117
  const timeout = this.#activeTimeouts.get(item);
12706
- if (!timeout || timeout.remaining <= 0) return;
13118
+ if (!timeout) return;
13119
+ if (timeout.id !== 0 || timeout.remaining <= 0) return;
12707
13120
  item.removeAttribute("data-paused");
12708
13121
  this.#startTimer(item, timeout.remaining);
12709
13122
  }
12710
13123
  /** Resolves the toast item element a pause/resume event targets. */
12711
13124
  #itemFromEvent(event) {
12712
- const target = event.currentTarget || event.target;
12713
- return target?.closest("[data-stimeo--toast-target='item']") ?? null;
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;
12714
13129
  }
12715
13130
  /** Classifies a pause/resume event as a hover or focus reason. */
12716
13131
  #pauseReason(event) {
@@ -12726,7 +13141,9 @@ var ToastController = class extends Controller {
12726
13141
  return reasons;
12727
13142
  }
12728
13143
  #startTimer(element, duration = this.durationValue) {
12729
- if (duration <= 0) return;
13144
+ if (duration <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
13145
+ return;
13146
+ }
12730
13147
  this.#clearTimer(element);
12731
13148
  const id = this.#timers.set(() => {
12732
13149
  this.#removeWithTransition(element, "timeout");
@@ -12742,12 +13159,13 @@ var ToastController = class extends Controller {
12742
13159
  this.#pauseReasons.delete(element);
12743
13160
  }
12744
13161
  #removeWithTransition(element, reason) {
13162
+ if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
12745
13163
  this.#clearTimer(element);
13164
+ this.#cancelAnimation(element);
12746
13165
  element.setAttribute("data-state", "leaving");
12747
13166
  const finalize = () => {
12748
- if (element.parentNode === this.listTarget) {
12749
- this.listTarget.removeChild(element);
12750
- }
13167
+ if (element.parentNode !== this.listTarget) return;
13168
+ this.listTarget.removeChild(element);
12751
13169
  this.dispatch("dismiss", { detail: { item: element, reason } });
12752
13170
  };
12753
13171
  const transitions = window.getComputedStyle(element).transitionDuration;
@@ -12769,14 +13187,59 @@ var ToastController = class extends Controller {
12769
13187
  */
12770
13188
  enforceMaxLimit() {
12771
13189
  const currentItems = this.itemTargets;
12772
- if (currentItems.length > this.maxValue) {
12773
- const excessCount = currentItems.length - this.maxValue;
13190
+ const max = Math.max(0, this.maxValue);
13191
+ if (currentItems.length > max) {
13192
+ const excessCount = currentItems.length - max;
12774
13193
  for (let i = 0; i < excessCount; i++) {
12775
13194
  const oldest = currentItems[i];
12776
13195
  if (oldest) this.#removeWithTransition(oldest, "timeout");
12777
13196
  }
12778
13197
  }
12779
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
+ }
12780
13243
  };
12781
13244
  function cssTimeToMs(value) {
12782
13245
  const first = value.split(",")[0]?.trim() ?? "";
@@ -12939,27 +13402,39 @@ var TooltipController = class extends Controller {
12939
13402
  hideDelay: { type: Number, default: 0 },
12940
13403
  closeOnScroll: { type: Boolean, default: false }
12941
13404
  };
12942
- static actions = ["hide", "onKeydown", "show"];
12943
- /** Pending show/hide timers, torn down together on disconnect. */
13405
+ static actions = ["hide", "show"];
13406
+ /** Registry whose pending timers are cancelled individually with their guard ids. */
12944
13407
  #timers = new SafeTimeout();
12945
- /** The id of the currently pending show or hide timer, if any. */
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. */
12946
13411
  #pendingShow = null;
13412
+ /** The id of the currently pending hide timer, if any. */
12947
13413
  #pendingHide = null;
13414
+ /** Whether focus or the pointer currently requires the tooltip to persist. */
13415
+ #focusActive = false;
13416
+ #pointerActive = false;
12948
13417
  /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
12949
13418
  #stopScrollDismiss = null;
12950
- /** Starts hidden. */
13419
+ /** Starts hidden with no stale timer or interaction state from a prior connection. */
12951
13420
  connect() {
13421
+ this.#cancelShow();
13422
+ this.#cancelHide();
13423
+ this.#resetInteractionState();
12952
13424
  this.#conceal();
12953
13425
  }
12954
- /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
13426
+ /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
12955
13427
  disconnect() {
12956
- this.#timers.clearAll();
12957
- document.removeEventListener("keydown", this.#onDocumentKeydown);
13428
+ this.#cancelShow();
13429
+ this.#cancelHide();
13430
+ this.#resetInteractionState();
13431
+ this.#escapeLayer.deactivate();
12958
13432
  this.#stopScrollDismiss?.();
12959
13433
  this.#stopScrollDismiss = null;
12960
13434
  }
12961
- /** Shows the tooltip, after `showDelay` ms (or immediately at 0). Cancels a pending hide. */
12962
- show() {
13435
+ /** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */
13436
+ show(event) {
13437
+ this.#activateInteraction(event);
12963
13438
  this.#cancelHide();
12964
13439
  if (this.#isVisible || this.#pendingShow !== null) return;
12965
13440
  if (this.showDelayValue <= 0) {
@@ -12971,8 +13446,13 @@ var TooltipController = class extends Controller {
12971
13446
  this.#reveal();
12972
13447
  }, this.showDelayValue);
12973
13448
  }
12974
- /** Hides the tooltip, after `hideDelay` ms (or immediately at 0). Cancels a pending show. */
12975
- 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
+ }
12976
13456
  this.#cancelShow();
12977
13457
  if (!this.#isVisible || this.#pendingHide !== null) return;
12978
13458
  if (this.hideDelayValue <= 0) {
@@ -12984,52 +13464,57 @@ var TooltipController = class extends Controller {
12984
13464
  this.#conceal();
12985
13465
  }, this.hideDelayValue);
12986
13466
  }
12987
- /**
12988
- * Dismisses the tooltip on `Escape` from the trigger. The authoritative
12989
- * dismissal path is the document-level listener (added while shown) so a
12990
- * hover-triggered tooltip is dismissible regardless of focus; this handler
12991
- * keeps the documented `keydown->#onKeydown` binding meaningful too.
12992
- */
12993
- onKeydown(event) {
12994
- if (event.key === "Escape" && this.#isVisible) {
12995
- event.preventDefault();
12996
- this.#cancelShow();
12997
- this.#cancelHide();
12998
- this.#conceal();
12999
- }
13000
- }
13001
- /** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
13467
+ /** Reveals the content and joins the Escape stack / starts the scroll watcher. */
13002
13468
  #reveal() {
13003
13469
  if (!this.hasContentTarget) return;
13004
13470
  this.contentTarget.hidden = false;
13005
13471
  this.contentTarget.setAttribute("data-state", "open");
13006
- document.addEventListener("keydown", this.#onDocumentKeydown);
13472
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
13007
13473
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
13008
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => {
13009
- this.#cancelShow();
13010
- this.#cancelHide();
13011
- this.#conceal();
13012
- });
13474
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
13013
13475
  }
13014
13476
  }
13015
- /** Hides the content and stops watching for `Escape`/scroll. */
13477
+ /** Hides the content and leaves the Escape stack / stops the scroll watcher. */
13016
13478
  #conceal() {
13017
- document.removeEventListener("keydown", this.#onDocumentKeydown);
13479
+ this.#escapeLayer.deactivate();
13018
13480
  this.#stopScrollDismiss?.();
13019
13481
  this.#stopScrollDismiss = null;
13020
13482
  if (!this.hasContentTarget) return;
13021
13483
  this.contentTarget.hidden = true;
13022
13484
  this.contentTarget.setAttribute("data-state", "closed");
13023
13485
  }
13024
- /** Document-level `Escape` watcher (active only while shown). */
13025
- #onDocumentKeydown = (event) => {
13026
- if (event.key === "Escape") {
13027
- event.preventDefault();
13028
- this.#cancelShow();
13029
- this.#cancelHide();
13030
- this.#conceal();
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;
13031
13502
  }
13032
- };
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
+ }
13033
13518
  /** Cancels any pending show timer. */
13034
13519
  #cancelShow() {
13035
13520
  if (this.#pendingShow !== null) {