stimeo-ui 0.1.0-beta.3 → 0.2.1

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 (174) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +6 -6
  3. package/dist/controllers/accordion_controller.d.ts +3 -2
  4. package/dist/controllers/accordion_controller.js.map +1 -1
  5. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  6. package/dist/controllers/alert_dialog_controller.js +318 -0
  7. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  8. package/dist/controllers/auto_submit_controller.js +56 -21
  9. package/dist/controllers/auto_submit_controller.js.map +1 -1
  10. package/dist/controllers/carousel_controller.d.ts +100 -0
  11. package/dist/controllers/carousel_controller.js +272 -0
  12. package/dist/controllers/carousel_controller.js.map +1 -0
  13. package/dist/controllers/character_counter_controller.js +52 -15
  14. package/dist/controllers/character_counter_controller.js.map +1 -1
  15. package/dist/controllers/clipboard_controller.d.ts +72 -0
  16. package/dist/controllers/clipboard_controller.js +144 -0
  17. package/dist/controllers/clipboard_controller.js.map +1 -0
  18. package/dist/controllers/collapsible_controller.d.ts +70 -0
  19. package/dist/controllers/collapsible_controller.js +327 -0
  20. package/dist/controllers/collapsible_controller.js.map +1 -0
  21. package/dist/controllers/color_picker_controller.d.ts +76 -0
  22. package/dist/controllers/color_picker_controller.js +213 -0
  23. package/dist/controllers/color_picker_controller.js.map +1 -0
  24. package/dist/controllers/combobox_controller.d.ts +6 -2
  25. package/dist/controllers/combobox_controller.js +64 -3
  26. package/dist/controllers/combobox_controller.js.map +1 -1
  27. package/dist/controllers/command_palette_controller.d.ts +13 -6
  28. package/dist/controllers/command_palette_controller.js +243 -39
  29. package/dist/controllers/command_palette_controller.js.map +1 -1
  30. package/dist/controllers/confirm_controller.js +94 -8
  31. package/dist/controllers/confirm_controller.js.map +1 -1
  32. package/dist/controllers/context_menu_controller.d.ts +11 -6
  33. package/dist/controllers/context_menu_controller.js +191 -13
  34. package/dist/controllers/context_menu_controller.js.map +1 -1
  35. package/dist/controllers/count_up_controller.js +8 -1
  36. package/dist/controllers/count_up_controller.js.map +1 -1
  37. package/dist/controllers/countdown_controller.d.ts +2 -2
  38. package/dist/controllers/countdown_controller.js +2 -2
  39. package/dist/controllers/countdown_controller.js.map +1 -1
  40. package/dist/controllers/currency_input_controller.d.ts +72 -0
  41. package/dist/controllers/currency_input_controller.js +147 -0
  42. package/dist/controllers/currency_input_controller.js.map +1 -0
  43. package/dist/controllers/data_grid_controller.d.ts +71 -0
  44. package/dist/controllers/data_grid_controller.js +168 -0
  45. package/dist/controllers/data_grid_controller.js.map +1 -0
  46. package/dist/controllers/date_range_picker_controller.d.ts +87 -0
  47. package/dist/controllers/date_range_picker_controller.js +417 -0
  48. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  49. package/dist/controllers/dialog_controller.js +94 -8
  50. package/dist/controllers/dialog_controller.js.map +1 -1
  51. package/dist/controllers/dismissible_controller.d.ts +57 -0
  52. package/dist/controllers/dismissible_controller.js +117 -0
  53. package/dist/controllers/dismissible_controller.js.map +1 -0
  54. package/dist/controllers/drawer_controller.d.ts +96 -0
  55. package/dist/controllers/drawer_controller.js +630 -0
  56. package/dist/controllers/drawer_controller.js.map +1 -0
  57. package/dist/controllers/dropdown_controller.d.ts +9 -6
  58. package/dist/controllers/dropdown_controller.js +139 -16
  59. package/dist/controllers/dropdown_controller.js.map +1 -1
  60. package/dist/controllers/editable_controller.d.ts +73 -0
  61. package/dist/controllers/editable_controller.js +168 -0
  62. package/dist/controllers/editable_controller.js.map +1 -0
  63. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  64. package/dist/controllers/file_dropzone_controller.js +165 -0
  65. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  66. package/dist/controllers/filter_controller.d.ts +60 -0
  67. package/dist/controllers/filter_controller.js +86 -0
  68. package/dist/controllers/filter_controller.js.map +1 -0
  69. package/dist/controllers/flash_controller.js +36 -5
  70. package/dist/controllers/flash_controller.js.map +1 -1
  71. package/dist/controllers/focus_controller.js +94 -8
  72. package/dist/controllers/focus_controller.js.map +1 -1
  73. package/dist/controllers/form_validation_controller.js +2 -2
  74. package/dist/controllers/form_validation_controller.js.map +1 -1
  75. package/dist/controllers/highlight_controller.js +6 -4
  76. package/dist/controllers/highlight_controller.js.map +1 -1
  77. package/dist/controllers/hover_card_controller.d.ts +15 -12
  78. package/dist/controllers/hover_card_controller.js +103 -27
  79. package/dist/controllers/hover_card_controller.js.map +1 -1
  80. package/dist/controllers/intersection_controller.js +41 -18
  81. package/dist/controllers/intersection_controller.js.map +1 -1
  82. package/dist/controllers/lazy_frame_controller.js +33 -11
  83. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  84. package/dist/controllers/listbox_controller.js +1 -0
  85. package/dist/controllers/listbox_controller.js.map +1 -1
  86. package/dist/controllers/masonry_controller.d.ts +54 -0
  87. package/dist/controllers/masonry_controller.js +142 -0
  88. package/dist/controllers/masonry_controller.js.map +1 -0
  89. package/dist/controllers/menu_controller.d.ts +18 -11
  90. package/dist/controllers/menu_controller.js +181 -8
  91. package/dist/controllers/menu_controller.js.map +1 -1
  92. package/dist/controllers/menubar_controller.d.ts +62 -0
  93. package/dist/controllers/menubar_controller.js +433 -0
  94. package/dist/controllers/menubar_controller.js.map +1 -0
  95. package/dist/controllers/multi_select_controller.d.ts +115 -0
  96. package/dist/controllers/multi_select_controller.js +472 -0
  97. package/dist/controllers/multi_select_controller.js.map +1 -0
  98. package/dist/controllers/navigation_menu_controller.d.ts +99 -0
  99. package/dist/controllers/navigation_menu_controller.js +384 -0
  100. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  101. package/dist/controllers/otp_controller.js +62 -22
  102. package/dist/controllers/otp_controller.js.map +1 -1
  103. package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
  104. package/dist/controllers/overflow_indicator_controller.js +178 -27
  105. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  106. package/dist/controllers/overflow_menu_controller.d.ts +3 -2
  107. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  108. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  109. package/dist/controllers/password_reveal_controller.js +117 -0
  110. package/dist/controllers/password_reveal_controller.js.map +1 -0
  111. package/dist/controllers/pointer_drag_controller.js +3 -3
  112. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  113. package/dist/controllers/popover_controller.d.ts +18 -10
  114. package/dist/controllers/popover_controller.js +118 -27
  115. package/dist/controllers/popover_controller.js.map +1 -1
  116. package/dist/controllers/range_slider_controller.d.ts +87 -0
  117. package/dist/controllers/range_slider_controller.js +166 -0
  118. package/dist/controllers/range_slider_controller.js.map +1 -0
  119. package/dist/controllers/read_more_controller.d.ts +52 -0
  120. package/dist/controllers/read_more_controller.js +194 -0
  121. package/dist/controllers/read_more_controller.js.map +1 -0
  122. package/dist/controllers/scroll_area_controller.js +15 -2
  123. package/dist/controllers/scroll_area_controller.js.map +1 -1
  124. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  125. package/dist/controllers/scroll_restore_controller.js +93 -0
  126. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  127. package/dist/controllers/scroll_visibility_controller.js +8 -4
  128. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  129. package/dist/controllers/scrollspy_controller.js +33 -11
  130. package/dist/controllers/scrollspy_controller.js.map +1 -1
  131. package/dist/controllers/separator_controller.d.ts +54 -0
  132. package/dist/controllers/separator_controller.js +87 -0
  133. package/dist/controllers/separator_controller.js.map +1 -0
  134. package/dist/controllers/sidebar_controller.d.ts +107 -0
  135. package/dist/controllers/sidebar_controller.js +761 -0
  136. package/dist/controllers/sidebar_controller.js.map +1 -0
  137. package/dist/controllers/stepper_controller.d.ts +4 -0
  138. package/dist/controllers/stepper_controller.js +28 -12
  139. package/dist/controllers/stepper_controller.js.map +1 -1
  140. package/dist/controllers/stick_to_bottom_controller.js +8 -4
  141. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  142. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  143. package/dist/controllers/sticky_observer_controller.js +88 -20
  144. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  145. package/dist/controllers/tabs_controller.d.ts +3 -3
  146. package/dist/controllers/tabs_controller.js.map +1 -1
  147. package/dist/controllers/tags_input_controller.d.ts +91 -0
  148. package/dist/controllers/tags_input_controller.js +275 -0
  149. package/dist/controllers/tags_input_controller.js.map +1 -0
  150. package/dist/controllers/theme_controller.js +20 -10
  151. package/dist/controllers/theme_controller.js.map +1 -1
  152. package/dist/controllers/time_picker_controller.d.ts +67 -0
  153. package/dist/controllers/time_picker_controller.js +212 -0
  154. package/dist/controllers/time_picker_controller.js.map +1 -0
  155. package/dist/controllers/toast_controller.d.ts +8 -18
  156. package/dist/controllers/toast_controller.js +144 -38
  157. package/dist/controllers/toast_controller.js.map +1 -1
  158. package/dist/controllers/tooltip_controller.d.ts +15 -18
  159. package/dist/controllers/tooltip_controller.js +148 -42
  160. package/dist/controllers/tooltip_controller.js.map +1 -1
  161. package/dist/controllers/transition_controller.d.ts +14 -8
  162. package/dist/controllers/transition_controller.js +153 -38
  163. package/dist/controllers/transition_controller.js.map +1 -1
  164. package/dist/controllers/tree_view_controller.d.ts +54 -0
  165. package/dist/controllers/tree_view_controller.js +275 -0
  166. package/dist/controllers/tree_view_controller.js.map +1 -0
  167. package/dist/index.d.ts +27 -1788
  168. package/dist/index.js +1644 -643
  169. package/dist/index.js.map +1 -1
  170. package/dist/inspector/cli.js.map +1 -1
  171. package/dist/inspector/cli_bin.js.map +1 -1
  172. package/dist/inspector/examples.json +8 -8
  173. package/dist/inspector/manifest.json +30 -5
  174. package/package.json +7 -7
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/tooltip_controller.ts
4
4
 
5
+ // src/utils/escape_layer.ts
6
+ var EscapeLayer = class _EscapeLayer {
7
+ static #registries = /* @__PURE__ */ new WeakMap();
8
+ #ownerDocument = null;
9
+ /** Dismissal callback while active; `null` when inactive. */
10
+ #onDismiss = null;
11
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
12
+ #claims = null;
13
+ /**
14
+ * Activates this layer at the top of its document's Escape stack, installing
15
+ * the document's shared resolver listener if this is its first layer.
16
+ * Re-activating an already-active layer moves it to the top.
17
+ */
18
+ activate(ownerDocument = document, options) {
19
+ this.deactivate();
20
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
21
+ if (!registry) {
22
+ registry = _EscapeLayer.#createRegistry();
23
+ _EscapeLayer.#registries.set(ownerDocument, registry);
24
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
25
+ }
26
+ registry.stack.push(this);
27
+ this.#ownerDocument = ownerDocument;
28
+ this.#onDismiss = options.onDismiss;
29
+ this.#claims = options.claims ?? null;
30
+ }
31
+ /**
32
+ * Removes this layer from its document's Escape stack, uninstalling the
33
+ * shared listener when the stack empties. Safe to call when inactive.
34
+ */
35
+ deactivate() {
36
+ const ownerDocument = this.#ownerDocument;
37
+ if (!ownerDocument) return;
38
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
39
+ if (registry) {
40
+ const index = registry.stack.lastIndexOf(this);
41
+ if (index >= 0) registry.stack.splice(index, 1);
42
+ if (registry.stack.length === 0) {
43
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
44
+ _EscapeLayer.#registries.delete(ownerDocument);
45
+ }
46
+ }
47
+ this.#ownerDocument = null;
48
+ this.#onDismiss = null;
49
+ this.#claims = null;
50
+ }
51
+ /**
52
+ * Whether this active layer would own a press right now: it is the topmost
53
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
54
+ * and diagnostics — production dismissal goes through the shared listener.
55
+ */
56
+ get ownsEscape() {
57
+ const ownerDocument = this.#ownerDocument;
58
+ if (!ownerDocument) return false;
59
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
60
+ if (!registry) return false;
61
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
62
+ }
63
+ /** Builds a document's registry with its shared resolver listener. */
64
+ static #createRegistry() {
65
+ const registry = {
66
+ stack: [],
67
+ onKeydown: (event) => {
68
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
69
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
70
+ if (!owner) return;
71
+ event.preventDefault();
72
+ owner.#onDismiss?.();
73
+ }
74
+ };
75
+ return registry;
76
+ }
77
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
78
+ static #resolveOwner(stack) {
79
+ for (let index = stack.length - 1; index >= 0; index--) {
80
+ const layer = stack[index];
81
+ if (!layer) continue;
82
+ if (layer.#claims && !layer.#claims()) continue;
83
+ return layer;
84
+ }
85
+ return null;
86
+ }
87
+ };
88
+
5
89
  // src/utils/safe_timeout.ts
6
90
  var TimerRegistry = class {
7
91
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -88,27 +172,39 @@ var TooltipController = class extends Controller {
88
172
  hideDelay: { type: Number, default: 0 },
89
173
  closeOnScroll: { type: Boolean, default: false }
90
174
  };
91
- static actions = ["hide", "onKeydown", "show"];
92
- /** Pending show/hide timers, torn down together on disconnect. */
175
+ static actions = ["hide", "show"];
176
+ /** Registry whose pending timers are cancelled individually with their guard ids. */
93
177
  #timers = new SafeTimeout();
94
- /** The id of the currently pending show or hide timer, if any. */
178
+ /** Escape-stack membership while shown; the shared resolver dismisses via it. */
179
+ #escapeLayer = new EscapeLayer();
180
+ /** The id of the currently pending show timer, if any. */
95
181
  #pendingShow = null;
182
+ /** The id of the currently pending hide timer, if any. */
96
183
  #pendingHide = null;
184
+ /** Whether focus or the pointer currently requires the tooltip to persist. */
185
+ #focusActive = false;
186
+ #pointerActive = false;
97
187
  /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
98
188
  #stopScrollDismiss = null;
99
- /** Starts hidden. */
189
+ /** Starts hidden with no stale timer or interaction state from a prior connection. */
100
190
  connect() {
191
+ this.#cancelShow();
192
+ this.#cancelHide();
193
+ this.#resetInteractionState();
101
194
  this.#conceal();
102
195
  }
103
- /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */
196
+ /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
104
197
  disconnect() {
105
- this.#timers.clearAll();
106
- document.removeEventListener("keydown", this.#onDocumentKeydown);
198
+ this.#cancelShow();
199
+ this.#cancelHide();
200
+ this.#resetInteractionState();
201
+ this.#escapeLayer.deactivate();
107
202
  this.#stopScrollDismiss?.();
108
203
  this.#stopScrollDismiss = null;
109
204
  }
110
- /** Shows the tooltip, after `showDelay` ms (or immediately at 0). Cancels a pending hide. */
111
- show() {
205
+ /** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */
206
+ show(event) {
207
+ this.#activateInteraction(event);
112
208
  this.#cancelHide();
113
209
  if (this.#isVisible || this.#pendingShow !== null) return;
114
210
  if (this.showDelayValue <= 0) {
@@ -120,8 +216,13 @@ var TooltipController = class extends Controller {
120
216
  this.#reveal();
121
217
  }, this.showDelayValue);
122
218
  }
123
- /** Hides the tooltip, after `hideDelay` ms (or immediately at 0). Cancels a pending show. */
124
- hide() {
219
+ /** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */
220
+ hide(event) {
221
+ const interactionEnded = this.#deactivateInteraction(event);
222
+ if (interactionEnded && this.#hasActiveInteraction) {
223
+ this.#cancelHide();
224
+ return;
225
+ }
125
226
  this.#cancelShow();
126
227
  if (!this.#isVisible || this.#pendingHide !== null) return;
127
228
  if (this.hideDelayValue <= 0) {
@@ -133,52 +234,57 @@ var TooltipController = class extends Controller {
133
234
  this.#conceal();
134
235
  }, this.hideDelayValue);
135
236
  }
136
- /**
137
- * Dismisses the tooltip on `Escape` from the trigger. The authoritative
138
- * dismissal path is the document-level listener (added while shown) so a
139
- * hover-triggered tooltip is dismissible regardless of focus; this handler
140
- * keeps the documented `keydown->#onKeydown` binding meaningful too.
141
- */
142
- onKeydown(event) {
143
- if (event.key === "Escape" && this.#isVisible) {
144
- event.preventDefault();
145
- this.#cancelShow();
146
- this.#cancelHide();
147
- this.#conceal();
148
- }
149
- }
150
- /** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
237
+ /** Reveals the content and joins the Escape stack / starts the scroll watcher. */
151
238
  #reveal() {
152
239
  if (!this.hasContentTarget) return;
153
240
  this.contentTarget.hidden = false;
154
241
  this.contentTarget.setAttribute("data-state", "open");
155
- document.addEventListener("keydown", this.#onDocumentKeydown);
242
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
156
243
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
157
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => {
158
- this.#cancelShow();
159
- this.#cancelHide();
160
- this.#conceal();
161
- });
244
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
162
245
  }
163
246
  }
164
- /** Hides the content and stops watching for `Escape`/scroll. */
247
+ /** Hides the content and leaves the Escape stack / stops the scroll watcher. */
165
248
  #conceal() {
166
- document.removeEventListener("keydown", this.#onDocumentKeydown);
249
+ this.#escapeLayer.deactivate();
167
250
  this.#stopScrollDismiss?.();
168
251
  this.#stopScrollDismiss = null;
169
252
  if (!this.hasContentTarget) return;
170
253
  this.contentTarget.hidden = true;
171
254
  this.contentTarget.setAttribute("data-state", "closed");
172
255
  }
173
- /** Document-level `Escape` watcher (active only while shown). */
174
- #onDocumentKeydown = (event) => {
175
- if (event.key === "Escape") {
176
- event.preventDefault();
177
- this.#cancelShow();
178
- this.#cancelHide();
179
- this.#conceal();
256
+ /** Cancels pending timers and conceals immediately (shared Escape / scroll path). */
257
+ #dismiss() {
258
+ this.#cancelShow();
259
+ this.#cancelHide();
260
+ this.#conceal();
261
+ }
262
+ /** Records the modality whose enter/focus event requires the tooltip to stay visible. */
263
+ #activateInteraction(event) {
264
+ if (event?.type === "mouseenter") this.#pointerActive = true;
265
+ if (event?.type === "focusin") this.#focusActive = true;
266
+ }
267
+ /** Clears a modality on leave/blur and reports whether the event represented such a change. */
268
+ #deactivateInteraction(event) {
269
+ if (event?.type === "mouseleave") {
270
+ this.#pointerActive = false;
271
+ return true;
180
272
  }
181
- };
273
+ if (event?.type === "focusout") {
274
+ this.#focusActive = false;
275
+ return true;
276
+ }
277
+ return false;
278
+ }
279
+ /** Discards interaction reasons at a lifecycle boundary. */
280
+ #resetInteractionState() {
281
+ this.#focusActive = false;
282
+ this.#pointerActive = false;
283
+ }
284
+ /** Whether focus or pointer presence still requires a persistent tooltip. */
285
+ get #hasActiveInteraction() {
286
+ return this.#focusActive || this.#pointerActive;
287
+ }
182
288
  /** Cancels any pending show timer. */
183
289
  #cancelShow() {
184
290
  if (this.#pendingShow !== null) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/tooltip_controller.ts"],"names":[],"mappings":"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","file":"tooltip_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **tooltip** behavior.\n *\n * Markup contract (identifier: `stimeo--tooltip`):\n * <span data-controller=\"stimeo--tooltip\">\n * <button data-stimeo--tooltip-target=\"trigger\" aria-describedby=\"tip\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\n * focusin->stimeo--tooltip#show\n * focusout->stimeo--tooltip#hide\n * keydown->stimeo--tooltip#onKeydown\">Save</button>\n * <span id=\"tip\" role=\"tooltip\" data-stimeo--tooltip-target=\"content\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\" hidden>…</span>\n * </span>\n *\n * Implements the WAI-ARIA APG **Tooltip** pattern and WCAG 2.2 SC 1.4.13\n * (hoverable / dismissible / persistent). The tooltip never receives focus and\n * holds no interactive content — for that use `stimeo--hover-card` or\n * `stimeo--popover`. The `aria-describedby` association is declared in the\n * consumer's markup; this controller only toggles visibility.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Show on `mouseenter`/`focusin`, hide on `mouseleave`/`focusout`, each gated by\n * `showDelay`/`hideDelay` to prevent flicker.\n * - **Hoverable bridge**: binding show/hide on the content too means moving the\n * pointer from trigger into the tooltip cancels the pending hide, so it stays up.\n * - **Dismissible**: while shown, `Escape` is watched at the `document` level so it\n * dismisses even when a hover (not focus) triggered it and focus is elsewhere.\n * - Visibility flips `hidden` and `data-state` (`open`/`closed`); the\n * `aria-describedby` reference is always preserved.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while shown, scrolling a tracked\n * scroll-parent ancestor (or the window) hides the tooltip — the Radix / floating-ui\n * convention, useful for focus-triggered tooltips that a pointer-leave cannot\n * dismiss. Off by default.\n */\nexport class TooltipController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"content\"];\n static override values = {\n showDelay: { type: Number, default: 0 },\n hideDelay: { type: Number, default: 0 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"hide\", \"onKeydown\", \"show\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n declare readonly showDelayValue: number;\n declare readonly hideDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending show/hide timers, torn down together on disconnect. */\n readonly #timers = new SafeTimeout();\n /** The id of the currently pending show or hide timer, if any. */\n #pendingShow: number | null = null;\n #pendingHide: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts hidden. */\n override connect(): void {\n this.#conceal();\n }\n\n /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#timers.clearAll();\n document.removeEventListener(\"keydown\", this.#onDocumentKeydown);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Shows the tooltip, after `showDelay` ms (or immediately at 0). Cancels a pending hide. */\n show(): void {\n this.#cancelHide();\n if (this.#isVisible || this.#pendingShow !== null) return;\n if (this.showDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingShow = this.#timers.set(() => {\n this.#pendingShow = null;\n this.#reveal();\n }, this.showDelayValue);\n }\n\n /** Hides the tooltip, after `hideDelay` ms (or immediately at 0). Cancels a pending show. */\n hide(): void {\n this.#cancelShow();\n if (!this.#isVisible || this.#pendingHide !== null) return;\n if (this.hideDelayValue <= 0) {\n this.#conceal();\n return;\n }\n this.#pendingHide = this.#timers.set(() => {\n this.#pendingHide = null;\n this.#conceal();\n }, this.hideDelayValue);\n }\n\n /**\n * Dismisses the tooltip on `Escape` from the trigger. The authoritative\n * dismissal path is the document-level listener (added while shown) so a\n * hover-triggered tooltip is dismissible regardless of focus; this handler\n * keeps the documented `keydown->#onKeydown` binding meaningful too.\n */\n onKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\" && this.#isVisible) {\n event.preventDefault();\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n }\n }\n\n /** Reveals the content and starts watching for a dismissing `Escape`/scroll. */\n #reveal(): void {\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = false;\n this.contentTarget.setAttribute(\"data-state\", \"open\");\n document.addEventListener(\"keydown\", this.#onDocumentKeydown);\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => {\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n });\n }\n }\n\n /** Hides the content and stops watching for `Escape`/scroll. */\n #conceal(): void {\n // Release listeners first, unconditionally: if the content target was removed\n // from the DOM while shown, an early return would leak the document keydown\n // and scroll-dismiss listeners.\n document.removeEventListener(\"keydown\", this.#onDocumentKeydown);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = true;\n this.contentTarget.setAttribute(\"data-state\", \"closed\");\n }\n\n /** Document-level `Escape` watcher (active only while shown). */\n readonly #onDocumentKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n }\n };\n\n /** Cancels any pending show timer. */\n #cancelShow(): void {\n if (this.#pendingShow !== null) {\n this.#timers.clear(this.#pendingShow);\n this.#pendingShow = null;\n }\n }\n\n /** Cancels any pending hide timer. */\n #cancelHide(): void {\n if (this.#pendingHide !== null) {\n this.#timers.clear(this.#pendingHide);\n this.#pendingHide = null;\n }\n }\n\n /** Whether the tooltip is currently shown. */\n get #isVisible(): boolean {\n return this.hasContentTarget && !this.contentTarget.hidden;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/tooltip_controller.ts"],"names":[],"mappings":"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","file":"tooltip_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **tooltip** behavior.\n *\n * Markup contract (identifier: `stimeo--tooltip`):\n * <span data-controller=\"stimeo--tooltip\">\n * <button data-stimeo--tooltip-target=\"trigger\" aria-describedby=\"tip\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\n * focusin->stimeo--tooltip#show\n * focusout->stimeo--tooltip#hide\">Save</button>\n * <span id=\"tip\" role=\"tooltip\" data-stimeo--tooltip-target=\"content\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\" hidden>…</span>\n * </span>\n *\n * Implements the WAI-ARIA APG **Tooltip** pattern and WCAG 2.2 SC 1.4.13\n * (hoverable / dismissible / persistent). The tooltip never receives focus and\n * holds no interactive content — for that use `stimeo--hover-card` or\n * `stimeo--popover`. The `aria-describedby` association is declared in the\n * consumer's markup; this controller only toggles visibility.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Show on `mouseenter`/`focusin`, hide on `mouseleave`/`focusout`, each gated by\n * `showDelay`/`hideDelay` to prevent flicker.\n * - **Hoverable bridge**: binding show/hide on the content too means moving the\n * pointer from trigger into the tooltip cancels the pending hide, so it stays up.\n * - **Persistent across input modalities**: focus and pointer presence are tracked\n * separately, so leaving one does not hide while the other still requires the hint.\n * - **Dismissible**: while shown, the tooltip joins the shared {@link EscapeLayer}\n * stack, so `Escape` dismisses it even when a hover (not focus) triggered it and\n * focus is elsewhere. The resolver ignores an Escape already consumed by an\n * inner handler and lets the most recently shown layer own the press, so one\n * keypress closes exactly one layer (the shared layered-Escape contract).\n * - Visibility flips `hidden` and `data-state` (`open`/`closed`); the\n * `aria-describedby` reference is always preserved.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while shown, scrolling a tracked\n * scroll-parent ancestor (or the window) hides the tooltip — the Radix / floating-ui\n * convention, useful for focus-triggered tooltips that a pointer-leave cannot\n * dismiss. Off by default.\n */\nexport class TooltipController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"content\"];\n static override values = {\n showDelay: { type: Number, default: 0 },\n hideDelay: { type: Number, default: 0 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"hide\", \"show\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n declare readonly showDelayValue: number;\n declare readonly hideDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Registry whose pending timers are cancelled individually with their guard ids. */\n readonly #timers = new SafeTimeout();\n /** Escape-stack membership while shown; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n /** The id of the currently pending show timer, if any. */\n #pendingShow: number | null = null;\n /** The id of the currently pending hide timer, if any. */\n #pendingHide: number | null = null;\n /** Whether focus or the pointer currently requires the tooltip to persist. */\n #focusActive = false;\n #pointerActive = false;\n /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts hidden with no stale timer or interaction state from a prior connection. */\n override connect(): void {\n this.#cancelShow();\n this.#cancelHide();\n this.#resetInteractionState();\n this.#conceal();\n }\n\n /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#cancelShow();\n this.#cancelHide();\n this.#resetInteractionState();\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */\n show(event?: Event): void {\n this.#activateInteraction(event);\n this.#cancelHide();\n if (this.#isVisible || this.#pendingShow !== null) return;\n if (this.showDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingShow = this.#timers.set(() => {\n this.#pendingShow = null;\n this.#reveal();\n }, this.showDelayValue);\n }\n\n /** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */\n hide(event?: Event): void {\n const interactionEnded = this.#deactivateInteraction(event);\n if (interactionEnded && this.#hasActiveInteraction) {\n this.#cancelHide();\n return;\n }\n this.#cancelShow();\n if (!this.#isVisible || this.#pendingHide !== null) return;\n if (this.hideDelayValue <= 0) {\n this.#conceal();\n return;\n }\n this.#pendingHide = this.#timers.set(() => {\n this.#pendingHide = null;\n this.#conceal();\n }, this.hideDelayValue);\n }\n\n /** Reveals the content and joins the Escape stack / starts the scroll watcher. */\n #reveal(): void {\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = false;\n this.contentTarget.setAttribute(\"data-state\", \"open\");\n // No claims predicate: a shown hover hint is dismissible regardless of\n // where focus sits (WCAG 2.2 SC 1.4.13), so it always claims while shown.\n this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());\n }\n }\n\n /** Hides the content and leaves the Escape stack / stops the scroll watcher. */\n #conceal(): void {\n // Release the layer and observers first, unconditionally: if the content\n // target was removed from the DOM while shown, an early return would leak\n // the stack entry and the scroll-dismiss listeners.\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = true;\n this.contentTarget.setAttribute(\"data-state\", \"closed\");\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape / scroll path). */\n #dismiss(): void {\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n }\n\n /** Records the modality whose enter/focus event requires the tooltip to stay visible. */\n #activateInteraction(event?: Event): void {\n if (event?.type === \"mouseenter\") this.#pointerActive = true;\n if (event?.type === \"focusin\") this.#focusActive = true;\n }\n\n /** Clears a modality on leave/blur and reports whether the event represented such a change. */\n #deactivateInteraction(event?: Event): boolean {\n if (event?.type === \"mouseleave\") {\n this.#pointerActive = false;\n return true;\n }\n if (event?.type === \"focusout\") {\n this.#focusActive = false;\n return true;\n }\n return false;\n }\n\n /** Discards interaction reasons at a lifecycle boundary. */\n #resetInteractionState(): void {\n this.#focusActive = false;\n this.#pointerActive = false;\n }\n\n /** Whether focus or pointer presence still requires a persistent tooltip. */\n get #hasActiveInteraction(): boolean {\n return this.#focusActive || this.#pointerActive;\n }\n\n /** Cancels any pending show timer. */\n #cancelShow(): void {\n if (this.#pendingShow !== null) {\n this.#timers.clear(this.#pendingShow);\n this.#pendingShow = null;\n }\n }\n\n /** Cancels any pending hide timer. */\n #cancelHide(): void {\n if (this.#pendingHide !== null) {\n this.#timers.clear(this.#pendingHide);\n this.#pendingHide = null;\n }\n }\n\n /** Whether the tooltip is currently shown. */\n get #isVisible(): boolean {\n return this.hasContentTarget && !this.contentTarget.hidden;\n }\n}\n"]}
@@ -17,20 +17,26 @@ import { Controller } from '@hotwired/stimulus';
17
17
  * data-stimeo--transition-leave-to-value="opacity-0" hidden>…</div>
18
18
  *
19
19
  * `enter()` unhides the element, applies `enter` + `enterFrom`, then on the next frame
20
- * swaps `enterFrom` → `enterTo` so the CSS transition runs, and on `transitionend` (or
21
- * a safety timeout) settles to `entered`. `leave()` mirrors it and re-applies `hidden`.
20
+ * swaps `enterFrom` → `enterTo` so the CSS transition runs, and settles to `entered`
21
+ * once the transition completes. `leave()` mirrors it and re-applies `hidden`.
22
22
  * `toggle()` reverses the current direction. The element carries `data-transition-state`
23
23
  * (`entering` / `entered` / `leaving` / `left`) and `entered` / `left` events fire on
24
24
  * completion.
25
25
  *
26
26
  * @remarks
27
27
  * Behavior only — the animation itself is the consumer's CSS; this controls *when* the
28
- * stage classes are applied. Under `prefers-reduced-motion: reduce` it switches
29
- * instantly (no staging). An interrupting call cancels the in-flight transition and
30
- * starts the new one. State lives solely in `hidden` / `data-transition-state`, and
31
- * `connect()` reconciles to a stable state (stripping any half-applied stage classes
32
- * from a Turbo cache); the `transitionend` listener, rAF, and safety timer are released
33
- * on `disconnect()` (Turbo navigation included).
28
+ * stage classes are applied. Completion is owned by the shared
29
+ * {@link TransitionCompletion}: every declared transition property must settle
30
+ * (`transitionend` / `transitioncancel`, pseudo-element events excluded) with a
31
+ * `max(duration + delay) + 50ms` bounded fallback, and a computed 0ms transition
32
+ * settles synchronously at the staging frame. A positive `timeout` Value replaces the
33
+ * fallback verbatim and keeps the wait armed even for a computed 0ms transition.
34
+ * Under `prefers-reduced-motion: reduce` it switches instantly (no staging). An
35
+ * interrupting call cancels the in-flight transition and starts the new one. State
36
+ * lives solely in `hidden` / `data-transition-state`, and `connect()` reconciles to a
37
+ * stable state (stripping any half-applied stage classes from a Turbo cache); the
38
+ * terminal-event listeners, rAF, and fallback timer are released on `disconnect()`
39
+ * (Turbo navigation included).
34
40
  */
35
41
  declare class TransitionController extends Controller<HTMLElement> {
36
42
  #private;
@@ -2,6 +2,11 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/transition_controller.ts
4
4
 
5
+ // src/utils/reduced_motion.ts
6
+ function prefersReducedMotion() {
7
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
+ }
9
+
5
10
  // src/utils/safe_timeout.ts
6
11
  var TimerRegistry = class {
7
12
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -55,14 +60,149 @@ var SafeTimeout = class extends TimerRegistry {
55
60
  }
56
61
  };
57
62
 
63
+ // src/utils/transition_completion.ts
64
+ function timeMs(value) {
65
+ const trimmed = value.trim();
66
+ const amount = Number.parseFloat(trimmed);
67
+ if (!Number.isFinite(amount)) return 0;
68
+ if (trimmed.endsWith("ms")) return amount;
69
+ if (trimmed.endsWith("s")) return amount * 1e3;
70
+ return 0;
71
+ }
72
+ function cssList(value) {
73
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
74
+ }
75
+ function transitionTimings(style) {
76
+ const properties = cssList(style.transitionProperty);
77
+ const durations = cssList(style.transitionDuration).map(timeMs);
78
+ const delays = cssList(style.transitionDelay).map(timeMs);
79
+ const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
80
+ const effectiveDurations = durations.length > 0 ? durations : [0];
81
+ const effectiveDelays = delays.length > 0 ? delays : [0];
82
+ return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
83
+ property,
84
+ totalMs: Math.max(
85
+ 0,
86
+ (effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
87
+ )
88
+ }));
89
+ }
90
+ function maxTotalMs(timings) {
91
+ return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
92
+ }
93
+ var TransitionCompletion = class {
94
+ #timers = new SafeTimeout();
95
+ #element = null;
96
+ #complete = null;
97
+ #pendingProperties = null;
98
+ #deadline = 0;
99
+ /**
100
+ * Replaces any prior wait and invokes `complete` synchronously for a 0ms
101
+ * transition (including when `getComputedStyle` is unavailable).
102
+ *
103
+ * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
104
+ * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
105
+ */
106
+ wait(element, complete, options = {}) {
107
+ this.cancel();
108
+ const requested = options.timeoutMs ?? 0;
109
+ const override = Number.isFinite(requested) && requested > 0 ? requested : 0;
110
+ const timings = typeof window.getComputedStyle === "function" ? transitionTimings(window.getComputedStyle(element)) : [];
111
+ const maximum = maxTotalMs(timings);
112
+ if (maximum <= 0 && override <= 0) {
113
+ complete();
114
+ return;
115
+ }
116
+ this.#element = element;
117
+ this.#complete = complete;
118
+ this.#deadline = Date.now() + maximum;
119
+ const activeProperties = this.#activeTransitionProperties(element);
120
+ this.#pendingProperties = activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);
121
+ element.addEventListener("transitionend", this.#onTerminal);
122
+ element.addEventListener("transitioncancel", this.#onTerminal);
123
+ this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
124
+ }
125
+ /** Cancels the pending wait without invoking its completion callback. */
126
+ cancel() {
127
+ this.#complete = null;
128
+ this.#teardown();
129
+ }
130
+ /**
131
+ * Handles terminal events from the observed element only.
132
+ *
133
+ * For explicit property lists, every declared positive-time property must
134
+ * settle. For `all`, no reliable property set exists, so an event can finish
135
+ * only after the computed maximum time; the safety timeout owns the usual path.
136
+ */
137
+ #onTerminal = (event) => {
138
+ if (event.target !== this.#element) return;
139
+ const transitionEvent = event;
140
+ if (transitionEvent.pseudoElement) return;
141
+ if (this.#pendingProperties) {
142
+ const propertyName = transitionEvent.propertyName;
143
+ const active = this.#activeTransitionProperties(this.#element);
144
+ if (active.has(propertyName)) return;
145
+ if (!this.#pendingProperties.delete(propertyName)) return;
146
+ if (this.#pendingProperties.size > 0) return;
147
+ if (active.size > 0) {
148
+ this.#pendingProperties = active;
149
+ return;
150
+ }
151
+ this.#finish();
152
+ return;
153
+ }
154
+ if (Date.now() >= this.#deadline) this.#finish();
155
+ };
156
+ /**
157
+ * Returns active CSS transition properties expanded to the names reported by
158
+ * terminal events. CSS animations and pseudo-element effects are excluded.
159
+ */
160
+ #activeTransitionProperties(element) {
161
+ if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
162
+ try {
163
+ const properties = element.getAnimations().flatMap((animation) => {
164
+ if (animation.playState === "idle" || animation.playState === "finished") return [];
165
+ const effect = animation.effect;
166
+ if (effect?.pseudoElement) return [];
167
+ const target = effect?.target;
168
+ if (target && target !== element) return [];
169
+ const property = animation.transitionProperty;
170
+ return typeof property === "string" && property.length > 0 ? [property] : [];
171
+ });
172
+ return new Set(properties);
173
+ } catch {
174
+ return /* @__PURE__ */ new Set();
175
+ }
176
+ }
177
+ /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
178
+ #explicitPendingProperties(timings) {
179
+ if (timings.some(({ property }) => property === "all")) return null;
180
+ const pending = new Set(
181
+ timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property)
182
+ );
183
+ return pending.size > 0 ? pending : null;
184
+ }
185
+ /** Completes exactly once, releasing listeners and the fallback before the callback. */
186
+ #finish() {
187
+ const complete = this.#complete;
188
+ if (!complete) return;
189
+ this.#complete = null;
190
+ this.#teardown();
191
+ complete();
192
+ }
193
+ /** Releases the exact element listeners and timer owned by the current wait. */
194
+ #teardown() {
195
+ this.#timers.clearAll();
196
+ this.#element?.removeEventListener("transitionend", this.#onTerminal);
197
+ this.#element?.removeEventListener("transitioncancel", this.#onTerminal);
198
+ this.#element = null;
199
+ this.#pendingProperties = null;
200
+ this.#deadline = 0;
201
+ }
202
+ };
203
+
58
204
  // src/controllers/transition_controller.ts
59
205
  var tokensOf = (value) => value.split(/\s+/).filter(Boolean);
60
- var firstTimeMs = (value) => {
61
- const first = value.split(",")[0]?.trim() ?? "";
62
- const amount = Number.parseFloat(first);
63
- if (Number.isNaN(amount)) return 0;
64
- return first.endsWith("ms") ? amount : amount * 1e3;
65
- };
66
206
  var TransitionController = class extends Controller {
67
207
  static values = {
68
208
  enter: { type: String, default: "" },
@@ -75,9 +215,9 @@ var TransitionController = class extends Controller {
75
215
  };
76
216
  static actions = ["enter", "leave", "toggle"];
77
217
  static events = ["entered", "left"];
78
- #timers = new SafeTimeout();
218
+ /** Owns the cancellable completion wait (terminal events + bounded fallback). */
219
+ #transition = new TransitionCompletion();
79
220
  #rafId = null;
80
- #endListener = null;
81
221
  connect() {
82
222
  this.#strip();
83
223
  this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
@@ -104,7 +244,7 @@ var TransitionController = class extends Controller {
104
244
  const isEnter = kind === "enter";
105
245
  if (isEnter) this.element.hidden = false;
106
246
  this.element.setAttribute("data-transition-state", isEnter ? "entering" : "leaving");
107
- if (this.#prefersReducedMotion()) {
247
+ if (prefersReducedMotion()) {
108
248
  this.#finish(kind);
109
249
  return;
110
250
  }
@@ -116,12 +256,13 @@ var TransitionController = class extends Controller {
116
256
  this.#rafId = null;
117
257
  this.#remove(from);
118
258
  this.#add(to);
119
- this.#awaitEnd(() => this.#finish(kind));
259
+ this.#transition.wait(this.element, () => this.#finish(kind), {
260
+ timeoutMs: this.timeoutValue
261
+ });
120
262
  });
121
263
  }
122
264
  /** Settles the element into the completed state, clearing the stage classes. */
123
265
  #finish(kind) {
124
- this.#cleanupEnd();
125
266
  this.#strip();
126
267
  if (kind === "enter") {
127
268
  this.element.setAttribute("data-transition-state", "entered");
@@ -132,29 +273,13 @@ var TransitionController = class extends Controller {
132
273
  this.dispatch("left", { detail: {} });
133
274
  }
134
275
  }
135
- /** Resolves on the element's own `transitionend`, with a safety timeout fallback. */
136
- #awaitEnd(done) {
137
- this.#endListener = (event) => {
138
- if (event.target === this.element) done();
139
- };
140
- this.element.addEventListener("transitionend", this.#endListener);
141
- const ms = this.timeoutValue > 0 ? this.timeoutValue : this.#duration();
142
- this.#timers.set(done, ms);
143
- }
144
- #cleanupEnd() {
145
- if (this.#endListener) {
146
- this.element.removeEventListener("transitionend", this.#endListener);
147
- this.#endListener = null;
148
- }
149
- this.#timers.clearAll();
150
- }
151
276
  /** Cancels any in-flight transition (interruption / teardown). */
152
277
  #cancel() {
153
278
  if (this.#rafId !== null) {
154
279
  this.#cancelRaf(this.#rafId);
155
280
  this.#rafId = null;
156
281
  }
157
- this.#cleanupEnd();
282
+ this.#transition.cancel();
158
283
  this.#strip();
159
284
  }
160
285
  #add(...lists) {
@@ -176,13 +301,6 @@ var TransitionController = class extends Controller {
176
301
  this.leaveToValue
177
302
  );
178
303
  }
179
- /** Auto-computed safety duration (transition time + delay, with a small buffer). */
180
- #duration() {
181
- if (typeof window.getComputedStyle !== "function") return 0;
182
- const style = window.getComputedStyle(this.element);
183
- const total = firstTimeMs(style.transitionDuration) + firstTimeMs(style.transitionDelay);
184
- return total > 0 ? total + 50 : 0;
185
- }
186
304
  #raf(callback) {
187
305
  if (typeof window.requestAnimationFrame === "function") {
188
306
  return window.requestAnimationFrame(() => callback());
@@ -193,9 +311,6 @@ var TransitionController = class extends Controller {
193
311
  if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
194
312
  else window.clearTimeout(id);
195
313
  }
196
- #prefersReducedMotion() {
197
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
198
- }
199
314
  };
200
315
 
201
316
  export { TransitionController };