stimeo-ui 0.1.0.pre.beta.2 → 0.2.0

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