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,90 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/focus_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. */
@@ -181,8 +181,8 @@ var FormValidationController = class _FormValidationController extends Controlle
181
181
  }
182
182
  /**
183
183
  * Applies (or clears) a declarative custom constraint via `setCustomValidity`,
184
- * for controls that opt in with `data-stimeo--form-field-disallow`. The one
185
- * supported rule today is `"whitespace"` — a value that is non-empty but blank
184
+ * for controls that opt in with `data-stimeo--form-field-disallow`. The supported
185
+ * rule is `"whitespace"` — a value that is non-empty but blank
186
186
  * after trimming (which slips past `required` / `minlength`); its message follows
187
187
  * the per-constraint (`value-missing`) → generic → default chain.
188
188
  *
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/hover_card_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/safe_timeout.ts
6
90
  var TimerRegistry = class {
7
91
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -88,21 +172,26 @@ var HoverCardController = class extends Controller {
88
172
  closeDelay: { type: Number, default: 200 },
89
173
  closeOnScroll: { type: Boolean, default: false }
90
174
  };
91
- static actions = ["close", "onKeydown", "open"];
92
- /** Pending open/close timers, torn down together on disconnect. */
175
+ static actions = ["close", "open"];
176
+ /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
93
177
  #timers = new SafeTimeout();
178
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
179
+ #escapeLayer = new EscapeLayer();
94
180
  #pendingOpen = null;
95
181
  #pendingClose = null;
96
182
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
97
183
  #stopScrollDismiss = null;
98
- /** Starts closed. */
184
+ /** Starts closed and discards any stale pending state from a prior connection. */
99
185
  connect() {
186
+ this.#cancelOpen();
187
+ this.#cancelClose();
100
188
  this.#conceal();
101
189
  }
102
- /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
190
+ /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
103
191
  disconnect() {
104
- this.#timers.clearAll();
105
- document.removeEventListener("keydown", this.#onDocumentKeydown);
192
+ this.#cancelOpen();
193
+ this.#cancelClose();
194
+ this.#escapeLayer.deactivate();
106
195
  this.#stopScrollDismiss?.();
107
196
  this.#stopScrollDismiss = null;
108
197
  }
@@ -134,32 +223,26 @@ var HoverCardController = class extends Controller {
134
223
  this.#conceal();
135
224
  }, this.closeDelayValue);
136
225
  }
137
- /** Closes immediately on `Escape` while open (keyboard dismissal from the trigger). */
138
- onKeydown(event) {
139
- if (event.key === "Escape" && this.#isOpen) {
140
- event.preventDefault();
141
- this.#dismiss();
142
- }
143
- }
144
- /** Reveals the card, reflects state, and starts watching for a dismissing `Escape`/scroll. */
226
+ /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
145
227
  #reveal() {
146
228
  if (!this.hasCardTarget) return;
147
229
  this.cardTarget.hidden = false;
148
230
  this.cardTarget.setAttribute("data-state", "open");
149
231
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
150
- document.addEventListener("keydown", this.#onDocumentKeydown);
232
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
151
233
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
152
234
  this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
153
235
  }
154
236
  }
155
- /** Hides the card, reflects state, and stops watching for `Escape`/scroll. */
237
+ /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
156
238
  #conceal() {
157
- document.removeEventListener("keydown", this.#onDocumentKeydown);
239
+ this.#escapeLayer.deactivate();
158
240
  this.#stopScrollDismiss?.();
159
241
  this.#stopScrollDismiss = null;
160
- if (!this.hasCardTarget) return;
161
- this.cardTarget.hidden = true;
162
- this.cardTarget.setAttribute("data-state", "closed");
242
+ if (this.hasCardTarget) {
243
+ this.cardTarget.hidden = true;
244
+ this.cardTarget.setAttribute("data-state", "closed");
245
+ }
163
246
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
164
247
  }
165
248
  /** Cancels pending timers and conceals immediately (shared Escape path). */
@@ -168,13 +251,6 @@ var HoverCardController = class extends Controller {
168
251
  this.#cancelClose();
169
252
  this.#conceal();
170
253
  }
171
- /** Document-level `Escape` watcher (active only while open). */
172
- #onDocumentKeydown = (event) => {
173
- if (event.key === "Escape") {
174
- event.preventDefault();
175
- this.#dismiss();
176
- }
177
- };
178
254
  /** Cancels any pending open timer. */
179
255
  #cancelOpen() {
180
256
  if (this.#pendingOpen !== null) {
@@ -145,6 +145,7 @@ var ListboxController = class extends Controller {
145
145
  this.#commitActive();
146
146
  break;
147
147
  case "Escape":
148
+ if (event.defaultPrevented || event.isComposing) break;
148
149
  event.preventDefault();
149
150
  this.close();
150
151
  this.triggerTarget.focus();
@@ -1,5 +1,150 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/menu_controller.ts
4
+
5
+ // src/utils/escape_layer.ts
6
+ function claimsWhileFocusWithin(element) {
7
+ return () => {
8
+ const active = element.ownerDocument.activeElement;
9
+ return active === null || active === element.ownerDocument.body || element.contains(active);
10
+ };
11
+ }
12
+ var EscapeLayer = class _EscapeLayer {
13
+ static #registries = /* @__PURE__ */ new WeakMap();
14
+ #ownerDocument = null;
15
+ /** Dismissal callback while active; `null` when inactive. */
16
+ #onDismiss = null;
17
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
18
+ #claims = null;
19
+ /**
20
+ * Activates this layer at the top of its document's Escape stack, installing
21
+ * the document's shared resolver listener if this is its first layer.
22
+ * Re-activating an already-active layer moves it to the top.
23
+ */
24
+ activate(ownerDocument = document, options) {
25
+ this.deactivate();
26
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
27
+ if (!registry) {
28
+ registry = _EscapeLayer.#createRegistry();
29
+ _EscapeLayer.#registries.set(ownerDocument, registry);
30
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
31
+ }
32
+ registry.stack.push(this);
33
+ this.#ownerDocument = ownerDocument;
34
+ this.#onDismiss = options.onDismiss;
35
+ this.#claims = options.claims ?? null;
36
+ }
37
+ /**
38
+ * Removes this layer from its document's Escape stack, uninstalling the
39
+ * shared listener when the stack empties. Safe to call when inactive.
40
+ */
41
+ deactivate() {
42
+ const ownerDocument = this.#ownerDocument;
43
+ if (!ownerDocument) return;
44
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
45
+ if (registry) {
46
+ const index = registry.stack.lastIndexOf(this);
47
+ if (index >= 0) registry.stack.splice(index, 1);
48
+ if (registry.stack.length === 0) {
49
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
50
+ _EscapeLayer.#registries.delete(ownerDocument);
51
+ }
52
+ }
53
+ this.#ownerDocument = null;
54
+ this.#onDismiss = null;
55
+ this.#claims = null;
56
+ }
57
+ /**
58
+ * Whether this active layer would own a press right now: it is the topmost
59
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
60
+ * and diagnostics — production dismissal goes through the shared listener.
61
+ */
62
+ get ownsEscape() {
63
+ const ownerDocument = this.#ownerDocument;
64
+ if (!ownerDocument) return false;
65
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
66
+ if (!registry) return false;
67
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
68
+ }
69
+ /** Builds a document's registry with its shared resolver listener. */
70
+ static #createRegistry() {
71
+ const registry = {
72
+ stack: [],
73
+ onKeydown: (event) => {
74
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
75
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
76
+ if (!owner) return;
77
+ event.preventDefault();
78
+ owner.#onDismiss?.();
79
+ }
80
+ };
81
+ return registry;
82
+ }
83
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
84
+ static #resolveOwner(stack) {
85
+ for (let index = stack.length - 1; index >= 0; index--) {
86
+ const layer = stack[index];
87
+ if (!layer) continue;
88
+ if (layer.#claims && !layer.#claims()) continue;
89
+ return layer;
90
+ }
91
+ return null;
92
+ }
93
+ };
94
+
95
+ // src/utils/safe_timeout.ts
96
+ var TimerRegistry = class {
97
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
98
+ ids = /* @__PURE__ */ new Set();
99
+ /**
100
+ * Cancels a single tracked timer.
101
+ *
102
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
103
+ * registry), so callers can clear defensively without guarding.
104
+ */
105
+ clear(id) {
106
+ if (this.ids.delete(id)) {
107
+ this.cancel(id);
108
+ }
109
+ }
110
+ /**
111
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
112
+ * to guarantee no timer outlives the element.
113
+ */
114
+ clearAll() {
115
+ for (const id of this.ids) {
116
+ this.cancel(id);
117
+ }
118
+ this.ids.clear();
119
+ }
120
+ /** Number of timers currently tracked (pending). */
121
+ get size() {
122
+ return this.ids.size;
123
+ }
124
+ };
125
+ var SafeTimeout = class extends TimerRegistry {
126
+ /**
127
+ * Schedules `callback` after `delay` ms and returns the timer id.
128
+ *
129
+ * The id is removed from the registry automatically when the timeout fires,
130
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
131
+ */
132
+ set(callback, delay) {
133
+ const id = this.schedule(() => {
134
+ this.ids.delete(id);
135
+ callback();
136
+ }, delay);
137
+ this.ids.add(id);
138
+ return id;
139
+ }
140
+ schedule(callback, delay) {
141
+ return window.setTimeout(callback, delay);
142
+ }
143
+ cancel(id) {
144
+ window.clearTimeout(id);
145
+ }
146
+ };
147
+
3
148
  // src/controllers/menu_controller.ts
4
149
  var MenuController = class extends Controller {
5
150
  static targets = ["trigger", "menu", "item"];
@@ -11,13 +156,20 @@ var MenuController = class extends Controller {
11
156
  "open",
12
157
  "toggle"
13
158
  ];
14
- /** Starts closed and registers the outside-click listener. */
159
+ #timers = new SafeTimeout();
160
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
161
+ #escapeLayer = new EscapeLayer();
162
+ /** Starts closed and registers delegated listeners. */
15
163
  connect() {
16
164
  this.close();
165
+ this.element.addEventListener("click", this.#onItemClickCapture, true);
17
166
  document.addEventListener("click", this.#onOutsideClick);
18
167
  }
19
- /** Removes the document-level listener registered in {@link connect}. */
168
+ /** Releases the listeners, stack membership, and any pending Tab-close task. */
20
169
  disconnect() {
170
+ this.#timers.clearAll();
171
+ this.#escapeLayer.deactivate();
172
+ this.element.removeEventListener("click", this.#onItemClickCapture, true);
21
173
  document.removeEventListener("click", this.#onOutsideClick);
22
174
  }
23
175
  /** Toggles the menu open/closed. Bound via `data-action` (click). */
@@ -31,12 +183,19 @@ var MenuController = class extends Controller {
31
183
  }
32
184
  /** Opens the menu and reflects the expanded state on the trigger. */
33
185
  open() {
186
+ this.#timers.clearAll();
34
187
  if (!this.hasMenuTarget) return;
188
+ this.#escapeLayer.activate(document, {
189
+ onDismiss: () => this.#closeAndRestore(),
190
+ claims: claimsWhileFocusWithin(this.element)
191
+ });
35
192
  this.menuTarget.hidden = false;
36
193
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
37
194
  }
38
195
  /** Closes the menu and reflects the collapsed state on the trigger. */
39
196
  close() {
197
+ this.#timers.clearAll();
198
+ this.#escapeLayer.deactivate();
40
199
  if (!this.hasMenuTarget) return;
41
200
  this.menuTarget.hidden = true;
42
201
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
@@ -81,18 +240,18 @@ var MenuController = class extends Controller {
81
240
  event.preventDefault();
82
241
  items[items.length - 1]?.focus();
83
242
  break;
84
- case "Escape":
85
- event.preventDefault();
86
- this.close();
87
- if (this.hasTriggerTarget) this.triggerTarget.focus();
88
- break;
89
243
  case "Tab":
90
- this.close();
244
+ this.#timers.clearAll();
245
+ this.#timers.set(() => this.close(), 0);
91
246
  break;
92
247
  }
93
248
  }
94
249
  /** Closes the menu after an item is activated. Bound via `data-action`. */
95
250
  activate() {
251
+ this.#closeAndRestore();
252
+ }
253
+ /** Closes and returns focus to the trigger (Escape / item-activation path). */
254
+ #closeAndRestore() {
96
255
  this.close();
97
256
  if (this.hasTriggerTarget) this.triggerTarget.focus();
98
257
  }
@@ -100,6 +259,20 @@ var MenuController = class extends Controller {
100
259
  #onOutsideClick = (event) => {
101
260
  if (this.#isOpen && !this.element.contains(event.target)) this.close();
102
261
  };
262
+ /**
263
+ * Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
264
+ * Native Enter/Space activation also synthesizes a click and is blocked here.
265
+ */
266
+ #onItemClickCapture = (event) => {
267
+ const target = event.target;
268
+ if (!(target instanceof Node)) return;
269
+ const disabled = this.itemTargets.some(
270
+ (item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
271
+ );
272
+ if (!disabled) return;
273
+ event.preventDefault();
274
+ event.stopImmediatePropagation();
275
+ };
103
276
  /** Moves focus to the first navigable item (no-op if none). */
104
277
  #focusFirst() {
105
278
  this.#navigableItems[0]?.focus();