stimeo-ui 0.16.0 → 0.17.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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,14 +1,157 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/menu_controller.ts
4
-
5
- // src/utils/arrow_step.ts
6
3
  function isReservedArrowChord(event, allow = []) {
7
4
  if (!event.key.startsWith("Arrow")) return false;
8
5
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
6
  }
10
7
 
11
- // src/utils/escape_layer.ts
8
+ function sharedRegistry(key, create) {
9
+ const symbol = Symbol.for(key);
10
+ const scope = globalThis;
11
+ const existing = scope[symbol];
12
+ if (existing !== void 0) return existing;
13
+ const registry = create();
14
+ Object.defineProperty(globalThis, symbol, { value: registry });
15
+ return registry;
16
+ }
17
+
18
+ function isAttributeValue(value) {
19
+ return value === null || typeof value === "string";
20
+ }
21
+ function readLeaseRecord(element, record) {
22
+ try {
23
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
24
+ if (!Array.isArray(parsed)) return void 0;
25
+ const [original, written, ...rest] = parsed;
26
+ const linked = rest[0] !== false;
27
+ const beneath = linked ? rest : rest.slice(1);
28
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
29
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
30
+ } catch {
31
+ return void 0;
32
+ }
33
+ }
34
+ var AttributeLease = class _AttributeLease {
35
+ static #registry = sharedRegistry(
36
+ "stimeo-ui.attribute-lease.registry.v1",
37
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
38
+ );
39
+ #attribute;
40
+ #record;
41
+ #base;
42
+ #held = /* @__PURE__ */ new Set();
43
+ constructor(attribute, owner, options = {}) {
44
+ this.#attribute = attribute;
45
+ this.#record = `data-${owner}-${attribute}-lease`;
46
+ this.#base = options.base === true;
47
+ const names = _AttributeLease.#registry.names;
48
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
49
+ }
50
+ write(element, value) {
51
+ const stack = this.#stack(element);
52
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
53
+ this.#held.add(element);
54
+ const shows = !this.#covered(element, stack, hold);
55
+ hold.written = value;
56
+ this.#mark(element, stack);
57
+ if (shows) this.#reflect(element, value);
58
+ }
59
+ return(element) {
60
+ const held = this.#held.delete(element);
61
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
62
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
63
+ const hold = this.#take(stack);
64
+ if (!hold) return false;
65
+ const carries = element.getAttribute(this.#attribute) === hold.written;
66
+ const owns = carries && !this.#covered(element, stack, hold);
67
+ const index = stack.indexOf(hold);
68
+ stack.splice(index, 1);
69
+ const above = stack[index];
70
+ if (above) above.linked &&= hold.linked;
71
+ this.#mark(element, stack);
72
+ if (!owns) return true;
73
+ const below = hold.linked ? stack[index - 1] : void 0;
74
+ this.#reflect(element, below ? below.written : hold.original);
75
+ return true;
76
+ }
77
+ returnAll() {
78
+ for (const element of Array.from(this.#held)) this.return(element);
79
+ }
80
+ #stack(element) {
81
+ const stacks = _AttributeLease.#registry.stacks;
82
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
83
+ stacks.set(element, byAttribute);
84
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
85
+ byAttribute.set(this.#attribute, stack);
86
+ return stack;
87
+ }
88
+ #seed(element) {
89
+ const found = [];
90
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
91
+ const read = readLeaseRecord(element, record);
92
+ if (read) found.push({ ...read, record });
93
+ }
94
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
95
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
96
+ lease: null,
97
+ record,
98
+ written,
99
+ original,
100
+ linked: linked && index > 0
101
+ }));
102
+ }
103
+ #take(stack) {
104
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
105
+ if (hold) hold.lease = this;
106
+ return hold;
107
+ }
108
+ #acquire(element, stack) {
109
+ const current = element.getAttribute(this.#attribute);
110
+ const top = stack.at(-1);
111
+ const bottom = stack[0];
112
+ const over = top?.written === current ? top : void 0;
113
+ const from = this.#base ? bottom : over;
114
+ const hold = {
115
+ lease: this,
116
+ record: this.#record,
117
+ written: current,
118
+ original: from ? from.original : current,
119
+ linked: !this.#base && over !== void 0
120
+ };
121
+ if (this.#base && bottom) {
122
+ bottom.linked = true;
123
+ stack.unshift(hold);
124
+ } else stack.push(hold);
125
+ return hold;
126
+ }
127
+ #covered(element, stack, hold) {
128
+ const current = element.getAttribute(this.#attribute);
129
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
130
+ }
131
+ #mark(element, stack) {
132
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
133
+ stack.forEach((hold, index) => {
134
+ const names = stack.slice(0, index).map((under) => under.record);
135
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
136
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
137
+ records.set(
138
+ hold.record,
139
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
140
+ );
141
+ });
142
+ for (const [name, recorded] of records) {
143
+ if (element.getAttribute(name) === recorded) continue;
144
+ if (recorded === null) element.removeAttribute(name);
145
+ else element.setAttribute(name, recorded);
146
+ }
147
+ }
148
+ #reflect(element, value) {
149
+ if (element.getAttribute(this.#attribute) === value) return;
150
+ if (value === null) element.removeAttribute(this.#attribute);
151
+ else element.setAttribute(this.#attribute, value);
152
+ }
153
+ };
154
+
12
155
  function claimsWhileFocusWithin(element) {
13
156
  return () => {
14
157
  const active = element.ownerDocument.activeElement;
@@ -16,17 +159,11 @@ function claimsWhileFocusWithin(element) {
16
159
  };
17
160
  }
18
161
  var EscapeLayer = class _EscapeLayer {
19
- static #registries = /* @__PURE__ */ new WeakMap();
20
- #ownerDocument = null;
21
- /** Dismissal callback while active; `null` when inactive. */
22
- #onDismiss = null;
23
- /** Live predicate deciding whether the layer claims a press; `null` = always. */
24
- #claims = null;
25
- /**
26
- * Activates this layer at the top of its document's Escape stack, installing
27
- * the document's shared resolver listener if this is its first layer.
28
- * Re-activating an already-active layer moves it to the top.
29
- */
162
+ static #registries = sharedRegistry(
163
+ "stimeo-ui.escape-layer.registry.v1",
164
+ () => /* @__PURE__ */ new WeakMap()
165
+ );
166
+ #active = null;
30
167
  activate(ownerDocument = document, options) {
31
168
  this.deactivate();
32
169
  let registry = _EscapeLayer.#registries.get(ownerDocument);
@@ -35,44 +172,31 @@ var EscapeLayer = class _EscapeLayer {
35
172
  _EscapeLayer.#registries.set(ownerDocument, registry);
36
173
  ownerDocument.addEventListener("keydown", registry.onKeydown);
37
174
  }
38
- registry.stack.push(this);
39
- this.#ownerDocument = ownerDocument;
40
- this.#onDismiss = options.onDismiss;
41
- this.#claims = options.claims ?? null;
42
- }
43
- /**
44
- * Removes this layer from its document's Escape stack, uninstalling the
45
- * shared listener when the stack empties. Safe to call when inactive.
46
- */
175
+ const entry = { onDismiss: options.onDismiss, claims: options.claims ?? null };
176
+ registry.stack.push(entry);
177
+ this.#active = { document: ownerDocument, entry };
178
+ }
47
179
  deactivate() {
48
- const ownerDocument = this.#ownerDocument;
49
- if (!ownerDocument) return;
50
- const registry = _EscapeLayer.#registries.get(ownerDocument);
180
+ const active = this.#active;
181
+ if (!active) return;
182
+ const registry = _EscapeLayer.#registries.get(active.document);
51
183
  if (registry) {
52
- const index = registry.stack.lastIndexOf(this);
184
+ const index = registry.stack.lastIndexOf(active.entry);
53
185
  if (index >= 0) registry.stack.splice(index, 1);
54
186
  if (registry.stack.length === 0) {
55
- ownerDocument.removeEventListener("keydown", registry.onKeydown);
56
- _EscapeLayer.#registries.delete(ownerDocument);
187
+ active.document.removeEventListener("keydown", registry.onKeydown);
188
+ _EscapeLayer.#registries.delete(active.document);
57
189
  }
58
190
  }
59
- this.#ownerDocument = null;
60
- this.#onDismiss = null;
61
- this.#claims = null;
62
- }
63
- /**
64
- * Whether this active layer would own a press right now: it is the topmost
65
- * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
66
- * and diagnostics — production dismissal goes through the shared listener.
67
- */
191
+ this.#active = null;
192
+ }
68
193
  get ownsEscape() {
69
- const ownerDocument = this.#ownerDocument;
70
- if (!ownerDocument) return false;
71
- const registry = _EscapeLayer.#registries.get(ownerDocument);
194
+ const active = this.#active;
195
+ if (!active) return false;
196
+ const registry = _EscapeLayer.#registries.get(active.document);
72
197
  if (!registry) return false;
73
- return _EscapeLayer.#resolveOwner(registry.stack) === this;
198
+ return _EscapeLayer.#resolveOwner(registry.stack) === active.entry;
74
199
  }
75
- /** Builds a document's registry with its shared resolver listener. */
76
200
  static #createRegistry() {
77
201
  const registry = {
78
202
  stack: [],
@@ -81,24 +205,22 @@ var EscapeLayer = class _EscapeLayer {
81
205
  const owner = _EscapeLayer.#resolveOwner(registry.stack);
82
206
  if (!owner) return;
83
207
  event.preventDefault();
84
- owner.#onDismiss?.();
208
+ owner.onDismiss();
85
209
  }
86
210
  };
87
211
  return registry;
88
212
  }
89
- /** The topmost stack layer whose claims predicate passes, or `null`. */
90
213
  static #resolveOwner(stack) {
91
214
  for (let index = stack.length - 1; index >= 0; index--) {
92
215
  const layer = stack[index];
93
216
  if (!layer) continue;
94
- if (layer.#claims && !layer.#claims()) continue;
217
+ if (layer.claims && !layer.claims()) continue;
95
218
  return layer;
96
219
  }
97
220
  return null;
98
221
  }
99
222
  };
100
223
 
101
- // src/utils/event_owner.ts
102
224
  function ownerIndex(candidates, node) {
103
225
  if (!(node instanceof Node)) return -1;
104
226
  return candidates.findIndex((candidate) => candidate.contains(node));
@@ -107,43 +229,24 @@ function ownerOf(candidates, node) {
107
229
  return candidates[ownerIndex(candidates, node)] ?? null;
108
230
  }
109
231
 
110
- // src/utils/safe_timeout.ts
111
232
  var TimerRegistry = class {
112
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
113
233
  ids = /* @__PURE__ */ new Set();
114
- /**
115
- * Cancels a single tracked timer.
116
- *
117
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
118
- * registry), so callers can clear defensively without guarding.
119
- */
120
234
  clear(id) {
121
235
  if (this.ids.delete(id)) {
122
236
  this.cancel(id);
123
237
  }
124
238
  }
125
- /**
126
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
127
- * to guarantee no timer outlives the element.
128
- */
129
239
  clearAll() {
130
240
  for (const id of this.ids) {
131
241
  this.cancel(id);
132
242
  }
133
243
  this.ids.clear();
134
244
  }
135
- /** Number of timers currently tracked (pending). */
136
245
  get size() {
137
246
  return this.ids.size;
138
247
  }
139
248
  };
140
249
  var SafeTimeout = class extends TimerRegistry {
141
- /**
142
- * Schedules `callback` after `delay` ms and returns the timer id.
143
- *
144
- * The id is removed from the registry automatically when the timeout fires,
145
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
146
- */
147
250
  set(callback, delay) {
148
251
  const id = this.schedule(() => {
149
252
  this.ids.delete(id);
@@ -160,7 +263,6 @@ var SafeTimeout = class extends TimerRegistry {
160
263
  }
161
264
  };
162
265
 
163
- // src/utils/state_reason.ts
164
266
  var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
165
267
  var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
166
268
  function stateReasonFor(event) {
@@ -170,7 +272,6 @@ function stateReasonFor(event) {
170
272
  return "user";
171
273
  }
172
274
 
173
- // src/controllers/menu_controller.ts
174
275
  var MenuController = class extends Controller {
175
276
  static targets = ["trigger", "menu", "item"];
176
277
  static actions = [
@@ -183,29 +284,25 @@ var MenuController = class extends Controller {
183
284
  ];
184
285
  static events = ["close", "open"];
185
286
  #timers = new SafeTimeout();
186
- /** Escape-stack membership while open; the shared resolver dismisses via it. */
187
287
  #escapeLayer = new EscapeLayer();
188
- /**
189
- * The item a click started on, recorded in the capture pass. Read once by the
190
- * delegated bubble listener, which must not re-derive it: consumer handlers run
191
- * in between and may detach the item.
192
- */
288
+ #menuHidden = new AttributeLease("hidden", this.identifier);
289
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
290
+ #menu = null;
193
291
  #clickOwner = null;
194
- /** Clicks already turned into an activation, so the two paths run it once. */
195
292
  #activated = /* @__PURE__ */ new WeakSet();
196
- /** Whether state moves are reported: set once `connect()` settled the baseline. */
293
+ #connected = false;
197
294
  #reporting = false;
198
- /** Starts closed and registers activation / outside-click listeners. */
199
295
  connect() {
200
296
  this.#close("api");
201
297
  this.element.addEventListener("click", this.#onItemClickCapture, true);
202
298
  this.element.addEventListener("click", this.#onDelegatedItemClick);
203
299
  this.element.addEventListener("keydown", this.#onDelegatedItemKeydown);
204
300
  document.addEventListener("click", this.#onOutsideClick, true);
301
+ this.#connected = true;
205
302
  this.#reporting = true;
206
303
  }
207
- /** Releases the listeners, stack membership, and any pending Tab-close task. */
208
304
  disconnect() {
305
+ this.#connected = false;
209
306
  this.#reporting = false;
210
307
  this.#timers.clearAll();
211
308
  this.#escapeLayer.deactivate();
@@ -214,7 +311,20 @@ var MenuController = class extends Controller {
214
311
  this.element.removeEventListener("keydown", this.#onDelegatedItemKeydown);
215
312
  document.removeEventListener("click", this.#onOutsideClick, true);
216
313
  }
217
- /** Toggles the menu open/closed. Bound via `data-action` (click). */
314
+ triggerTargetConnected() {
315
+ if (this.#connected) this.#reflectTrigger();
316
+ }
317
+ triggerTargetDisconnected(trigger) {
318
+ if (!this.triggerTargets.includes(trigger)) this.#expanded.return(trigger);
319
+ if (this.#connected) this.#reflectTrigger();
320
+ }
321
+ menuTargetConnected() {
322
+ this.#adoptMenu();
323
+ }
324
+ menuTargetDisconnected(menu) {
325
+ this.#adoptMenu();
326
+ if (!this.menuTargets.includes(menu)) this.#menuHidden.return(menu);
327
+ }
218
328
  toggle(event) {
219
329
  if (this.#isOpen) {
220
330
  this.close(event);
@@ -223,45 +333,52 @@ var MenuController = class extends Controller {
223
333
  this.#focusFirst();
224
334
  }
225
335
  }
226
- /** Opens the menu and reflects the expanded state on the trigger. */
227
336
  open(event) {
228
337
  this.#open(stateReasonFor(event));
229
338
  }
230
- /** Closes the menu and reflects the collapsed state on the trigger. */
231
339
  close(event) {
232
340
  this.#close(stateReasonFor(event));
233
341
  }
234
- /** Opens the menu, reflects the expanded state, and reports a move. */
235
342
  #open(reason) {
236
343
  this.#timers.clearAll();
237
344
  if (!this.hasMenuTarget) return;
345
+ const menu = this.menuTarget;
238
346
  const was = this.#isOpen;
239
347
  this.#escapeLayer.activate(document, {
240
348
  onDismiss: () => this.#closeAndRestore("escape"),
241
349
  claims: claimsWhileFocusWithin(this.element)
242
350
  });
243
- this.menuTarget.hidden = false;
244
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
351
+ this.#menuHidden.write(menu, null);
352
+ this.#menu = menu;
353
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, "true");
245
354
  if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
246
355
  }
247
- /** Closes the menu, reflects the collapsed state, and reports a move. */
248
356
  #close(reason) {
249
357
  this.#timers.clearAll();
250
358
  this.#escapeLayer.deactivate();
251
- if (!this.hasMenuTarget) return;
359
+ const menu = this.hasMenuTarget ? this.menuTarget : null;
360
+ this.#menu = menu;
361
+ if (!menu) return;
252
362
  const was = this.#isOpen;
253
- this.menuTarget.hidden = true;
254
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
363
+ this.#menuHidden.write(menu, "");
364
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, "false");
255
365
  if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
256
366
  }
257
- /**
258
- * Opens the menu with the keyboard per the APG (Down → first, Up → last).
259
- *
260
- * Enter/Space are intentionally not handled here: on a native `<button>`
261
- * trigger the browser turns them into a click, which already runs
262
- * {@link toggle} (open + focus first item). Handling them again here would
263
- * open and then immediately re-toggle the menu.
264
- */
367
+ #adoptMenu() {
368
+ if (!this.#connected) return;
369
+ const menu = this.hasMenuTarget ? this.menuTarget : null;
370
+ const previous = this.#menu;
371
+ this.#menu = menu;
372
+ if (!menu) {
373
+ this.#escapeLayer.deactivate();
374
+ } else if (menu !== previous) {
375
+ this.#menuHidden.write(menu, previous?.hidden ?? true ? "" : null);
376
+ }
377
+ this.#reflectTrigger();
378
+ }
379
+ #reflectTrigger() {
380
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, String(this.#isOpen));
381
+ }
265
382
  onTriggerKeydown(event) {
266
383
  if (event.defaultPrevented) return;
267
384
  if (isReservedArrowChord(event)) return;
@@ -275,15 +392,9 @@ var MenuController = class extends Controller {
275
392
  this.#focusLast();
276
393
  }
277
394
  }
278
- /** Implements roving focus and closing keys inside the menu. */
279
395
  onItemKeydown(event) {
280
396
  this.#handleItemKeydown(event, event.currentTarget);
281
397
  }
282
- /**
283
- * The roving-focus body, shared by the per-element action and its delegated
284
- * twin. `from` is the item the key belongs to: the bound element for the
285
- * action, the resolved owner of the event target for the delegate.
286
- */
287
398
  #handleItemKeydown(event, from) {
288
399
  if (event.defaultPrevented) return;
289
400
  if (isReservedArrowChord(event)) return;
@@ -312,18 +423,6 @@ var MenuController = class extends Controller {
312
423
  break;
313
424
  }
314
425
  }
315
- /**
316
- * Closes the menu after an item is activated. Bound via `data-action`, and
317
- * also reached from the delegated listener.
318
- *
319
- * Markup that carries the per-element action *and* gets the delegate would run
320
- * this twice for one gesture. `close()` writes the state hooks (`hidden`,
321
- * `aria-expanded`), and an identical reassign still queues a MutationRecord,
322
- * so a second pass is observable to anyone watching them. The event is
323
- * therefore claimed: the path that gets there first does the work, the other
324
- * one finds it claimed and returns. A programmatic call with no event always
325
- * runs.
326
- */
327
426
  activate(event) {
328
427
  if (event !== void 0) {
329
428
  if (this.#activated.has(event)) return;
@@ -331,58 +430,27 @@ var MenuController = class extends Controller {
331
430
  }
332
431
  this.#closeAndRestore("select");
333
432
  }
334
- /** Closes and returns focus to the trigger (Escape / item-activation path). */
335
433
  #closeAndRestore(reason) {
336
434
  this.#close(reason);
337
435
  if (this.hasTriggerTarget) this.triggerTarget.focus();
338
436
  }
339
- /** Closes the menu when a click lands outside the controller's element. */
340
437
  #onOutsideClick = (event) => {
341
438
  if (this.#isOpen && !this.element.contains(event.target)) this.#close("outside");
342
439
  };
343
- /**
344
- * Delegated twin of {@link onItemKeydown}, bound on the controller element.
345
- *
346
- * An item that arrives *after* connect — `stimeo--overflow-menu` moves toolbar controls
347
- * into this menu at runtime — carries whatever `data-action` its author wrote,
348
- * and that is exactly the binding a consumer forgets, because the markup that
349
- * declares the item lives nowhere near the menu. Listening on the container
350
- * makes membership in the `item` target enough to be operable. The per-element
351
- * action stays supported and is not double-handled: see
352
- * {@link #handleItemKeydown}.
353
- */
354
440
  #onDelegatedItemKeydown = (event) => {
355
441
  const item = this.#itemFrom(event.target);
356
442
  if (item === null) return;
357
443
  this.#handleItemKeydown(event, item);
358
444
  };
359
- /**
360
- * Delegated twin of {@link activate}, for the same reason as the keydown one.
361
- *
362
- * It works from the item the capture pass recorded, not from a fresh lookup:
363
- * by the time a click bubbles up here, a consumer handler may already have
364
- * detached the row it lives in, and `itemTargets` is a live query that would
365
- * then resolve nothing — the activation would be lost and the menu left open
366
- * over an item that no longer exists. `aria-disabled` items never reach this
367
- * listener at all: the capture pass stops the click outright.
368
- */
369
445
  #onDelegatedItemClick = (event) => {
370
446
  const item = this.#clickOwner;
371
447
  this.#clickOwner = null;
372
448
  if (item === null) return;
373
449
  this.activate(event);
374
450
  };
375
- /** The item target that is, or contains, `node`; `null` when it is neither. */
376
451
  #itemFrom(node) {
377
452
  return ownerOf(this.itemTargets, node);
378
453
  }
379
- /**
380
- * First look at every click inside the controller, and the only point at which
381
- * the DOM is still the one the user clicked. Two jobs: record which item owns
382
- * the click for the delegate above, and stop an `aria-disabled` command before
383
- * it reaches consumer handlers (native Enter/Space activation synthesizes a
384
- * click and is blocked here too).
385
- */
386
454
  #onItemClickCapture = (event) => {
387
455
  const target = event.target;
388
456
  if (!(target instanceof Node)) {
@@ -396,36 +464,26 @@ var MenuController = class extends Controller {
396
464
  event.preventDefault();
397
465
  event.stopImmediatePropagation();
398
466
  };
399
- /** Moves focus to the first navigable item (no-op if none). */
400
467
  #focusFirst() {
401
468
  if (!this.#isOpen) return;
402
469
  this.#navigableItems[0]?.focus();
403
470
  }
404
- /** Moves focus to the last navigable item (no-op if none). */
405
471
  #focusLast() {
406
472
  if (!this.#isOpen) return;
407
473
  const items = this.#navigableItems;
408
474
  items[items.length - 1]?.focus();
409
475
  }
410
- /** Menu items eligible for roving focus (excludes disabled / hidden). */
411
476
  get #navigableItems() {
412
477
  return this.itemTargets.filter((item) => this.#isNavigable(item));
413
478
  }
414
- /**
415
- * An item can take roving focus unless it is `hidden` or a natively `disabled`
416
- * form control. `aria-disabled` remains focusable for discoverability; capture
417
- * suppresses activation. CSS-only visibility is the consumer's responsibility.
418
- */
419
479
  #isNavigable(item) {
420
480
  if (item.hasAttribute("hidden")) return false;
421
481
  return !item.disabled;
422
482
  }
423
- /** Whether the menu is currently visible. */
424
483
  get #isOpen() {
425
484
  return this.hasMenuTarget && !this.menuTarget.hidden;
426
485
  }
427
486
  };
428
487
 
429
488
  export { MenuController };
430
- //# sourceMappingURL=menu_controller.js.map
431
489
  //# sourceMappingURL=menu_controller.js.map