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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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}. */
@@ -50,6 +183,8 @@ var FocusTrap = class {
50
183
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
51
184
  document.addEventListener("keydown", this.#onKeydown);
52
185
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
186
+ const onEscape = this.#options.onEscape;
187
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
53
188
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
54
189
  }
55
190
  /**
@@ -62,6 +197,7 @@ var FocusTrap = class {
62
197
  deactivate({ restoreFocus = true } = {}) {
63
198
  if (!this.#activeState) return;
64
199
  this.#activeState = false;
200
+ this.#escapeLayer.deactivate();
65
201
  document.removeEventListener("keydown", this.#onKeydown);
66
202
  document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
67
203
  if (this.#scrollLocked) {
@@ -90,15 +226,12 @@ var FocusTrap = class {
90
226
  #onBeforeCache = () => {
91
227
  this.deactivate({ restoreFocus: false });
92
228
  };
93
- /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
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
+ */
94
234
  #onKeydown = (event) => {
95
- if (event.key === "Escape") {
96
- if (this.#options.onEscape) {
97
- event.preventDefault();
98
- this.#options.onEscape();
99
- }
100
- return;
101
- }
102
235
  if (event.key === "Tab") this.#trapTab(event);
103
236
  };
104
237
  /** Keeps `Tab` focus cycling within the container's focusable elements. */
@@ -172,7 +305,9 @@ var FocusTrap = class {
172
305
  };
173
306
 
174
307
  // src/controllers/command_palette_controller.ts
175
- 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";
176
311
  static targets = ["dialog", "input", "list", "option", "empty"];
177
312
  static values = {
178
313
  hotkey: { type: String, default: "mod+k" },
@@ -190,6 +325,12 @@ var CommandPaletteController = class extends Controller {
190
325
  static events = ["select"];
191
326
  /** The index of the currently active option within the visible subset. */
192
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
+ });
193
334
  /**
194
335
  * Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
195
336
  * restore). Escape closes; focus on open goes to the input, and is restored to
@@ -212,6 +353,8 @@ var CommandPaletteController = class extends Controller {
212
353
  */
213
354
  connect() {
214
355
  document.addEventListener("keydown", this.#onGlobalKeydown);
356
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
357
+ this.#syncOptionSemantics();
215
358
  const shouldOpen = this.#isOpen || this.openValue;
216
359
  this.#resetToClosedState();
217
360
  if (shouldOpen) this.open();
@@ -219,9 +362,22 @@ var CommandPaletteController = class extends Controller {
219
362
  /** Tears down the global hotkey listener and reverts the modal side effects. */
220
363
  disconnect() {
221
364
  document.removeEventListener("keydown", this.#onGlobalKeydown);
365
+ this.#composition.disconnect();
222
366
  this.#trap.deactivate({ restoreFocus: false });
223
367
  this.#resetToClosedState();
224
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
+ }
225
381
  /** Toggles the open state of the command palette. */
226
382
  toggle() {
227
383
  if (this.#isOpen) {
@@ -246,8 +402,9 @@ var CommandPaletteController = class extends Controller {
246
402
  this.#trap.deactivate();
247
403
  }
248
404
  /** Filters option elements in-memory matching the input value. Bound to input target. */
249
- filter() {
250
- if (!this.hasInputTarget) return;
405
+ filter(event) {
406
+ if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
407
+ this.#syncOptionSemantics();
251
408
  const query = this.inputTarget.value.trim().toLowerCase();
252
409
  let hasSelectableMatch = false;
253
410
  for (const option of this.optionTargets) {
@@ -270,6 +427,7 @@ var CommandPaletteController = class extends Controller {
270
427
  const target = event.currentTarget;
271
428
  if (!target) return;
272
429
  const option = target.closest("[role='option']");
430
+ if (option) this.#syncOptionSemanticsFor(option);
273
431
  if (option && !this.#isDisabled(option)) {
274
432
  this.#confirmSelection(option);
275
433
  }
@@ -285,7 +443,7 @@ var CommandPaletteController = class extends Controller {
285
443
  * focus — not only the input.
286
444
  */
287
445
  onKeydown(event) {
288
- if (!this.#isOpen) return;
446
+ if (this.#composition.isComposing(event) || !this.#isOpen) return;
289
447
  switch (event.key) {
290
448
  case "ArrowDown":
291
449
  event.preventDefault();
@@ -320,23 +478,25 @@ var CommandPaletteController = class extends Controller {
320
478
  this.#setActiveIndex(newIndex);
321
479
  }
322
480
  #setActiveIndex(index) {
481
+ this.#syncOptionSemantics();
323
482
  const visible = this.#visibleOptions;
324
- this.#activeIndex = index;
325
- visible.forEach((option, i) => {
326
- if (i === index) {
327
- option.setAttribute("aria-selected", "true");
328
- option.setAttribute("data-active", "true");
329
- if (this.hasInputTarget) {
330
- this.inputTarget.setAttribute("aria-activedescendant", option.id || "");
331
- }
332
- 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);
333
497
  } else {
334
- option.setAttribute("aria-selected", "false");
335
- option.removeAttribute("data-active");
498
+ this.inputTarget.removeAttribute("aria-activedescendant");
336
499
  }
337
- });
338
- if (index === -1 && this.hasInputTarget) {
339
- this.inputTarget.removeAttribute("aria-activedescendant");
340
500
  }
341
501
  }
342
502
  #confirmSelection(option) {
@@ -349,8 +509,9 @@ var CommandPaletteController = class extends Controller {
349
509
  for (const option of this.optionTargets) {
350
510
  option.removeAttribute("hidden");
351
511
  }
352
- if (this.hasEmptyTarget) this.emptyTarget.hidden = true;
353
- this.#setActiveIndex(0);
512
+ const hasSelectableOption = this.#visibleOptions.length > 0;
513
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
514
+ this.#setActiveIndex(hasSelectableOption ? 0 : -1);
354
515
  }
355
516
  get #visibleOptions() {
356
517
  return this.optionTargets.filter(
@@ -358,23 +519,66 @@ var CommandPaletteController = class extends Controller {
358
519
  );
359
520
  }
360
521
  #isDisabled(option) {
361
- 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);
362
550
  }
363
551
  get #isOpen() {
364
552
  return this.hasDialogTarget && !this.dialogTarget.hidden;
365
553
  }
366
554
  #onGlobalKeydown = (event) => {
367
- const hotkey = this.hotkeyValue.toLowerCase();
368
- const isMod = hotkey.includes("mod+");
369
- const key = hotkey.split("+").pop();
370
- if (!key) return;
371
- const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
372
- const keyMatch = event.key.toLowerCase() === key;
373
- if (modPressed && keyMatch) {
374
- event.preventDefault();
375
- this.toggle();
376
- }
555
+ if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
556
+ event.preventDefault();
557
+ this.toggle();
377
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
+ }
378
582
  /** Resets transient open state so reconnect starts from a predictable closed snapshot. */
379
583
  #resetToClosedState() {
380
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}. */
@@ -50,6 +136,8 @@ var FocusTrap = class {
50
136
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
51
137
  document.addEventListener("keydown", this.#onKeydown);
52
138
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
139
+ const onEscape = this.#options.onEscape;
140
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
53
141
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
54
142
  }
55
143
  /**
@@ -62,6 +150,7 @@ var FocusTrap = class {
62
150
  deactivate({ restoreFocus = true } = {}) {
63
151
  if (!this.#activeState) return;
64
152
  this.#activeState = false;
153
+ this.#escapeLayer.deactivate();
65
154
  document.removeEventListener("keydown", this.#onKeydown);
66
155
  document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
67
156
  if (this.#scrollLocked) {
@@ -90,15 +179,12 @@ var FocusTrap = class {
90
179
  #onBeforeCache = () => {
91
180
  this.deactivate({ restoreFocus: false });
92
181
  };
93
- /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
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
+ */
94
187
  #onKeydown = (event) => {
95
- if (event.key === "Escape") {
96
- if (this.#options.onEscape) {
97
- event.preventDefault();
98
- this.#options.onEscape();
99
- }
100
- return;
101
- }
102
188
  if (event.key === "Tab") this.#trapTab(event);
103
189
  };
104
190
  /** Keeps `Tab` focus cycling within the container's focusable elements. */