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
@@ -365,18 +365,32 @@ var FocusTrap = class {
365
365
  }
366
366
  };
367
367
 
368
+ // src/utils/state_reason.ts
369
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
370
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
371
+ function stateReasonFor(event) {
372
+ if (!event) return "api";
373
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
374
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
375
+ return "user";
376
+ }
377
+
368
378
  // src/controllers/dialog_controller.ts
369
379
  var DialogController = class extends Controller {
370
380
  static targets = ["trigger", "dialog"];
371
381
  static actions = ["close", "closeOnBackdrop", "open"];
382
+ static events = ["close", "open"];
372
383
  /** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
373
384
  #trap = new FocusTrap(() => this.dialogTarget, {
374
- onEscape: () => this.close(),
385
+ onEscape: () => this.#close("escape"),
375
386
  fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
376
387
  });
388
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
389
+ #reporting = false;
377
390
  /** Starts closed (idempotently reflects the closed state on the markup). */
378
391
  connect() {
379
392
  if (this.hasDialogTarget) this.dialogTarget.hidden = true;
393
+ this.#reporting = true;
380
394
  }
381
395
  /**
382
396
  * Reverts the modal side effects (scroll lock, background `inert`, keydown
@@ -385,24 +399,37 @@ var DialogController = class extends Controller {
385
399
  * teardown.
386
400
  */
387
401
  disconnect() {
402
+ this.#reporting = false;
388
403
  this.#trap.deactivate({ restoreFocus: false });
389
404
  }
390
405
  /** Opens the dialog, traps focus, and locks background scroll. */
391
- open() {
406
+ open(event) {
407
+ this.#open(stateReasonFor(event));
408
+ }
409
+ /** Closes the dialog, restores scroll, and returns focus to the opener. */
410
+ close(event) {
411
+ this.#close(stateReasonFor(event));
412
+ }
413
+ /** Closes when the backdrop (the dialog target itself) is clicked. */
414
+ closeOnBackdrop(event) {
415
+ if (event.target === this.dialogTarget) this.#close("outside");
416
+ }
417
+ /** Reveals the dialog, reports a move, then traps focus and locks scroll. */
418
+ #open(reason) {
392
419
  if (!this.hasDialogTarget || this.#isOpen) return;
393
420
  this.dialogTarget.hidden = false;
421
+ if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
422
+ if (!this.#isOpen) return;
394
423
  this.#trap.activate();
395
424
  }
396
- /** Closes the dialog, restores scroll, and returns focus to the opener. */
397
- close() {
425
+ /** Hides the dialog, reports a move, then restores scroll and focus. */
426
+ #close(reason) {
398
427
  if (!this.hasDialogTarget || !this.#isOpen) return;
399
428
  this.dialogTarget.hidden = true;
429
+ if (this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
430
+ if (this.#isOpen) return;
400
431
  this.#trap.deactivate();
401
432
  }
402
- /** Closes when the backdrop (the dialog target itself) is clicked. */
403
- closeOnBackdrop(event) {
404
- if (event.target === this.dialogTarget) this.close();
405
- }
406
433
  /** Whether the dialog is currently visible. */
407
434
  get #isOpen() {
408
435
  return this.hasDialogTarget && !this.dialogTarget.hidden;
@@ -121,6 +121,12 @@ var SafeTimeout = class extends TimerRegistry {
121
121
  }
122
122
  };
123
123
 
124
+ // src/utils/template_row.ts
125
+ function cloneTemplateRoot(template) {
126
+ const root = template.content.firstElementChild;
127
+ return root instanceof HTMLElement ? root.cloneNode(true) : null;
128
+ }
129
+
124
130
  // src/controllers/direct_upload_controller.ts
125
131
  var REMOVE_DELAY = 4e3;
126
132
  var DirectUploadController = class extends Controller {
@@ -186,7 +192,12 @@ var DirectUploadController = class extends Controller {
186
192
  this.#syncAggregate();
187
193
  this.dispatch("progress", { detail: { id, percent: clamped } });
188
194
  }
189
- /** Marks a not-yet-settled row done at 100%, announces it, and emits `done`. */
195
+ /**
196
+ * Marks a not-yet-settled row done at 100%, announces it, and emits `done`.
197
+ *
198
+ * @stimeoRuntimeOnly `announceDoneText` words the one announcement of this completion and
199
+ * `removeOnDone` decides whether it arms the row's removal.
200
+ */
190
201
  #complete(id, name) {
191
202
  const row = this.#rowFor(id, name);
192
203
  if (row === null || this.#isSettled(row)) return;
@@ -204,6 +215,8 @@ var DirectUploadController = class extends Controller {
204
215
  * Returns whether the failure is rendered by this widget (used to decide the
205
216
  * `direct-upload:error` default), which also holds when the row already
206
217
  * displays an earlier failure.
218
+ *
219
+ * @stimeoRuntimeOnly `announceErrorText` words the one announcement of this failure.
207
220
  */
208
221
  #fail(id, error, name) {
209
222
  const row = this.#rowFor(id, name);
@@ -219,7 +232,12 @@ var DirectUploadController = class extends Controller {
219
232
  const state = row.getAttribute("data-upload-state");
220
233
  return state === "done" || state === "error";
221
234
  }
222
- /** Re-arms `removeOnDone` for completed rows after a reconnect. */
235
+ /**
236
+ * Re-arms `removeOnDone` for completed rows after a reconnect.
237
+ *
238
+ * @stimeoRuntimeOnly `removeOnDone` decides whether reconnecting arms the removal of finished
239
+ * rows again.
240
+ */
223
241
  #rescheduleRemovals() {
224
242
  if (!this.removeOnDoneValue) return;
225
243
  this.#prune();
@@ -242,8 +260,8 @@ var DirectUploadController = class extends Controller {
242
260
  this.#rows.delete(key);
243
261
  }
244
262
  if (!this.hasRowTarget || !this.hasListTarget) return null;
245
- const clone = this.rowTarget.content.firstElementChild?.cloneNode(true);
246
- if (!(clone instanceof HTMLElement)) return null;
263
+ const clone = cloneTemplateRoot(this.rowTarget);
264
+ if (!clone) return null;
247
265
  this.#applyName(clone, name);
248
266
  clone.setAttribute("data-upload-state", "uploading");
249
267
  this.#applyProgress(clone, 0);
@@ -79,7 +79,20 @@ var SafeTimeout = class extends TimerRegistry {
79
79
  }
80
80
  };
81
81
 
82
+ // src/utils/transient_hooks.ts
83
+ var TransientHooks = class {
84
+ #attributes;
85
+ constructor(options) {
86
+ this.#attributes = options.attributes;
87
+ }
88
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
89
+ reset(element) {
90
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
91
+ }
92
+ };
93
+
82
94
  // src/controllers/dirty_form_controller.ts
95
+ var TRANSIENT = new TransientHooks({ attributes: ["data-dirty"] });
83
96
  var visitCoordinators = /* @__PURE__ */ new WeakMap();
84
97
  var TurboVisitCoordinator = class {
85
98
  #document;
@@ -206,7 +219,7 @@ var DirtyFormController = class extends Controller {
206
219
  this.#resetSubmission();
207
220
  this.#baseline = this.#serialize();
208
221
  this.#setDirty(false);
209
- this.element.removeAttribute("data-dirty");
222
+ TRANSIENT.reset(this.element);
210
223
  this.#listeners.add(this.element, "input", this.#onFieldChange);
211
224
  this.#listeners.add(this.element, "change", this.#onFieldChange);
212
225
  this.#listeners.add(this.element, "submit", this.#onSubmit);
@@ -123,6 +123,7 @@ var DismissibleController = class extends Controller {
123
123
  get #root() {
124
124
  return this.hasRootTarget ? this.rootTarget : this.element;
125
125
  }
126
+ /** @stimeoRuntimeOnly `mode` decides how this one dismissal takes the element away. */
126
127
  #performDismiss() {
127
128
  const root = this.#root;
128
129
  const mode = this.modeValue === "hide" ? "hide" : "remove";
@@ -365,6 +365,16 @@ var FocusTrap = class {
365
365
  }
366
366
  };
367
367
 
368
+ // src/utils/state_reason.ts
369
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
370
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
371
+ function stateReasonFor(event) {
372
+ if (!event) return "api";
373
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
374
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
375
+ return "user";
376
+ }
377
+
368
378
  // src/utils/safe_timeout.ts
369
379
  var TimerRegistry = class {
370
380
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -567,17 +577,20 @@ var DrawerController = class extends Controller {
567
577
  open: { type: Boolean, default: false }
568
578
  };
569
579
  static actions = ["close", "closeOnBackdrop", "open"];
580
+ static events = ["close", "open"];
570
581
  /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
571
582
  #activePanel = null;
572
583
  /** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
573
584
  #trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
574
- onEscape: () => this.close(),
585
+ onEscape: () => this.#close("escape"),
575
586
  fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
576
587
  });
577
588
  /** Owns the cancellable close-transition wait and its bounded fallback. */
578
589
  #transition = new TransitionCompletion();
579
590
  /** Distinguishes dynamic target churn from callbacks around controller teardown. */
580
591
  #connected = false;
592
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
593
+ #reporting = false;
581
594
  /**
582
595
  * Reflects placement and establishes the initial open/closed state.
583
596
  *
@@ -585,9 +598,9 @@ var DrawerController = class extends Controller {
585
598
  * restored snapshot whose panel is already `data-state="open"` stays open
586
599
  * rather than being re-derived from the declarative `open` Value (which would
587
600
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
588
- * render. We normalize to a clean closed baseline first so {@link open} runs its
589
- * full reveal + trap activation — the `FocusTrap` is inactive after a
590
- * disconnect and must be re-activated.
601
+ * render. We normalize to a clean closed baseline first so
602
+ * {@link DrawerController.open | open} runs its full reveal + trap activation —
603
+ * the `FocusTrap` is inactive after a disconnect and must be re-activated.
591
604
  */
592
605
  connect() {
593
606
  this.#connected = true;
@@ -595,11 +608,13 @@ var DrawerController = class extends Controller {
595
608
  this.#reflectPlacement();
596
609
  const shouldOpen = this.#isOpen || this.openValue;
597
610
  this.#applyClosedState();
598
- if (shouldOpen) this.open();
611
+ if (shouldOpen) this.#open("api");
612
+ this.#reporting = true;
599
613
  }
600
614
  /** Reverts the modal side effects and pending hide if torn down while open. */
601
615
  disconnect() {
602
616
  this.#connected = false;
617
+ this.#reporting = false;
603
618
  this.#transition.cancel();
604
619
  this.#trap.deactivate({ restoreFocus: false });
605
620
  this.#activePanel = null;
@@ -630,7 +645,11 @@ var DrawerController = class extends Controller {
630
645
  this.#reflectPlacement();
631
646
  }
632
647
  /** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
633
- open() {
648
+ open(event) {
649
+ this.#open(stateReasonFor(event));
650
+ }
651
+ /** Reveals the drawer, syncs `data-state`, reports a move, then traps focus. */
652
+ #open(reason) {
634
653
  if (!this.hasPanelTarget || this.#isOpen) return;
635
654
  this.#transition.cancel();
636
655
  this.#activePanel = this.panelTarget;
@@ -639,31 +658,46 @@ var DrawerController = class extends Controller {
639
658
  void this.panelTarget.offsetWidth;
640
659
  this.#setState("open");
641
660
  this.openValue = true;
661
+ if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
662
+ if (!this.#isOpen) return;
642
663
  this.#trap.activate();
643
664
  }
644
665
  /**
645
666
  * Closes the drawer: syncs `data-state` to start the exit transition, then
646
667
  * defers both `hidden` *and* the modal teardown (scroll lock / background
647
- * `inert` / focus restore) until the transition finishes — see
648
- * `#applyHidden`. This keeps the background inert and focus trapped while
649
- * the drawer is still visually on screen, preserving the modal contract during
650
- * the exit animation.
668
+ * `inert` / focus restore) until the transition finishes. This keeps the
669
+ * background inert and focus trapped while the drawer is still visually on
670
+ * screen, preserving the modal contract during the exit animation.
651
671
  */
652
- close() {
672
+ close(event) {
673
+ this.#close(stateReasonFor(event));
674
+ }
675
+ /** Closes only when the overlay itself (not its contents) is clicked. */
676
+ closeOnBackdrop(event) {
677
+ if (this.hasOverlayTarget && event.target === this.overlayTarget) this.#close("outside");
678
+ }
679
+ /** Starts the exit transition, reports a move, then defers `hidden` and teardown. */
680
+ #close(reason) {
653
681
  if (!this.hasPanelTarget || !this.#isOpen) return;
654
682
  this.openValue = false;
655
683
  this.#setState("closed");
684
+ if (this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
685
+ if (this.#isOpen) return;
656
686
  this.#hideAfterTransition();
657
687
  }
658
- /** Closes only when the overlay itself (not its contents) is clicked. */
659
- closeOnBackdrop(event) {
660
- if (this.hasOverlayTarget && event.target === this.overlayTarget) this.close();
661
- }
662
- /** Writes `data-placement` from the current `placement` value. */
688
+ /**
689
+ * Writes `data-placement` from the current `placement` value.
690
+ *
691
+ * @stimeoRenderRoot
692
+ */
663
693
  #reflectPlacement() {
664
694
  if (this.hasPanelTarget) this.panelTarget.setAttribute("data-placement", this.#placement);
665
695
  }
666
- /** Reconciles a replacement panel and companion overlay from its explicit DOM state. */
696
+ /**
697
+ * Reconciles a replacement panel and companion overlay from its explicit DOM state.
698
+ *
699
+ * @stimeoRenderRoot
700
+ */
667
701
  #adoptPanel(panel) {
668
702
  this.#transition.cancel();
669
703
  const trapWasActive = this.#trap.active;
@@ -719,8 +753,9 @@ var DrawerController = class extends Controller {
719
753
  * Runs once the close transition has finished: applies `hidden` to the panel
720
754
  * and overlay, then reverts the modal side effects (scroll lock, background
721
755
  * `inert`, keydown listener) and restores focus to the opener. Deferring the
722
- * `FocusTrap` teardown to here — rather than at {@link close} time — keeps
723
- * the background unreachable and focus trapped for the whole exit animation.
756
+ * `FocusTrap` teardown to here — rather than at
757
+ * {@link DrawerController.close | close} time — keeps the background unreachable
758
+ * and focus trapped for the whole exit animation.
724
759
  */
725
760
  #applyHidden(panel) {
726
761
  panel.hidden = true;
@@ -109,40 +109,64 @@ var EscapeLayer = class _EscapeLayer {
109
109
  }
110
110
  };
111
111
 
112
+ // src/utils/state_reason.ts
113
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
114
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
115
+ function stateReasonFor(event) {
116
+ if (!event) return "api";
117
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
118
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
119
+ return "user";
120
+ }
121
+
112
122
  // src/controllers/dropdown_controller.ts
113
123
  var DropdownController = class extends Controller {
114
124
  static targets = ["trigger", "menu"];
115
125
  static actions = ["close", "open", "toggle"];
126
+ static events = ["close", "open"];
116
127
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
117
128
  #escapeLayer = new EscapeLayer();
129
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
130
+ #reporting = false;
118
131
  /** Closes the menu when a click lands outside the controller's element. */
119
132
  #onOutsideClick = (event) => {
120
133
  if (!this.element.contains(event.target)) {
121
- this.close();
134
+ this.#close("outside");
122
135
  }
123
136
  };
124
137
  /** Starts in the closed state and registers outside-click handling. */
125
138
  connect() {
126
139
  this.#associateTriggerWithMenu();
127
- this.close();
140
+ this.#close("api");
128
141
  document.addEventListener("click", this.#onOutsideClick, true);
142
+ this.#reporting = true;
129
143
  }
130
144
  /** Removes the listeners registered in {@link connect}. */
131
145
  disconnect() {
146
+ this.#reporting = false;
132
147
  this.#escapeLayer.deactivate();
133
148
  document.removeEventListener("click", this.#onOutsideClick, true);
134
149
  }
135
150
  /** Toggles the menu between open and closed. Bound via `data-action`. */
136
- toggle() {
151
+ toggle(event) {
137
152
  if (this.#isOpen) {
138
- this.close();
153
+ this.close(event);
139
154
  } else {
140
- this.open();
155
+ this.open(event);
141
156
  }
142
157
  }
143
158
  /** Reveals the menu and reflects the open state on the trigger. */
144
- open() {
159
+ open(event) {
160
+ this.#open(stateReasonFor(event));
161
+ }
162
+ /** Hides the menu and reflects the closed state on the trigger. */
163
+ close(event) {
164
+ this.#close(stateReasonFor(event));
165
+ }
166
+ /** Reveals the menu, reflects the open state, and reports a move. */
167
+ #open(reason) {
145
168
  if (!this.hasMenuTarget) return;
169
+ const was = this.#isOpen;
146
170
  this.#escapeLayer.activate(document, {
147
171
  onDismiss: () => this.#closeAndRestore(),
148
172
  claims: claimsWhileFocusWithin(this.element)
@@ -151,19 +175,22 @@ var DropdownController = class extends Controller {
151
175
  if (this.hasTriggerTarget) {
152
176
  this.triggerTarget.setAttribute("aria-expanded", "true");
153
177
  }
178
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
154
179
  }
155
- /** Hides the menu and reflects the closed state on the trigger. */
156
- close() {
180
+ /** Hides the menu, reflects the closed state, and reports a move. */
181
+ #close(reason) {
182
+ const was = this.#isOpen;
157
183
  this.#escapeLayer.deactivate();
158
184
  if (!this.hasMenuTarget) return;
159
185
  this.menuTarget.hidden = true;
160
186
  if (this.hasTriggerTarget) {
161
187
  this.triggerTarget.setAttribute("aria-expanded", "false");
162
188
  }
189
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
163
190
  }
164
191
  /** Closes and restores focus to the trigger (the keyboard-dismissal path). */
165
192
  #closeAndRestore() {
166
- this.close();
193
+ this.#close("escape");
167
194
  if (this.hasTriggerTarget) this.triggerTarget.focus();
168
195
  }
169
196
  /** Whether the menu is currently visible. */
@@ -3,11 +3,19 @@ import { Controller } from '@hotwired/stimulus';
3
3
  // src/controllers/editable_controller.ts
4
4
 
5
5
  // src/utils/composition_tracker.ts
6
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
7
+ "insertCompositionText",
8
+ "insertFromComposition",
9
+ "deleteCompositionText",
10
+ "deleteByComposition"
11
+ ]);
6
12
  var CompositionTracker = class {
7
13
  #observedTargets = /* @__PURE__ */ new Set();
8
14
  #activeTargets = /* @__PURE__ */ new Set();
9
15
  #onStart;
10
16
  #onEnd;
17
+ /** The field whose confirming `input` is still owed, while the window is open. */
18
+ #confirmedTarget = null;
11
19
  constructor(options = {}) {
12
20
  this.#onStart = options.onStart;
13
21
  this.#onEnd = options.onEnd;
@@ -17,6 +25,7 @@ var CompositionTracker = class {
17
25
  if (this.#observedTargets.has(target)) return;
18
26
  target.addEventListener("compositionstart", this.#handleStart);
19
27
  target.addEventListener("compositionend", this.#handleEnd);
28
+ target.addEventListener("keydown", this.#handleKeydown);
20
29
  this.#observedTargets.add(target);
21
30
  }
22
31
  /** Stops tracking one target and clears any active composition it owned. */
@@ -24,29 +33,52 @@ var CompositionTracker = class {
24
33
  if (!this.#observedTargets.delete(target)) return;
25
34
  target.removeEventListener("compositionstart", this.#handleStart);
26
35
  target.removeEventListener("compositionend", this.#handleEnd);
36
+ target.removeEventListener("keydown", this.#handleKeydown);
27
37
  this.#activeTargets.delete(target);
38
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
28
39
  }
29
40
  /** Releases every listener and clears state so reconnect starts cleanly. */
30
41
  disconnect() {
31
42
  for (const target of this.#observedTargets) {
32
43
  target.removeEventListener("compositionstart", this.#handleStart);
33
44
  target.removeEventListener("compositionend", this.#handleEnd);
45
+ target.removeEventListener("keydown", this.#handleKeydown);
34
46
  }
35
47
  this.#observedTargets.clear();
36
48
  this.#activeTargets.clear();
49
+ this.#confirmedTarget = null;
37
50
  }
38
51
  /** True when lifecycle tracking or the current event reports composition. */
39
52
  isComposing(event) {
40
53
  return this.#activeTargets.size > 0 || event?.isComposing === true;
41
54
  }
55
+ /**
56
+ * Whether `event` is the `input` echoing the composition just confirmed.
57
+ *
58
+ * Asking closes the window either way, so one confirmation is folded at most
59
+ * once and a consumer asks once per `input`.
60
+ */
61
+ consumesConfirmedInput(event) {
62
+ const confirmed = this.#confirmedTarget;
63
+ this.#confirmedTarget = null;
64
+ if (confirmed === null || confirmed !== event.target) return false;
65
+ const inputType = event.inputType;
66
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
67
+ }
42
68
  #handleStart = (event) => {
69
+ this.#confirmedTarget = null;
43
70
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
71
  this.#onStart?.(event);
45
72
  };
46
73
  #handleEnd = (event) => {
47
74
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
75
+ this.#confirmedTarget = event.target;
48
76
  this.#onEnd?.(event);
49
77
  };
78
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
79
+ #handleKeydown = () => {
80
+ this.#confirmedTarget = null;
81
+ };
50
82
  };
51
83
 
52
84
  // src/controllers/editable_controller.ts
@@ -79,6 +111,8 @@ var EditableController = class extends Controller {
79
111
  * "saves wherever focus moved" true only for focus that leaves the input
80
112
  * itself, and tabbing straight past an inner button would strand the editor
81
113
  * open.
114
+ *
115
+ * @stimeoRuntimeOnly `submitOnBlur` decides whether this one blur commits the edit.
82
116
  */
83
117
  #onFocusOut = (event) => {
84
118
  const from = event.target;