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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,137 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/command_palette_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
52
+ // src/utils/escape_layer.ts
53
+ var EscapeLayer = class _EscapeLayer {
54
+ static #registries = /* @__PURE__ */ new WeakMap();
55
+ #ownerDocument = null;
56
+ /** Dismissal callback while active; `null` when inactive. */
57
+ #onDismiss = null;
58
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
59
+ #claims = null;
60
+ /**
61
+ * Activates this layer at the top of its document's Escape stack, installing
62
+ * the document's shared resolver listener if this is its first layer.
63
+ * Re-activating an already-active layer moves it to the top.
64
+ */
65
+ activate(ownerDocument = document, options) {
66
+ this.deactivate();
67
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
68
+ if (!registry) {
69
+ registry = _EscapeLayer.#createRegistry();
70
+ _EscapeLayer.#registries.set(ownerDocument, registry);
71
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
72
+ }
73
+ registry.stack.push(this);
74
+ this.#ownerDocument = ownerDocument;
75
+ this.#onDismiss = options.onDismiss;
76
+ this.#claims = options.claims ?? null;
77
+ }
78
+ /**
79
+ * Removes this layer from its document's Escape stack, uninstalling the
80
+ * shared listener when the stack empties. Safe to call when inactive.
81
+ */
82
+ deactivate() {
83
+ const ownerDocument = this.#ownerDocument;
84
+ if (!ownerDocument) return;
85
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
86
+ if (registry) {
87
+ const index = registry.stack.lastIndexOf(this);
88
+ if (index >= 0) registry.stack.splice(index, 1);
89
+ if (registry.stack.length === 0) {
90
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
91
+ _EscapeLayer.#registries.delete(ownerDocument);
92
+ }
93
+ }
94
+ this.#ownerDocument = null;
95
+ this.#onDismiss = null;
96
+ this.#claims = null;
97
+ }
98
+ /**
99
+ * Whether this active layer would own a press right now: it is the topmost
100
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
101
+ * and diagnostics — production dismissal goes through the shared listener.
102
+ */
103
+ get ownsEscape() {
104
+ const ownerDocument = this.#ownerDocument;
105
+ if (!ownerDocument) return false;
106
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
107
+ if (!registry) return false;
108
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
109
+ }
110
+ /** Builds a document's registry with its shared resolver listener. */
111
+ static #createRegistry() {
112
+ const registry = {
113
+ stack: [],
114
+ onKeydown: (event) => {
115
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
116
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
117
+ if (!owner) return;
118
+ event.preventDefault();
119
+ owner.#onDismiss?.();
120
+ }
121
+ };
122
+ return registry;
123
+ }
124
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
125
+ static #resolveOwner(stack) {
126
+ for (let index = stack.length - 1; index >= 0; index--) {
127
+ const layer = stack[index];
128
+ if (!layer) continue;
129
+ if (layer.#claims && !layer.#claims()) continue;
130
+ return layer;
131
+ }
132
+ return null;
133
+ }
134
+ };
135
+
5
136
  // src/utils/focus_trap.ts
6
137
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
138
  var FocusTrap = class {
@@ -15,6 +146,8 @@ var FocusTrap = class {
15
146
  #inertedSiblings = [];
16
147
  /** Whether the modal side effects are currently applied. */
17
148
  #activeState = false;
149
+ /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
150
+ #escapeLayer = new EscapeLayer();
18
151
  /** Returns the trapped element; called on every operation for the live target. */
19
152
  #getContainer;
20
153
  /** Closing/focus hooks; see {@link FocusTrapOptions}. */
@@ -49,6 +182,9 @@ var FocusTrap = class {
49
182
  }
50
183
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
51
184
  document.addEventListener("keydown", this.#onKeydown);
185
+ document.addEventListener("turbo:before-cache", this.#onBeforeCache);
186
+ const onEscape = this.#options.onEscape;
187
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
52
188
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
53
189
  }
54
190
  /**
@@ -61,7 +197,9 @@ var FocusTrap = class {
61
197
  deactivate({ restoreFocus = true } = {}) {
62
198
  if (!this.#activeState) return;
63
199
  this.#activeState = false;
200
+ this.#escapeLayer.deactivate();
64
201
  document.removeEventListener("keydown", this.#onKeydown);
202
+ document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
65
203
  if (this.#scrollLocked) {
66
204
  document.body.style.overflow = this.#previousBodyOverflow;
67
205
  this.#scrollLocked = false;
@@ -77,15 +215,23 @@ var FocusTrap = class {
77
215
  if (option === void 0) return fallback;
78
216
  return typeof option === "function" ? option() : option;
79
217
  }
80
- /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
218
+ /**
219
+ * Reverts the side effects just before Turbo caches the page snapshot, so an
220
+ * overlay left open does not bake the scroll lock into `body[style]` — a
221
+ * restored page would feed that locked value back into {@link activate} as the
222
+ * baseline, and closing would then never unlock the page. Markup state stays
223
+ * untouched (restore-open designs reopen against a clean baseline), and focus
224
+ * is left alone mid-navigation. The listener lives only while active.
225
+ */
226
+ #onBeforeCache = () => {
227
+ this.deactivate({ restoreFocus: false });
228
+ };
229
+ /**
230
+ * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
231
+ * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
232
+ * which layer currently owns Escape.
233
+ */
81
234
  #onKeydown = (event) => {
82
- if (event.key === "Escape") {
83
- if (this.#options.onEscape) {
84
- event.preventDefault();
85
- this.#options.onEscape();
86
- }
87
- return;
88
- }
89
235
  if (event.key === "Tab") this.#trapTab(event);
90
236
  };
91
237
  /** Keeps `Tab` focus cycling within the container's focusable elements. */
@@ -159,7 +305,9 @@ var FocusTrap = class {
159
305
  };
160
306
 
161
307
  // src/controllers/command_palette_controller.ts
162
- var CommandPaletteController = class extends Controller {
308
+ var CommandPaletteController = class _CommandPaletteController extends Controller {
309
+ static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
310
+ static #ABSENT_ARIA_DISABLED = "absent";
163
311
  static targets = ["dialog", "input", "list", "option", "empty"];
164
312
  static values = {
165
313
  hotkey: { type: String, default: "mod+k" },
@@ -177,6 +325,12 @@ var CommandPaletteController = class extends Controller {
177
325
  static events = ["select"];
178
326
  /** The index of the currently active option within the visible subset. */
179
327
  #activeIndex = -1;
328
+ /** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
329
+ #composition = new CompositionTracker({
330
+ onEnd: () => {
331
+ if (this.#isOpen) this.filter();
332
+ }
333
+ });
180
334
  /**
181
335
  * Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
182
336
  * restore). Escape closes; focus on open goes to the input, and is restored to
@@ -199,6 +353,8 @@ var CommandPaletteController = class extends Controller {
199
353
  */
200
354
  connect() {
201
355
  document.addEventListener("keydown", this.#onGlobalKeydown);
356
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
357
+ this.#syncOptionSemantics();
202
358
  const shouldOpen = this.#isOpen || this.openValue;
203
359
  this.#resetToClosedState();
204
360
  if (shouldOpen) this.open();
@@ -206,9 +362,22 @@ var CommandPaletteController = class extends Controller {
206
362
  /** Tears down the global hotkey listener and reverts the modal side effects. */
207
363
  disconnect() {
208
364
  document.removeEventListener("keydown", this.#onGlobalKeydown);
365
+ this.#composition.disconnect();
209
366
  this.#trap.deactivate({ restoreFocus: false });
210
367
  this.#resetToClosedState();
211
368
  }
369
+ /** Initializes semantics for an option added before or after the controller connects. */
370
+ optionTargetConnected(option) {
371
+ this.#syncOptionSemanticsFor(option);
372
+ }
373
+ /** Tracks an input added initially or after connect without extra consumer actions. */
374
+ inputTargetConnected(input) {
375
+ this.#composition.observe(input);
376
+ }
377
+ /** Removes controller-owned listeners when the input target is replaced or removed. */
378
+ inputTargetDisconnected(input) {
379
+ this.#composition.unobserve(input);
380
+ }
212
381
  /** Toggles the open state of the command palette. */
213
382
  toggle() {
214
383
  if (this.#isOpen) {
@@ -233,8 +402,9 @@ var CommandPaletteController = class extends Controller {
233
402
  this.#trap.deactivate();
234
403
  }
235
404
  /** Filters option elements in-memory matching the input value. Bound to input target. */
236
- filter() {
237
- if (!this.hasInputTarget) return;
405
+ filter(event) {
406
+ if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
407
+ this.#syncOptionSemantics();
238
408
  const query = this.inputTarget.value.trim().toLowerCase();
239
409
  let hasSelectableMatch = false;
240
410
  for (const option of this.optionTargets) {
@@ -257,6 +427,7 @@ var CommandPaletteController = class extends Controller {
257
427
  const target = event.currentTarget;
258
428
  if (!target) return;
259
429
  const option = target.closest("[role='option']");
430
+ if (option) this.#syncOptionSemanticsFor(option);
260
431
  if (option && !this.#isDisabled(option)) {
261
432
  this.#confirmSelection(option);
262
433
  }
@@ -272,7 +443,7 @@ var CommandPaletteController = class extends Controller {
272
443
  * focus — not only the input.
273
444
  */
274
445
  onKeydown(event) {
275
- if (!this.#isOpen) return;
446
+ if (this.#composition.isComposing(event) || !this.#isOpen) return;
276
447
  switch (event.key) {
277
448
  case "ArrowDown":
278
449
  event.preventDefault();
@@ -307,23 +478,25 @@ var CommandPaletteController = class extends Controller {
307
478
  this.#setActiveIndex(newIndex);
308
479
  }
309
480
  #setActiveIndex(index) {
481
+ this.#syncOptionSemantics();
310
482
  const visible = this.#visibleOptions;
311
- this.#activeIndex = index;
312
- visible.forEach((option, i) => {
313
- if (i === index) {
314
- option.setAttribute("aria-selected", "true");
315
- option.setAttribute("data-active", "true");
316
- if (this.hasInputTarget) {
317
- this.inputTarget.setAttribute("aria-activedescendant", option.id || "");
318
- }
319
- option.scrollIntoView({ block: "nearest" });
483
+ const activeOption = visible[index] ?? null;
484
+ this.#activeIndex = activeOption ? index : -1;
485
+ for (const option of this.optionTargets) {
486
+ option.setAttribute("aria-selected", "false");
487
+ option.removeAttribute("data-active");
488
+ }
489
+ if (activeOption) {
490
+ activeOption.setAttribute("aria-selected", "true");
491
+ activeOption.setAttribute("data-active", "true");
492
+ activeOption.scrollIntoView({ block: "nearest" });
493
+ }
494
+ if (this.hasInputTarget) {
495
+ if (activeOption?.id) {
496
+ this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
320
497
  } else {
321
- option.setAttribute("aria-selected", "false");
322
- option.removeAttribute("data-active");
498
+ this.inputTarget.removeAttribute("aria-activedescendant");
323
499
  }
324
- });
325
- if (index === -1 && this.hasInputTarget) {
326
- this.inputTarget.removeAttribute("aria-activedescendant");
327
500
  }
328
501
  }
329
502
  #confirmSelection(option) {
@@ -336,8 +509,9 @@ var CommandPaletteController = class extends Controller {
336
509
  for (const option of this.optionTargets) {
337
510
  option.removeAttribute("hidden");
338
511
  }
339
- if (this.hasEmptyTarget) this.emptyTarget.hidden = true;
340
- this.#setActiveIndex(0);
512
+ const hasSelectableOption = this.#visibleOptions.length > 0;
513
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
514
+ this.#setActiveIndex(hasSelectableOption ? 0 : -1);
341
515
  }
342
516
  get #visibleOptions() {
343
517
  return this.optionTargets.filter(
@@ -345,23 +519,66 @@ var CommandPaletteController = class extends Controller {
345
519
  );
346
520
  }
347
521
  #isDisabled(option) {
348
- return option.dataset.disabled === "true";
522
+ return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
523
+ }
524
+ /** Synchronizes every option's controller-owned selection and disabled semantics. */
525
+ #syncOptionSemantics() {
526
+ for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
527
+ }
528
+ /** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
529
+ #syncOptionSemanticsFor(option) {
530
+ if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
531
+ const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
532
+ const originalValue = option.getAttribute(originalAttribute);
533
+ if (option.dataset.disabled === "true") {
534
+ if (originalValue === null) {
535
+ option.setAttribute(
536
+ originalAttribute,
537
+ option.getAttribute("aria-disabled") ?? _CommandPaletteController.#ABSENT_ARIA_DISABLED
538
+ );
539
+ }
540
+ option.setAttribute("aria-disabled", "true");
541
+ return;
542
+ }
543
+ if (originalValue === null) return;
544
+ if (originalValue === _CommandPaletteController.#ABSENT_ARIA_DISABLED) {
545
+ option.removeAttribute("aria-disabled");
546
+ } else {
547
+ option.setAttribute("aria-disabled", originalValue);
548
+ }
549
+ option.removeAttribute(originalAttribute);
349
550
  }
350
551
  get #isOpen() {
351
552
  return this.hasDialogTarget && !this.dialogTarget.hidden;
352
553
  }
353
554
  #onGlobalKeydown = (event) => {
354
- const hotkey = this.hotkeyValue.toLowerCase();
355
- const isMod = hotkey.includes("mod+");
356
- const key = hotkey.split("+").pop();
357
- if (!key) return;
358
- const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
359
- const keyMatch = event.key.toLowerCase() === key;
360
- if (modPressed && keyMatch) {
361
- event.preventDefault();
362
- this.toggle();
363
- }
555
+ if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
556
+ event.preventDefault();
557
+ this.toggle();
364
558
  };
559
+ /** Matches the configured `mod+key` or bare-key hotkey without extra modifiers. */
560
+ #matchesHotkey(event) {
561
+ const hotkey = this.#parseHotkey();
562
+ if (!hotkey || event.altKey || event.shiftKey) return false;
563
+ if (hotkey.requiresMod) {
564
+ const hasExactlyOneModKey = event.metaKey !== event.ctrlKey;
565
+ if (!hasExactlyOneModKey) return false;
566
+ } else if (event.metaKey || event.ctrlKey) {
567
+ return false;
568
+ }
569
+ return event.key.toLowerCase() === hotkey.key;
570
+ }
571
+ /** Parses the intentionally small public hotkey grammar. */
572
+ #parseHotkey() {
573
+ const parts = this.hotkeyValue.toLowerCase().split("+");
574
+ if (parts.length === 1 && parts[0] && parts[0] !== "mod") {
575
+ return { key: parts[0], requiresMod: false };
576
+ }
577
+ if (parts.length === 2 && parts[0] === "mod" && parts[1]) {
578
+ return { key: parts[1], requiresMod: true };
579
+ }
580
+ return null;
581
+ }
365
582
  /** Resets transient open state so reconnect starts from a predictable closed snapshot. */
366
583
  #resetToClosedState() {
367
584
  this.#activeIndex = -1;
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/confirm_controller.ts
4
4
 
5
+ // src/utils/escape_layer.ts
6
+ var EscapeLayer = class _EscapeLayer {
7
+ static #registries = /* @__PURE__ */ new WeakMap();
8
+ #ownerDocument = null;
9
+ /** Dismissal callback while active; `null` when inactive. */
10
+ #onDismiss = null;
11
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
12
+ #claims = null;
13
+ /**
14
+ * Activates this layer at the top of its document's Escape stack, installing
15
+ * the document's shared resolver listener if this is its first layer.
16
+ * Re-activating an already-active layer moves it to the top.
17
+ */
18
+ activate(ownerDocument = document, options) {
19
+ this.deactivate();
20
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
21
+ if (!registry) {
22
+ registry = _EscapeLayer.#createRegistry();
23
+ _EscapeLayer.#registries.set(ownerDocument, registry);
24
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
25
+ }
26
+ registry.stack.push(this);
27
+ this.#ownerDocument = ownerDocument;
28
+ this.#onDismiss = options.onDismiss;
29
+ this.#claims = options.claims ?? null;
30
+ }
31
+ /**
32
+ * Removes this layer from its document's Escape stack, uninstalling the
33
+ * shared listener when the stack empties. Safe to call when inactive.
34
+ */
35
+ deactivate() {
36
+ const ownerDocument = this.#ownerDocument;
37
+ if (!ownerDocument) return;
38
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
39
+ if (registry) {
40
+ const index = registry.stack.lastIndexOf(this);
41
+ if (index >= 0) registry.stack.splice(index, 1);
42
+ if (registry.stack.length === 0) {
43
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
44
+ _EscapeLayer.#registries.delete(ownerDocument);
45
+ }
46
+ }
47
+ this.#ownerDocument = null;
48
+ this.#onDismiss = null;
49
+ this.#claims = null;
50
+ }
51
+ /**
52
+ * Whether this active layer would own a press right now: it is the topmost
53
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
54
+ * and diagnostics — production dismissal goes through the shared listener.
55
+ */
56
+ get ownsEscape() {
57
+ const ownerDocument = this.#ownerDocument;
58
+ if (!ownerDocument) return false;
59
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
60
+ if (!registry) return false;
61
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
62
+ }
63
+ /** Builds a document's registry with its shared resolver listener. */
64
+ static #createRegistry() {
65
+ const registry = {
66
+ stack: [],
67
+ onKeydown: (event) => {
68
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
69
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
70
+ if (!owner) return;
71
+ event.preventDefault();
72
+ owner.#onDismiss?.();
73
+ }
74
+ };
75
+ return registry;
76
+ }
77
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
78
+ static #resolveOwner(stack) {
79
+ for (let index = stack.length - 1; index >= 0; index--) {
80
+ const layer = stack[index];
81
+ if (!layer) continue;
82
+ if (layer.#claims && !layer.#claims()) continue;
83
+ return layer;
84
+ }
85
+ return null;
86
+ }
87
+ };
88
+
5
89
  // src/utils/focus_trap.ts
6
90
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
91
  var FocusTrap = class {
@@ -15,6 +99,8 @@ var FocusTrap = class {
15
99
  #inertedSiblings = [];
16
100
  /** Whether the modal side effects are currently applied. */
17
101
  #activeState = false;
102
+ /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
103
+ #escapeLayer = new EscapeLayer();
18
104
  /** Returns the trapped element; called on every operation for the live target. */
19
105
  #getContainer;
20
106
  /** Closing/focus hooks; see {@link FocusTrapOptions}. */
@@ -49,6 +135,9 @@ var FocusTrap = class {
49
135
  }
50
136
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
51
137
  document.addEventListener("keydown", this.#onKeydown);
138
+ document.addEventListener("turbo:before-cache", this.#onBeforeCache);
139
+ const onEscape = this.#options.onEscape;
140
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
52
141
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
53
142
  }
54
143
  /**
@@ -61,7 +150,9 @@ var FocusTrap = class {
61
150
  deactivate({ restoreFocus = true } = {}) {
62
151
  if (!this.#activeState) return;
63
152
  this.#activeState = false;
153
+ this.#escapeLayer.deactivate();
64
154
  document.removeEventListener("keydown", this.#onKeydown);
155
+ document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
65
156
  if (this.#scrollLocked) {
66
157
  document.body.style.overflow = this.#previousBodyOverflow;
67
158
  this.#scrollLocked = false;
@@ -77,15 +168,23 @@ var FocusTrap = class {
77
168
  if (option === void 0) return fallback;
78
169
  return typeof option === "function" ? option() : option;
79
170
  }
80
- /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
171
+ /**
172
+ * Reverts the side effects just before Turbo caches the page snapshot, so an
173
+ * overlay left open does not bake the scroll lock into `body[style]` — a
174
+ * restored page would feed that locked value back into {@link activate} as the
175
+ * baseline, and closing would then never unlock the page. Markup state stays
176
+ * untouched (restore-open designs reopen against a clean baseline), and focus
177
+ * is left alone mid-navigation. The listener lives only while active.
178
+ */
179
+ #onBeforeCache = () => {
180
+ this.deactivate({ restoreFocus: false });
181
+ };
182
+ /**
183
+ * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
+ * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
185
+ * which layer currently owns Escape.
186
+ */
81
187
  #onKeydown = (event) => {
82
- if (event.key === "Escape") {
83
- if (this.#options.onEscape) {
84
- event.preventDefault();
85
- this.#options.onEscape();
86
- }
87
- return;
88
- }
89
188
  if (event.key === "Tab") this.#trapTab(event);
90
189
  };
91
190
  /** Keeps `Tab` focus cycling within the container's focusable elements. */