stimeo-ui 0.15.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 +209 -0
  3. data/README.md +128 -1
  4. data/dist/cable/index.js +392 -417
  5. data/dist/controllers/accordion_controller.js +228 -28
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -191
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +295 -110
  10. data/dist/controllers/avatar_controller.js +172 -106
  11. data/dist/controllers/breadcrumb_controller.js +0 -129
  12. data/dist/controllers/bulk_select_controller.js +264 -53
  13. data/dist/controllers/calendar_controller.js +346 -180
  14. data/dist/controllers/carousel_controller.js +587 -300
  15. data/dist/controllers/character_counter_controller.js +271 -143
  16. data/dist/controllers/checkbox_controller.js +77 -75
  17. data/dist/controllers/clipboard_controller.js +187 -108
  18. data/dist/controllers/collapsible_controller.js +374 -108
  19. data/dist/controllers/color_picker_controller.js +183 -127
  20. data/dist/controllers/combobox_controller.js +426 -109
  21. data/dist/controllers/command_palette_controller.js +994 -350
  22. data/dist/controllers/conditional_fields_controller.js +212 -125
  23. data/dist/controllers/confirm_controller.js +939 -245
  24. data/dist/controllers/context_menu_controller.js +275 -127
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +235 -97
  27. data/dist/controllers/currency_input_controller.js +241 -118
  28. data/dist/controllers/data_grid_controller.js +229 -135
  29. data/dist/controllers/date_range_picker_controller.js +359 -165
  30. data/dist/controllers/dialog_controller.js +887 -225
  31. data/dist/controllers/direct_upload_controller.js +80 -142
  32. data/dist/controllers/dirty_form_controller.js +26 -47
  33. data/dist/controllers/dismissible_controller.js +175 -20
  34. data/dist/controllers/drawer_controller.js +963 -336
  35. data/dist/controllers/dropdown_controller.js +342 -85
  36. data/dist/controllers/editable_controller.js +182 -58
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +426 -229
  39. data/dist/controllers/filter_controller.js +78 -36
  40. data/dist/controllers/flash_controller.js +328 -211
  41. data/dist/controllers/focus_controller.js +654 -239
  42. data/dist/controllers/form_field_controller.js +153 -136
  43. data/dist/controllers/form_validation_controller.js +29 -96
  44. data/dist/controllers/frame_loading_controller.js +263 -198
  45. data/dist/controllers/highlight_controller.js +119 -70
  46. data/dist/controllers/hover_card_controller.js +335 -118
  47. data/dist/controllers/idle_controller.js +316 -65
  48. data/dist/controllers/input_mask_controller.js +116 -61
  49. data/dist/controllers/intersection_controller.js +147 -103
  50. data/dist/controllers/lazy_frame_controller.js +9 -56
  51. data/dist/controllers/listbox_controller.js +413 -153
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -95
  54. data/dist/controllers/menu_controller.js +254 -167
  55. data/dist/controllers/menubar_controller.js +206 -331
  56. data/dist/controllers/meter_controller.js +159 -71
  57. data/dist/controllers/multi_select_controller.js +797 -388
  58. data/dist/controllers/navigation_menu_controller.js +156 -231
  59. data/dist/controllers/nested_form_controller.js +373 -174
  60. data/dist/controllers/network_status_controller.js +241 -45
  61. data/dist/controllers/number_input_controller.js +387 -227
  62. data/dist/controllers/optimistic_controller.js +28 -75
  63. data/dist/controllers/otp_controller.js +344 -196
  64. data/dist/controllers/overflow_indicator_controller.js +167 -93
  65. data/dist/controllers/overflow_menu_controller.js +408 -277
  66. data/dist/controllers/pagination_controller.js +342 -120
  67. data/dist/controllers/password_reveal_controller.js +424 -99
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +252 -96
  70. data/dist/controllers/pointer_drag_controller.js +263 -164
  71. data/dist/controllers/popover_controller.js +267 -94
  72. data/dist/controllers/portal_controller.js +209 -72
  73. data/dist/controllers/preview_guard_controller.js +243 -112
  74. data/dist/controllers/progress_controller.js +132 -59
  75. data/dist/controllers/radio_group_controller.js +138 -133
  76. data/dist/controllers/range_slider_controller.js +245 -112
  77. data/dist/controllers/rating_controller.js +429 -194
  78. data/dist/controllers/read_more_controller.js +185 -47
  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 +294 -118
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -221
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -146
  87. data/dist/controllers/scrollspy_controller.js +181 -236
  88. data/dist/controllers/separator_controller.js +288 -132
  89. data/dist/controllers/sidebar_controller.js +1071 -375
  90. data/dist/controllers/skeleton_controller.js +248 -92
  91. data/dist/controllers/slider_controller.js +211 -96
  92. data/dist/controllers/smart_sticky_header_controller.js +163 -54
  93. data/dist/controllers/sortable_controller.js +13 -110
  94. data/dist/controllers/spinner_controller.js +277 -161
  95. data/dist/controllers/step_indicator_controller.js +107 -53
  96. data/dist/controllers/stepper_controller.js +171 -48
  97. data/dist/controllers/stick_to_bottom_controller.js +310 -116
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -192
  100. data/dist/controllers/switch_controller.js +107 -43
  101. data/dist/controllers/tabs_controller.js +50 -25
  102. data/dist/controllers/tags_input_controller.js +290 -149
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +148 -80
  105. data/dist/controllers/time_picker_controller.js +162 -81
  106. data/dist/controllers/toast_controller.js +365 -193
  107. data/dist/controllers/toggle_group_controller.js +194 -99
  108. data/dist/controllers/toolbar_controller.js +92 -90
  109. data/dist/controllers/tooltip_controller.js +185 -125
  110. data/dist/controllers/transition_controller.js +123 -115
  111. data/dist/controllers/tree_view_controller.js +248 -290
  112. data/dist/index.js +8649 -8572
  113. data/dist/positioning/index.js +148 -45
  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,15 @@ var SafeTimeout = class extends TimerRegistry {
160
263
  }
161
264
  };
162
265
 
163
- // src/controllers/menu_controller.ts
266
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
267
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
268
+ function stateReasonFor(event) {
269
+ if (!event) return "api";
270
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
271
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
272
+ return "user";
273
+ }
274
+
164
275
  var MenuController = class extends Controller {
165
276
  static targets = ["trigger", "menu", "item"];
166
277
  static actions = [
@@ -171,27 +282,28 @@ var MenuController = class extends Controller {
171
282
  "open",
172
283
  "toggle"
173
284
  ];
285
+ static events = ["close", "open"];
174
286
  #timers = new SafeTimeout();
175
- /** Escape-stack membership while open; the shared resolver dismisses via it. */
176
287
  #escapeLayer = new EscapeLayer();
177
- /**
178
- * The item a click started on, recorded in the capture pass. Read once by the
179
- * delegated bubble listener, which must not re-derive it: consumer handlers run
180
- * in between and may detach the item.
181
- */
288
+ #menuHidden = new AttributeLease("hidden", this.identifier);
289
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
290
+ #menu = null;
182
291
  #clickOwner = null;
183
- /** Clicks already turned into an activation, so the two paths run it once. */
184
292
  #activated = /* @__PURE__ */ new WeakSet();
185
- /** Starts closed and registers activation / outside-click listeners. */
293
+ #connected = false;
294
+ #reporting = false;
186
295
  connect() {
187
- this.close();
296
+ this.#close("api");
188
297
  this.element.addEventListener("click", this.#onItemClickCapture, true);
189
298
  this.element.addEventListener("click", this.#onDelegatedItemClick);
190
299
  this.element.addEventListener("keydown", this.#onDelegatedItemKeydown);
191
300
  document.addEventListener("click", this.#onOutsideClick, true);
301
+ this.#connected = true;
302
+ this.#reporting = true;
192
303
  }
193
- /** Releases the listeners, stack membership, and any pending Tab-close task. */
194
304
  disconnect() {
305
+ this.#connected = false;
306
+ this.#reporting = false;
195
307
  this.#timers.clearAll();
196
308
  this.#escapeLayer.deactivate();
197
309
  this.element.removeEventListener("click", this.#onItemClickCapture, true);
@@ -199,64 +311,90 @@ var MenuController = class extends Controller {
199
311
  this.element.removeEventListener("keydown", this.#onDelegatedItemKeydown);
200
312
  document.removeEventListener("click", this.#onOutsideClick, true);
201
313
  }
202
- /** Toggles the menu open/closed. Bound via `data-action` (click). */
203
- toggle() {
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
+ }
328
+ toggle(event) {
204
329
  if (this.#isOpen) {
205
- this.close();
330
+ this.close(event);
206
331
  } else {
207
- this.open();
332
+ this.open(event);
208
333
  this.#focusFirst();
209
334
  }
210
335
  }
211
- /** Opens the menu and reflects the expanded state on the trigger. */
212
- open() {
336
+ open(event) {
337
+ this.#open(stateReasonFor(event));
338
+ }
339
+ close(event) {
340
+ this.#close(stateReasonFor(event));
341
+ }
342
+ #open(reason) {
213
343
  this.#timers.clearAll();
214
344
  if (!this.hasMenuTarget) return;
345
+ const menu = this.menuTarget;
346
+ const was = this.#isOpen;
215
347
  this.#escapeLayer.activate(document, {
216
- onDismiss: () => this.#closeAndRestore(),
348
+ onDismiss: () => this.#closeAndRestore("escape"),
217
349
  claims: claimsWhileFocusWithin(this.element)
218
350
  });
219
- this.menuTarget.hidden = false;
220
- 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");
354
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
221
355
  }
222
- /** Closes the menu and reflects the collapsed state on the trigger. */
223
- close() {
356
+ #close(reason) {
224
357
  this.#timers.clearAll();
225
358
  this.#escapeLayer.deactivate();
226
- if (!this.hasMenuTarget) return;
227
- this.menuTarget.hidden = true;
228
- if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
229
- }
230
- /**
231
- * Opens the menu with the keyboard per the APG (Down → first, Up → last).
232
- *
233
- * Enter/Space are intentionally not handled here: on a native `<button>`
234
- * trigger the browser turns them into a click, which already runs
235
- * {@link toggle} (open + focus first item). Handling them again here would
236
- * open and then immediately re-toggle the menu.
237
- */
359
+ const menu = this.hasMenuTarget ? this.menuTarget : null;
360
+ this.#menu = menu;
361
+ if (!menu) return;
362
+ const was = this.#isOpen;
363
+ this.#menuHidden.write(menu, "");
364
+ if (this.hasTriggerTarget) this.#expanded.write(this.triggerTarget, "false");
365
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
366
+ }
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
+ }
238
382
  onTriggerKeydown(event) {
239
383
  if (event.defaultPrevented) return;
240
384
  if (isReservedArrowChord(event)) return;
241
385
  if (event.key === "ArrowDown") {
242
386
  event.preventDefault();
243
- this.open();
387
+ this.open(event);
244
388
  this.#focusFirst();
245
389
  } else if (event.key === "ArrowUp") {
246
390
  event.preventDefault();
247
- this.open();
391
+ this.open(event);
248
392
  this.#focusLast();
249
393
  }
250
394
  }
251
- /** Implements roving focus and closing keys inside the menu. */
252
395
  onItemKeydown(event) {
253
396
  this.#handleItemKeydown(event, event.currentTarget);
254
397
  }
255
- /**
256
- * The roving-focus body, shared by the per-element action and its delegated
257
- * twin. `from` is the item the key belongs to: the bound element for the
258
- * action, the resolved owner of the event target for the delegate.
259
- */
260
398
  #handleItemKeydown(event, from) {
261
399
  if (event.defaultPrevented) return;
262
400
  if (isReservedArrowChord(event)) return;
@@ -281,81 +419,38 @@ var MenuController = class extends Controller {
281
419
  break;
282
420
  case "Tab":
283
421
  this.#timers.clearAll();
284
- this.#timers.set(() => this.close(), 0);
422
+ this.#timers.set(() => this.#close("focus"), 0);
285
423
  break;
286
424
  }
287
425
  }
288
- /**
289
- * Closes the menu after an item is activated. Bound via `data-action`, and
290
- * also reached from the delegated listener.
291
- *
292
- * Markup that carries the per-element action *and* gets the delegate would run
293
- * this twice for one gesture. `close()` writes the state hooks (`hidden`,
294
- * `aria-expanded`), and an identical reassign still queues a MutationRecord,
295
- * so a second pass is observable to anyone watching them. The event is
296
- * therefore claimed: the path that gets there first does the work, the other
297
- * one finds it claimed and returns. A programmatic call with no event always
298
- * runs.
299
- */
300
426
  activate(event) {
301
427
  if (event !== void 0) {
302
428
  if (this.#activated.has(event)) return;
303
429
  this.#activated.add(event);
304
430
  }
305
- this.#closeAndRestore();
431
+ this.#closeAndRestore("select");
306
432
  }
307
- /** Closes and returns focus to the trigger (Escape / item-activation path). */
308
- #closeAndRestore() {
309
- this.close();
433
+ #closeAndRestore(reason) {
434
+ this.#close(reason);
310
435
  if (this.hasTriggerTarget) this.triggerTarget.focus();
311
436
  }
312
- /** Closes the menu when a click lands outside the controller's element. */
313
437
  #onOutsideClick = (event) => {
314
- if (this.#isOpen && !this.element.contains(event.target)) this.close();
438
+ if (this.#isOpen && !this.element.contains(event.target)) this.#close("outside");
315
439
  };
316
- /**
317
- * Delegated twin of {@link onItemKeydown}, bound on the controller element.
318
- *
319
- * An item that arrives *after* connect — Overflow Menu moves toolbar controls
320
- * into this menu at runtime — carries whatever `data-action` its author wrote,
321
- * and that is exactly the binding a consumer forgets, because the markup that
322
- * declares the item lives nowhere near the menu. Listening on the container
323
- * makes membership in the `item` target enough to be operable. The per-element
324
- * action stays supported and is not double-handled: see
325
- * {@link #handleItemKeydown}.
326
- */
327
440
  #onDelegatedItemKeydown = (event) => {
328
441
  const item = this.#itemFrom(event.target);
329
442
  if (item === null) return;
330
443
  this.#handleItemKeydown(event, item);
331
444
  };
332
- /**
333
- * Delegated twin of {@link activate}, for the same reason as the keydown one.
334
- *
335
- * It works from the item the capture pass recorded, not from a fresh lookup:
336
- * by the time a click bubbles up here, a consumer handler may already have
337
- * detached the row it lives in, and `itemTargets` is a live query that would
338
- * then resolve nothing — the activation would be lost and the menu left open
339
- * over an item that no longer exists. `aria-disabled` items never reach this
340
- * listener at all: the capture pass stops the click outright.
341
- */
342
445
  #onDelegatedItemClick = (event) => {
343
446
  const item = this.#clickOwner;
344
447
  this.#clickOwner = null;
345
448
  if (item === null) return;
346
449
  this.activate(event);
347
450
  };
348
- /** The item target that is, or contains, `node`; `null` when it is neither. */
349
451
  #itemFrom(node) {
350
452
  return ownerOf(this.itemTargets, node);
351
453
  }
352
- /**
353
- * First look at every click inside the controller, and the only point at which
354
- * the DOM is still the one the user clicked. Two jobs: record which item owns
355
- * the click for the delegate above, and stop an `aria-disabled` command before
356
- * it reaches consumer handlers (native Enter/Space activation synthesizes a
357
- * click and is blocked here too).
358
- */
359
454
  #onItemClickCapture = (event) => {
360
455
  const target = event.target;
361
456
  if (!(target instanceof Node)) {
@@ -369,34 +464,26 @@ var MenuController = class extends Controller {
369
464
  event.preventDefault();
370
465
  event.stopImmediatePropagation();
371
466
  };
372
- /** Moves focus to the first navigable item (no-op if none). */
373
467
  #focusFirst() {
468
+ if (!this.#isOpen) return;
374
469
  this.#navigableItems[0]?.focus();
375
470
  }
376
- /** Moves focus to the last navigable item (no-op if none). */
377
471
  #focusLast() {
472
+ if (!this.#isOpen) return;
378
473
  const items = this.#navigableItems;
379
474
  items[items.length - 1]?.focus();
380
475
  }
381
- /** Menu items eligible for roving focus (excludes disabled / hidden). */
382
476
  get #navigableItems() {
383
477
  return this.itemTargets.filter((item) => this.#isNavigable(item));
384
478
  }
385
- /**
386
- * An item can take roving focus unless it is `hidden` or a natively `disabled`
387
- * form control. `aria-disabled` remains focusable for discoverability; capture
388
- * suppresses activation. CSS-only visibility is the consumer's responsibility.
389
- */
390
479
  #isNavigable(item) {
391
480
  if (item.hasAttribute("hidden")) return false;
392
481
  return !item.disabled;
393
482
  }
394
- /** Whether the menu is currently visible. */
395
483
  get #isOpen() {
396
484
  return this.hasMenuTarget && !this.menuTarget.hidden;
397
485
  }
398
486
  };
399
487
 
400
488
  export { MenuController };
401
- //# sourceMappingURL=menu_controller.js.map
402
489
  //# sourceMappingURL=menu_controller.js.map