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
@@ -21,11 +21,19 @@ function isReservedArrowChord(event, allow = []) {
21
21
  }
22
22
 
23
23
  // src/utils/composition_tracker.ts
24
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
25
+ "insertCompositionText",
26
+ "insertFromComposition",
27
+ "deleteCompositionText",
28
+ "deleteByComposition"
29
+ ]);
24
30
  var CompositionTracker = class {
25
31
  #observedTargets = /* @__PURE__ */ new Set();
26
32
  #activeTargets = /* @__PURE__ */ new Set();
27
33
  #onStart;
28
34
  #onEnd;
35
+ /** The field whose confirming `input` is still owed, while the window is open. */
36
+ #confirmedTarget = null;
29
37
  constructor(options = {}) {
30
38
  this.#onStart = options.onStart;
31
39
  this.#onEnd = options.onEnd;
@@ -35,6 +43,7 @@ var CompositionTracker = class {
35
43
  if (this.#observedTargets.has(target)) return;
36
44
  target.addEventListener("compositionstart", this.#handleStart);
37
45
  target.addEventListener("compositionend", this.#handleEnd);
46
+ target.addEventListener("keydown", this.#handleKeydown);
38
47
  this.#observedTargets.add(target);
39
48
  }
40
49
  /** Stops tracking one target and clears any active composition it owned. */
@@ -42,31 +51,59 @@ var CompositionTracker = class {
42
51
  if (!this.#observedTargets.delete(target)) return;
43
52
  target.removeEventListener("compositionstart", this.#handleStart);
44
53
  target.removeEventListener("compositionend", this.#handleEnd);
54
+ target.removeEventListener("keydown", this.#handleKeydown);
45
55
  this.#activeTargets.delete(target);
56
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
46
57
  }
47
58
  /** Releases every listener and clears state so reconnect starts cleanly. */
48
59
  disconnect() {
49
60
  for (const target of this.#observedTargets) {
50
61
  target.removeEventListener("compositionstart", this.#handleStart);
51
62
  target.removeEventListener("compositionend", this.#handleEnd);
63
+ target.removeEventListener("keydown", this.#handleKeydown);
52
64
  }
53
65
  this.#observedTargets.clear();
54
66
  this.#activeTargets.clear();
67
+ this.#confirmedTarget = null;
55
68
  }
56
69
  /** True when lifecycle tracking or the current event reports composition. */
57
70
  isComposing(event) {
58
71
  return this.#activeTargets.size > 0 || event?.isComposing === true;
59
72
  }
73
+ /**
74
+ * Whether `event` is the `input` echoing the composition just confirmed.
75
+ *
76
+ * Asking closes the window either way, so one confirmation is folded at most
77
+ * once and a consumer asks once per `input`.
78
+ */
79
+ consumesConfirmedInput(event) {
80
+ const confirmed = this.#confirmedTarget;
81
+ this.#confirmedTarget = null;
82
+ if (confirmed === null || confirmed !== event.target) return false;
83
+ const inputType = event.inputType;
84
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
85
+ }
60
86
  #handleStart = (event) => {
87
+ this.#confirmedTarget = null;
61
88
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
62
89
  this.#onStart?.(event);
63
90
  };
64
91
  #handleEnd = (event) => {
65
92
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
93
+ this.#confirmedTarget = event.target;
66
94
  this.#onEnd?.(event);
67
95
  };
96
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
97
+ #handleKeydown = () => {
98
+ this.#confirmedTarget = null;
99
+ };
68
100
  };
69
101
 
102
+ // src/utils/field_mirror.ts
103
+ function commitField(target) {
104
+ target.dispatchEvent(new Event("change", { bubbles: true }));
105
+ }
106
+
70
107
  // src/utils/microtask_coalescer.ts
71
108
  var MicrotaskCoalescer = class {
72
109
  #run;
@@ -112,20 +149,64 @@ function scrollOptionIntoView(list, option) {
112
149
  }
113
150
  }
114
151
 
152
+ // src/utils/state_regions.ts
153
+ var StateRegions = class {
154
+ #whenTrue;
155
+ #whenFalse;
156
+ #taken = /* @__PURE__ */ new WeakSet();
157
+ constructor(options) {
158
+ this.#whenTrue = options.whenTrue;
159
+ this.#whenFalse = options.whenFalse ?? null;
160
+ }
161
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
162
+ reflect(host, isTrue) {
163
+ const shown = this.#inside(host, this.#whenTrue());
164
+ if (!this.#whenFalse) {
165
+ for (const region of shown) this.#write(region, !isTrue);
166
+ return;
167
+ }
168
+ const hiddenSide = this.#inside(host, this.#whenFalse());
169
+ if (shown.length === 0 || hiddenSide.length === 0) {
170
+ for (const region of shown) this.#give(region);
171
+ for (const region of hiddenSide) this.#give(region);
172
+ return;
173
+ }
174
+ for (const region of shown) this.#write(region, !isTrue);
175
+ for (const region of hiddenSide) this.#write(region, isTrue);
176
+ }
177
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
178
+ #write(region, hidden) {
179
+ if (hidden) this.#taken.add(region);
180
+ else this.#taken.delete(region);
181
+ if (region.hidden !== hidden) region.hidden = hidden;
182
+ }
183
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
184
+ #give(region) {
185
+ if (this.#taken.delete(region)) region.hidden = false;
186
+ }
187
+ /** The declared regions that sit within `host`, which may be the host itself. */
188
+ #inside(host, regions) {
189
+ return regions.filter((region) => host.contains(region));
190
+ }
191
+ };
192
+
115
193
  // src/controllers/combobox_controller.ts
116
194
  var ComboboxController = class extends Controller {
117
- static targets = ["input", "list", "option"];
195
+ static targets = ["input", "list", "option", "empty"];
118
196
  static actions = ["close", "filter", "onKeydown", "open", "selectByClick"];
119
197
  static events = ["selected"];
198
+ /** Owns `hidden` on the regions declared for the empty state. */
199
+ #emptyRegion = new StateRegions({ whenTrue: () => this.emptyTargets });
120
200
  /** Stable ID of the active option; the live element is resolved before every use. */
121
201
  #activeId = null;
122
202
  #connected = false;
123
203
  /** Collapses one mutation batch of target callbacks into a single pass. */
124
204
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
125
205
  /**
126
- * Suppresses {@link open} for the duration of the programmatic re-focus in
127
- * `#select`, so committing a value (which returns focus to the input)
128
- * does not immediately re-open the listbox via a `focus`-bound action.
206
+ * Suppresses {@link ComboboxController.open | open} for the duration of the
207
+ * programmatic re-focus in `#select`, so committing a value (which returns focus
208
+ * to the input) does not immediately re-open the listbox via a `focus`-bound
209
+ * action.
129
210
  */
130
211
  #suppressOpen = false;
131
212
  /** Owns IME lifecycle state; confirmed text re-filters the list once. */
@@ -154,6 +235,10 @@ var ComboboxController = class extends Controller {
154
235
  inputTargetDisconnected(input) {
155
236
  this.#composition.unobserve(input);
156
237
  }
238
+ /** Settles a connecting region on the side the empty state is on. */
239
+ emptyTargetConnected() {
240
+ this.#reflectEmptyState();
241
+ }
157
242
  /** Establishes the active-state baseline, then reconciles a runtime addition. */
158
243
  optionTargetConnected(option) {
159
244
  option.setAttribute("aria-selected", "false");
@@ -206,7 +291,7 @@ var ComboboxController = class extends Controller {
206
291
  close() {
207
292
  if (!this.hasListTarget) return;
208
293
  this.listTarget.hidden = true;
209
- this.element.removeAttribute("data-stimeo--combobox-empty");
294
+ this.#reflectEmptyState();
210
295
  if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
211
296
  this.#setActive(-1);
212
297
  }
@@ -300,10 +385,10 @@ var ComboboxController = class extends Controller {
300
385
  * Commits an option: fills the input, closes the listbox, notifies listeners.
301
386
  *
302
387
  * An input removed while the popup is open leaves the options clickable, and
303
- * {@link close} already survives that state. Selection does too: the popup
304
- * comes down and listeners still hear the choice, with only the field-bound
305
- * half — the value write, the focus return, and the native `change` — skipped,
306
- * because there is no field to carry them.
388
+ * {@link ComboboxController.close | close} already survives that state. Selection
389
+ * does too: the popup comes down and listeners still hear the choice, with only
390
+ * the field-bound half — the value write, the focus return, and the native
391
+ * `change` — skipped, because there is no field to carry them.
307
392
  */
308
393
  #select(option) {
309
394
  const value = option.dataset.value ?? (option.textContent ?? "").trim();
@@ -319,23 +404,28 @@ var ComboboxController = class extends Controller {
319
404
  this.inputTarget.focus();
320
405
  this.#suppressOpen = false;
321
406
  if (changed) {
322
- this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }));
407
+ commitField(this.inputTarget);
323
408
  }
324
409
  this.dispatch("selected", { detail: { value } });
325
410
  }
326
411
  /**
327
- * Reflects whether the open listbox currently has zero matching options by
328
- * toggling `data-stimeo--combobox-empty` on the root element. Behavior only:
329
- * consumers decide how to present the empty state (hide the list, show a
330
- * "no results" node, etc.) via CSS keyed off this attribute.
412
+ * Writes whether the open listbox has zero matching options to the state hook
413
+ * `data-stimeo--combobox-empty` on the root element and to the regions declared
414
+ * for that state. Behavior only: consumers decide how to present the empty state
415
+ * via CSS keyed off the attribute, while the `empty` targets carry the copy.
416
+ *
417
+ * Every path that can change the answer — opening, filtering, reconciling a
418
+ * runtime option change, and closing — ends here, so the hook and the regions
419
+ * are a pure function of the state rather than of the order things happened in.
331
420
  */
332
421
  #reflectEmptyState() {
333
422
  const empty = !this.#isClosed && this.#visibleOptions().length === 0;
334
423
  if (empty) {
335
- this.element.setAttribute("data-stimeo--combobox-empty", "");
424
+ this.element.setAttribute(`data-${this.identifier}-empty`, "");
336
425
  } else {
337
- this.element.removeAttribute("data-stimeo--combobox-empty");
426
+ this.element.removeAttribute(`data-${this.identifier}-empty`);
338
427
  }
428
+ this.#emptyRegion.reflect(this.element, empty);
339
429
  }
340
430
  /**
341
431
  * Marks the visible option at `index` active via `aria-selected` and the
@@ -21,11 +21,19 @@ function isReservedArrowChord(event, allow = []) {
21
21
  }
22
22
 
23
23
  // src/utils/composition_tracker.ts
24
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
25
+ "insertCompositionText",
26
+ "insertFromComposition",
27
+ "deleteCompositionText",
28
+ "deleteByComposition"
29
+ ]);
24
30
  var CompositionTracker = class {
25
31
  #observedTargets = /* @__PURE__ */ new Set();
26
32
  #activeTargets = /* @__PURE__ */ new Set();
27
33
  #onStart;
28
34
  #onEnd;
35
+ /** The field whose confirming `input` is still owed, while the window is open. */
36
+ #confirmedTarget = null;
29
37
  constructor(options = {}) {
30
38
  this.#onStart = options.onStart;
31
39
  this.#onEnd = options.onEnd;
@@ -35,6 +43,7 @@ var CompositionTracker = class {
35
43
  if (this.#observedTargets.has(target)) return;
36
44
  target.addEventListener("compositionstart", this.#handleStart);
37
45
  target.addEventListener("compositionend", this.#handleEnd);
46
+ target.addEventListener("keydown", this.#handleKeydown);
38
47
  this.#observedTargets.add(target);
39
48
  }
40
49
  /** Stops tracking one target and clears any active composition it owned. */
@@ -42,29 +51,52 @@ var CompositionTracker = class {
42
51
  if (!this.#observedTargets.delete(target)) return;
43
52
  target.removeEventListener("compositionstart", this.#handleStart);
44
53
  target.removeEventListener("compositionend", this.#handleEnd);
54
+ target.removeEventListener("keydown", this.#handleKeydown);
45
55
  this.#activeTargets.delete(target);
56
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
46
57
  }
47
58
  /** Releases every listener and clears state so reconnect starts cleanly. */
48
59
  disconnect() {
49
60
  for (const target of this.#observedTargets) {
50
61
  target.removeEventListener("compositionstart", this.#handleStart);
51
62
  target.removeEventListener("compositionend", this.#handleEnd);
63
+ target.removeEventListener("keydown", this.#handleKeydown);
52
64
  }
53
65
  this.#observedTargets.clear();
54
66
  this.#activeTargets.clear();
67
+ this.#confirmedTarget = null;
55
68
  }
56
69
  /** True when lifecycle tracking or the current event reports composition. */
57
70
  isComposing(event) {
58
71
  return this.#activeTargets.size > 0 || event?.isComposing === true;
59
72
  }
73
+ /**
74
+ * Whether `event` is the `input` echoing the composition just confirmed.
75
+ *
76
+ * Asking closes the window either way, so one confirmation is folded at most
77
+ * once and a consumer asks once per `input`.
78
+ */
79
+ consumesConfirmedInput(event) {
80
+ const confirmed = this.#confirmedTarget;
81
+ this.#confirmedTarget = null;
82
+ if (confirmed === null || confirmed !== event.target) return false;
83
+ const inputType = event.inputType;
84
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
85
+ }
60
86
  #handleStart = (event) => {
87
+ this.#confirmedTarget = null;
61
88
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
62
89
  this.#onStart?.(event);
63
90
  };
64
91
  #handleEnd = (event) => {
65
92
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
93
+ this.#confirmedTarget = event.target;
66
94
  this.#onEnd?.(event);
67
95
  };
96
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
97
+ #handleKeydown = () => {
98
+ this.#confirmedTarget = null;
99
+ };
68
100
  };
69
101
 
70
102
  // src/utils/before_cache_reset.ts
@@ -511,9 +543,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
511
543
  * the restored snapshot already shows the dialog open, honor that rather than
512
544
  * re-deriving from the declarative `open` Value (which would slam a user-opened
513
545
  * palette shut). The `open` Value only seeds the initial state of a genuinely
514
- * fresh render. We normalize to a clean closed baseline first so {@link open}
515
- * runs its full setup — the `FocusTrap` is a fresh instance after a
516
- * reconnect and must be re-activated.
546
+ * fresh render. We normalize to a clean closed baseline first so
547
+ * {@link CommandPaletteController.open | open} runs its full setup — the
548
+ * `FocusTrap` is a fresh instance after a reconnect and must be re-activated.
517
549
  */
518
550
  connect() {
519
551
  document.addEventListener("keydown", this.#onGlobalKeydown);
@@ -26,6 +26,54 @@ function canTakeFocus(element) {
26
26
  return !inheritsFieldsetDisabled(element);
27
27
  }
28
28
 
29
+ // src/utils/form_reset_watcher.ts
30
+ var FormResetWatcher = class {
31
+ #owns;
32
+ #onReset;
33
+ #frames = /* @__PURE__ */ new Set();
34
+ #generation = 0;
35
+ /**
36
+ * @param owns - whether `form` owns a control this controller derives from.
37
+ * @param onReset - the reconciliation pass, run once per honoured reset.
38
+ */
39
+ constructor(owns, onReset) {
40
+ this.#owns = owns;
41
+ this.#onReset = onReset;
42
+ }
43
+ /** Subscribes to `reset` at the document; call from `connect()`. Idempotent. */
44
+ observe() {
45
+ document.addEventListener("reset", this.#handleReset, true);
46
+ }
47
+ /** Unsubscribes and drops every pending pass; call from `disconnect()`. */
48
+ disconnect() {
49
+ document.removeEventListener("reset", this.#handleReset, true);
50
+ this.#generation += 1;
51
+ for (const frame of this.#frames) cancelAnimationFrame(frame);
52
+ this.#frames.clear();
53
+ }
54
+ #handleReset = (event) => {
55
+ const form = event.target;
56
+ if (!(form instanceof HTMLFormElement) || !this.#owns(form)) return;
57
+ const generation = this.#generation;
58
+ queueMicrotask(() => {
59
+ if (generation !== this.#generation) return;
60
+ if (event.eventPhase === Event.NONE) {
61
+ this.#settle(event);
62
+ return;
63
+ }
64
+ const frame = requestAnimationFrame(() => {
65
+ this.#frames.delete(frame);
66
+ this.#settle(event);
67
+ });
68
+ this.#frames.add(frame);
69
+ });
70
+ };
71
+ /** Runs the pass unless a listener cancelled the reset before it landed. */
72
+ #settle(event) {
73
+ if (!event.defaultPrevented) this.#onReset();
74
+ }
75
+ };
76
+
29
77
  // src/utils/listener_set.ts
30
78
  var ListenerSet = class {
31
79
  /** The generation every `add` joins until the next `dispose()`. */
@@ -175,6 +223,10 @@ var ConditionalFieldsController = class extends Controller {
175
223
  /** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
176
224
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
177
225
  #listeners = new ListenerSet();
226
+ #formReset = new FormResetWatcher(
227
+ (form) => this.#hasTriggerOwnedBy(form),
228
+ () => this.#reconcile.schedule()
229
+ );
178
230
  /** Provides the last-resort focus landmark without claiming an authored tabindex. */
179
231
  #rootTabindex = new TabindexLoan();
180
232
  /** Watches retained targets and controls whose declarative or reflected state changed. */
@@ -191,15 +243,6 @@ var ConditionalFieldsController = class extends Controller {
191
243
  #onMorph = () => {
192
244
  this.#reconcile.schedule();
193
245
  };
194
- /** Reconciles the settled defaults after a non-cancelled owning form reset. */
195
- #onReset = (event) => {
196
- const form = event.target;
197
- if (form instanceof HTMLFormElement && this.#hasTriggerOwnedBy(form)) {
198
- queueMicrotask(() => {
199
- if (!event.defaultPrevented) this.#reconcile.schedule();
200
- });
201
- }
202
- };
203
246
  /** Reflects initial live state and opens every retained-DOM reconciliation path. */
204
247
  connect() {
205
248
  this.#lastVisible = /* @__PURE__ */ new WeakMap();
@@ -216,7 +259,7 @@ var ConditionalFieldsController = class extends Controller {
216
259
  this.#listeners.add(this.element, "change", this.#onTriggerInput);
217
260
  this.#listeners.add(this.element, "input", this.#onTriggerInput);
218
261
  this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
219
- document.addEventListener("reset", this.#onReset, true);
262
+ this.#formReset.observe();
220
263
  }
221
264
  /** Releases all external resources and returns the temporary focus landmark loan. */
222
265
  disconnect() {
@@ -224,7 +267,7 @@ var ConditionalFieldsController = class extends Controller {
224
267
  this.#observing = false;
225
268
  this.#observer.disconnect();
226
269
  this.#listeners.dispose();
227
- document.removeEventListener("reset", this.#onReset, true);
270
+ this.#formReset.disconnect();
228
271
  this.#rootTabindex.returnAll();
229
272
  }
230
273
  /** Reconciles after a trigger target is inserted or restored. */
@@ -258,6 +301,8 @@ var ConditionalFieldsController = class extends Controller {
258
301
  /**
259
302
  * Computes one settled plan, shows destinations before hiding sources, and then
260
303
  * returns logical transitions in DOM order after every region is internally coherent.
304
+ *
305
+ * @stimeoRenderRoot
261
306
  */
262
307
  #settle(triggers = this.triggerTargets) {
263
308
  const regions = this.regionTargets;
@@ -423,6 +423,9 @@ var ConfirmController = class extends Controller {
423
423
  /**
424
424
  * Opens the dialog for `message` and resolves once the user confirms or cancels.
425
425
  * Degrades to native `window.confirm` when no dialog target is present.
426
+ *
427
+ * @stimeoRuntimeOnly The labels word the one dialog this call opens; every opening writes both
428
+ * labels again, and the dialog stays hidden between prompts.
426
429
  */
427
430
  #prompt(message) {
428
431
  if (!this.hasDialogTarget) return Promise.resolve(window.confirm(message));
@@ -151,22 +151,37 @@ var SafeTimeout = class extends TimerRegistry {
151
151
  }
152
152
  };
153
153
 
154
+ // src/utils/state_reason.ts
155
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
156
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
157
+ function stateReasonFor(event) {
158
+ if (!event) return "api";
159
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
160
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
161
+ return "user";
162
+ }
163
+
154
164
  // src/controllers/context_menu_controller.ts
155
165
  var ContextMenuController = class extends Controller {
156
166
  static targets = ["region", "menu", "item"];
157
167
  static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
168
+ static events = ["close", "open"];
158
169
  #timers = new SafeTimeout();
159
170
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
160
171
  #escapeLayer = new EscapeLayer();
172
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
173
+ #reporting = false;
161
174
  /** Starts closed and registers delegated activation and outside-pointer listeners. */
162
175
  connect() {
163
- this.#closeMenu();
176
+ this.#closeMenu("api");
164
177
  this.element.addEventListener("click", this.#onItemClickCapture, true);
165
178
  document.addEventListener("click", this.#onOutsidePointer, true);
166
179
  document.addEventListener("contextmenu", this.#onOutsidePointer, true);
180
+ this.#reporting = true;
167
181
  }
168
182
  /** Releases the listeners, stack membership, and pending Tab-close task. */
169
183
  disconnect() {
184
+ this.#reporting = false;
170
185
  this.#timers.clearAll();
171
186
  this.#escapeLayer.deactivate();
172
187
  this.element.removeEventListener("click", this.#onItemClickCapture, true);
@@ -179,7 +194,7 @@ var ContextMenuController = class extends Controller {
179
194
  */
180
195
  open(event) {
181
196
  event.preventDefault();
182
- this.#openAt(event.clientX, event.clientY);
197
+ this.#openAt(event.clientX, event.clientY, stateReasonFor(event));
183
198
  }
184
199
  /** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */
185
200
  onRegionKeydown(event) {
@@ -188,7 +203,7 @@ var ContextMenuController = class extends Controller {
188
203
  if (!isContextKey) return;
189
204
  event.preventDefault();
190
205
  const rect = this.hasRegionTarget ? this.regionTarget.getBoundingClientRect() : { left: 0, top: 0, width: 0, height: 0 };
191
- this.#openAt(rect.left + rect.width / 2, rect.top + rect.height / 2);
206
+ this.#openAt(rect.left + rect.width / 2, rect.top + rect.height / 2, stateReasonFor(event));
192
207
  }
193
208
  /** Roving focus and closing keys inside the menu. */
194
209
  onItemKeydown(event) {
@@ -221,39 +236,44 @@ var ContextMenuController = class extends Controller {
221
236
  break;
222
237
  case "Tab":
223
238
  this.#timers.clearAll();
224
- this.#timers.set(() => this.#closeMenu(), 0);
239
+ this.#timers.set(() => this.#closeMenu("focus"), 0);
225
240
  break;
226
241
  }
227
242
  }
228
243
  /** Closes after an item is activated and restores focus to the region. */
229
244
  activate() {
230
- this.#closeAndRestore();
245
+ this.#closeAndRestore("select");
231
246
  }
232
247
  /** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
233
- #openAt(x, y) {
248
+ #openAt(x, y, reason) {
234
249
  if (!this.hasMenuTarget) return;
235
250
  this.#timers.clearAll();
251
+ const was = this.#isOpen;
236
252
  this.#escapeLayer.activate(document, {
237
- onDismiss: () => this.#closeAndRestore(),
253
+ onDismiss: () => this.#closeAndRestore("escape"),
238
254
  claims: claimsWhileFocusWithin(this.element)
239
255
  });
240
256
  this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
241
257
  this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
242
258
  this.menuTarget.hidden = false;
243
259
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
260
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
261
+ if (!this.#isOpen) return;
244
262
  this.#navigableItems[0]?.focus();
245
263
  }
246
- /** Hides the menu and reflects the collapsed state on the region. */
247
- #closeMenu() {
264
+ /** Hides the menu, reflects the collapsed state on the region, and reports a move. */
265
+ #closeMenu(reason) {
248
266
  this.#timers.clearAll();
249
267
  this.#escapeLayer.deactivate();
250
268
  if (!this.hasMenuTarget) return;
269
+ const was = this.#isOpen;
251
270
  this.menuTarget.hidden = true;
252
271
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
272
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
253
273
  }
254
274
  /** Closes the menu and returns focus to the region (Escape / activation). */
255
- #closeAndRestore() {
256
- this.#closeMenu();
275
+ #closeAndRestore(reason) {
276
+ this.#closeMenu(reason);
257
277
  if (this.hasRegionTarget) this.regionTarget.focus();
258
278
  }
259
279
  /**
@@ -264,7 +284,7 @@ var ContextMenuController = class extends Controller {
264
284
  * open.
265
285
  */
266
286
  #onOutsidePointer = (event) => {
267
- if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
287
+ if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu("outside");
268
288
  };
269
289
  /**
270
290
  * Captures clicks so `aria-disabled` commands cannot reach consumer handlers.