stimeo-ui 0.15.0 → 0.16.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +54 -10
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +56 -11
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +14 -1
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +305 -46
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -8,6 +8,16 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/field_mirror.ts
12
+ function writeField(field, value) {
13
+ if (field.value === value) return false;
14
+ field.value = value;
15
+ return true;
16
+ }
17
+ function commitField(target) {
18
+ target.dispatchEvent(new Event("change", { bubbles: true }));
19
+ }
20
+
11
21
  // src/utils/microtask_coalescer.ts
12
22
  var MicrotaskCoalescer = class {
13
23
  #run;
@@ -165,45 +175,79 @@ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel
165
175
  }
166
176
 
167
177
  // src/controllers/listbox_controller.ts
178
+ var OBSERVED_ATTRIBUTES = ["aria-selected", "data-value"];
179
+ function setAttributeIfChanged(element, name, value) {
180
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
181
+ }
168
182
  var ListboxController = class extends Controller {
169
183
  static targets = ["trigger", "value", "list", "option", "field"];
184
+ static values = {
185
+ placeholder: { type: String, default: "" }
186
+ };
170
187
  static actions = ["close", "onTriggerKeydown", "open", "select", "toggle"];
171
- static events = ["change"];
188
+ static events = ["change", "reconcile"];
172
189
  /** Stable ID of the active option; DOM targets are resolved afresh before use. */
173
190
  #activeId = null;
174
191
  /** Target ID order captured while an option is active, used only for removal fallback. */
175
192
  #activeOrder = [];
176
- #connected = false;
177
- /** Collapses one mutation batch of target callbacks into a single pass. */
178
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
193
+ /**
194
+ * Collapses the target callbacks, Value changes and observed option writes of
195
+ * one DOM mutation into a single pass; refused before `connect()` and after
196
+ * `disconnect()`.
197
+ */
198
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
199
+ /** Watches the option attributes a page can rewrite in place; set while connected. */
200
+ #observer = null;
201
+ /** The selection last settled: on connect, by the user, or by a reported pass. */
202
+ #settled = { option: null, value: "" };
179
203
  /** Accumulated typeahead query and its idle-reset timer. */
180
204
  #typeahead = new Typeahead();
181
- /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */
205
+ /**
206
+ * Establishes the ARIA baseline, settles the selection without reporting it,
207
+ * starts closed, and listens for outside clicks.
208
+ */
182
209
  connect() {
183
210
  this.#normalizeSelection();
184
211
  this.close();
185
212
  document.addEventListener("click", this.#onOutsideClick, true);
186
- this.#connected = true;
213
+ this.#settled = this.#selection();
187
214
  this.#reconcile.activate();
215
+ this.#observeOptions();
188
216
  }
189
217
  /**
190
- * Establishes an inactive baseline for a late option, re-resolves active
191
- * identity, and re-applies the selection baseline.
218
+ * Establishes an inactive baseline for a late option, then re-applies the
219
+ * selection baseline and re-resolves active identity once the batch is in.
192
220
  */
193
221
  optionTargetConnected(option) {
194
222
  option.removeAttribute("data-active");
195
- if (!this.#connected) return;
196
- this.#normalizeSelection();
197
- this.#queueOptionReconciliation();
223
+ this.#reconcile.schedule();
198
224
  }
199
- /** Removes controller-owned active state and reconciles the surviving targets. */
225
+ /**
226
+ * Removes controller-owned active state, then settles the selection and the
227
+ * active option among the surviving targets once the batch is in.
228
+ */
200
229
  optionTargetDisconnected(option) {
201
230
  option.removeAttribute("data-active");
202
- if (this.#connected) this.#queueOptionReconciliation();
231
+ this.#reconcile.schedule();
232
+ }
233
+ /** Fills a form field inserted or replaced at runtime with the current selection. */
234
+ fieldTargetConnected() {
235
+ this.#reconcile.schedule();
236
+ }
237
+ /** Writes the current selection into a trigger value inserted or replaced at runtime. */
238
+ valueTargetConnected() {
239
+ this.#reconcile.schedule();
240
+ }
241
+ /**
242
+ * Re-renders the empty-selection label when application code (or a Turbo morph)
243
+ * changes `placeholder` at runtime.
244
+ */
245
+ placeholderValueChanged() {
246
+ this.#reconcile.schedule();
203
247
  }
204
248
  /**
205
249
  * Brings the authored DOM to the shape the APG requires, and derives the state
206
- * that follows from the initial selection.
250
+ * that follows from the selection.
207
251
  *
208
252
  * Three things happen, and only these three — which option is chosen is the
209
253
  * author's, and is never changed:
@@ -212,27 +256,59 @@ var ListboxController = class extends Controller {
212
256
  * selectable" in ARIA, so a forgotten attribute hides a selectable option
213
257
  * from assistive technology.
214
258
  * 2. At most one stays `true`. The first in DOM order wins, since that is the
215
- * only deterministic reading of "which one did the author mean".
216
- * 3. The trigger label and the hidden field are derived from that selection.
217
- * Without this the widget announces a choice it does not submit: the popup
218
- * says "Banana", the trigger still says "Choose…", and the form posts "".
259
+ * only deterministic reading of "which one did the author mean"; an option
260
+ * the page inserts ahead of the selection, already selected, takes it over.
261
+ * 3. The trigger label and the hidden field are derived from that selection,
262
+ * an empty one included. Without this the widget announces a choice it does
263
+ * not submit: the popup says "Banana", the trigger still says "Choose…", and
264
+ * the form posts "".
219
265
  *
220
- * No `change` fires — nothing changed, this is the initial state being told
221
- * properly. The scan is the `option` target set: a `role="option"` without the
222
- * target is outside the contract and is neither counted nor written.
266
+ * Nothing is reported here: `connect()` describes the initial state, and a
267
+ * page-driven pass reports a moved selection itself. The scan is the `option`
268
+ * target set: a `role="option"` without the target is outside the contract and
269
+ * is neither counted nor written.
223
270
  */
224
271
  #normalizeSelection() {
225
- const options = this.optionTargets;
226
- const selected = options.find((option) => option.getAttribute("aria-selected") === "true");
227
- for (const option of options) {
228
- option.setAttribute("aria-selected", option === selected ? "true" : "false");
229
- }
230
- if (selected) this.#applySelection(selected);
272
+ const selected = this.optionTargets.find(
273
+ (option) => option.getAttribute("aria-selected") === "true"
274
+ );
275
+ this.#applySelection(selected);
276
+ }
277
+ /**
278
+ * The pass the page's changes run: the selection is normalized and mirrored,
279
+ * the active option re-resolved, then a selection that moved is reported. The
280
+ * report comes last, so options a subscriber rewrites are the next pass's to
281
+ * settle.
282
+ */
283
+ #reconcileOptions() {
284
+ this.#normalizeSelection();
285
+ this.#reconcileActive();
286
+ this.#reportMove();
231
287
  }
232
- /** Removes the document listener and clears the typeahead timer. */
288
+ /**
289
+ * Reports the selection as `reconcile` when its option or value differs from the
290
+ * selection last settled. The settled selection is replaced before dispatching,
291
+ * so a move a subscriber makes is measured against what it was told.
292
+ */
293
+ #reportMove() {
294
+ const { option, value } = this.#selection();
295
+ if (option === this.#settled.option && value === this.#settled.value) return;
296
+ this.#settled = { option, value };
297
+ this.dispatch("reconcile", { detail: { value, option } });
298
+ }
299
+ /** The selected option target, if any, and the value its field submits. */
300
+ #selection() {
301
+ const option = this.optionTargets.find((candidate) => candidate.getAttribute("aria-selected") === "true") ?? null;
302
+ return { option, value: option ? this.#optionValue(option) : "" };
303
+ }
304
+ /**
305
+ * Releases the outside-click listener and the option observer, drops a queued
306
+ * pass, and clears the typeahead timer.
307
+ */
233
308
  disconnect() {
234
- this.#connected = false;
235
309
  this.#reconcile.cancel();
310
+ this.#observer?.disconnect();
311
+ this.#observer = null;
236
312
  document.removeEventListener("click", this.#onOutsideClick, true);
237
313
  this.#typeahead.reset();
238
314
  }
@@ -354,27 +430,113 @@ var ListboxController = class extends Controller {
354
430
  /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */
355
431
  #selectOption(option) {
356
432
  const { value, fieldChanged } = this.#applySelection(option);
357
- if (fieldChanged) {
358
- this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
359
- }
433
+ this.#settled = { option, value };
434
+ if (fieldChanged) commitField(this.fieldTarget);
360
435
  this.dispatch("change", { detail: { value, option } });
361
436
  }
362
437
  /**
363
- * Writes `option` into `aria-selected`, the trigger label and the hidden field.
364
- * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the
365
- * state is being described rather than changed.
438
+ * Writes `option` — or, when it is `undefined`, no selection — into
439
+ * `aria-selected`, the trigger label and the hidden field. Emits nothing: the
440
+ * user's commit and a page-driven pass each report it their own way, and
441
+ * `connect()` only describes the state it finds.
366
442
  */
367
443
  #applySelection(option) {
368
444
  for (const candidate of this.optionTargets) {
369
- candidate.setAttribute("aria-selected", candidate === option ? "true" : "false");
445
+ setAttributeIfChanged(candidate, "aria-selected", candidate === option ? "true" : "false");
370
446
  }
371
- const label = (option.textContent ?? "").trim();
372
- const value = option.dataset.value ?? label;
373
- if (this.hasValueTarget) this.valueTarget.textContent = label;
374
- const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;
375
- if (fieldChanged) this.fieldTarget.value = value;
447
+ this.#dropOwnRecords();
448
+ const value = option ? this.#optionValue(option) : "";
449
+ this.#renderLabel(option);
450
+ const fieldChanged = this.hasFieldTarget && writeField(this.fieldTarget, value);
376
451
  return { value, fieldChanged };
377
452
  }
453
+ /**
454
+ * Keeps the trigger value on the selection: the selected option's text, or, with
455
+ * nothing selected, `placeholder` when it is set, else the text the value held
456
+ * before this controller first wrote into it.
457
+ *
458
+ * The value is this controller's only while it holds the text last written into
459
+ * it and nothing else — the `owns-label` marker carries that text — and the text
460
+ * it held before is kept in the `original-label` marker. A value the page
461
+ * rewrote or added an element to after that — the marker is still there but no
462
+ * longer matches — is the page's: with nothing selected it is left exactly as it
463
+ * is, `placeholder` or not, and the next selection remembers its text before
464
+ * replacing it. A value with no marker at all — the author's, or one a morph put
465
+ * back without the markers — is taken over the same way by `placeholder` or by a
466
+ * selection. A value that only ever showed a selection has no text of its own to
467
+ * return to, so it is emptied rather than left naming an option that is not
468
+ * selected.
469
+ *
470
+ * @stimeoRenderRoot
471
+ */
472
+ #renderLabel(option) {
473
+ if (!this.hasValueTarget) return;
474
+ const label = this.valueTarget;
475
+ const ownsLabel = `data-${this.identifier}-owns-label`;
476
+ const originalLabel = `data-${this.identifier}-original-label`;
477
+ const owned = label.getAttribute(ownsLabel) === label.textContent && label.childElementCount === 0;
478
+ if (option === void 0 && !owned && label.hasAttribute(ownsLabel)) return;
479
+ const text = option ? this.#optionLabel(option) : this.placeholderValue || null;
480
+ if (text === null) {
481
+ if (!owned) return;
482
+ label.textContent = label.getAttribute(originalLabel) ?? "";
483
+ label.removeAttribute(ownsLabel);
484
+ label.removeAttribute(originalLabel);
485
+ return;
486
+ }
487
+ if (!owned) {
488
+ const current = (label.textContent ?? "").trim();
489
+ if (option === void 0 || current !== text) {
490
+ setAttributeIfChanged(label, originalLabel, current);
491
+ }
492
+ }
493
+ if (label.textContent !== text) label.textContent = text;
494
+ setAttributeIfChanged(label, ownsLabel, text);
495
+ }
496
+ /** An option's visible label: its own text, trimmed. */
497
+ #optionLabel(option) {
498
+ return (option.textContent ?? "").trim();
499
+ }
500
+ /** The value an option submits: its `data-value`, else its label. */
501
+ #optionValue(option) {
502
+ return option.dataset.value ?? this.#optionLabel(option);
503
+ }
504
+ /**
505
+ * Watches the options' `aria-selected` and `data-value`. The listbox's own writes
506
+ * to them are dropped from the queue as they happen, so each record the callback
507
+ * receives is the page's, and only a record on one of the listbox's own options
508
+ * schedules a pass.
509
+ */
510
+ #observeOptions() {
511
+ const observer = new MutationObserver((records) => {
512
+ if (this.#concernsOptions(records)) this.#reconcile.schedule();
513
+ });
514
+ observer.observe(this.element, {
515
+ subtree: true,
516
+ attributes: true,
517
+ attributeFilter: OBSERVED_ATTRIBUTES
518
+ });
519
+ this.#observer = observer;
520
+ }
521
+ /**
522
+ * Whether any record is about one of this listbox's own options. A widget nested
523
+ * inside it writes the same attributes on its own elements, and those are no
524
+ * reason to reconcile the listbox.
525
+ */
526
+ #concernsOptions(records) {
527
+ const options = new Set(this.optionTargets);
528
+ return records.some((record) => options.has(record.target));
529
+ }
530
+ /**
531
+ * Drops the records the listbox's own writes just queued, so the observer never
532
+ * takes them for the page's. A page write queued just before goes with them and
533
+ * owes nothing: every such write leaves every option's `aria-selected` at the
534
+ * value the selection gives it, and the selection is then read back from the DOM,
535
+ * so it already holds what the page wrote.
536
+ */
537
+ #dropOwnRecords() {
538
+ this.#observer?.takeRecords();
539
+ }
378
540
  /**
379
541
  * Marks the option at `index` active via `data-active` and the trigger's
380
542
  * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not
@@ -444,10 +606,6 @@ var ListboxController = class extends Controller {
444
606
  }
445
607
  return -1;
446
608
  }
447
- /** Coalesces all target callbacks from one MutationObserver batch. */
448
- #queueOptionReconciliation() {
449
- this.#reconcile.schedule();
450
- }
451
609
  /**
452
610
  * Advances the typeahead query and activates the next matching option.
453
611
  *
@@ -215,8 +215,6 @@ var MasonryController = class extends Controller {
215
215
  * once per pass. The assignment is independent of the measurement because the
216
216
  * columns are uniform in width, so the order of the two passes does not change
217
217
  * the result.
218
- *
219
- * @stimeoRenderRoot
220
218
  */
221
219
  #relayout() {
222
220
  const items = this.itemTargets;
@@ -160,6 +160,16 @@ var SafeTimeout = class extends TimerRegistry {
160
160
  }
161
161
  };
162
162
 
163
+ // src/utils/state_reason.ts
164
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
165
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
166
+ function stateReasonFor(event) {
167
+ if (!event) return "api";
168
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
169
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
170
+ return "user";
171
+ }
172
+
163
173
  // src/controllers/menu_controller.ts
164
174
  var MenuController = class extends Controller {
165
175
  static targets = ["trigger", "menu", "item"];
@@ -171,6 +181,7 @@ var MenuController = class extends Controller {
171
181
  "open",
172
182
  "toggle"
173
183
  ];
184
+ static events = ["close", "open"];
174
185
  #timers = new SafeTimeout();
175
186
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
176
187
  #escapeLayer = new EscapeLayer();
@@ -182,16 +193,20 @@ var MenuController = class extends Controller {
182
193
  #clickOwner = null;
183
194
  /** Clicks already turned into an activation, so the two paths run it once. */
184
195
  #activated = /* @__PURE__ */ new WeakSet();
196
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
197
+ #reporting = false;
185
198
  /** Starts closed and registers activation / outside-click listeners. */
186
199
  connect() {
187
- this.close();
200
+ this.#close("api");
188
201
  this.element.addEventListener("click", this.#onItemClickCapture, true);
189
202
  this.element.addEventListener("click", this.#onDelegatedItemClick);
190
203
  this.element.addEventListener("keydown", this.#onDelegatedItemKeydown);
191
204
  document.addEventListener("click", this.#onOutsideClick, true);
205
+ this.#reporting = true;
192
206
  }
193
207
  /** Releases the listeners, stack membership, and any pending Tab-close task. */
194
208
  disconnect() {
209
+ this.#reporting = false;
195
210
  this.#timers.clearAll();
196
211
  this.#escapeLayer.deactivate();
197
212
  this.element.removeEventListener("click", this.#onItemClickCapture, true);
@@ -200,32 +215,44 @@ var MenuController = class extends Controller {
200
215
  document.removeEventListener("click", this.#onOutsideClick, true);
201
216
  }
202
217
  /** Toggles the menu open/closed. Bound via `data-action` (click). */
203
- toggle() {
218
+ toggle(event) {
204
219
  if (this.#isOpen) {
205
- this.close();
220
+ this.close(event);
206
221
  } else {
207
- this.open();
222
+ this.open(event);
208
223
  this.#focusFirst();
209
224
  }
210
225
  }
211
226
  /** Opens the menu and reflects the expanded state on the trigger. */
212
- open() {
227
+ open(event) {
228
+ this.#open(stateReasonFor(event));
229
+ }
230
+ /** Closes the menu and reflects the collapsed state on the trigger. */
231
+ close(event) {
232
+ this.#close(stateReasonFor(event));
233
+ }
234
+ /** Opens the menu, reflects the expanded state, and reports a move. */
235
+ #open(reason) {
213
236
  this.#timers.clearAll();
214
237
  if (!this.hasMenuTarget) return;
238
+ const was = this.#isOpen;
215
239
  this.#escapeLayer.activate(document, {
216
- onDismiss: () => this.#closeAndRestore(),
240
+ onDismiss: () => this.#closeAndRestore("escape"),
217
241
  claims: claimsWhileFocusWithin(this.element)
218
242
  });
219
243
  this.menuTarget.hidden = false;
220
244
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
245
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
221
246
  }
222
- /** Closes the menu and reflects the collapsed state on the trigger. */
223
- close() {
247
+ /** Closes the menu, reflects the collapsed state, and reports a move. */
248
+ #close(reason) {
224
249
  this.#timers.clearAll();
225
250
  this.#escapeLayer.deactivate();
226
251
  if (!this.hasMenuTarget) return;
252
+ const was = this.#isOpen;
227
253
  this.menuTarget.hidden = true;
228
254
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
255
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
229
256
  }
230
257
  /**
231
258
  * Opens the menu with the keyboard per the APG (Down → first, Up → last).
@@ -240,11 +267,11 @@ var MenuController = class extends Controller {
240
267
  if (isReservedArrowChord(event)) return;
241
268
  if (event.key === "ArrowDown") {
242
269
  event.preventDefault();
243
- this.open();
270
+ this.open(event);
244
271
  this.#focusFirst();
245
272
  } else if (event.key === "ArrowUp") {
246
273
  event.preventDefault();
247
- this.open();
274
+ this.open(event);
248
275
  this.#focusLast();
249
276
  }
250
277
  }
@@ -281,7 +308,7 @@ var MenuController = class extends Controller {
281
308
  break;
282
309
  case "Tab":
283
310
  this.#timers.clearAll();
284
- this.#timers.set(() => this.close(), 0);
311
+ this.#timers.set(() => this.#close("focus"), 0);
285
312
  break;
286
313
  }
287
314
  }
@@ -302,21 +329,21 @@ var MenuController = class extends Controller {
302
329
  if (this.#activated.has(event)) return;
303
330
  this.#activated.add(event);
304
331
  }
305
- this.#closeAndRestore();
332
+ this.#closeAndRestore("select");
306
333
  }
307
334
  /** Closes and returns focus to the trigger (Escape / item-activation path). */
308
- #closeAndRestore() {
309
- this.close();
335
+ #closeAndRestore(reason) {
336
+ this.#close(reason);
310
337
  if (this.hasTriggerTarget) this.triggerTarget.focus();
311
338
  }
312
339
  /** Closes the menu when a click lands outside the controller's element. */
313
340
  #onOutsideClick = (event) => {
314
- if (this.#isOpen && !this.element.contains(event.target)) this.close();
341
+ if (this.#isOpen && !this.element.contains(event.target)) this.#close("outside");
315
342
  };
316
343
  /**
317
344
  * Delegated twin of {@link onItemKeydown}, bound on the controller element.
318
345
  *
319
- * An item that arrives *after* connect — Overflow Menu moves toolbar controls
346
+ * An item that arrives *after* connect — `stimeo--overflow-menu` moves toolbar controls
320
347
  * into this menu at runtime — carries whatever `data-action` its author wrote,
321
348
  * and that is exactly the binding a consumer forgets, because the markup that
322
349
  * declares the item lives nowhere near the menu. Listening on the container
@@ -371,10 +398,12 @@ var MenuController = class extends Controller {
371
398
  };
372
399
  /** Moves focus to the first navigable item (no-op if none). */
373
400
  #focusFirst() {
401
+ if (!this.#isOpen) return;
374
402
  this.#navigableItems[0]?.focus();
375
403
  }
376
404
  /** Moves focus to the last navigable item (no-op if none). */
377
405
  #focusLast() {
406
+ if (!this.#isOpen) return;
378
407
  const items = this.#navigableItems;
379
408
  items[items.length - 1]?.focus();
380
409
  }