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 (279) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +120 -0
  3. package/dist/cable/index.js +123 -29
  4. package/dist/cable/index.js.map +1 -1
  5. package/dist/controllers/accordion_controller.d.ts +30 -3
  6. package/dist/controllers/accordion_controller.js +98 -9
  7. package/dist/controllers/accordion_controller.js.map +1 -1
  8. package/dist/controllers/alert_dialog_controller.d.ts +8 -7
  9. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  10. package/dist/controllers/announcer_controller.d.ts +3 -2
  11. package/dist/controllers/announcer_controller.js +96 -62
  12. package/dist/controllers/announcer_controller.js.map +1 -1
  13. package/dist/controllers/auto_submit_controller.js +83 -7
  14. package/dist/controllers/auto_submit_controller.js.map +1 -1
  15. package/dist/controllers/avatar_controller.js +1 -1
  16. package/dist/controllers/avatar_controller.js.map +1 -1
  17. package/dist/controllers/breadcrumb_controller.d.ts +10 -7
  18. package/dist/controllers/breadcrumb_controller.js +38 -11
  19. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  20. package/dist/controllers/bulk_select_controller.d.ts +4 -4
  21. package/dist/controllers/bulk_select_controller.js +7 -6
  22. package/dist/controllers/bulk_select_controller.js.map +1 -1
  23. package/dist/controllers/calendar_controller.d.ts +101 -23
  24. package/dist/controllers/calendar_controller.js +340 -123
  25. package/dist/controllers/calendar_controller.js.map +1 -1
  26. package/dist/controllers/carousel_controller.d.ts +67 -22
  27. package/dist/controllers/carousel_controller.js +263 -38
  28. package/dist/controllers/carousel_controller.js.map +1 -1
  29. package/dist/controllers/character_counter_controller.d.ts +1 -1
  30. package/dist/controllers/character_counter_controller.js +40 -2
  31. package/dist/controllers/character_counter_controller.js.map +1 -1
  32. package/dist/controllers/checkbox_controller.js +81 -12
  33. package/dist/controllers/checkbox_controller.js.map +1 -1
  34. package/dist/controllers/clipboard_controller.d.ts +28 -2
  35. package/dist/controllers/clipboard_controller.js +63 -5
  36. package/dist/controllers/clipboard_controller.js.map +1 -1
  37. package/dist/controllers/collapsible_controller.d.ts +25 -1
  38. package/dist/controllers/collapsible_controller.js +99 -14
  39. package/dist/controllers/collapsible_controller.js.map +1 -1
  40. package/dist/controllers/color_picker_controller.d.ts +29 -8
  41. package/dist/controllers/color_picker_controller.js +80 -34
  42. package/dist/controllers/color_picker_controller.js.map +1 -1
  43. package/dist/controllers/combobox_controller.d.ts +10 -1
  44. package/dist/controllers/combobox_controller.js +106 -16
  45. package/dist/controllers/combobox_controller.js.map +1 -1
  46. package/dist/controllers/command_palette_controller.d.ts +3 -3
  47. package/dist/controllers/command_palette_controller.js +35 -3
  48. package/dist/controllers/command_palette_controller.js.map +1 -1
  49. package/dist/controllers/conditional_fields_controller.js +85 -17
  50. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  51. package/dist/controllers/confirm_controller.js +3 -0
  52. package/dist/controllers/confirm_controller.js.map +1 -1
  53. package/dist/controllers/context_menu_controller.d.ts +6 -0
  54. package/dist/controllers/context_menu_controller.js +32 -12
  55. package/dist/controllers/context_menu_controller.js.map +1 -1
  56. package/dist/controllers/count_up_controller.js.map +1 -1
  57. package/dist/controllers/countdown_controller.d.ts +30 -1
  58. package/dist/controllers/countdown_controller.js +129 -26
  59. package/dist/controllers/countdown_controller.js.map +1 -1
  60. package/dist/controllers/currency_input_controller.d.ts +77 -16
  61. package/dist/controllers/currency_input_controller.js +221 -67
  62. package/dist/controllers/currency_input_controller.js.map +1 -1
  63. package/dist/controllers/data_grid_controller.d.ts +63 -18
  64. package/dist/controllers/data_grid_controller.js +195 -29
  65. package/dist/controllers/data_grid_controller.js.map +1 -1
  66. package/dist/controllers/date_range_picker_controller.d.ts +41 -6
  67. package/dist/controllers/date_range_picker_controller.js +151 -30
  68. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  69. package/dist/controllers/dialog_controller.d.ts +10 -3
  70. package/dist/controllers/dialog_controller.js +35 -8
  71. package/dist/controllers/dialog_controller.js.map +1 -1
  72. package/dist/controllers/direct_upload_controller.js +22 -4
  73. package/dist/controllers/direct_upload_controller.js.map +1 -1
  74. package/dist/controllers/dirty_form_controller.d.ts +2 -2
  75. package/dist/controllers/dirty_form_controller.js +46 -13
  76. package/dist/controllers/dirty_form_controller.js.map +1 -1
  77. package/dist/controllers/dismissible_controller.js +1 -0
  78. package/dist/controllers/dismissible_controller.js.map +1 -1
  79. package/dist/controllers/drawer_controller.d.ts +18 -10
  80. package/dist/controllers/drawer_controller.js +54 -19
  81. package/dist/controllers/drawer_controller.js.map +1 -1
  82. package/dist/controllers/dropdown_controller.d.ts +9 -3
  83. package/dist/controllers/dropdown_controller.js +36 -9
  84. package/dist/controllers/dropdown_controller.js.map +1 -1
  85. package/dist/controllers/editable_controller.js +34 -0
  86. package/dist/controllers/editable_controller.js.map +1 -1
  87. package/dist/controllers/file_dropzone_controller.js +144 -51
  88. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  89. package/dist/controllers/filter_controller.d.ts +10 -4
  90. package/dist/controllers/filter_controller.js +20 -6
  91. package/dist/controllers/filter_controller.js.map +1 -1
  92. package/dist/controllers/flash_controller.d.ts +26 -6
  93. package/dist/controllers/flash_controller.js +432 -71
  94. package/dist/controllers/flash_controller.js.map +1 -1
  95. package/dist/controllers/focus_controller.js +1 -0
  96. package/dist/controllers/focus_controller.js.map +1 -1
  97. package/dist/controllers/form_field_controller.js +7 -5
  98. package/dist/controllers/form_field_controller.js.map +1 -1
  99. package/dist/controllers/form_validation_controller.js +19 -13
  100. package/dist/controllers/form_validation_controller.js.map +1 -1
  101. package/dist/controllers/frame_loading_controller.js +45 -8
  102. package/dist/controllers/frame_loading_controller.js.map +1 -1
  103. package/dist/controllers/highlight_controller.js +82 -25
  104. package/dist/controllers/highlight_controller.js.map +1 -1
  105. package/dist/controllers/hover_card_controller.d.ts +10 -2
  106. package/dist/controllers/hover_card_controller.js +40 -14
  107. package/dist/controllers/hover_card_controller.js.map +1 -1
  108. package/dist/controllers/idle_controller.d.ts +16 -3
  109. package/dist/controllers/idle_controller.js +90 -5
  110. package/dist/controllers/idle_controller.js.map +1 -1
  111. package/dist/controllers/input_mask_controller.d.ts +5 -2
  112. package/dist/controllers/input_mask_controller.js +65 -9
  113. package/dist/controllers/input_mask_controller.js.map +1 -1
  114. package/dist/controllers/intersection_controller.js +3 -0
  115. package/dist/controllers/intersection_controller.js.map +1 -1
  116. package/dist/controllers/lazy_frame_controller.js +11 -2
  117. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  118. package/dist/controllers/listbox_controller.d.ts +50 -8
  119. package/dist/controllers/listbox_controller.js +203 -45
  120. package/dist/controllers/listbox_controller.js.map +1 -1
  121. package/dist/controllers/local_time_controller.js +10 -5
  122. package/dist/controllers/local_time_controller.js.map +1 -1
  123. package/dist/controllers/masonry_controller.d.ts +3 -3
  124. package/dist/controllers/masonry_controller.js +31 -15
  125. package/dist/controllers/masonry_controller.js.map +1 -1
  126. package/dist/controllers/menu_controller.d.ts +9 -3
  127. package/dist/controllers/menu_controller.js +45 -16
  128. package/dist/controllers/menu_controller.js.map +1 -1
  129. package/dist/controllers/menubar_controller.d.ts +11 -0
  130. package/dist/controllers/menubar_controller.js +58 -24
  131. package/dist/controllers/menubar_controller.js.map +1 -1
  132. package/dist/controllers/meter_controller.js +9 -5
  133. package/dist/controllers/meter_controller.js.map +1 -1
  134. package/dist/controllers/multi_select_controller.d.ts +18 -3
  135. package/dist/controllers/multi_select_controller.js +278 -104
  136. package/dist/controllers/multi_select_controller.js.map +1 -1
  137. package/dist/controllers/navigation_menu_controller.d.ts +11 -0
  138. package/dist/controllers/navigation_menu_controller.js +48 -15
  139. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  140. package/dist/controllers/nested_form_controller.js +37 -8
  141. package/dist/controllers/nested_form_controller.js.map +1 -1
  142. package/dist/controllers/network_status_controller.js +9 -1
  143. package/dist/controllers/network_status_controller.js.map +1 -1
  144. package/dist/controllers/number_input_controller.d.ts +36 -8
  145. package/dist/controllers/number_input_controller.js +124 -21
  146. package/dist/controllers/number_input_controller.js.map +1 -1
  147. package/dist/controllers/optimistic_controller.js +42 -5
  148. package/dist/controllers/optimistic_controller.js.map +1 -1
  149. package/dist/controllers/otp_controller.d.ts +22 -7
  150. package/dist/controllers/otp_controller.js +198 -55
  151. package/dist/controllers/otp_controller.js.map +1 -1
  152. package/dist/controllers/overflow_indicator_controller.d.ts +8 -12
  153. package/dist/controllers/overflow_indicator_controller.js +115 -21
  154. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  155. package/dist/controllers/overflow_menu_controller.d.ts +26 -6
  156. package/dist/controllers/overflow_menu_controller.js +141 -44
  157. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  158. package/dist/controllers/pagination_controller.d.ts +19 -11
  159. package/dist/controllers/pagination_controller.js +74 -28
  160. package/dist/controllers/pagination_controller.js.map +1 -1
  161. package/dist/controllers/password_reveal_controller.d.ts +15 -1
  162. package/dist/controllers/password_reveal_controller.js +59 -2
  163. package/dist/controllers/password_reveal_controller.js.map +1 -1
  164. package/dist/controllers/persist_controller.js +30 -8
  165. package/dist/controllers/persist_controller.js.map +1 -1
  166. package/dist/controllers/pointer_drag_controller.js +131 -52
  167. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  168. package/dist/controllers/popover_controller.d.ts +9 -3
  169. package/dist/controllers/popover_controller.js +45 -11
  170. package/dist/controllers/popover_controller.js.map +1 -1
  171. package/dist/controllers/portal_controller.d.ts +1 -1
  172. package/dist/controllers/portal_controller.js +6 -2
  173. package/dist/controllers/portal_controller.js.map +1 -1
  174. package/dist/controllers/preview_guard_controller.js +16 -1
  175. package/dist/controllers/preview_guard_controller.js.map +1 -1
  176. package/dist/controllers/progress_controller.js +8 -4
  177. package/dist/controllers/progress_controller.js.map +1 -1
  178. package/dist/controllers/radio_group_controller.d.ts +6 -4
  179. package/dist/controllers/radio_group_controller.js +42 -17
  180. package/dist/controllers/radio_group_controller.js.map +1 -1
  181. package/dist/controllers/range_slider_controller.d.ts +49 -1
  182. package/dist/controllers/range_slider_controller.js +88 -42
  183. package/dist/controllers/range_slider_controller.js.map +1 -1
  184. package/dist/controllers/rating_controller.d.ts +14 -3
  185. package/dist/controllers/rating_controller.js +39 -15
  186. package/dist/controllers/rating_controller.js.map +1 -1
  187. package/dist/controllers/read_more_controller.d.ts +24 -2
  188. package/dist/controllers/read_more_controller.js +100 -7
  189. package/dist/controllers/read_more_controller.js.map +1 -1
  190. package/dist/controllers/reading_progress_controller.js +65 -19
  191. package/dist/controllers/reading_progress_controller.js.map +1 -1
  192. package/dist/controllers/relative_time_controller.js +10 -5
  193. package/dist/controllers/relative_time_controller.js.map +1 -1
  194. package/dist/controllers/resizable_controller.d.ts +16 -2
  195. package/dist/controllers/resizable_controller.js +82 -22
  196. package/dist/controllers/resizable_controller.js.map +1 -1
  197. package/dist/controllers/scroll_area_controller.js +75 -27
  198. package/dist/controllers/scroll_area_controller.js.map +1 -1
  199. package/dist/controllers/scroll_restore_controller.js +37 -16
  200. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  201. package/dist/controllers/scroll_visibility_controller.js +49 -30
  202. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  203. package/dist/controllers/scrollspy_controller.d.ts +3 -2
  204. package/dist/controllers/scrollspy_controller.js +71 -26
  205. package/dist/controllers/scrollspy_controller.js.map +1 -1
  206. package/dist/controllers/separator_controller.d.ts +41 -14
  207. package/dist/controllers/separator_controller.js +66 -37
  208. package/dist/controllers/separator_controller.js.map +1 -1
  209. package/dist/controllers/sidebar_controller.d.ts +20 -3
  210. package/dist/controllers/sidebar_controller.js +77 -18
  211. package/dist/controllers/sidebar_controller.js.map +1 -1
  212. package/dist/controllers/skeleton_controller.js +6 -1
  213. package/dist/controllers/skeleton_controller.js.map +1 -1
  214. package/dist/controllers/slider_controller.d.ts +45 -7
  215. package/dist/controllers/slider_controller.js +82 -47
  216. package/dist/controllers/slider_controller.js.map +1 -1
  217. package/dist/controllers/smart_sticky_header_controller.js +60 -26
  218. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  219. package/dist/controllers/sortable_controller.js +17 -2
  220. package/dist/controllers/sortable_controller.js.map +1 -1
  221. package/dist/controllers/spinner_controller.js +10 -2
  222. package/dist/controllers/spinner_controller.js.map +1 -1
  223. package/dist/controllers/step_indicator_controller.d.ts +19 -17
  224. package/dist/controllers/step_indicator_controller.js +18 -17
  225. package/dist/controllers/step_indicator_controller.js.map +1 -1
  226. package/dist/controllers/stepper_controller.d.ts +34 -9
  227. package/dist/controllers/stepper_controller.js +101 -19
  228. package/dist/controllers/stepper_controller.js.map +1 -1
  229. package/dist/controllers/stick_to_bottom_controller.d.ts +19 -0
  230. package/dist/controllers/stick_to_bottom_controller.js +104 -8
  231. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  232. package/dist/controllers/submit_once_controller.d.ts +3 -2
  233. package/dist/controllers/submit_once_controller.js +45 -9
  234. package/dist/controllers/submit_once_controller.js.map +1 -1
  235. package/dist/controllers/switch_controller.d.ts +29 -7
  236. package/dist/controllers/switch_controller.js +101 -10
  237. package/dist/controllers/switch_controller.js.map +1 -1
  238. package/dist/controllers/tabs_controller.d.ts +12 -0
  239. package/dist/controllers/tabs_controller.js +21 -2
  240. package/dist/controllers/tabs_controller.js.map +1 -1
  241. package/dist/controllers/tags_input_controller.d.ts +15 -3
  242. package/dist/controllers/tags_input_controller.js +209 -59
  243. package/dist/controllers/tags_input_controller.js.map +1 -1
  244. package/dist/controllers/textarea_autosize_controller.js +29 -3
  245. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  246. package/dist/controllers/theme_controller.d.ts +20 -3
  247. package/dist/controllers/theme_controller.js +64 -14
  248. package/dist/controllers/theme_controller.js.map +1 -1
  249. package/dist/controllers/time_picker_controller.js +23 -8
  250. package/dist/controllers/time_picker_controller.js.map +1 -1
  251. package/dist/controllers/toast_controller.d.ts +55 -15
  252. package/dist/controllers/toast_controller.js +451 -105
  253. package/dist/controllers/toast_controller.js.map +1 -1
  254. package/dist/controllers/toggle_group_controller.d.ts +49 -7
  255. package/dist/controllers/toggle_group_controller.js +159 -23
  256. package/dist/controllers/toggle_group_controller.js.map +1 -1
  257. package/dist/controllers/toolbar_controller.js +32 -0
  258. package/dist/controllers/toolbar_controller.js.map +1 -1
  259. package/dist/controllers/tooltip_controller.d.ts +8 -0
  260. package/dist/controllers/tooltip_controller.js +39 -13
  261. package/dist/controllers/tooltip_controller.js.map +1 -1
  262. package/dist/controllers/transition_controller.js +4 -0
  263. package/dist/controllers/transition_controller.js.map +1 -1
  264. package/dist/controllers/tree_view_controller.d.ts +39 -8
  265. package/dist/controllers/tree_view_controller.js +169 -16
  266. package/dist/controllers/tree_view_controller.js.map +1 -1
  267. package/dist/index.d.ts +28 -1
  268. package/dist/index.js +5002 -1911
  269. package/dist/index.js.map +1 -1
  270. package/dist/inspector/cli.d.ts +72 -6
  271. package/dist/inspector/cli.js +262 -51
  272. package/dist/inspector/cli.js.map +1 -1
  273. package/dist/inspector/cli_bin.js +309 -51
  274. package/dist/inspector/cli_bin.js.map +1 -1
  275. package/dist/inspector/examples.json +40 -40
  276. package/dist/inspector/manifest.json +529 -61
  277. package/dist/positioning/index.js +2 -0
  278. package/dist/positioning/index.js.map +1 -1
  279. package/package.json +2 -2
@@ -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/hover_card_controller.ts
168
178
  var HoverCardController = class extends Controller {
169
179
  static targets = ["trigger", "card"];
@@ -173,6 +183,7 @@ var HoverCardController = class extends Controller {
173
183
  closeOnScroll: { type: Boolean, default: false }
174
184
  };
175
185
  static actions = ["close", "open"];
186
+ static events = ["close", "open"];
176
187
  /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */
177
188
  #timers = new SafeTimeout();
178
189
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
@@ -181,14 +192,18 @@ var HoverCardController = class extends Controller {
181
192
  #pendingClose = null;
182
193
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
183
194
  #stopScrollDismiss = null;
195
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
196
+ #reporting = false;
184
197
  /** Starts closed and discards any stale pending state from a prior connection. */
185
198
  connect() {
186
199
  this.#cancelOpen();
187
200
  this.#cancelClose();
188
- this.#conceal();
201
+ this.#conceal("api");
202
+ this.#reporting = true;
189
203
  }
190
204
  /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
191
205
  disconnect() {
206
+ this.#reporting = false;
192
207
  this.#cancelOpen();
193
208
  this.#cancelClose();
194
209
  this.#escapeLayer.deactivate();
@@ -196,16 +211,17 @@ var HoverCardController = class extends Controller {
196
211
  this.#stopScrollDismiss = null;
197
212
  }
198
213
  /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */
199
- open() {
214
+ open(event) {
215
+ const reason = stateReasonFor(event);
200
216
  this.#cancelClose();
201
217
  if (this.#isOpen || this.#pendingOpen !== null) return;
202
218
  if (this.openDelayValue <= 0) {
203
- this.#reveal();
219
+ this.#reveal(reason);
204
220
  return;
205
221
  }
206
222
  this.#pendingOpen = this.#timers.set(() => {
207
223
  this.#pendingOpen = null;
208
- this.#reveal();
224
+ this.#reveal(reason);
209
225
  }, this.openDelayValue);
210
226
  }
211
227
  /**
@@ -214,28 +230,37 @@ var HoverCardController = class extends Controller {
214
230
  * (e.g. a link in the card) and, if so, aborts the close — covering keyboard
215
231
  * traversal that the pointer-only hoverable bridge cannot.
216
232
  */
217
- close() {
233
+ close(event) {
234
+ const reason = stateReasonFor(event);
218
235
  this.#cancelOpen();
219
236
  if (!this.#isOpen || this.#pendingClose !== null) return;
220
237
  this.#pendingClose = this.#timers.set(() => {
221
238
  this.#pendingClose = null;
222
239
  if (this.element.contains(document.activeElement)) return;
223
- this.#conceal();
240
+ this.#conceal(reason);
224
241
  }, this.closeDelayValue);
225
242
  }
226
- /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */
227
- #reveal() {
243
+ /**
244
+ * Reveals the card, reflects state, reports a move, and joins the Escape stack.
245
+ *
246
+ * @stimeoRuntimeOnly `closeOnScroll` decides whether this reveal wires the scroll dismissal; what
247
+ * is shown does not depend on it.
248
+ */
249
+ #reveal(reason) {
228
250
  if (!this.hasCardTarget) return;
251
+ const was = this.#isOpen;
229
252
  this.cardTarget.hidden = false;
230
253
  this.cardTarget.setAttribute("data-state", "open");
231
254
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
232
- this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
255
+ if (!was && this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
256
+ this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss("escape") });
233
257
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
234
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
258
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss("scroll"));
235
259
  }
236
260
  }
237
- /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */
238
- #conceal() {
261
+ /** Hides the card, reflects state, reports a move, and leaves the Escape stack. */
262
+ #conceal(reason) {
263
+ const was = this.#isOpen;
239
264
  this.#escapeLayer.deactivate();
240
265
  this.#stopScrollDismiss?.();
241
266
  this.#stopScrollDismiss = null;
@@ -244,12 +269,13 @@ var HoverCardController = class extends Controller {
244
269
  this.cardTarget.setAttribute("data-state", "closed");
245
270
  }
246
271
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
272
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
247
273
  }
248
274
  /** Cancels pending timers and conceals immediately (shared Escape path). */
249
- #dismiss() {
275
+ #dismiss(reason) {
250
276
  this.#cancelOpen();
251
277
  this.#cancelClose();
252
- this.#conceal();
278
+ this.#conceal(reason);
253
279
  }
254
280
  /** Cancels any pending open timer. */
255
281
  #cancelOpen() {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/hover_card_controller.ts"],"names":[],"mappings":"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","file":"hover_card_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 handler resolves the\n * press. A keydown that is part of an IME composition (`event.isComposing`)\n * cancels the composition, never a layer, and is ignored here for every layer\n * 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 * Largest delay a timer can hold: the platform stores it in a 32-bit signed\n * integer, and anything larger overflows to `1`, so a delay meant to be far in\n * the future fires almost immediately. A declared delay above this bound names\n * no delay at all, and a controller reading one falls back to its default.\n */\nexport const MAX_TIMER_DELAY_MS = 2_147_483_647;\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 **hover card** behavior.\n *\n * Markup contract (identifier: `stimeo--hover-card`):\n * <span data-controller=\"stimeo--hover-card\">\n * <a href=\"/users/jane\" data-stimeo--hover-card-target=\"trigger\"\n * aria-expanded=\"false\" aria-controls=\"hc\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\">@jane</a>\n * <div id=\"hc\" data-stimeo--hover-card-target=\"card\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\" hidden>…</div>\n * </span>\n *\n * There is no dedicated APG pattern; this follows the **Disclosure** convention\n * (`aria-expanded`) for a hover/focus-opened, non-modal popover that *may* hold\n * interactive content (unlike a tooltip). The card is **not** a `role=\"dialog\"`:\n * it is supplementary, so its content must also be reachable from the trigger\n * itself. For a short text hint use `stimeo--tooltip`; for a click-opened\n * action panel use `stimeo--popover`.\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 * - Open on `mouseenter`/`focusin`, close on `mouseleave`/`focusout`, each gated by\n * `openDelay`/`closeDelay` to prevent accidental flicker.\n * - **Hoverable bridge**: binding open/close on the card cancels a pending close\n * when the pointer crosses into it. Matching focus actions on the card cancel\n * the trigger's pending close while focus is inside, then schedule close once\n * focus leaves the whole controller.\n * - **Dismissible**: while open, the card joins the shared `EscapeLayer`\n * stack, so `Escape` closes it regardless of where focus sits (card, trigger,\n * or elsewhere). The resolver ignores an Escape already consumed by an inner\n * handler and lets the most recently shown layer own the press, so one\n * keypress closes exactly one layer.\n * - Open/closed flips the trigger's `aria-expanded`, the card's `hidden`, and a\n * `data-state` (`open`/`closed`). Focus is never stolen on open.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the card, the usual convention for\n * anchored popups. Covers keyboard/programmatic scroll and scrollbar-drag, which the\n * pointer-leave close cannot. Off by default.\n */\nexport class HoverCardController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"card\"];\n static override values = {\n openDelay: { type: Number, default: 300 },\n closeDelay: { type: Number, default: 200 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly cardTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasCardTarget: boolean;\n declare readonly openDelayValue: number;\n declare readonly closeDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */\n readonly #timers = new SafeTimeout();\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n #pendingOpen: number | null = null;\n #pendingClose: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts closed and discards any stale pending state from a prior connection. */\n override connect(): void {\n this.#cancelOpen();\n this.#cancelClose();\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.#cancelOpen();\n this.#cancelClose();\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */\n open(): void {\n this.#cancelClose();\n if (this.#isOpen || this.#pendingOpen !== null) return;\n if (this.openDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingOpen = this.#timers.set(() => {\n this.#pendingOpen = null;\n this.#reveal();\n }, this.openDelayValue);\n }\n\n /**\n * Schedules the card to close after `closeDelay`. Cancels a pending open. The\n * delayed callback re-checks whether focus has landed inside the controller\n * (e.g. a link in the card) and, if so, aborts the close — covering keyboard\n * traversal that the pointer-only hoverable bridge cannot.\n */\n close(): void {\n this.#cancelOpen();\n if (!this.#isOpen || this.#pendingClose !== null) return;\n this.#pendingClose = this.#timers.set(() => {\n this.#pendingClose = null;\n if (this.element.contains(document.activeElement)) return;\n this.#conceal();\n }, this.closeDelayValue);\n }\n\n /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */\n #reveal(): void {\n if (!this.hasCardTarget) return;\n this.cardTarget.hidden = false;\n this.cardTarget.setAttribute(\"data-state\", \"open\");\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n // No claims predicate: hover-revealed content is dismissible regardless of\n // where focus sits (WCAG 2.2 SC 1.4.13), so it always claims while open.\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 card, reflects state, and leaves the Escape stack / scroll watcher. */\n #conceal(): void {\n // Release the layer and observers first, unconditionally: if the card\n // target was removed from the DOM while open, 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.hasCardTarget) {\n this.cardTarget.hidden = true;\n this.cardTarget.setAttribute(\"data-state\", \"closed\");\n }\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape path). */\n #dismiss(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Cancels any pending open timer. */\n #cancelOpen(): void {\n if (this.#pendingOpen !== null) {\n this.#timers.clear(this.#pendingOpen);\n this.#pendingOpen = null;\n }\n }\n\n /** Cancels any pending close timer. */\n #cancelClose(): void {\n if (this.#pendingClose !== null) {\n this.#timers.clear(this.#pendingClose);\n this.#pendingClose = null;\n }\n }\n\n /** Whether the card is currently visible. */\n get #isOpen(): boolean {\n return this.hasCardTarget && !this.cardTarget.hidden;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/utils/state_reason.ts","../../src/controllers/hover_card_controller.ts"],"names":[],"mappings":"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","file":"hover_card_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 handler resolves the\n * press. A keydown that is part of an IME composition (`event.isComposing`)\n * cancels the composition, never a layer, and is ignored here for every layer\n * 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 * Largest delay a timer can hold: the platform stores it in a 32-bit signed\n * integer, and anything larger overflows to `1`, so a delay meant to be far in\n * the future fires almost immediately. A declared delay above this bound names\n * no delay at all, and a controller reading one falls back to its default.\n */\nexport const MAX_TIMER_DELAY_MS = 2_147_483_647;\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","/**\n * Why a component's public open/closed state moved, carried as `detail.reason`\n * on the state event that reports the move.\n *\n * The attribute a component publishes (`aria-expanded`, `hidden`, `data-state`)\n * only says *what* the state is now. A subscriber that mirrors the state\n * elsewhere, or reports it, needs *why*: closing on `Escape` and closing because\n * the consumer called the action are the same attribute write and different\n * events to the page around it.\n *\n * `\"api\"` is the one every subscriber has to look at. A close the consumer asked\n * for arrives back at their own listener, so a listener that closes something\n * else on `close` loops unless it ignores its own calls.\n *\n * | Reason | The state moved because |\n * | --- | --- |\n * | `\"user\"` | a control of this component was operated |\n * | `\"select\"` | an item inside it was activated |\n * | `\"escape\"` | `Escape` was pressed and this layer owned it |\n * | `\"outside\"` | a pointer landed outside it, or on its backdrop |\n * | `\"focus\"` | focus entered or left it |\n * | `\"pointer\"` | the pointer entered or left it |\n * | `\"scroll\"` | a tracked scroll container scrolled |\n * | `\"api\"` | a public action was called with no DOM event |\n */\nexport type StateReason =\n | \"user\"\n | \"select\"\n | \"escape\"\n | \"outside\"\n | \"focus\"\n | \"pointer\"\n | \"scroll\"\n | \"api\";\n\n/** Focus modality: the events a focus move delivers to an action. */\nconst FOCUS_EVENTS = new Set([\"blur\", \"focus\", \"focusin\", \"focusout\"]);\n\n/** Pointer modality: the crossing events an action is bound to for hover. */\nconst POINTER_EVENTS = new Set([\"mouseenter\", \"mouseleave\", \"pointerenter\", \"pointerleave\"]);\n\n/**\n * Reads the interaction behind a **public action** from the DOM event it was\n * handed.\n *\n * Stimulus always passes the event to an action it invoked from `data-action`,\n * and a consumer calling the method themselves passes nothing — which is what\n * separates `\"api\"` from the rest.\n *\n * Only an action entry may resolve a reason this way. Inside the component the\n * event type no longer identifies the interaction: one `click` is the trigger\n * being pressed, the page outside being pressed, and an item being activated,\n * and those paths pass their own reason instead.\n *\n * @param event - The event the action received, if any.\n * @returns `\"api\"` with no event, else the modality the event type names, else\n * `\"user\"`.\n *\n * @example\n * ```ts\n * toggle(event?: Event): void {\n * this.#apply(!this.#isOpen, stateReasonFor(event));\n * }\n * ```\n */\nexport function stateReasonFor(event?: Event | null): \"user\" | \"focus\" | \"pointer\" | \"api\" {\n if (!event) return \"api\";\n if (FOCUS_EVENTS.has(event.type)) return \"focus\";\n if (POINTER_EVENTS.has(event.type)) return \"pointer\";\n return \"user\";\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\";\nimport { type StateReason, stateReasonFor } from \"../utils/state_reason\";\n\n/**\n * Headless, accessible **hover card** behavior.\n *\n * Markup contract (identifier: `stimeo--hover-card`):\n * <span data-controller=\"stimeo--hover-card\">\n * <a href=\"/users/jane\" data-stimeo--hover-card-target=\"trigger\"\n * aria-expanded=\"false\" aria-controls=\"hc\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\">@jane</a>\n * <div id=\"hc\" data-stimeo--hover-card-target=\"card\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\" hidden>…</div>\n * </span>\n *\n * There is no dedicated APG pattern; this follows the **Disclosure** convention\n * (`aria-expanded`) for a hover/focus-opened, non-modal popover that *may* hold\n * interactive content (unlike a tooltip). The card is **not** a `role=\"dialog\"`:\n * it is supplementary, so its content must also be reachable from the trigger\n * itself. For a short text hint use `stimeo--tooltip`; for a click-opened\n * action panel use `stimeo--popover`.\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 * - Open on `mouseenter`/`focusin`, close on `mouseleave`/`focusout`, each gated by\n * `openDelay`/`closeDelay` to prevent accidental flicker.\n * - **Hoverable bridge**: binding open/close on the card cancels a pending close\n * when the pointer crosses into it. Matching focus actions on the card cancel\n * the trigger's pending close while focus is inside, then schedule close once\n * focus leaves the whole controller.\n * - **Dismissible**: while open, the card joins the shared `EscapeLayer`\n * stack, so `Escape` closes it regardless of where focus sits (card, trigger,\n * or elsewhere). The resolver ignores an Escape already consumed by an inner\n * handler and lets the most recently shown layer own the press, so one\n * keypress closes exactly one layer.\n * - Open/closed flips the trigger's `aria-expanded`, the card's `hidden`, and a\n * `data-state` (`open`/`closed`). Focus is never stolen on open.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the card, the usual convention for\n * anchored popups. Covers keyboard/programmatic scroll and scrollbar-drag, which the\n * pointer-leave close cannot. Off by default.\n * - Each move of the open state is reported: `stimeo--hover-card:open` and\n * `stimeo--hover-card:close` dispatch `{ reason: StateReason }`, after the\n * state attributes are written. The reason is taken from the event that\n * started the move and survives the delay, so a `mouseleave` that closes\n * `closeDelay` later still reports `\"pointer\"`. Both are informational, so\n * neither is cancelable. A call that leaves the state where it already was,\n * the normalization in {@link connect}, and {@link disconnect} are all silent.\n */\nexport class HoverCardController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"card\"];\n static override values = {\n openDelay: { type: Number, default: 300 },\n closeDelay: { type: Number, default: 200 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\"] as const;\n static events = [\"close\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly cardTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasCardTarget: boolean;\n declare readonly openDelayValue: number;\n declare readonly closeDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */\n readonly #timers = new SafeTimeout();\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n #pendingOpen: number | null = null;\n #pendingClose: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Whether state moves are reported: set once `connect()` settled the baseline. */\n #reporting = false;\n\n /** Starts closed and discards any stale pending state from a prior connection. */\n override connect(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal(\"api\");\n this.#reporting = true;\n }\n\n /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#reporting = false;\n this.#cancelOpen();\n this.#cancelClose();\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */\n open(event?: Event): void {\n const reason = stateReasonFor(event);\n this.#cancelClose();\n if (this.#isOpen || this.#pendingOpen !== null) return;\n if (this.openDelayValue <= 0) {\n this.#reveal(reason);\n return;\n }\n this.#pendingOpen = this.#timers.set(() => {\n this.#pendingOpen = null;\n this.#reveal(reason);\n }, this.openDelayValue);\n }\n\n /**\n * Schedules the card to close after `closeDelay`. Cancels a pending open. The\n * delayed callback re-checks whether focus has landed inside the controller\n * (e.g. a link in the card) and, if so, aborts the close — covering keyboard\n * traversal that the pointer-only hoverable bridge cannot.\n */\n close(event?: Event): void {\n const reason = stateReasonFor(event);\n this.#cancelOpen();\n if (!this.#isOpen || this.#pendingClose !== null) return;\n this.#pendingClose = this.#timers.set(() => {\n this.#pendingClose = null;\n if (this.element.contains(document.activeElement)) return;\n this.#conceal(reason);\n }, this.closeDelayValue);\n }\n\n /**\n * Reveals the card, reflects state, reports a move, and joins the Escape stack.\n *\n * @stimeoRuntimeOnly `closeOnScroll` decides whether this reveal wires the scroll dismissal; what\n * is shown does not depend on it.\n */\n #reveal(reason: StateReason): void {\n if (!this.hasCardTarget) return;\n const was = this.#isOpen;\n this.cardTarget.hidden = false;\n this.cardTarget.setAttribute(\"data-state\", \"open\");\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (!was && this.#reporting) this.dispatch(\"open\", { detail: { reason }, cancelable: false });\n // No claims predicate: hover-revealed content is dismissible regardless of\n // where focus sits (WCAG 2.2 SC 1.4.13), so it always claims while open.\n this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss(\"escape\") });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss(\"scroll\"));\n }\n }\n\n /** Hides the card, reflects state, reports a move, and leaves the Escape stack. */\n #conceal(reason: StateReason): void {\n // Release the layer and observers first, unconditionally: if the card\n // target was removed from the DOM while open, an early return would leak\n // the stack entry and the scroll-dismiss listeners.\n const was = this.#isOpen;\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (this.hasCardTarget) {\n this.cardTarget.hidden = true;\n this.cardTarget.setAttribute(\"data-state\", \"closed\");\n }\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n if (was && this.#reporting) this.dispatch(\"close\", { detail: { reason }, cancelable: false });\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape path). */\n #dismiss(reason: StateReason): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal(reason);\n }\n\n /** Cancels any pending open timer. */\n #cancelOpen(): void {\n if (this.#pendingOpen !== null) {\n this.#timers.clear(this.#pendingOpen);\n this.#pendingOpen = null;\n }\n }\n\n /** Cancels any pending close timer. */\n #cancelClose(): void {\n if (this.#pendingClose !== null) {\n this.#timers.clear(this.#pendingClose);\n this.#pendingClose = null;\n }\n }\n\n /** Whether the card is currently visible. */\n get #isOpen(): boolean {\n return this.hasCardTarget && !this.cardTarget.hidden;\n }\n}\n"]}
@@ -11,12 +11,18 @@ import { Controller } from '@hotwired/stimulus';
11
11
  * data-stimeo--idle-timeout-value="900000"
12
12
  * data-stimeo--idle-prompt-before-value="60000"
13
13
  * data-action="stimeo--idle:prompt->session#warn
14
- * stimeo--idle:idle->session#logout"></body>
14
+ * stimeo--idle:idle->session#logout">
15
+ * <p data-stimeo--idle-target="prompt" hidden>Your session expires in a minute.</p>
16
+ * <p data-stimeo--idle-target="idle" hidden>You have been signed out.</p>
17
+ * </body>
15
18
  *
16
19
  * Activity events (`events`, passive) are watched on `document` with capture so
17
20
  * non-bubbling ones like `scroll` are seen anywhere; returning to a hidden tab
18
- * (`visibilitychange` → visible) counts as activity too. While idle the controller
19
- * element carries `data-idle`.
21
+ * (`visibilitychange` → visible) counts as activity too. The controller element carries
22
+ * `data-prompt` while the warning stands and `data-idle` once the timeout is reached,
23
+ * and the optional `prompt` / `idle` targets are the regions those phases reveal:
24
+ * declared where the page has something to show, shown while their phase holds and
25
+ * hidden otherwise, whatever visibility the markup was authored with.
20
26
  *
21
27
  * `prompt` dispatches `{ remaining }`; `idle` and `active` dispatch `{}`.
22
28
  *
@@ -43,12 +49,19 @@ declare class IdleController extends Controller<HTMLElement> {
43
49
  default: string;
44
50
  };
45
51
  };
52
+ static targets: string[];
46
53
  static events: readonly ["prompt", "idle", "active"];
47
54
  timeoutValue: number;
48
55
  promptBeforeValue: number;
49
56
  eventsValue: string;
57
+ readonly promptTargets: HTMLElement[];
58
+ readonly idleTargets: HTMLElement[];
50
59
  connect(): void;
51
60
  disconnect(): void;
61
+ /** Applies the current phase to a warning region inserted or replaced at runtime. */
62
+ promptTargetConnected(): void;
63
+ /** Applies the current phase to an idle region inserted or replaced at runtime. */
64
+ idleTargetConnected(): void;
52
65
  }
53
66
 
54
67
  export { IdleController };
@@ -55,6 +55,47 @@ var SafeTimeout = class extends TimerRegistry {
55
55
  }
56
56
  };
57
57
 
58
+ // src/utils/state_regions.ts
59
+ var StateRegions = class {
60
+ #whenTrue;
61
+ #whenFalse;
62
+ #taken = /* @__PURE__ */ new WeakSet();
63
+ constructor(options) {
64
+ this.#whenTrue = options.whenTrue;
65
+ this.#whenFalse = options.whenFalse ?? null;
66
+ }
67
+ /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
68
+ reflect(host, isTrue) {
69
+ const shown = this.#inside(host, this.#whenTrue());
70
+ if (!this.#whenFalse) {
71
+ for (const region of shown) this.#write(region, !isTrue);
72
+ return;
73
+ }
74
+ const hiddenSide = this.#inside(host, this.#whenFalse());
75
+ if (shown.length === 0 || hiddenSide.length === 0) {
76
+ for (const region of shown) this.#give(region);
77
+ for (const region of hiddenSide) this.#give(region);
78
+ return;
79
+ }
80
+ for (const region of shown) this.#write(region, !isTrue);
81
+ for (const region of hiddenSide) this.#write(region, isTrue);
82
+ }
83
+ /** Writes `hidden` where it moves, noting which regions it takes out of view. */
84
+ #write(region, hidden) {
85
+ if (hidden) this.#taken.add(region);
86
+ else this.#taken.delete(region);
87
+ if (region.hidden !== hidden) region.hidden = hidden;
88
+ }
89
+ /** Returns a region this instance hid; one it never hid keeps what it carries. */
90
+ #give(region) {
91
+ if (this.#taken.delete(region)) region.hidden = false;
92
+ }
93
+ /** The declared regions that sit within `host`, which may be the host itself. */
94
+ #inside(host, regions) {
95
+ return regions.filter((region) => host.contains(region));
96
+ }
97
+ };
98
+
58
99
  // src/utils/string_list.ts
59
100
  function parseStringList(raw, fallback = []) {
60
101
  const text = raw.trim();
@@ -87,7 +128,12 @@ var IdleController = class extends Controller {
87
128
  // runs, so one malformed attribute would stop the detector connecting.
88
129
  events: { type: String, default: "" }
89
130
  };
131
+ static targets = ["prompt", "idle"];
90
132
  static events = ["prompt", "idle", "active"];
133
+ /** The regions of the warning window, revealed alongside `data-prompt`. */
134
+ #promptRegions = new StateRegions({ whenTrue: () => this.promptTargets });
135
+ /** The regions of the elapsed timeout, revealed alongside `data-idle`. */
136
+ #idleRegions = new StateRegions({ whenTrue: () => this.idleTargets });
91
137
  #timeouts = new SafeTimeout();
92
138
  #idle = false;
93
139
  #prompted = false;
@@ -104,7 +150,7 @@ var IdleController = class extends Controller {
104
150
  if (this.#idle || this.#prompted) {
105
151
  this.#idle = false;
106
152
  this.#prompted = false;
107
- this.element.removeAttribute("data-idle");
153
+ this.#reflect();
108
154
  this.dispatch("active", { detail: {} });
109
155
  this.#arm();
110
156
  }
@@ -115,7 +161,7 @@ var IdleController = class extends Controller {
115
161
  connect() {
116
162
  this.#idle = false;
117
163
  this.#prompted = false;
118
- this.element.removeAttribute("data-idle");
164
+ this.#reflect();
119
165
  this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);
120
166
  for (const type of this.#boundEvents) {
121
167
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
@@ -131,7 +177,11 @@ var IdleController = class extends Controller {
131
177
  document.removeEventListener("visibilitychange", this.#onVisibility);
132
178
  this.#timeouts.clearAll();
133
179
  }
134
- /** Schedules the prompt and idle checks from the current activity baseline. */
180
+ /**
181
+ * Schedules the prompt and idle checks from the current activity baseline.
182
+ *
183
+ * @stimeoRuntimeOnly `timeout` and `promptBefore` time the checks this call arms.
184
+ */
135
185
  #arm() {
136
186
  this.#timeouts.clearAll();
137
187
  this.#lastActivity = Date.now();
@@ -145,6 +195,9 @@ var IdleController = class extends Controller {
145
195
  * Idle-timer callback: go idle only if there has genuinely been no activity for
146
196
  * `timeout`; otherwise reschedule for the remaining time. This lets activity events
147
197
  * stay O(1) (a timestamp write) while the deadline still tracks the last activity.
198
+ *
199
+ * @stimeoRuntimeOnly `timeout` sets the deadline this one check compares against; the phase it
200
+ * shows follows the elapsed time.
148
201
  */
149
202
  #checkIdle() {
150
203
  const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);
@@ -154,19 +207,51 @@ var IdleController = class extends Controller {
154
207
  }
155
208
  this.#idle = true;
156
209
  this.#prompted = false;
157
- this.element.setAttribute("data-idle", "true");
210
+ this.#reflect();
158
211
  this.dispatch("idle", { detail: {} });
159
212
  }
160
- /** Prompt-timer callback: warn at `promptBefore` before the idle deadline. */
213
+ /**
214
+ * Prompt-timer callback: warn at `promptBefore` before the idle deadline. A window
215
+ * narrowed while the cycle runs can push the warning onto the deadline or past it,
216
+ * and the phase it belongs to is the one before idle, so a lapsed cycle keeps the
217
+ * phase it reached. The next window opens with the cycle that activity arms.
218
+ *
219
+ * @stimeoRuntimeOnly `timeout` and `promptBefore` set the deadlines this one check compares
220
+ * against; the phase it shows follows the elapsed time.
221
+ */
161
222
  #checkPrompt() {
223
+ if (this.#idle) return;
162
224
  const remaining = this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);
163
225
  if (remaining > 0) {
164
226
  this.#timeouts.set(() => this.#checkPrompt(), remaining);
165
227
  return;
166
228
  }
167
229
  this.#prompted = true;
230
+ this.#reflect();
168
231
  this.dispatch("prompt", { detail: { remaining: this.promptBeforeValue } });
169
232
  }
233
+ /** Applies the current phase to a warning region inserted or replaced at runtime. */
234
+ promptTargetConnected() {
235
+ this.#promptRegions.reflect(this.element, this.#prompted);
236
+ }
237
+ /** Applies the current phase to an idle region inserted or replaced at runtime. */
238
+ idleTargetConnected() {
239
+ this.#idleRegions.reflect(this.element, this.#idle);
240
+ }
241
+ /**
242
+ * Writes the phase to the element and to the declared regions. Both hooks and both
243
+ * regions are a pure function of the two flags, so every place that moves a flag ends
244
+ * here and the markup can never disagree with the phase the detector is in.
245
+ */
246
+ #reflect() {
247
+ const { element } = this;
248
+ if (this.#prompted) element.setAttribute("data-prompt", "true");
249
+ else element.removeAttribute("data-prompt");
250
+ if (this.#idle) element.setAttribute("data-idle", "true");
251
+ else element.removeAttribute("data-idle");
252
+ this.#promptRegions.reflect(element, this.#prompted);
253
+ this.#idleRegions.reflect(element, this.#idle);
254
+ }
170
255
  };
171
256
 
172
257
  export { IdleController };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/utils/string_list.ts","../../src/controllers/idle_controller.ts"],"names":[],"mappings":"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","file":"idle_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 * Largest delay a timer can hold: the platform stores it in a 32-bit signed\n * integer, and anything larger overflows to `1`, so a delay meant to be far in\n * the future fires almost immediately. A declared delay above this bound names\n * no delay at all, and a controller reading one falls back to its default.\n */\nexport const MAX_TIMER_DELAY_MS = 2_147_483_647;\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 * JSON string-list parsing for the controllers that take a list as a Value.\n *\n * Stimulus offers an `Array` Value type, but its reader runs `JSON.parse` inside\n * the value observer — **before** the `<name>ValueChanged` callback — and\n * rethrows on malformed text. The throw propagates out of the observer that runs\n * during connection, so a single unparseable attribute stops the controller from\n * connecting at all: no lifecycle callback runs and the element is left inert\n * rather than degraded. A consumer cannot guard against it either, because the\n * callback that would hold the `try` never runs.\n *\n * Declaring the attribute as a `String` Value and parsing it here keeps a\n * malformed declaration local to the value it declares. The attribute text is\n * identical either way (`'[\"a\", \"b\"]'`), so markup does not change with the\n * declaration.\n */\n\n/**\n * Parses `raw` as a JSON array of strings.\n *\n * Returns `fallback` when the text is absent, unparseable, or not an array, so a\n * malformed declaration behaves like an omitted one. A parsed array keeps only\n * its string entries: every caller indexes or compares strings, and a stray\n * number would otherwise sit in the list without ever matching.\n *\n * An explicit `\"[]\"` is honoured as an empty list rather than falling back — it\n * is the only way to declare \"none\" against a non-empty default.\n *\n * @param raw - the attribute text, e.g. `'[\"password\"]'`.\n * @param fallback - the list to use when `raw` declares nothing usable.\n */\nexport function parseStringList(raw: string, fallback: readonly string[] = []): string[] {\n // The common case is an undeclared attribute, and `JSON.parse(\"\")` answers it\n // by throwing. Reaching the same fallback without building an exception keeps\n // the callers that ask per element off that path.\n const text = raw.trim();\n if (text.length === 0) return [...fallback];\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n return [...fallback];\n }\n\n if (!Array.isArray(parsed)) return [...fallback];\n return parsed.filter((entry): entry is string => typeof entry === \"string\");\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { parseStringList } from \"../utils/string_list\";\n\n/** Activity signals watched unless the consumer declares its own list. */\nconst DEFAULT_ACTIVITY_EVENTS = [\n \"mousemove\",\n \"mousedown\",\n \"keydown\",\n \"wheel\",\n \"touchstart\",\n \"scroll\",\n];\n\n/**\n * Headless inactivity / session-timeout detector: fires `idle` after `timeout` ms\n * with no user activity, an optional `prompt` `promptBefore` ms earlier, and\n * `active` when the user returns (no dedicated APG pattern; supports WCAG 2.2.1 by\n * giving the app a warning hook before a timeout).\n *\n * Markup contract (identifier: `stimeo--idle`):\n * <body data-controller=\"stimeo--idle\"\n * data-stimeo--idle-timeout-value=\"900000\"\n * data-stimeo--idle-prompt-before-value=\"60000\"\n * data-action=\"stimeo--idle:prompt->session#warn\n * stimeo--idle:idle->session#logout\"></body>\n *\n * Activity events (`events`, passive) are watched on `document` with capture so\n * non-bubbling ones like `scroll` are seen anywhere; returning to a hidden tab\n * (`visibilitychange` → visible) counts as activity too. While idle the controller\n * element carries `data-idle`.\n *\n * `prompt` dispatches `{ remaining }`; `idle` and `active` dispatch `{}`.\n *\n * @remarks\n * Behavior only — it renders no warning UI (pair with Dialog/Confirm) and never\n * touches the server session. Timers are owned by `SafeTimeout` and the\n * listeners are removed on `disconnect()` (Turbo navigation included). Place one on\n * the root element. Every visit reconnects the controller and re-arms the timeout\n * from that moment — `data-turbo-permanent` keeps the element, not the elapsed count.\n */\nexport class IdleController extends Controller<HTMLElement> {\n static override values = {\n timeout: { type: Number, default: 900_000 },\n promptBefore: { type: Number, default: 0 },\n // A JSON list read through `parseStringList` rather than Stimulus's `Array`\n // type: that reader throws out of the value observer before any callback\n // runs, so one malformed attribute would stop the detector connecting.\n events: { type: String, default: \"\" },\n };\n static events = [\"prompt\", \"idle\", \"active\"] as const;\n\n declare timeoutValue: number;\n declare promptBeforeValue: number;\n declare eventsValue: string;\n\n readonly #timeouts = new SafeTimeout();\n #idle = false;\n #prompted = false;\n /** Timestamp of the last activity; the timers self-reschedule against it. */\n #lastActivity = 0;\n /**\n * Activity types actually registered on `document`, so `disconnect()` unbinds the\n * same set even when `events` changed while connected (a Turbo morph can rewrite\n * the Value in place, and the removal must match the registration, not the Value).\n */\n #boundEvents: string[] = [];\n\n readonly #onActivity = (): void => {\n // Hot path (fires on every mousemove/scroll/wheel): just record the time. The\n // prompt/idle timers re-check this when they fire and reschedule if needed, so we\n // never tear down and re-create timers on each event (no per-event timer churn).\n this.#lastActivity = Date.now();\n if (this.#idle || this.#prompted) {\n // We were already idle/prompted, so the timers have lapsed — wake and re-arm.\n this.#idle = false;\n this.#prompted = false;\n this.element.removeAttribute(\"data-idle\");\n this.dispatch(\"active\", { detail: {} });\n this.#arm();\n }\n };\n\n readonly #onVisibility = (): void => {\n // Returning to the tab is activity; leaving it keeps the clock running (being\n // away counts toward the timeout).\n if (document.visibilityState === \"visible\") this.#onActivity();\n };\n\n override connect(): void {\n // Connecting always starts a fresh cycle (#arm() re-bases the clock), so an idle\n // marker that arrived with the DOM — a restored Turbo snapshot, a moved element —\n // describes a period this instance is not in. Drop it, or `data-idle` claims the\n // user is idle for the whole next active window with no `active` to correct it.\n this.#idle = false;\n this.#prompted = false;\n this.element.removeAttribute(\"data-idle\");\n this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);\n for (const type of this.#boundEvents) {\n document.addEventListener(type, this.#onActivity, { passive: true, capture: true });\n }\n document.addEventListener(\"visibilitychange\", this.#onVisibility);\n this.#arm();\n }\n\n override disconnect(): void {\n for (const type of this.#boundEvents) {\n document.removeEventListener(type, this.#onActivity, { capture: true });\n }\n this.#boundEvents = [];\n document.removeEventListener(\"visibilitychange\", this.#onVisibility);\n this.#timeouts.clearAll();\n }\n\n /** Schedules the prompt and idle checks from the current activity baseline. */\n #arm(): void {\n this.#timeouts.clearAll();\n this.#lastActivity = Date.now();\n const { promptBeforeValue: prompt, timeoutValue: timeout } = this;\n if (prompt > 0 && prompt < timeout) {\n this.#timeouts.set(() => this.#checkPrompt(), timeout - prompt);\n }\n this.#timeouts.set(() => this.#checkIdle(), timeout);\n }\n\n /**\n * Idle-timer callback: go idle only if there has genuinely been no activity for\n * `timeout`; otherwise reschedule for the remaining time. This lets activity events\n * stay O(1) (a timestamp write) while the deadline still tracks the last activity.\n */\n #checkIdle(): void {\n const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkIdle(), remaining);\n return;\n }\n this.#idle = true;\n this.#prompted = false;\n this.element.setAttribute(\"data-idle\", \"true\");\n this.dispatch(\"idle\", { detail: {} });\n }\n\n /** Prompt-timer callback: warn at `promptBefore` before the idle deadline. */\n #checkPrompt(): void {\n const remaining =\n this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkPrompt(), remaining);\n return;\n }\n this.#prompted = true;\n this.dispatch(\"prompt\", { detail: { remaining: this.promptBeforeValue } });\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/utils/state_regions.ts","../../src/utils/string_list.ts","../../src/controllers/idle_controller.ts"],"names":[],"mappings":"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","file":"idle_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 * Largest delay a timer can hold: the platform stores it in a 32-bit signed\n * integer, and anything larger overflows to `1`, so a delay meant to be far in\n * the future fires almost immediately. A declared delay above this bound names\n * no delay at all, and a controller reading one falls back to its default.\n */\nexport const MAX_TIMER_DELAY_MS = 2_147_483_647;\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","/** What a consumer declares once about the regions one boolean state reveals. */\nexport interface StateRegionsOptions {\n /** Returns the declared regions shown while the state holds. */\n readonly whenTrue: () => readonly HTMLElement[];\n /**\n * Returns the declared regions shown while the state does not hold. Omitted by a\n * state whose other side has nothing of its own to show.\n */\n readonly whenFalse?: () => readonly HTMLElement[];\n}\n\n/**\n * Owns `hidden` on the regions a boolean state reveals.\n *\n * A region is a **declared** target, so the consumer decides whether one exists at\n * all; where it does, which side is shown is a pure function of the state and the\n * controller writes it unconditionally. An authored `hidden` is therefore not read\n * back — the first reflection after a connection settles it, and a restored DOM\n * cannot leave a region contradicting the state it belongs to.\n *\n * The regions arrive as the caller's own target arrays, which Stimulus has already\n * scoped to this instance: a nested controller registered under the same identifier\n * keeps its own regions. `host` narrows that set further, to the regions inside one\n * trigger or item, so a widget with several of them reflects each independently.\n *\n * **Two sides are a pair.** Where the state has a `whenFalse` side and only one half\n * sits inside `host`, the half that is there is left in view: hiding it would take\n * the only label with it, and the static check names the missing one instead. What\n * the author wrote on a lone half is theirs and stays — only a half this instance\n * took out of view is given back, which is what a pair losing one side at runtime\n * leaves behind.\n *\n * `hidden` moves only when it changes, so a consumer watching its own subtree for\n * attribute mutations sees one record per transition rather than one per reflection.\n *\n * @example\n * ```ts\n * readonly #labels = new StateRegions({\n * whenTrue: () => this.expandedLabelTargets,\n * whenFalse: () => this.collapsedLabelTargets,\n * });\n *\n * #reflect(): void {\n * this.#labels.reflect(this.triggerTarget, this.#expanded);\n * }\n * ```\n */\nexport class StateRegions {\n readonly #whenTrue: () => readonly HTMLElement[];\n readonly #whenFalse: (() => readonly HTMLElement[]) | null;\n readonly #taken = new WeakSet<HTMLElement>();\n\n constructor(options: StateRegionsOptions) {\n this.#whenTrue = options.whenTrue;\n this.#whenFalse = options.whenFalse ?? null;\n }\n\n /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */\n reflect(host: Element, isTrue: boolean): void {\n const shown = this.#inside(host, this.#whenTrue());\n if (!this.#whenFalse) {\n for (const region of shown) this.#write(region, !isTrue);\n return;\n }\n const hiddenSide = this.#inside(host, this.#whenFalse());\n if (shown.length === 0 || hiddenSide.length === 0) {\n for (const region of shown) this.#give(region);\n for (const region of hiddenSide) this.#give(region);\n return;\n }\n for (const region of shown) this.#write(region, !isTrue);\n for (const region of hiddenSide) this.#write(region, isTrue);\n }\n\n /** Writes `hidden` where it moves, noting which regions it takes out of view. */\n #write(region: HTMLElement, hidden: boolean): void {\n if (hidden) this.#taken.add(region);\n else this.#taken.delete(region);\n if (region.hidden !== hidden) region.hidden = hidden;\n }\n\n /** Returns a region this instance hid; one it never hid keeps what it carries. */\n #give(region: HTMLElement): void {\n if (this.#taken.delete(region)) region.hidden = false;\n }\n\n /** The declared regions that sit within `host`, which may be the host itself. */\n #inside(host: Element, regions: readonly HTMLElement[]): HTMLElement[] {\n return regions.filter((region) => host.contains(region));\n }\n}\n","/**\n * JSON string-list parsing for the controllers that take a list as a Value.\n *\n * Stimulus offers an `Array` Value type, but its reader runs `JSON.parse` inside\n * the value observer — **before** the `<name>ValueChanged` callback — and\n * rethrows on malformed text. The throw propagates out of the observer that runs\n * during connection, so a single unparseable attribute stops the controller from\n * connecting at all: no lifecycle callback runs and the element is left inert\n * rather than degraded. A consumer cannot guard against it either, because the\n * callback that would hold the `try` never runs.\n *\n * Declaring the attribute as a `String` Value and parsing it here keeps a\n * malformed declaration local to the value it declares. The attribute text is\n * identical either way (`'[\"a\", \"b\"]'`), so markup does not change with the\n * declaration.\n */\n\n/**\n * Parses `raw` as a JSON array of strings.\n *\n * Returns `fallback` when the text is absent, unparseable, or not an array, so a\n * malformed declaration behaves like an omitted one. A parsed array keeps only\n * its string entries: every caller indexes or compares strings, and a stray\n * number would otherwise sit in the list without ever matching.\n *\n * An explicit `\"[]\"` is honoured as an empty list rather than falling back — it\n * is the only way to declare \"none\" against a non-empty default.\n *\n * @param raw - the attribute text, e.g. `'[\"password\"]'`.\n * @param fallback - the list to use when `raw` declares nothing usable.\n */\nexport function parseStringList(raw: string, fallback: readonly string[] = []): string[] {\n // The common case is an undeclared attribute, and `JSON.parse(\"\")` answers it\n // by throwing. Reaching the same fallback without building an exception keeps\n // the callers that ask per element off that path.\n const text = raw.trim();\n if (text.length === 0) return [...fallback];\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n return [...fallback];\n }\n\n if (!Array.isArray(parsed)) return [...fallback];\n return parsed.filter((entry): entry is string => typeof entry === \"string\");\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { StateRegions } from \"../utils/state_regions\";\nimport { parseStringList } from \"../utils/string_list\";\n\n/** Activity signals watched unless the consumer declares its own list. */\nconst DEFAULT_ACTIVITY_EVENTS = [\n \"mousemove\",\n \"mousedown\",\n \"keydown\",\n \"wheel\",\n \"touchstart\",\n \"scroll\",\n];\n\n/**\n * Headless inactivity / session-timeout detector: fires `idle` after `timeout` ms\n * with no user activity, an optional `prompt` `promptBefore` ms earlier, and\n * `active` when the user returns (no dedicated APG pattern; supports WCAG 2.2.1 by\n * giving the app a warning hook before a timeout).\n *\n * Markup contract (identifier: `stimeo--idle`):\n * <body data-controller=\"stimeo--idle\"\n * data-stimeo--idle-timeout-value=\"900000\"\n * data-stimeo--idle-prompt-before-value=\"60000\"\n * data-action=\"stimeo--idle:prompt->session#warn\n * stimeo--idle:idle->session#logout\">\n * <p data-stimeo--idle-target=\"prompt\" hidden>Your session expires in a minute.</p>\n * <p data-stimeo--idle-target=\"idle\" hidden>You have been signed out.</p>\n * </body>\n *\n * Activity events (`events`, passive) are watched on `document` with capture so\n * non-bubbling ones like `scroll` are seen anywhere; returning to a hidden tab\n * (`visibilitychange` → visible) counts as activity too. The controller element carries\n * `data-prompt` while the warning stands and `data-idle` once the timeout is reached,\n * and the optional `prompt` / `idle` targets are the regions those phases reveal:\n * declared where the page has something to show, shown while their phase holds and\n * hidden otherwise, whatever visibility the markup was authored with.\n *\n * `prompt` dispatches `{ remaining }`; `idle` and `active` dispatch `{}`.\n *\n * @remarks\n * Behavior only — it renders no warning UI (pair with Dialog/Confirm) and never\n * touches the server session. Timers are owned by `SafeTimeout` and the\n * listeners are removed on `disconnect()` (Turbo navigation included). Place one on\n * the root element. Every visit reconnects the controller and re-arms the timeout\n * from that moment — `data-turbo-permanent` keeps the element, not the elapsed count.\n */\nexport class IdleController extends Controller<HTMLElement> {\n static override values = {\n timeout: { type: Number, default: 900_000 },\n promptBefore: { type: Number, default: 0 },\n // A JSON list read through `parseStringList` rather than Stimulus's `Array`\n // type: that reader throws out of the value observer before any callback\n // runs, so one malformed attribute would stop the detector connecting.\n events: { type: String, default: \"\" },\n };\n static override targets = [\"prompt\", \"idle\"];\n static events = [\"prompt\", \"idle\", \"active\"] as const;\n\n declare timeoutValue: number;\n declare promptBeforeValue: number;\n declare eventsValue: string;\n declare readonly promptTargets: HTMLElement[];\n declare readonly idleTargets: HTMLElement[];\n\n /** The regions of the warning window, revealed alongside `data-prompt`. */\n readonly #promptRegions = new StateRegions({ whenTrue: () => this.promptTargets });\n /** The regions of the elapsed timeout, revealed alongside `data-idle`. */\n readonly #idleRegions = new StateRegions({ whenTrue: () => this.idleTargets });\n\n readonly #timeouts = new SafeTimeout();\n #idle = false;\n #prompted = false;\n /** Timestamp of the last activity; the timers self-reschedule against it. */\n #lastActivity = 0;\n /**\n * Activity types actually registered on `document`, so `disconnect()` unbinds the\n * same set even when `events` changed while connected (a Turbo morph can rewrite\n * the Value in place, and the removal must match the registration, not the Value).\n */\n #boundEvents: string[] = [];\n\n readonly #onActivity = (): void => {\n // Hot path (fires on every mousemove/scroll/wheel): just record the time. The\n // prompt/idle timers re-check this when they fire and reschedule if needed, so we\n // never tear down and re-create timers on each event (no per-event timer churn).\n this.#lastActivity = Date.now();\n if (this.#idle || this.#prompted) {\n // We were already idle/prompted, so the timers have lapsed — wake and re-arm.\n this.#idle = false;\n this.#prompted = false;\n this.#reflect();\n this.dispatch(\"active\", { detail: {} });\n this.#arm();\n }\n };\n\n readonly #onVisibility = (): void => {\n // Returning to the tab is activity; leaving it keeps the clock running (being\n // away counts toward the timeout).\n if (document.visibilityState === \"visible\") this.#onActivity();\n };\n\n override connect(): void {\n // Connecting always starts a fresh cycle (#arm() re-bases the clock), so the phase\n // that arrived with the DOM — a restored Turbo snapshot, a moved element — describes\n // a period this instance is not in. Reflecting the fresh cycle drops it; leaving it\n // would claim the user is idle for the whole next active window with no `active` to\n // correct it. Normalizing is not a transition, so nothing is dispatched here.\n this.#idle = false;\n this.#prompted = false;\n this.#reflect();\n this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);\n for (const type of this.#boundEvents) {\n document.addEventListener(type, this.#onActivity, { passive: true, capture: true });\n }\n document.addEventListener(\"visibilitychange\", this.#onVisibility);\n this.#arm();\n }\n\n override disconnect(): void {\n for (const type of this.#boundEvents) {\n document.removeEventListener(type, this.#onActivity, { capture: true });\n }\n this.#boundEvents = [];\n document.removeEventListener(\"visibilitychange\", this.#onVisibility);\n this.#timeouts.clearAll();\n }\n\n /**\n * Schedules the prompt and idle checks from the current activity baseline.\n *\n * @stimeoRuntimeOnly `timeout` and `promptBefore` time the checks this call arms.\n */\n #arm(): void {\n this.#timeouts.clearAll();\n this.#lastActivity = Date.now();\n const { promptBeforeValue: prompt, timeoutValue: timeout } = this;\n if (prompt > 0 && prompt < timeout) {\n this.#timeouts.set(() => this.#checkPrompt(), timeout - prompt);\n }\n this.#timeouts.set(() => this.#checkIdle(), timeout);\n }\n\n /**\n * Idle-timer callback: go idle only if there has genuinely been no activity for\n * `timeout`; otherwise reschedule for the remaining time. This lets activity events\n * stay O(1) (a timestamp write) while the deadline still tracks the last activity.\n *\n * @stimeoRuntimeOnly `timeout` sets the deadline this one check compares against; the phase it\n * shows follows the elapsed time.\n */\n #checkIdle(): void {\n const remaining = this.timeoutValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkIdle(), remaining);\n return;\n }\n this.#idle = true;\n this.#prompted = false;\n this.#reflect();\n this.dispatch(\"idle\", { detail: {} });\n }\n\n /**\n * Prompt-timer callback: warn at `promptBefore` before the idle deadline. A window\n * narrowed while the cycle runs can push the warning onto the deadline or past it,\n * and the phase it belongs to is the one before idle, so a lapsed cycle keeps the\n * phase it reached. The next window opens with the cycle that activity arms.\n *\n * @stimeoRuntimeOnly `timeout` and `promptBefore` set the deadlines this one check compares\n * against; the phase it shows follows the elapsed time.\n */\n #checkPrompt(): void {\n if (this.#idle) return;\n const remaining =\n this.timeoutValue - this.promptBeforeValue - (Date.now() - this.#lastActivity);\n if (remaining > 0) {\n this.#timeouts.set(() => this.#checkPrompt(), remaining);\n return;\n }\n this.#prompted = true;\n this.#reflect();\n this.dispatch(\"prompt\", { detail: { remaining: this.promptBeforeValue } });\n }\n\n /** Applies the current phase to a warning region inserted or replaced at runtime. */\n promptTargetConnected(): void {\n this.#promptRegions.reflect(this.element, this.#prompted);\n }\n\n /** Applies the current phase to an idle region inserted or replaced at runtime. */\n idleTargetConnected(): void {\n this.#idleRegions.reflect(this.element, this.#idle);\n }\n\n /**\n * Writes the phase to the element and to the declared regions. Both hooks and both\n * regions are a pure function of the two flags, so every place that moves a flag ends\n * here and the markup can never disagree with the phase the detector is in.\n */\n #reflect(): void {\n const { element } = this;\n if (this.#prompted) element.setAttribute(\"data-prompt\", \"true\");\n else element.removeAttribute(\"data-prompt\");\n if (this.#idle) element.setAttribute(\"data-idle\", \"true\");\n else element.removeAttribute(\"data-idle\");\n this.#promptRegions.reflect(element, this.#prompted);\n this.#idleRegions.reflect(element, this.#idle);\n }\n}\n"]}
@@ -24,7 +24,7 @@ declare function applyMask(value: string, pattern: string, tokens: ReadonlyMap<s
24
24
  * Headless **input mask** — formats a field in place against a fixed pattern
25
25
  * (`9`=digit, `a`=letter, `*`=alphanumeric, others literal), preserving the caret,
26
26
  * rejecting invalid characters, and syncing the raw value to a hidden field. No
27
- * dedicated APG pattern; Currency Input owns money-specific formatting.
27
+ * dedicated APG pattern; `stimeo--currency-input` owns money-specific formatting.
28
28
  *
29
29
  * Markup contract (identifier: `stimeo--input-mask`, on the `<input>`):
30
30
  * <input type="text" inputmode="numeric"
@@ -53,7 +53,10 @@ declare function applyMask(value: string, pattern: string, tokens: ReadonlyMap<s
53
53
  * exactly where it was. Backspace and Delete always progress: when the keystroke
54
54
  * only removed literals the mask re-inserts, the significant character it aimed at
55
55
  * goes instead. Events fired mid-IME-composition are ignored; the confirmed text is
56
- * formatted once on `compositionend`.
56
+ * formatted once on `compositionend`. A `pattern` / `tokens` / `unmaskToHidden`
57
+ * change that arrives mid-composition leaves the uncommitted text alone and applies
58
+ * with that same format: as the user's `change` when the composition committed
59
+ * text, as `reconcile` when it ended on the text it began with.
57
60
  *
58
61
  * It reflects `data-mask-complete` / `data-mask-empty`, dispatches
59
62
  * `stimeo--input-mask:change` only when a user edit moves the committed value, and