stimeo-ui 0.14.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -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 +81 -12
  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 +85 -17
  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 +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  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 +46 -13
  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 +432 -71
  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 +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  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/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -29,6 +29,29 @@ function ownerOf(candidates, node) {
29
29
  return candidates[ownerIndex(candidates, node)] ?? null;
30
30
  }
31
31
 
32
+ // src/utils/field_mirror.ts
33
+ function writeFields(container, values, { name, form = "" }) {
34
+ const current = [...container.children];
35
+ const submits = current.length === values.length && current.every(
36
+ (child, index) => child instanceof HTMLInputElement && child.type === "hidden" && child.value === values[index] && child.name === name && (child.getAttribute("form") ?? "") === form
37
+ );
38
+ if (submits) return false;
39
+ container.replaceChildren(
40
+ ...values.map((value) => {
41
+ const input = document.createElement("input");
42
+ input.type = "hidden";
43
+ input.name = name;
44
+ input.value = value;
45
+ if (form !== "") input.setAttribute("form", form);
46
+ return input;
47
+ })
48
+ );
49
+ return true;
50
+ }
51
+ function commitField(target) {
52
+ target.dispatchEvent(new Event("change", { bubbles: true }));
53
+ }
54
+
32
55
  // src/utils/focus_candidate.ts
33
56
  function inheritsFieldsetDisabled(control) {
34
57
  let fieldset = control.closest("fieldset[disabled]");
@@ -57,6 +80,39 @@ function isInteractiveHost(element) {
57
80
  return false;
58
81
  }
59
82
 
83
+ // src/utils/microtask_coalescer.ts
84
+ var MicrotaskCoalescer = class {
85
+ #run;
86
+ #queued = false;
87
+ #active = false;
88
+ #generation = 0;
89
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
90
+ constructor(run) {
91
+ this.#run = run;
92
+ }
93
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
94
+ activate() {
95
+ this.#active = true;
96
+ }
97
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
98
+ cancel() {
99
+ this.#active = false;
100
+ this.#queued = false;
101
+ this.#generation += 1;
102
+ }
103
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
104
+ schedule() {
105
+ if (!this.#active || this.#queued) return;
106
+ this.#queued = true;
107
+ const generation = this.#generation;
108
+ queueMicrotask(() => {
109
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
110
+ this.#queued = false;
111
+ this.#run();
112
+ });
113
+ }
114
+ };
115
+
60
116
  // src/utils/roving_tabindex.ts
61
117
  var RovingTabindex = class {
62
118
  /** Returns the current ordered item elements; called on every operation. */
@@ -100,6 +156,7 @@ function rovingMove(current, length, delta, wrap) {
100
156
  // src/controllers/toggle_group_controller.ts
101
157
  var ITEM_ATTRIBUTES = [
102
158
  "aria-pressed",
159
+ "data-value",
103
160
  "disabled",
104
161
  "hidden",
105
162
  "type",
@@ -109,24 +166,37 @@ var ITEM_ATTRIBUTES = [
109
166
  ];
110
167
  var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
111
168
  var ToggleGroupController = class extends Controller {
112
- static targets = ["item"];
169
+ static targets = ["item", "fields"];
113
170
  static values = {
114
- mode: { type: String, default: "multiple" }
171
+ mode: { type: String, default: "multiple" },
172
+ name: { type: String, default: "values[]" },
173
+ form: { type: String, default: "" }
115
174
  };
116
175
  static actions = ["onKeydown", "toggle"];
117
- static events = ["change"];
176
+ static events = ["change", "reconcile"];
118
177
  #roving = new RovingTabindex(() => this.#managedTargets);
119
178
  #handledEvents = /* @__PURE__ */ new WeakSet();
120
179
  #managedItems = /* @__PURE__ */ new Set();
121
180
  #originalTabindex = /* @__PURE__ */ new Map();
122
181
  #ownedPressed = /* @__PURE__ */ new Set();
123
182
  #internalPressedValues = /* @__PURE__ */ new Map();
183
+ /** One pass per batch of item, Value, and retained-element changes. */
184
+ #pass = new MicrotaskCoalescer(() => this.#reconcileDom());
124
185
  #observer = null;
125
186
  #connected = false;
187
+ /** The pressed set last published: read on connect, then committed or reported. */
188
+ #committedValues = [];
189
+ /**
190
+ * How many presses the user has committed, so a press whose reports are still
191
+ * going out can tell that a listener has committed a newer one meanwhile.
192
+ */
193
+ #presses = 0;
126
194
  /**
127
195
  * Normalizes state and establishes one roving entry point. A single authored
128
196
  * `tabindex="0"` survives reconnect; otherwise the first pressed, navigable
129
- * item wins, falling back to the first navigable item.
197
+ * item wins, falling back to the first navigable item. The pressed set it
198
+ * settles on is the one a later move is measured from; connecting reports
199
+ * nothing.
130
200
  */
131
201
  connect() {
132
202
  const currentItems = new Set(this.itemTargets);
@@ -140,6 +210,8 @@ var ToggleGroupController = class extends Controller {
140
210
  for (const item of this.itemTargets) this.#reconcileHost(item, false);
141
211
  this.#normalizeSelection();
142
212
  this.#ensureTabStop(preferred);
213
+ this.#mirrorFields(false);
214
+ this.#committedValues = this.#pressedValues();
143
215
  this.element.addEventListener("click", this.#onClickCapture, true);
144
216
  this.element.addEventListener("keydown", this.#onKeydownCapture, true);
145
217
  this.element.addEventListener("click", this.#onClick);
@@ -147,11 +219,16 @@ var ToggleGroupController = class extends Controller {
147
219
  this.element.addEventListener("focusin", this.#onFocusin);
148
220
  this.#connected = true;
149
221
  this.#internalPressedValues.clear();
222
+ this.#pass.activate();
150
223
  this.#observeMutations();
151
224
  }
152
- /** Releases every listener and observer while retaining DOM state for Turbo cache/reconnect. */
225
+ /**
226
+ * Releases every listener, the observer, and a pass still queued, while
227
+ * retaining DOM state for Turbo cache/reconnect.
228
+ */
153
229
  disconnect() {
154
230
  this.#connected = false;
231
+ this.#pass.cancel();
155
232
  this.element.removeEventListener("click", this.#onClickCapture, true);
156
233
  this.element.removeEventListener("keydown", this.#onKeydownCapture, true);
157
234
  this.element.removeEventListener("click", this.#onClick);
@@ -161,25 +238,29 @@ var ToggleGroupController = class extends Controller {
161
238
  this.#observer = null;
162
239
  this.#internalPressedValues.clear();
163
240
  }
164
- /** Drops a newly connected item from the Tab sequence before reconciling the group. */
241
+ /** Drops a newly connected item from the Tab sequence before the batch is reconciled. */
165
242
  itemTargetConnected(item) {
166
243
  if (!this.#connected) return;
167
244
  this.#reconcileHost(item, true);
168
- this.#normalizeSelection();
169
- this.#ensureTabStop();
245
+ this.#pass.schedule();
170
246
  }
171
- /** Restores attributes owned only while an element is an item and repairs the Tab stop. */
247
+ /** Restores attributes owned only while an element is an item, then reconciles the batch. */
172
248
  itemTargetDisconnected(item) {
173
249
  if (!this.#connected) return;
174
250
  this.#releaseItem(item);
175
- this.#normalizeSelection();
176
- this.#ensureTabStop();
251
+ this.#pass.schedule();
177
252
  }
178
253
  /** Reconciles the single-selection invariant when the Stimulus Value changes. */
179
254
  modeValueChanged() {
180
- if (!this.#connected) return;
181
- this.#normalizeSelection();
182
- this.#ensureTabStop();
255
+ this.#pass.schedule();
256
+ }
257
+ /** Rebuilds the submitted fields when the public name changes at runtime. */
258
+ nameValueChanged() {
259
+ this.#pass.schedule();
260
+ }
261
+ /** Repoints the submitted fields when the owning form changes at runtime. */
262
+ formValueChanged() {
263
+ this.#pass.schedule();
183
264
  }
184
265
  /**
185
266
  * Toggles the action's item. Per-item action wiring is optional because click
@@ -272,7 +353,17 @@ var ToggleGroupController = class extends Controller {
272
353
  event.preventDefault();
273
354
  if (destination) this.#setActive(destination, true);
274
355
  }
275
- /** Applies one user toggle and dispatches the documented change detail. */
356
+ /**
357
+ * Applies one user toggle and dispatches the documented change detail. The set
358
+ * it leaves is taken as published before either report goes out, so a
359
+ * listener that presses again or writes `aria-pressed` is measured from it. A
360
+ * listener of the native `change` that presses again has had that newer press
361
+ * reported, so this press's `change` would describe a set that is gone and is
362
+ * not dispatched.
363
+ *
364
+ * @stimeoRuntimeOnly `mode` is the rule this one press follows; the lasting single-press
365
+ * normalisation is a render root of its own.
366
+ */
276
367
  #toggleItem(item) {
277
368
  if (!this.#isSupportedHost(item) || this.#isActivationDisabled(item)) return;
278
369
  if (!this.#managedItems.has(item)) this.#reconcileHost(item, true);
@@ -284,15 +375,58 @@ var ToggleGroupController = class extends Controller {
284
375
  this.#setPressed(item, willPress);
285
376
  }
286
377
  this.#setActive(item);
378
+ const values = this.#pressedValues();
379
+ this.#committedValues = values;
380
+ this.#presses += 1;
381
+ const press = this.#presses;
382
+ this.#mirrorFields(true);
383
+ if (press !== this.#presses) return;
287
384
  this.dispatch("change", {
288
- detail: { value: this.#itemValue(item), pressed: willPress, values: this.#pressedValues() }
385
+ detail: { value: this.#itemValue(item), pressed: willPress, values: [...values] }
289
386
  });
290
387
  }
291
- /** Reconciles supported hosts, pressed state, and the roving invariant after a DOM mutation. */
292
- #reconcile() {
388
+ /**
389
+ * Mirrors the pressed values into the optional fields container.
390
+ *
391
+ * @stimeoRenderRoot
392
+ */
393
+ #mirrorFields(notify) {
394
+ if (!this.hasFieldsTarget) return;
395
+ const options = { name: this.nameValue, form: this.formValue };
396
+ if (writeFields(this.fieldsTarget, this.#pressedValues(), options) && notify) {
397
+ commitField(this.fieldsTarget);
398
+ }
399
+ }
400
+ /**
401
+ * Reconciles supported hosts, pressed state, the roving invariant, and the
402
+ * fields after the page changed the group, then reports a pressed set that
403
+ * moved. Records still queued are read first, so an author's write is not lost
404
+ * when observation is suspended for this pass's own writes; observation resumes
405
+ * before the report, so an edit a listener makes is seen by the next pass.
406
+ */
407
+ #reconcileDom() {
408
+ const observer = this.#observer;
409
+ if (observer) {
410
+ this.#releaseAuthoredPressed(observer.takeRecords());
411
+ observer.disconnect();
412
+ }
293
413
  for (const item of this.itemTargets) this.#reconcileHost(item, true);
294
414
  this.#normalizeSelection();
295
415
  this.#ensureTabStop();
416
+ this.#mirrorFields(false);
417
+ this.#internalPressedValues.clear();
418
+ if (observer) this.#observeWith(observer);
419
+ this.#reportMove();
420
+ }
421
+ /** Reports a pressed set that moved since the one this group last published. */
422
+ #reportMove() {
423
+ const values = this.#pressedValues();
424
+ const previous = this.#committedValues;
425
+ if (values.length === previous.length && values.every((value, i) => value === previous[i])) {
426
+ return;
427
+ }
428
+ this.#committedValues = values;
429
+ this.dispatch("reconcile", { detail: { values: [...values] } });
296
430
  }
297
431
  /** Begins or ends ownership according to the item's current host semantics. */
298
432
  #reconcileHost(item, dropFromTabSequence) {
@@ -328,7 +462,11 @@ var ToggleGroupController = class extends Controller {
328
462
  item.setAttribute("aria-pressed", "false");
329
463
  }
330
464
  }
331
- /** Makes the first DOM-ordered pressed item the sole pressed item in single mode. */
465
+ /**
466
+ * Makes the first DOM-ordered pressed item the sole pressed item in single mode.
467
+ *
468
+ * @stimeoRenderRoot
469
+ */
332
470
  #normalizeSelection() {
333
471
  for (const item of this.#managedTargets) this.#normalizePressed(item);
334
472
  if (this.modeValue !== "single") return;
@@ -377,7 +515,7 @@ var ToggleGroupController = class extends Controller {
377
515
  }
378
516
  /** Whether the closest Toggle Group scope around a target is this instance. */
379
517
  #ownsEventTarget(target) {
380
- return target instanceof Element && target.closest('[data-controller~="stimeo--toggle-group"]') === this.element;
518
+ return target instanceof Element && target.closest(`[data-controller~="${this.identifier}"]`) === this.element;
381
519
  }
382
520
  /** Hosts whose activation model can be owned without conflicting native behavior. */
383
521
  #isSupportedHost(item) {
@@ -425,9 +563,7 @@ var ToggleGroupController = class extends Controller {
425
563
  #observeMutations() {
426
564
  const observer = new MutationObserver((records) => {
427
565
  this.#releaseAuthoredPressed(records);
428
- observer.disconnect();
429
- this.#reconcile();
430
- this.#observeWith(observer);
566
+ this.#pass.schedule();
431
567
  });
432
568
  this.#observer = observer;
433
569
  this.#observeWith(observer);
@@ -14,11 +14,19 @@ function isReservedArrowChord(event, allow = []) {
14
14
  }
15
15
 
16
16
  // src/utils/composition_tracker.ts
17
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
18
+ "insertCompositionText",
19
+ "insertFromComposition",
20
+ "deleteCompositionText",
21
+ "deleteByComposition"
22
+ ]);
17
23
  var CompositionTracker = class {
18
24
  #observedTargets = /* @__PURE__ */ new Set();
19
25
  #activeTargets = /* @__PURE__ */ new Set();
20
26
  #onStart;
21
27
  #onEnd;
28
+ /** The field whose confirming `input` is still owed, while the window is open. */
29
+ #confirmedTarget = null;
22
30
  constructor(options = {}) {
23
31
  this.#onStart = options.onStart;
24
32
  this.#onEnd = options.onEnd;
@@ -28,6 +36,7 @@ var CompositionTracker = class {
28
36
  if (this.#observedTargets.has(target)) return;
29
37
  target.addEventListener("compositionstart", this.#handleStart);
30
38
  target.addEventListener("compositionend", this.#handleEnd);
39
+ target.addEventListener("keydown", this.#handleKeydown);
31
40
  this.#observedTargets.add(target);
32
41
  }
33
42
  /** Stops tracking one target and clears any active composition it owned. */
@@ -35,29 +44,52 @@ var CompositionTracker = class {
35
44
  if (!this.#observedTargets.delete(target)) return;
36
45
  target.removeEventListener("compositionstart", this.#handleStart);
37
46
  target.removeEventListener("compositionend", this.#handleEnd);
47
+ target.removeEventListener("keydown", this.#handleKeydown);
38
48
  this.#activeTargets.delete(target);
49
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
39
50
  }
40
51
  /** Releases every listener and clears state so reconnect starts cleanly. */
41
52
  disconnect() {
42
53
  for (const target of this.#observedTargets) {
43
54
  target.removeEventListener("compositionstart", this.#handleStart);
44
55
  target.removeEventListener("compositionend", this.#handleEnd);
56
+ target.removeEventListener("keydown", this.#handleKeydown);
45
57
  }
46
58
  this.#observedTargets.clear();
47
59
  this.#activeTargets.clear();
60
+ this.#confirmedTarget = null;
48
61
  }
49
62
  /** True when lifecycle tracking or the current event reports composition. */
50
63
  isComposing(event) {
51
64
  return this.#activeTargets.size > 0 || event?.isComposing === true;
52
65
  }
66
+ /**
67
+ * Whether `event` is the `input` echoing the composition just confirmed.
68
+ *
69
+ * Asking closes the window either way, so one confirmation is folded at most
70
+ * once and a consumer asks once per `input`.
71
+ */
72
+ consumesConfirmedInput(event) {
73
+ const confirmed = this.#confirmedTarget;
74
+ this.#confirmedTarget = null;
75
+ if (confirmed === null || confirmed !== event.target) return false;
76
+ const inputType = event.inputType;
77
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
78
+ }
53
79
  #handleStart = (event) => {
80
+ this.#confirmedTarget = null;
54
81
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
55
82
  this.#onStart?.(event);
56
83
  };
57
84
  #handleEnd = (event) => {
58
85
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
86
+ this.#confirmedTarget = event.target;
59
87
  this.#onEnd?.(event);
60
88
  };
89
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
90
+ #handleKeydown = () => {
91
+ this.#confirmedTarget = null;
92
+ };
61
93
  };
62
94
 
63
95
  // src/utils/event_owner.ts
@@ -164,6 +164,16 @@ function isScrollable(element) {
164
164
  return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
165
165
  }
166
166
 
167
+ // src/utils/state_reason.ts
168
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
169
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
170
+ function stateReasonFor(event) {
171
+ if (!event) return "api";
172
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
173
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
174
+ return "user";
175
+ }
176
+
167
177
  // src/controllers/tooltip_controller.ts
168
178
  var TooltipController = class extends Controller {
169
179
  static targets = ["trigger", "content"];
@@ -173,6 +183,7 @@ var TooltipController = class extends Controller {
173
183
  closeOnScroll: { type: Boolean, default: false }
174
184
  };
175
185
  static actions = ["hide", "show"];
186
+ static events = ["close", "open"];
176
187
  /** Registry whose pending timers are cancelled individually with their guard ids. */
177
188
  #timers = new SafeTimeout();
178
189
  /** Escape-stack membership while shown; the shared resolver dismisses via it. */
@@ -186,15 +197,19 @@ var TooltipController = class extends Controller {
186
197
  #pointerActive = false;
187
198
  /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
188
199
  #stopScrollDismiss = null;
200
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
201
+ #reporting = false;
189
202
  /** Starts hidden with no stale timer or interaction state from a prior connection. */
190
203
  connect() {
191
204
  this.#cancelShow();
192
205
  this.#cancelHide();
193
206
  this.#resetInteractionState();
194
- this.#conceal();
207
+ this.#conceal("api");
208
+ this.#reporting = true;
195
209
  }
196
210
  /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
197
211
  disconnect() {
212
+ this.#reporting = false;
198
213
  this.#cancelShow();
199
214
  this.#cancelHide();
200
215
  this.#resetInteractionState();
@@ -204,20 +219,22 @@ var TooltipController = class extends Controller {
204
219
  }
205
220
  /** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */
206
221
  show(event) {
222
+ const reason = stateReasonFor(event);
207
223
  this.#activateInteraction(event);
208
224
  this.#cancelHide();
209
225
  if (this.#isVisible || this.#pendingShow !== null) return;
210
226
  if (this.showDelayValue <= 0) {
211
- this.#reveal();
227
+ this.#reveal(reason);
212
228
  return;
213
229
  }
214
230
  this.#pendingShow = this.#timers.set(() => {
215
231
  this.#pendingShow = null;
216
- this.#reveal();
232
+ this.#reveal(reason);
217
233
  }, this.showDelayValue);
218
234
  }
219
235
  /** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */
220
236
  hide(event) {
237
+ const reason = stateReasonFor(event);
221
238
  const interactionEnded = this.#deactivateInteraction(event);
222
239
  if (interactionEnded && this.#hasActiveInteraction) {
223
240
  this.#cancelHide();
@@ -226,38 +243,47 @@ var TooltipController = class extends Controller {
226
243
  this.#cancelShow();
227
244
  if (!this.#isVisible || this.#pendingHide !== null) return;
228
245
  if (this.hideDelayValue <= 0) {
229
- this.#conceal();
246
+ this.#conceal(reason);
230
247
  return;
231
248
  }
232
249
  this.#pendingHide = this.#timers.set(() => {
233
250
  this.#pendingHide = null;
234
- this.#conceal();
251
+ this.#conceal(reason);
235
252
  }, this.hideDelayValue);
236
253
  }
237
- /** Reveals the content and joins the Escape stack / starts the scroll watcher. */
238
- #reveal() {
254
+ /**
255
+ * Reveals the content, reports a move, and joins the Escape stack / scroll watcher.
256
+ *
257
+ * @stimeoRuntimeOnly `closeOnScroll` decides whether this reveal wires the scroll dismissal; what
258
+ * is shown does not depend on it.
259
+ */
260
+ #reveal(reason) {
239
261
  if (!this.hasContentTarget) return;
262
+ const was = this.#isVisible;
240
263
  this.contentTarget.hidden = false;
241
264
  this.contentTarget.setAttribute("data-state", "open");
242
- this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
265
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
266
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss("escape") });
243
267
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
244
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
268
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss("scroll"));
245
269
  }
246
270
  }
247
- /** Hides the content and leaves the Escape stack / stops the scroll watcher. */
248
- #conceal() {
271
+ /** Hides the content, reports a move, and leaves the Escape stack / scroll watcher. */
272
+ #conceal(reason) {
273
+ const was = this.#isVisible;
249
274
  this.#escapeLayer.deactivate();
250
275
  this.#stopScrollDismiss?.();
251
276
  this.#stopScrollDismiss = null;
252
277
  if (!this.hasContentTarget) return;
253
278
  this.contentTarget.hidden = true;
254
279
  this.contentTarget.setAttribute("data-state", "closed");
280
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
255
281
  }
256
282
  /** Cancels pending timers and conceals immediately (shared Escape / scroll path). */
257
- #dismiss() {
283
+ #dismiss(reason) {
258
284
  this.#cancelShow();
259
285
  this.#cancelHide();
260
- this.#conceal();
286
+ this.#conceal(reason);
261
287
  }
262
288
  /** Records the modality whose enter/focus event requires the tooltip to stay visible. */
263
289
  #activateInteraction(event) {
@@ -277,6 +277,10 @@ var TransitionController = class extends Controller {
277
277
  if (state === "entered" || state === "entering") this.leave();
278
278
  else this.enter();
279
279
  }
280
+ /**
281
+ * @stimeoRuntimeOnly The class lists and `timeout` shape this one transition; `#finish` strips
282
+ * the classes, so none of them stays at rest.
283
+ */
280
284
  #run(kind) {
281
285
  this.#cancel();
282
286
  const isEnter = kind === "enter";