stimeo-ui 0.1.0.pre.beta.3 → 0.2.1

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