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.
@@ -1,17 +1,171 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/context_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/context_menu_controller.ts
4
149
  var ContextMenuController = class extends Controller {
5
150
  static targets = ["region", "menu", "item"];
6
151
  static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
7
- /** Starts closed and registers the outside-click listener. */
152
+ #timers = new SafeTimeout();
153
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
154
+ #escapeLayer = new EscapeLayer();
155
+ /** Starts closed and registers delegated activation and outside-pointer listeners. */
8
156
  connect() {
9
157
  this.#closeMenu();
10
- document.addEventListener("click", this.#onOutsideClick);
158
+ this.element.addEventListener("click", this.#onItemClickCapture, true);
159
+ document.addEventListener("click", this.#onOutsidePointer);
160
+ document.addEventListener("contextmenu", this.#onOutsidePointer);
11
161
  }
12
- /** Removes the document-level listener registered in {@link connect}. */
162
+ /** Releases the listeners, stack membership, and pending Tab-close task. */
13
163
  disconnect() {
14
- document.removeEventListener("click", this.#onOutsideClick);
164
+ this.#timers.clearAll();
165
+ this.#escapeLayer.deactivate();
166
+ this.element.removeEventListener("click", this.#onItemClickCapture, true);
167
+ document.removeEventListener("click", this.#onOutsidePointer);
168
+ document.removeEventListener("contextmenu", this.#onOutsidePointer);
15
169
  }
16
170
  /**
17
171
  * Opens the menu from a `contextmenu` event: suppresses the native menu and
@@ -36,11 +190,17 @@ var ContextMenuController = class extends Controller {
36
190
  switch (event.key) {
37
191
  case "ArrowDown":
38
192
  event.preventDefault();
39
- if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();
193
+ if (items.length > 0) {
194
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
195
+ items[nextIndex]?.focus();
196
+ }
40
197
  break;
41
198
  case "ArrowUp":
42
199
  event.preventDefault();
43
- if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();
200
+ if (items.length > 0) {
201
+ const previousIndex = currentIndex < 0 ? items.length - 1 : currentIndex - 1;
202
+ items[(previousIndex + items.length) % items.length]?.focus();
203
+ }
44
204
  break;
45
205
  case "Home":
46
206
  event.preventDefault();
@@ -50,12 +210,9 @@ var ContextMenuController = class extends Controller {
50
210
  event.preventDefault();
51
211
  items[items.length - 1]?.focus();
52
212
  break;
53
- case "Escape":
54
- event.preventDefault();
55
- this.#closeAndRestore();
56
- break;
57
213
  case "Tab":
58
- this.#closeMenu();
214
+ this.#timers.clearAll();
215
+ this.#timers.set(() => this.#closeMenu(), 0);
59
216
  break;
60
217
  }
61
218
  }
@@ -66,6 +223,11 @@ var ContextMenuController = class extends Controller {
66
223
  /** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
67
224
  #openAt(x, y) {
68
225
  if (!this.hasMenuTarget) return;
226
+ this.#timers.clearAll();
227
+ this.#escapeLayer.activate(document, {
228
+ onDismiss: () => this.#closeAndRestore(),
229
+ claims: claimsWhileFocusWithin(this.element)
230
+ });
69
231
  this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
70
232
  this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
71
233
  this.menuTarget.hidden = false;
@@ -74,6 +236,8 @@ var ContextMenuController = class extends Controller {
74
236
  }
75
237
  /** Hides the menu and reflects the collapsed state on the region. */
76
238
  #closeMenu() {
239
+ this.#timers.clearAll();
240
+ this.#escapeLayer.deactivate();
77
241
  if (!this.hasMenuTarget) return;
78
242
  this.menuTarget.hidden = true;
79
243
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
@@ -83,10 +247,24 @@ var ContextMenuController = class extends Controller {
83
247
  this.#closeMenu();
84
248
  if (this.hasRegionTarget) this.regionTarget.focus();
85
249
  }
86
- /** Closes the menu when a click lands outside the controller's element. */
87
- #onOutsideClick = (event) => {
250
+ /** Closes when a click or context-menu invocation lands outside this instance. */
251
+ #onOutsidePointer = (event) => {
88
252
  if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
89
253
  };
254
+ /**
255
+ * Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
256
+ * Native Enter/Space activation also synthesizes a click and is blocked here.
257
+ */
258
+ #onItemClickCapture = (event) => {
259
+ const target = event.target;
260
+ if (!(target instanceof Node)) return;
261
+ const disabled = this.itemTargets.some(
262
+ (item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
263
+ );
264
+ if (!disabled) return;
265
+ event.preventDefault();
266
+ event.stopImmediatePropagation();
267
+ };
90
268
  /** Menu items eligible for roving focus (excludes disabled / hidden). */
91
269
  get #navigableItems() {
92
270
  return this.itemTargets.filter((item) => this.#isNavigable(item));
@@ -107,8 +107,8 @@ var CountdownController = class extends Controller {
107
107
  * paused (or completed) one resets the displayed amount but stays paused until the
108
108
  * user resumes — it never silently restarts. The run state is read from the DOM,
109
109
  * not re-derived from the declarative `autostart` Value (which only governs the
110
- * initial state on connect); re-deriving it would override a user's pause, the same
111
- * anti-pattern the Turbo-lifecycle guide warns about.
110
+ * initial state on connect); re-deriving it would override a user's pause
111
+ * the DOM, not a re-run of declarative config, is the source of truth.
112
112
  */
113
113
  reset() {
114
114
  const wasRunning = this.#state === "running";
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dialog_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. */
@@ -1,35 +1,136 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/dropdown_controller.ts
4
+
5
+ // src/utils/aria_ids.ts
6
+ var counter = 0;
7
+ function uniqueId(prefix = "stimeo") {
8
+ let candidate;
9
+ do {
10
+ counter += 1;
11
+ candidate = `${prefix}-${counter}`;
12
+ } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
13
+ return candidate;
14
+ }
15
+ function ensureId(element, prefix = "stimeo") {
16
+ if (element.id) return element.id;
17
+ const id = uniqueId(prefix);
18
+ element.id = id;
19
+ return id;
20
+ }
21
+
22
+ // src/utils/escape_layer.ts
23
+ function claimsWhileFocusWithin(element) {
24
+ return () => {
25
+ const active = element.ownerDocument.activeElement;
26
+ return active === null || active === element.ownerDocument.body || element.contains(active);
27
+ };
28
+ }
29
+ var EscapeLayer = class _EscapeLayer {
30
+ static #registries = /* @__PURE__ */ new WeakMap();
31
+ #ownerDocument = null;
32
+ /** Dismissal callback while active; `null` when inactive. */
33
+ #onDismiss = null;
34
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
35
+ #claims = null;
36
+ /**
37
+ * Activates this layer at the top of its document's Escape stack, installing
38
+ * the document's shared resolver listener if this is its first layer.
39
+ * Re-activating an already-active layer moves it to the top.
40
+ */
41
+ activate(ownerDocument = document, options) {
42
+ this.deactivate();
43
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
44
+ if (!registry) {
45
+ registry = _EscapeLayer.#createRegistry();
46
+ _EscapeLayer.#registries.set(ownerDocument, registry);
47
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
48
+ }
49
+ registry.stack.push(this);
50
+ this.#ownerDocument = ownerDocument;
51
+ this.#onDismiss = options.onDismiss;
52
+ this.#claims = options.claims ?? null;
53
+ }
54
+ /**
55
+ * Removes this layer from its document's Escape stack, uninstalling the
56
+ * shared listener when the stack empties. Safe to call when inactive.
57
+ */
58
+ deactivate() {
59
+ const ownerDocument = this.#ownerDocument;
60
+ if (!ownerDocument) return;
61
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
62
+ if (registry) {
63
+ const index = registry.stack.lastIndexOf(this);
64
+ if (index >= 0) registry.stack.splice(index, 1);
65
+ if (registry.stack.length === 0) {
66
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
67
+ _EscapeLayer.#registries.delete(ownerDocument);
68
+ }
69
+ }
70
+ this.#ownerDocument = null;
71
+ this.#onDismiss = null;
72
+ this.#claims = null;
73
+ }
74
+ /**
75
+ * Whether this active layer would own a press right now: it is the topmost
76
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
77
+ * and diagnostics — production dismissal goes through the shared listener.
78
+ */
79
+ get ownsEscape() {
80
+ const ownerDocument = this.#ownerDocument;
81
+ if (!ownerDocument) return false;
82
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
83
+ if (!registry) return false;
84
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
85
+ }
86
+ /** Builds a document's registry with its shared resolver listener. */
87
+ static #createRegistry() {
88
+ const registry = {
89
+ stack: [],
90
+ onKeydown: (event) => {
91
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
92
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
93
+ if (!owner) return;
94
+ event.preventDefault();
95
+ owner.#onDismiss?.();
96
+ }
97
+ };
98
+ return registry;
99
+ }
100
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
101
+ static #resolveOwner(stack) {
102
+ for (let index = stack.length - 1; index >= 0; index--) {
103
+ const layer = stack[index];
104
+ if (!layer) continue;
105
+ if (layer.#claims && !layer.#claims()) continue;
106
+ return layer;
107
+ }
108
+ return null;
109
+ }
110
+ };
111
+
3
112
  // src/controllers/dropdown_controller.ts
4
113
  var DropdownController = class extends Controller {
5
114
  static targets = ["trigger", "menu"];
6
115
  static actions = ["close", "open", "toggle"];
116
+ /** Escape-stack membership while open; the shared resolver dismisses via it. */
117
+ #escapeLayer = new EscapeLayer();
7
118
  /** Closes the menu when a click lands outside the controller's element. */
8
119
  #onOutsideClick = (event) => {
9
120
  if (!this.element.contains(event.target)) {
10
121
  this.close();
11
122
  }
12
123
  };
13
- /** Closes the menu on `Escape` and restores focus to the trigger. */
14
- #onKeydown = (event) => {
15
- if (event.key === "Escape" && this.#isOpen) {
16
- this.close();
17
- if (this.hasTriggerTarget) this.triggerTarget.focus();
18
- }
19
- };
20
- /**
21
- * Starts in the closed state and registers the document-level listeners that
22
- * power outside-click and `Escape` handling.
23
- */
124
+ /** Starts in the closed state and registers outside-click handling. */
24
125
  connect() {
126
+ this.#associateTriggerWithMenu();
25
127
  this.close();
26
- document.addEventListener("click", this.#onOutsideClick);
27
- document.addEventListener("keydown", this.#onKeydown);
128
+ document.addEventListener("click", this.#onOutsideClick, true);
28
129
  }
29
- /** Removes the document-level listeners registered in {@link connect}. */
130
+ /** Removes the listeners registered in {@link connect}. */
30
131
  disconnect() {
31
- document.removeEventListener("click", this.#onOutsideClick);
32
- document.removeEventListener("keydown", this.#onKeydown);
132
+ this.#escapeLayer.deactivate();
133
+ document.removeEventListener("click", this.#onOutsideClick, true);
33
134
  }
34
135
  /** Toggles the menu between open and closed. Bound via `data-action`. */
35
136
  toggle() {
@@ -42,6 +143,10 @@ var DropdownController = class extends Controller {
42
143
  /** Reveals the menu and reflects the open state on the trigger. */
43
144
  open() {
44
145
  if (!this.hasMenuTarget) return;
146
+ this.#escapeLayer.activate(document, {
147
+ onDismiss: () => this.#closeAndRestore(),
148
+ claims: claimsWhileFocusWithin(this.element)
149
+ });
45
150
  this.menuTarget.hidden = false;
46
151
  if (this.hasTriggerTarget) {
47
152
  this.triggerTarget.setAttribute("aria-expanded", "true");
@@ -49,16 +154,34 @@ var DropdownController = class extends Controller {
49
154
  }
50
155
  /** Hides the menu and reflects the closed state on the trigger. */
51
156
  close() {
157
+ this.#escapeLayer.deactivate();
52
158
  if (!this.hasMenuTarget) return;
53
159
  this.menuTarget.hidden = true;
54
160
  if (this.hasTriggerTarget) {
55
161
  this.triggerTarget.setAttribute("aria-expanded", "false");
56
162
  }
57
163
  }
164
+ /** Closes and restores focus to the trigger (the keyboard-dismissal path). */
165
+ #closeAndRestore() {
166
+ this.close();
167
+ if (this.hasTriggerTarget) this.triggerTarget.focus();
168
+ }
58
169
  /** Whether the menu is currently visible. */
59
170
  get #isOpen() {
60
171
  return this.hasMenuTarget && !this.menuTarget.hidden;
61
172
  }
173
+ /**
174
+ * Associates the disclosure trigger and controlled region without clobbering
175
+ * authored markup.
176
+ */
177
+ #associateTriggerWithMenu() {
178
+ if (!this.hasTriggerTarget || !this.hasMenuTarget) return;
179
+ if (this.triggerTarget.hasAttribute("aria-controls")) return;
180
+ this.triggerTarget.setAttribute(
181
+ "aria-controls",
182
+ ensureId(this.menuTarget, "stimeo--dropdown-menu")
183
+ );
184
+ }
62
185
  };
63
186
 
64
187
  export { DropdownController };