stimeo-ui 0.16.0 → 0.17.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 (328) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +8 -1
  3. package/dist/cable/index.d.ts +79 -23
  4. package/dist/cable/index.js +363 -436
  5. package/dist/cable/index.js.map +1 -1
  6. package/dist/controllers/accordion_controller.d.ts +4 -2
  7. package/dist/controllers/accordion_controller.js +166 -55
  8. package/dist/controllers/accordion_controller.js.map +1 -1
  9. package/dist/controllers/alert_dialog_controller.d.ts +12 -2
  10. package/dist/controllers/alert_dialog_controller.js +862 -221
  11. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  12. package/dist/controllers/announcer_controller.d.ts +35 -5
  13. package/dist/controllers/announcer_controller.js +135 -198
  14. package/dist/controllers/announcer_controller.js.map +1 -1
  15. package/dist/controllers/aspect_ratio_controller.js +0 -10
  16. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  17. package/dist/controllers/auto_submit_controller.d.ts +30 -6
  18. package/dist/controllers/auto_submit_controller.js +271 -127
  19. package/dist/controllers/auto_submit_controller.js.map +1 -1
  20. package/dist/controllers/avatar_controller.js +171 -105
  21. package/dist/controllers/avatar_controller.js.map +1 -1
  22. package/dist/controllers/breadcrumb_controller.js +0 -130
  23. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  24. package/dist/controllers/bulk_select_controller.d.ts +18 -0
  25. package/dist/controllers/bulk_select_controller.js +262 -52
  26. package/dist/controllers/bulk_select_controller.js.map +1 -1
  27. package/dist/controllers/calendar_controller.d.ts +24 -5
  28. package/dist/controllers/calendar_controller.js +175 -209
  29. package/dist/controllers/calendar_controller.js.map +1 -1
  30. package/dist/controllers/carousel_controller.d.ts +34 -7
  31. package/dist/controllers/carousel_controller.js +461 -399
  32. package/dist/controllers/carousel_controller.js.map +1 -1
  33. package/dist/controllers/character_counter_controller.d.ts +17 -1
  34. package/dist/controllers/character_counter_controller.js +246 -156
  35. package/dist/controllers/character_counter_controller.js.map +1 -1
  36. package/dist/controllers/checkbox_controller.d.ts +7 -2
  37. package/dist/controllers/checkbox_controller.js +32 -74
  38. package/dist/controllers/checkbox_controller.js.map +1 -1
  39. package/dist/controllers/clipboard_controller.d.ts +16 -5
  40. package/dist/controllers/clipboard_controller.js +168 -147
  41. package/dist/controllers/clipboard_controller.js.map +1 -1
  42. package/dist/controllers/collapsible_controller.d.ts +16 -4
  43. package/dist/controllers/collapsible_controller.js +301 -120
  44. package/dist/controllers/collapsible_controller.js.map +1 -1
  45. package/dist/controllers/color_picker_controller.d.ts +18 -6
  46. package/dist/controllers/color_picker_controller.js +149 -139
  47. package/dist/controllers/color_picker_controller.js.map +1 -1
  48. package/dist/controllers/combobox_controller.d.ts +23 -5
  49. package/dist/controllers/combobox_controller.js +360 -133
  50. package/dist/controllers/combobox_controller.js.map +1 -1
  51. package/dist/controllers/command_palette_controller.d.ts +45 -14
  52. package/dist/controllers/command_palette_controller.js +970 -358
  53. package/dist/controllers/command_palette_controller.js.map +1 -1
  54. package/dist/controllers/conditional_fields_controller.d.ts +15 -2
  55. package/dist/controllers/conditional_fields_controller.js +166 -124
  56. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  57. package/dist/controllers/confirm_controller.d.ts +49 -12
  58. package/dist/controllers/confirm_controller.js +939 -248
  59. package/dist/controllers/confirm_controller.js.map +1 -1
  60. package/dist/controllers/context_menu_controller.d.ts +31 -4
  61. package/dist/controllers/context_menu_controller.js +256 -128
  62. package/dist/controllers/context_menu_controller.js.map +1 -1
  63. package/dist/controllers/count_up_controller.d.ts +13 -1
  64. package/dist/controllers/count_up_controller.js +73 -21
  65. package/dist/controllers/count_up_controller.js.map +1 -1
  66. package/dist/controllers/countdown_controller.d.ts +45 -1
  67. package/dist/controllers/countdown_controller.js +183 -148
  68. package/dist/controllers/countdown_controller.js.map +1 -1
  69. package/dist/controllers/currency_input_controller.d.ts +21 -2
  70. package/dist/controllers/currency_input_controller.js +134 -158
  71. package/dist/controllers/currency_input_controller.js.map +1 -1
  72. package/dist/controllers/data_grid_controller.d.ts +15 -4
  73. package/dist/controllers/data_grid_controller.js +114 -186
  74. package/dist/controllers/data_grid_controller.js.map +1 -1
  75. package/dist/controllers/date_range_picker_controller.d.ts +30 -5
  76. package/dist/controllers/date_range_picker_controller.js +288 -206
  77. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  78. package/dist/controllers/dialog_controller.d.ts +13 -1
  79. package/dist/controllers/dialog_controller.js +858 -223
  80. package/dist/controllers/dialog_controller.js.map +1 -1
  81. package/dist/controllers/direct_upload_controller.d.ts +24 -7
  82. package/dist/controllers/direct_upload_controller.js +73 -153
  83. package/dist/controllers/direct_upload_controller.js.map +1 -1
  84. package/dist/controllers/dirty_form_controller.js +14 -48
  85. package/dist/controllers/dirty_form_controller.js.map +1 -1
  86. package/dist/controllers/dismissible_controller.d.ts +19 -0
  87. package/dist/controllers/dismissible_controller.js +175 -21
  88. package/dist/controllers/dismissible_controller.js.map +1 -1
  89. package/dist/controllers/drawer_controller.d.ts +39 -11
  90. package/dist/controllers/drawer_controller.js +933 -341
  91. package/dist/controllers/drawer_controller.js.map +1 -1
  92. package/dist/controllers/dropdown_controller.d.ts +31 -1
  93. package/dist/controllers/dropdown_controller.js +312 -82
  94. package/dist/controllers/dropdown_controller.js.map +1 -1
  95. package/dist/controllers/editable_controller.d.ts +12 -1
  96. package/dist/controllers/editable_controller.js +158 -68
  97. package/dist/controllers/editable_controller.js.map +1 -1
  98. package/dist/controllers/empty_state_controller.js +67 -53
  99. package/dist/controllers/empty_state_controller.js.map +1 -1
  100. package/dist/controllers/file_dropzone_controller.d.ts +60 -18
  101. package/dist/controllers/file_dropzone_controller.js +311 -207
  102. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  103. package/dist/controllers/filter_controller.js +67 -39
  104. package/dist/controllers/filter_controller.js.map +1 -1
  105. package/dist/controllers/flash_controller.d.ts +31 -8
  106. package/dist/controllers/flash_controller.js +172 -314
  107. package/dist/controllers/flash_controller.js.map +1 -1
  108. package/dist/controllers/focus_controller.d.ts +12 -3
  109. package/dist/controllers/focus_controller.js +654 -240
  110. package/dist/controllers/focus_controller.js.map +1 -1
  111. package/dist/controllers/form_field_controller.js +147 -132
  112. package/dist/controllers/form_field_controller.js.map +1 -1
  113. package/dist/controllers/form_validation_controller.js +13 -86
  114. package/dist/controllers/form_validation_controller.js.map +1 -1
  115. package/dist/controllers/frame_loading_controller.d.ts +41 -9
  116. package/dist/controllers/frame_loading_controller.js +264 -213
  117. package/dist/controllers/frame_loading_controller.js.map +1 -1
  118. package/dist/controllers/highlight_controller.d.ts +22 -2
  119. package/dist/controllers/highlight_controller.js +107 -75
  120. package/dist/controllers/highlight_controller.js.map +1 -1
  121. package/dist/controllers/hover_card_controller.d.ts +56 -3
  122. package/dist/controllers/hover_card_controller.js +309 -118
  123. package/dist/controllers/hover_card_controller.js.map +1 -1
  124. package/dist/controllers/idle_controller.d.ts +42 -1
  125. package/dist/controllers/idle_controller.js +267 -101
  126. package/dist/controllers/idle_controller.js.map +1 -1
  127. package/dist/controllers/input_mask_controller.js +71 -72
  128. package/dist/controllers/input_mask_controller.js.map +1 -1
  129. package/dist/controllers/intersection_controller.d.ts +32 -13
  130. package/dist/controllers/intersection_controller.js +147 -106
  131. package/dist/controllers/intersection_controller.js.map +1 -1
  132. package/dist/controllers/lazy_frame_controller.d.ts +16 -0
  133. package/dist/controllers/lazy_frame_controller.js +9 -65
  134. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  135. package/dist/controllers/listbox_controller.d.ts +34 -2
  136. package/dist/controllers/listbox_controller.js +306 -204
  137. package/dist/controllers/listbox_controller.js.map +1 -1
  138. package/dist/controllers/local_time_controller.d.ts +1 -1
  139. package/dist/controllers/local_time_controller.js +54 -64
  140. package/dist/controllers/local_time_controller.js.map +1 -1
  141. package/dist/controllers/masonry_controller.d.ts +11 -3
  142. package/dist/controllers/masonry_controller.js +70 -93
  143. package/dist/controllers/masonry_controller.js.map +1 -1
  144. package/dist/controllers/menu_controller.d.ts +32 -7
  145. package/dist/controllers/menu_controller.js +214 -156
  146. package/dist/controllers/menu_controller.js.map +1 -1
  147. package/dist/controllers/menubar_controller.d.ts +4 -4
  148. package/dist/controllers/menubar_controller.js +170 -329
  149. package/dist/controllers/menubar_controller.js.map +1 -1
  150. package/dist/controllers/meter_controller.d.ts +20 -0
  151. package/dist/controllers/meter_controller.js +151 -67
  152. package/dist/controllers/meter_controller.js.map +1 -1
  153. package/dist/controllers/multi_select_controller.d.ts +32 -4
  154. package/dist/controllers/multi_select_controller.js +593 -358
  155. package/dist/controllers/multi_select_controller.js.map +1 -1
  156. package/dist/controllers/navigation_menu_controller.d.ts +8 -1
  157. package/dist/controllers/navigation_menu_controller.js +124 -232
  158. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  159. package/dist/controllers/nested_form_controller.d.ts +10 -0
  160. package/dist/controllers/nested_form_controller.js +352 -182
  161. package/dist/controllers/nested_form_controller.js.map +1 -1
  162. package/dist/controllers/network_status_controller.d.ts +23 -0
  163. package/dist/controllers/network_status_controller.js +242 -54
  164. package/dist/controllers/network_status_controller.js.map +1 -1
  165. package/dist/controllers/number_input_controller.d.ts +26 -2
  166. package/dist/controllers/number_input_controller.js +320 -263
  167. package/dist/controllers/number_input_controller.js.map +1 -1
  168. package/dist/controllers/optimistic_controller.js +16 -76
  169. package/dist/controllers/optimistic_controller.js.map +1 -1
  170. package/dist/controllers/otp_controller.d.ts +20 -4
  171. package/dist/controllers/otp_controller.js +198 -193
  172. package/dist/controllers/otp_controller.js.map +1 -1
  173. package/dist/controllers/overflow_indicator_controller.d.ts +5 -0
  174. package/dist/controllers/overflow_indicator_controller.js +116 -113
  175. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  176. package/dist/controllers/overflow_menu_controller.d.ts +48 -11
  177. package/dist/controllers/overflow_menu_controller.js +343 -283
  178. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  179. package/dist/controllers/pagination_controller.d.ts +35 -6
  180. package/dist/controllers/pagination_controller.js +295 -119
  181. package/dist/controllers/pagination_controller.js.map +1 -1
  182. package/dist/controllers/password_reveal_controller.d.ts +45 -8
  183. package/dist/controllers/password_reveal_controller.js +389 -121
  184. package/dist/controllers/password_reveal_controller.js.map +1 -1
  185. package/dist/controllers/password_strength_controller.d.ts +14 -5
  186. package/dist/controllers/password_strength_controller.js +99 -153
  187. package/dist/controllers/password_strength_controller.js.map +1 -1
  188. package/dist/controllers/persist_controller.d.ts +11 -4
  189. package/dist/controllers/persist_controller.js +237 -103
  190. package/dist/controllers/persist_controller.js.map +1 -1
  191. package/dist/controllers/pointer_drag_controller.d.ts +19 -7
  192. package/dist/controllers/pointer_drag_controller.js +185 -165
  193. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  194. package/dist/controllers/popover_controller.d.ts +38 -2
  195. package/dist/controllers/popover_controller.js +236 -97
  196. package/dist/controllers/popover_controller.js.map +1 -1
  197. package/dist/controllers/portal_controller.d.ts +44 -4
  198. package/dist/controllers/portal_controller.js +209 -76
  199. package/dist/controllers/portal_controller.js.map +1 -1
  200. package/dist/controllers/preview_guard_controller.d.ts +10 -4
  201. package/dist/controllers/preview_guard_controller.js +232 -116
  202. package/dist/controllers/preview_guard_controller.js.map +1 -1
  203. package/dist/controllers/progress_controller.d.ts +11 -0
  204. package/dist/controllers/progress_controller.js +125 -56
  205. package/dist/controllers/progress_controller.js.map +1 -1
  206. package/dist/controllers/radio_group_controller.d.ts +14 -5
  207. package/dist/controllers/radio_group_controller.js +117 -137
  208. package/dist/controllers/radio_group_controller.js.map +1 -1
  209. package/dist/controllers/range_slider_controller.d.ts +43 -3
  210. package/dist/controllers/range_slider_controller.js +192 -105
  211. package/dist/controllers/range_slider_controller.js.map +1 -1
  212. package/dist/controllers/rating_controller.d.ts +20 -4
  213. package/dist/controllers/rating_controller.js +418 -207
  214. package/dist/controllers/rating_controller.js.map +1 -1
  215. package/dist/controllers/read_more_controller.d.ts +2 -0
  216. package/dist/controllers/read_more_controller.js +134 -63
  217. package/dist/controllers/read_more_controller.js.map +1 -1
  218. package/dist/controllers/reading_progress_controller.d.ts +1 -1
  219. package/dist/controllers/reading_progress_controller.js +147 -135
  220. package/dist/controllers/reading_progress_controller.js.map +1 -1
  221. package/dist/controllers/relative_time_controller.d.ts +18 -4
  222. package/dist/controllers/relative_time_controller.js +145 -110
  223. package/dist/controllers/relative_time_controller.js.map +1 -1
  224. package/dist/controllers/reset_on_restore_controller.d.ts +85 -0
  225. package/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  226. package/dist/controllers/reset_on_restore_controller.js.map +1 -0
  227. package/dist/controllers/resizable_controller.d.ts +28 -2
  228. package/dist/controllers/resizable_controller.js +255 -139
  229. package/dist/controllers/resizable_controller.js.map +1 -1
  230. package/dist/controllers/roving_controller.js +39 -84
  231. package/dist/controllers/roving_controller.js.map +1 -1
  232. package/dist/controllers/scroll_area_controller.d.ts +7 -3
  233. package/dist/controllers/scroll_area_controller.js +428 -225
  234. package/dist/controllers/scroll_area_controller.js.map +1 -1
  235. package/dist/controllers/scroll_restore_controller.js +0 -53
  236. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  237. package/dist/controllers/scroll_visibility_controller.d.ts +28 -12
  238. package/dist/controllers/scroll_visibility_controller.js +395 -145
  239. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  240. package/dist/controllers/scrollspy_controller.d.ts +6 -1
  241. package/dist/controllers/scrollspy_controller.js +181 -242
  242. package/dist/controllers/scrollspy_controller.js.map +1 -1
  243. package/dist/controllers/separator_controller.d.ts +19 -4
  244. package/dist/controllers/separator_controller.js +267 -140
  245. package/dist/controllers/separator_controller.js.map +1 -1
  246. package/dist/controllers/sidebar_controller.d.ts +64 -7
  247. package/dist/controllers/sidebar_controller.js +1023 -386
  248. package/dist/controllers/sidebar_controller.js.map +1 -1
  249. package/dist/controllers/skeleton_controller.d.ts +23 -0
  250. package/dist/controllers/skeleton_controller.js +248 -97
  251. package/dist/controllers/skeleton_controller.js.map +1 -1
  252. package/dist/controllers/slider_controller.d.ts +27 -1
  253. package/dist/controllers/slider_controller.js +159 -79
  254. package/dist/controllers/slider_controller.js.map +1 -1
  255. package/dist/controllers/smart_sticky_header_controller.d.ts +21 -3
  256. package/dist/controllers/smart_sticky_header_controller.js +162 -63
  257. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  258. package/dist/controllers/sortable_controller.js +0 -112
  259. package/dist/controllers/sortable_controller.js.map +1 -1
  260. package/dist/controllers/spinner_controller.d.ts +39 -4
  261. package/dist/controllers/spinner_controller.js +277 -169
  262. package/dist/controllers/spinner_controller.js.map +1 -1
  263. package/dist/controllers/step_indicator_controller.d.ts +5 -0
  264. package/dist/controllers/step_indicator_controller.js +99 -46
  265. package/dist/controllers/step_indicator_controller.js.map +1 -1
  266. package/dist/controllers/stepper_controller.d.ts +12 -3
  267. package/dist/controllers/stepper_controller.js +114 -73
  268. package/dist/controllers/stepper_controller.js.map +1 -1
  269. package/dist/controllers/stick_to_bottom_controller.d.ts +6 -0
  270. package/dist/controllers/stick_to_bottom_controller.js +262 -138
  271. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  272. package/dist/controllers/sticky_observer_controller.d.ts +11 -0
  273. package/dist/controllers/sticky_observer_controller.js +214 -49
  274. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  275. package/dist/controllers/submit_once_controller.d.ts +23 -8
  276. package/dist/controllers/submit_once_controller.js +294 -205
  277. package/dist/controllers/submit_once_controller.js.map +1 -1
  278. package/dist/controllers/switch_controller.d.ts +12 -1
  279. package/dist/controllers/switch_controller.js +33 -60
  280. package/dist/controllers/switch_controller.js.map +1 -1
  281. package/dist/controllers/tabs_controller.d.ts +2 -2
  282. package/dist/controllers/tabs_controller.js +35 -29
  283. package/dist/controllers/tabs_controller.js.map +1 -1
  284. package/dist/controllers/tags_input_controller.d.ts +12 -0
  285. package/dist/controllers/tags_input_controller.js +136 -145
  286. package/dist/controllers/tags_input_controller.js.map +1 -1
  287. package/dist/controllers/textarea_autosize_controller.d.ts +10 -0
  288. package/dist/controllers/textarea_autosize_controller.js +71 -58
  289. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  290. package/dist/controllers/theme_controller.d.ts +21 -9
  291. package/dist/controllers/theme_controller.js +130 -112
  292. package/dist/controllers/theme_controller.js.map +1 -1
  293. package/dist/controllers/time_picker_controller.d.ts +18 -1
  294. package/dist/controllers/time_picker_controller.js +146 -80
  295. package/dist/controllers/time_picker_controller.js.map +1 -1
  296. package/dist/controllers/toast_controller.d.ts +36 -11
  297. package/dist/controllers/toast_controller.js +186 -297
  298. package/dist/controllers/toast_controller.js.map +1 -1
  299. package/dist/controllers/toggle_group_controller.d.ts +12 -3
  300. package/dist/controllers/toggle_group_controller.js +95 -136
  301. package/dist/controllers/toggle_group_controller.js.map +1 -1
  302. package/dist/controllers/toolbar_controller.js +68 -98
  303. package/dist/controllers/toolbar_controller.js.map +1 -1
  304. package/dist/controllers/tooltip_controller.d.ts +45 -3
  305. package/dist/controllers/tooltip_controller.js +158 -124
  306. package/dist/controllers/tooltip_controller.js.map +1 -1
  307. package/dist/controllers/transition_controller.d.ts +22 -4
  308. package/dist/controllers/transition_controller.js +123 -119
  309. package/dist/controllers/transition_controller.js.map +1 -1
  310. package/dist/controllers/tree_view_controller.d.ts +21 -11
  311. package/dist/controllers/tree_view_controller.js +150 -345
  312. package/dist/controllers/tree_view_controller.js.map +1 -1
  313. package/dist/index.d.ts +8 -5
  314. package/dist/index.js +6370 -9346
  315. package/dist/index.js.map +1 -1
  316. package/dist/inspector/cli.d.ts +23 -3
  317. package/dist/inspector/cli.js +92 -37
  318. package/dist/inspector/cli.js.map +1 -1
  319. package/dist/inspector/cli_bin.js +92 -56
  320. package/dist/inspector/cli_bin.js.map +1 -1
  321. package/dist/inspector/examples.json +8 -8
  322. package/dist/inspector/manifest.json +787 -100
  323. package/dist/positioning/index.d.ts +8 -0
  324. package/dist/positioning/index.js +148 -47
  325. package/dist/positioning/index.js.map +1 -1
  326. package/package.json +6 -6
  327. package/dist/controllers/reset_before_cache_controller.d.ts +0 -76
  328. package/dist/controllers/reset_before_cache_controller.js.map +0 -1
@@ -1,99 +1,63 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/optimistic_controller.ts
4
-
5
- // src/utils/detach_gate.ts
6
3
  var DetachGate = class _DetachGate {
7
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
8
4
  #pending = false;
9
- /**
10
- * True while a probe is queued — the last disconnect was ambiguous and no
11
- * reconnect has cancelled it yet. Read it from `connect()` to tell the
12
- * reconnect half of an in-page move from a first connect: a controller whose
13
- * initialisation restarts a measurement (a min-duration floor, an elapsed
14
- * counter) must skip it for the move, where nothing actually restarted.
15
- */
16
5
  get pending() {
17
6
  return this.#pending;
18
7
  }
19
- /**
20
- * True when the disconnect is definitely a real detach — the element left
21
- * the document, or `data-controller` no longer lists the identifier. False
22
- * means ambiguous (in-page move or observed-root exit), NOT "alive".
23
- */
24
8
  static isDetached(host) {
25
- if (!host.element.isConnected) return true;
26
- const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
27
- return !tokens.includes(host.identifier);
28
- }
29
- /**
30
- * Call from `disconnect()`: runs `teardown` synchronously on a definite
31
- * detach (fast path), otherwise defers it one microtask — a reconnect
32
- * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
33
- * One microtask is the whole probe window: Stimulus reconnects a moved
34
- * element within the same mutation batch, before the checkpoint drains.
35
- */
9
+ return !host.element.isConnected || !_DetachGate.#listed(host);
10
+ }
36
11
  disconnected(host, teardown) {
37
- if (_DetachGate.isDetached(host)) {
12
+ const carried = _DetachGate.#carried(host);
13
+ if (!carried && _DetachGate.isDetached(host)) {
38
14
  this.#pending = false;
39
15
  teardown();
40
16
  return;
41
17
  }
42
18
  this.#pending = true;
43
- queueMicrotask(() => {
19
+ const probe = () => {
44
20
  if (!this.#pending) return;
45
21
  this.#pending = false;
46
22
  teardown();
47
- });
23
+ };
24
+ if (carried) setTimeout(probe, 0);
25
+ else queueMicrotask(probe);
26
+ }
27
+ static #carried(host) {
28
+ const element = host.element;
29
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
30
+ }
31
+ static #listed(host) {
32
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
33
+ return tokens.includes(host.identifier);
48
34
  }
49
- /**
50
- * Disarms a pending probe. Call from `connect()` (the reconnect that proves
51
- * an in-page move) and from the head of any teardown path not routed through
52
- * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
53
- * never run the teardown a second time.
54
- */
55
35
  cancel() {
56
36
  this.#pending = false;
57
37
  }
58
38
  };
59
39
 
60
- // src/utils/listener_set.ts
61
40
  var ListenerSet = class {
62
- /** The generation every `add` joins until the next `dispose()`. */
63
41
  #abort = new AbortController();
64
- /**
65
- * Attaches `handler` to the open generation, exactly as the caller spelled it.
66
- *
67
- * The set supplies the signal, so `options` carries everything else the DOM
68
- * accepts — `capture` included, which has to match at release time and no
69
- * longer has a second place to drift from.
70
- */
71
42
  add(target, type, handler, options) {
72
43
  target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
73
44
  }
74
- /**
75
- * Releases every listener of the open generation, synchronously, and opens the
76
- * next one. Idempotent, and safe before anything has been added.
77
- */
78
45
  dispose() {
79
46
  this.#abort.abort();
80
47
  this.#abort = new AbortController();
81
48
  }
82
49
  };
83
50
 
84
- // src/utils/transient_hooks.ts
85
51
  var TransientHooks = class {
86
52
  #attributes;
87
53
  constructor(options) {
88
54
  this.#attributes = options.attributes;
89
55
  }
90
- /** Drops the declared hooks from `element`; what it carries besides stays. */
91
56
  reset(element) {
92
57
  for (const attribute of this.#attributes) element.removeAttribute(attribute);
93
58
  }
94
59
  };
95
60
 
96
- // src/controllers/optimistic_controller.ts
97
61
  var TRANSIENT = new TransientHooks({ attributes: ["data-optimistic"] });
98
62
  var HIDDEN_MARKER = "data-optimistic-toggled";
99
63
  var BUSY_MARKER = "data-optimistic-busy";
@@ -107,11 +71,9 @@ function submissionOf(event) {
107
71
  var OptimisticController = class extends Controller {
108
72
  static targets = ["show", "hide"];
109
73
  static events = ["commit", "rollback"];
110
- /** The submission that owns the applied optimistic state, if any. */
111
74
  #pending = null;
112
75
  #gate = new DetachGate();
113
76
  #listeners = new ListenerSet();
114
- /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
115
77
  #connected = false;
116
78
  connect() {
117
79
  const restored = !this.#connected && !this.#gate.pending;
@@ -154,12 +116,10 @@ var OptimisticController = class extends Controller {
154
116
  this.dispatch("rollback");
155
117
  }
156
118
  };
157
- /** Hides or reveals a target, recording the authored `hidden` on first write. */
158
119
  #setHidden(target, hidden) {
159
120
  if (target.hasAttribute("hidden") === hidden) return;
160
121
  this.#write(target, "hidden", hidden ? "" : null, HIDDEN_MARKER);
161
122
  }
162
- /** Writes an attribute, recording what it displaced under `marker` once. */
163
123
  #write(element, attribute, value, marker) {
164
124
  if (!element.hasAttribute(marker)) {
165
125
  const authored = element.getAttribute(attribute);
@@ -171,16 +131,6 @@ var OptimisticController = class extends Controller {
171
131
  if (value === null) element.removeAttribute(attribute);
172
132
  else element.setAttribute(attribute, value);
173
133
  }
174
- /**
175
- * Puts back the value `marker` recorded and drops the record.
176
- *
177
- * `written` is what this controller last put in the attribute. Anything else
178
- * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and
179
- * clears it before the terminal arrives, and a consumer may have moved a face
180
- * mid-flight — so the record is dropped without touching the attribute. A
181
- * marker this build did not write names no authored value, so the attribute
182
- * goes back to being absent.
183
- */
184
134
  #restore(element, attribute, marker, written) {
185
135
  const recorded = element.getAttribute(marker);
186
136
  if (recorded === null) return;
@@ -192,15 +142,6 @@ var OptimisticController = class extends Controller {
192
142
  }
193
143
  element.removeAttribute(attribute);
194
144
  }
195
- /**
196
- * Puts back exactly the markup this controller displaced (record-owned only).
197
- *
198
- * A face can be registered on both sides, and the hide pass runs last, so what
199
- * this controller wrote there is `hidden` — matching it against the reveal's
200
- * absent value instead would read as someone else's write and leave the face
201
- * hidden. Each target is therefore restored once, against the write it last
202
- * received.
203
- */
204
145
  #revert() {
205
146
  TRANSIENT.reset(this.element);
206
147
  this.#restore(this.element, "aria-busy", BUSY_MARKER, "true");
@@ -212,5 +153,4 @@ var OptimisticController = class extends Controller {
212
153
  };
213
154
 
214
155
  export { OptimisticController };
215
- //# sourceMappingURL=optimistic_controller.js.map
216
156
  //# sourceMappingURL=optimistic_controller.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/detach_gate.ts","../../src/utils/listener_set.ts","../../src/utils/transient_hooks.ts","../../src/controllers/optimistic_controller.ts"],"names":[],"mappings":"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","file":"optimistic_controller.js","sourcesContent":["/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * #teardown(): void {\n * this.#gate.cancel(); // disarm a still-queued probe (double-run guard)\n * // …\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n if (!host.element.isConnected) return true;\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return !tokens.includes(host.identifier);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n if (DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n queueMicrotask(() => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n });\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","/**\n * Owns every listener a controller attaches for one connected lifetime and\n * releases them together, so an event name is spelled once and the release\n * cannot drift from the registration.\n *\n * Detaching by hand repeats three things at the release site — the type, the\n * handler reference and the capture flag — and any one of them drifting leaves\n * a listener alive on an element the controller no longer drives. Aborting the\n * signal a registration was created with releases it whatever it was made of,\n * so the release site names nothing.\n *\n * One set per lifetime, held as a field and never rebuilt. {@link\n * ListenerSet.dispose} is the only thing that closes a generation, and it opens\n * the next one in the same step, so the lifecycle hooks can call `add` and\n * `dispose` in any order any number of times, and no `add` ever meets an\n * already aborted signal — a DOM that registers a listener on such a signal\n * would keep it forever. Rebuilding the set instead would leak:\n * `addEventListener` matches an existing registration on target, type, callback\n * and capture alone, so re-adding the same handler while the first registration\n * is live is discarded, and that live registration keeps the signal it was\n * attached with — a set built by a second `connect()` can never release what it\n * appears to own.\n *\n * Scope is the registration only. Timers, observers, leases and the order they\n * are unwound in stay with the controller, so `dispose()` belongs exactly where\n * the `removeEventListener` block stood. A receiver that changes at runtime (a\n * swapped target) is rebound by calling `dispose()` and then `add()` again from\n * the rebind, not by a second set.\n *\n * The set must own every registration of a tuple it holds: a listener the same\n * target already carries for the same type, callback and capture flag keeps the\n * right to remove it, and this set's `add` for that tuple is discarded. A\n * subscription shared between instances behind a participant count, or one that\n * lives for less than the connection, keeps its own pair of calls.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #listeners = new ListenerSet();\n *\n * connect(): void {\n * this.#listeners.add(this.element, \"submit\", this.#onNativeSubmit, { capture: true });\n * this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n * this.#listeners.add(this.element, \"turbo:submit-end\", this.#onSubmitEnd);\n * }\n *\n * disconnect(): void {\n * this.#listeners.dispose();\n * }\n * ```\n */\nexport class ListenerSet {\n /** The generation every `add` joins until the next `dispose()`. */\n #abort = new AbortController();\n\n /**\n * Attaches `handler` to the open generation, exactly as the caller spelled it.\n *\n * The set supplies the signal, so `options` carries everything else the DOM\n * accepts — `capture` included, which has to match at release time and no\n * longer has a second place to drift from.\n */\n add(\n target: EventTarget,\n type: string,\n handler: EventListener,\n options?: Omit<AddEventListenerOptions, \"signal\">,\n ): void {\n target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });\n }\n\n /**\n * Releases every listener of the open generation, synchronously, and opens the\n * next one. Idempotent, and safe before anything has been added.\n */\n dispose(): void {\n this.#abort.abort();\n this.#abort = new AbortController();\n }\n}\n","/**\n * Drops the transient state hooks a connection inherited, so the cycle it opens\n * starts from a DOM that claims nothing.\n *\n * A transient hook is an attribute a controller writes to say \"this is happening\n * right now\" — a drag in progress, a form with unsaved edits, a refused\n * subscription. Its lifetime belongs to the interaction, not to the element, and\n * the two come apart whenever the element outlives the instance that wrote it: a\n * Turbo cache snapshot taken mid-interaction restores the hook with the markup,\n * and an element re-inserted elsewhere in the page carries it along. Nothing\n * takes such a hook off, because the instance that owned it is gone; consumer\n * CSS then keeps claiming a state no one is in.\n *\n * **`turbo:before-cache` cannot do this job**, which is why the pass belongs in\n * `connect()`: that event fires for a navigation only, so an in-page move — the\n * other half of the population — never reaches it, and a hook that arrived with\n * a moved element would survive. `connect()` covers both, because both end in a\n * connection.\n *\n * Scope is the **drop** only. What the fresh cycle should say instead is the\n * consumer's: a value re-read from the live DOM, a roster refilled by the\n * stream, a measurement retaken from the current scroll position, a slot emptied\n * of the last result. Those answers differ per consumer and none of them is a\n * hook removal, so the pass stops where every consumer agrees.\n *\n * A declaration names one group of hooks and carries no per-instance state, so\n * it belongs at module scope, shared by every instance the page connects. A\n * consumer writes as many as it has groups — hooks it drops under different\n * conditions cannot share one. The exception is a hook whose name is only known\n * at runtime, such as one built from the registered identifier: that\n * declaration is a field, because the name is.\n *\n * **A drop is not always right.** The population splits on one question: can the\n * state a hook describes outlive the instance that wrote it? Where it cannot — a\n * drag ends with the pointer, a subscription dies with its socket — dropping on\n * every connection is correct. Where it can — a submission in flight is not\n * interrupted by an in-page move — only a connection that follows a real restore\n * may drop, and the consumer answers \"did a session survive?\" with the signal it\n * already holds for its own teardown (a live-session field, a `DetachGate`\n * probe). The drop then sits behind that answer, as the second example shows.\n *\n * @example Unconditional — the state cannot outlive the instance.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-sortable-dragging\"] });\n *\n * connect(): void {\n * TRANSIENT.reset(this.element);\n * }\n * ```\n *\n * @example Gated — an in-page move must not drop a live state.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-optimistic\"] });\n *\n * connect(): void {\n * const restored = !this.#connected && !this.#gate.pending;\n * this.#gate.cancel();\n * this.#connected = true;\n * if (restored) TRANSIENT.reset(this.element);\n * }\n * ```\n */\n\n/** What a consumer declares once about the hooks its interaction owns. */\nexport interface TransientHooksOptions {\n /**\n * The attribute names a connection may find written by an earlier one. Hooks\n * a consumer drops under different conditions belong to different\n * declarations, so each one can be reset on its own.\n */\n readonly attributes: readonly string[];\n}\n\n/**\n * One consumer's declaration of the hooks its interaction owns, dropped from an\n * element by {@link TransientHooks.reset}.\n *\n * Every member comment inside this class body is inlined into each consumer\n * bundle (`tsup` builds with `splitting: false`), so rationale belongs in this\n * file's own block, which `dist` drops, and only the contract belongs inside.\n */\nexport class TransientHooks {\n readonly #attributes: readonly string[];\n\n constructor(options: TransientHooksOptions) {\n this.#attributes = options.attributes;\n }\n\n /** Drops the declared hooks from `element`; what it carries besides stays. */\n reset(element: Element): void {\n for (const attribute of this.#attributes) element.removeAttribute(attribute);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { DetachGate } from \"../utils/detach_gate\";\nimport { ListenerSet } from \"../utils/listener_set\";\nimport { TransientHooks } from \"../utils/transient_hooks\";\n\n/** The hook a connection may find written by an earlier, now-gone one. */\nconst TRANSIENT = new TransientHooks({ attributes: [\"data-optimistic\"] });\n\n/** Records that this controller wrote `hidden`, and the authored value to put back. */\nconst HIDDEN_MARKER = \"data-optimistic-toggled\";\n\n/** The same record for `aria-busy` on the controller element. */\nconst BUSY_MARKER = \"data-optimistic-busy\";\n\n/** Marker value standing for \"the attribute was not there\". */\nconst AUTHORED_ABSENT = \"absent\";\n\n/** Marker prefix carrying the authored attribute value that follows it. */\nconst AUTHORED_VALUE = \"value:\";\n\n/**\n * Stands in for the submission a synthetic lifecycle event does not name. Turbo\n * puts a `formSubmission` on both events, so this only ever pairs hand-dispatched\n * events with each other — one shared instance, so a start and its end match.\n */\nconst SYNTHETIC_SUBMISSION = {};\n\n/** The submission a Turbo lifecycle event belongs to. */\nfunction submissionOf(event: Event): unknown {\n const detail = (event as CustomEvent<{ formSubmission?: unknown }>).detail;\n return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;\n}\n\n/**\n * Headless **optimistic UI** for Turbo form submissions — the one server-bound\n * behavior that needs no Action Cable: it wraps a Turbo form\n * and applies a *declared* optimistic state the moment the submission starts,\n * keeps it when the server confirms, and **rolls it back** when the submission\n * fails. The pattern: flip the like button instantly; the success response (a\n * Turbo Stream) replaces the fragment with the server truth anyway, so the\n * client only needs the instant flip and the failure rollback. Core (zero\n * dependencies — it rides Turbo's own `turbo:submit-*` events).\n *\n * Markup contract (identifier: `stimeo--optimistic`):\n * <form data-controller=\"stimeo--optimistic\" method=\"post\" action=\"/likes\">\n * <button type=\"submit\" aria-label=\"Like\">\n * <span data-stimeo--optimistic-target=\"hide\">♡</span>\n * <span hidden data-stimeo--optimistic-target=\"show\">♥</span>\n * </button>\n * </form>\n *\n * On `turbo:submit-start`, every `show` target is unhidden and every `hide`\n * target hidden, the element gains `data-optimistic=\"true\"` + `aria-busy=\"true\"`,\n * and the submission named by the event takes ownership of that state. On\n * `turbo:submit-end`: the terminal of any *other* submission is ignored; the\n * owner's terminal clears the pending hook and, on success, dispatches `commit`\n * (the toggled state stays — the server response owns the final DOM), or on\n * failure restores the authored markup and dispatches `rollback`.\n *\n * **Every write records what it displaced.** A target this controller hides or\n * reveals carries the authored `hidden` value in `data-optimistic-toggled`, and\n * the element carries the authored `aria-busy` in `data-optimistic-busy`, so a\n * rollback puts back exactly what the author wrote — not the inverse of whatever\n * the attribute says at that moment. The record lives in the DOM rather than in\n * memory because a Turbo cache snapshot has to carry it: `connect()` is what\n * rewinds a restored page.\n *\n * @remarks\n * Behavior only — what \"optimistic\" looks like is the author's markup (the\n * show/hide pair) and CSS (`[data-optimistic=\"true\"]`). Pairs with\n * `stimeo--submit-once` (double-submit guard) and `stimeo--live-counter`\n * (optimistic numbers). The listeners are delegated on the element, so a form\n * nested under it (or swapped for a new one) keeps working, and the submission\n * ownership is what keeps a sibling form's terminal from resolving this state.\n * With several submissions in flight the newest start owns the optimistic state,\n * so a submission whose terminal never arrives — its form replaced mid-flight —\n * cannot strand the element. `connect()` is idempotent: a Turbo cache snapshot\n * taken mid-submit is rewound, since no submission can be in flight across a\n * restore, while an in-page move (where the submission *does* survive) keeps it.\n * On a real detach the listeners go with the element.\n */\nexport class OptimisticController extends Controller<HTMLElement> {\n static override targets = [\"show\", \"hide\"];\n static events = [\"commit\", \"rollback\"] as const;\n\n declare readonly showTargets: HTMLElement[];\n declare readonly hideTargets: HTMLElement[];\n\n /** The submission that owns the applied optimistic state, if any. */\n #pending: unknown = null;\n\n readonly #gate = new DetachGate();\n readonly #listeners = new ListenerSet();\n\n /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */\n #connected = false;\n\n override connect(): void {\n // Only a page this instance did not apply the state to can be holding a stale\n // one: a restored snapshot. An in-page move and a repeat connect both leave a\n // live submission behind, so neither may rewind — the gate names the first and\n // the connected flag the second.\n const restored = !this.#connected && !this.#gate.pending;\n this.#gate.cancel();\n this.#connected = true;\n if (restored && this.element.hasAttribute(\"data-optimistic\")) this.#revert();\n this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n this.#listeners.add(this.element, \"turbo:submit-end\", this.#onSubmitEnd);\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#listeners.dispose();\n // Ownership outlives an in-page move: only a real detach forgets it.\n this.#gate.disconnected(this, () => {\n this.#pending = null;\n });\n }\n\n readonly #onSubmitStart = (event: Event): void => {\n this.#pending = submissionOf(event);\n // Turbo owns `aria-busy` on a form and marks it before this event, so what is\n // there now is Turbo's, not the author's. Recording it would put Turbo's value\n // back on a page restored mid-submit, where no terminal ever arrives to clear it.\n if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {\n this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);\n }\n this.#write(this.element, \"aria-busy\", \"true\", BUSY_MARKER);\n this.element.setAttribute(\"data-optimistic\", \"true\");\n for (const target of this.showTargets) this.#setHidden(target, false);\n for (const target of this.hideTargets) this.#setHidden(target, true);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n // A terminal always names a submission, so an element holding none (nothing\n // started, or this one already resolved) falls out here too.\n if (submissionOf(event) !== this.#pending) return;\n this.#pending = null;\n\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success === true;\n if (success) {\n // Keep the toggled faces (the server response owns the final DOM) but drop\n // their records, so a later failure cannot revert a confirmed state.\n this.element.removeAttribute(\"data-optimistic\");\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n for (const target of [...this.showTargets, ...this.hideTargets]) {\n target.removeAttribute(HIDDEN_MARKER);\n }\n this.dispatch(\"commit\");\n } else {\n this.#revert();\n this.dispatch(\"rollback\");\n }\n };\n\n /** Hides or reveals a target, recording the authored `hidden` on first write. */\n #setHidden(target: HTMLElement, hidden: boolean): void {\n if (target.hasAttribute(\"hidden\") === hidden) return;\n this.#write(target, \"hidden\", hidden ? \"\" : null, HIDDEN_MARKER);\n }\n\n /** Writes an attribute, recording what it displaced under `marker` once. */\n #write(element: Element, attribute: string, value: string | null, marker: string): void {\n if (!element.hasAttribute(marker)) {\n const authored = element.getAttribute(attribute);\n element.setAttribute(\n marker,\n authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`,\n );\n }\n if (value === null) element.removeAttribute(attribute);\n else element.setAttribute(attribute, value);\n }\n\n /**\n * Puts back the value `marker` recorded and drops the record.\n *\n * `written` is what this controller last put in the attribute. Anything else\n * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and\n * clears it before the terminal arrives, and a consumer may have moved a face\n * mid-flight — so the record is dropped without touching the attribute. A\n * marker this build did not write names no authored value, so the attribute\n * goes back to being absent.\n */\n #restore(element: Element, attribute: string, marker: string, written: string | null): void {\n const recorded = element.getAttribute(marker);\n if (recorded === null) return;\n element.removeAttribute(marker);\n if (element.getAttribute(attribute) !== written) return;\n if (recorded.startsWith(AUTHORED_VALUE)) {\n element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));\n return;\n }\n element.removeAttribute(attribute);\n }\n\n /**\n * Puts back exactly the markup this controller displaced (record-owned only).\n *\n * A face can be registered on both sides, and the hide pass runs last, so what\n * this controller wrote there is `hidden` — matching it against the reveal's\n * absent value instead would read as someone else's write and leave the face\n * hidden. Each target is therefore restored once, against the write it last\n * received.\n */\n #revert(): void {\n TRANSIENT.reset(this.element);\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n const hidden = new Set(this.hideTargets);\n for (const target of new Set([...this.showTargets, ...this.hideTargets])) {\n this.#restore(target, \"hidden\", HIDDEN_MARKER, hidden.has(target) ? \"\" : null);\n }\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/detach_gate.ts","../../src/utils/listener_set.ts","../../src/utils/transient_hooks.ts","../../src/controllers/optimistic_controller.ts"],"names":[],"mappings":"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","file":"optimistic_controller.js","sourcesContent":["/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * An element out of the document is a real detach, with one exception: a\n * `data-turbo-permanent` element, or one inside it, that Turbo carries to the\n * next page. Turbo takes it out of the old `<body>`, which Stimulus answers with\n * a `disconnect()` while it is out of the document, and puts it into the new one\n * a few microtasks later in the same task, where the same instance connects\n * again. Such a disconnect is probed until the next task instead.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * dismiss(): void {\n * this.#gate.cancel(); // a direct path: disarm a probe a disconnect queued\n * this.#teardown();\n * }\n *\n * #teardown(): void {\n * // … (`disconnected` has already disarmed the probe on its own path)\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n return !host.element.isConnected || !DetachGate.#listed(host);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains. An\n * element Turbo carries to the next page is probed until the next task, by\n * which time Turbo has put it back.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n const carried = DetachGate.#carried(host);\n if (!carried && DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n const probe = (): void => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n };\n if (carried) setTimeout(probe, 0);\n else queueMicrotask(probe);\n }\n\n /**\n * Whether `host` is out of the document inside a `data-turbo-permanent` element, its\n * identifier still listed: where Turbo holds an element it carries to the next page.\n */\n static #carried(host: DetachGateHost): boolean {\n const element = host.element;\n return (\n !element.isConnected &&\n element.closest(\"[data-turbo-permanent]\") !== null &&\n DetachGate.#listed(host)\n );\n }\n\n /** Whether the element's `data-controller` still lists the identifier. */\n static #listed(host: DetachGateHost): boolean {\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return tokens.includes(host.identifier);\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","/**\n * Owns every listener a controller attaches for one connected lifetime and\n * releases them together, so an event name is spelled once and the release\n * cannot drift from the registration.\n *\n * Detaching by hand repeats three things at the release site — the type, the\n * handler reference and the capture flag — and any one of them drifting leaves\n * a listener alive on an element the controller no longer drives. Aborting the\n * signal a registration was created with releases it whatever it was made of,\n * so the release site names nothing.\n *\n * One set per lifetime, held as a field and never rebuilt. {@link\n * ListenerSet.dispose} is the only thing that closes a generation, and it opens\n * the next one in the same step, so the lifecycle hooks can call `add` and\n * `dispose` in any order any number of times, and no `add` ever meets an\n * already aborted signal — a DOM that registers a listener on such a signal\n * would keep it forever. Rebuilding the set instead would leak:\n * `addEventListener` matches an existing registration on target, type, callback\n * and capture alone, so re-adding the same handler while the first registration\n * is live is discarded, and that live registration keeps the signal it was\n * attached with — a set built by a second `connect()` can never release what it\n * appears to own.\n *\n * Scope is the registration only. Timers, observers, leases and the order they\n * are unwound in stay with the controller, which places `dispose()` in its\n * cleanup sequence. A receiver that changes at runtime (a\n * swapped target) is rebound by calling `dispose()` and then `add()` again from\n * the rebind, not by a second set.\n *\n * The set must own every registration of a tuple it holds: a listener the same\n * target already carries for the same type, callback and capture flag keeps the\n * right to remove it, and this set's `add` for that tuple is discarded. A\n * subscription shared between instances behind a participant count, or one that\n * lives for less than the connection, keeps its own pair of calls.\n *\n * @example\n * ```ts\n * readonly #listeners = new ListenerSet();\n *\n * connect(): void {\n * this.#listeners.add(this.element, \"submit\", this.#onNativeSubmit, { capture: true });\n * this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n * this.#listeners.add(this.element, \"turbo:submit-end\", this.#onSubmitEnd);\n * }\n *\n * disconnect(): void {\n * this.#listeners.dispose();\n * }\n * ```\n */\nexport class ListenerSet {\n /** The generation every `add` joins until the next `dispose()`. */\n #abort = new AbortController();\n\n /**\n * Attaches `handler` to the open generation, exactly as the caller spelled it.\n *\n * The set supplies the signal, so `options` carries everything else the DOM\n * accepts — `capture` included, which has to match at release time and no\n * longer has a second place to drift from.\n */\n add(\n target: EventTarget,\n type: string,\n handler: EventListener,\n options?: Omit<AddEventListenerOptions, \"signal\">,\n ): void {\n target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });\n }\n\n /**\n * Releases every listener of the open generation, synchronously, and opens the\n * next one. Idempotent, and safe before anything has been added.\n */\n dispose(): void {\n this.#abort.abort();\n this.#abort = new AbortController();\n }\n}\n","/**\n * Drops the transient state hooks a connection inherited, so the cycle it opens\n * starts from a DOM that claims nothing.\n *\n * A transient hook is an attribute a controller writes to say \"this is happening\n * right now\" — a drag in progress, a form with unsaved edits, a refused\n * subscription. Its lifetime belongs to the interaction, not to the element, and\n * the two come apart whenever the element outlives the instance that wrote it: a\n * Turbo cache snapshot taken mid-interaction restores the hook with the markup,\n * and an element re-inserted elsewhere in the page carries it along. Nothing\n * takes such a hook off, because the instance that owned it is gone; consumer\n * CSS then keeps claiming a state no one is in.\n *\n * **`turbo:before-cache` cannot do this job**, which is why the pass belongs in\n * `connect()`: Turbo also dispatches that event on pages that stay, where the hook\n * is still true, and an in-page move — the other half of the population — never\n * reaches it, so a hook that arrived with a moved element would survive.\n * `connect()` covers both, because both end in a connection.\n *\n * Scope is the **drop** only. What the fresh cycle should say instead is the\n * consumer's: a value re-read from the live DOM, a roster refilled by the\n * stream, a measurement retaken from the current scroll position, a slot emptied\n * of the last result. Those answers differ per consumer and none of them is a\n * hook removal, so the pass stops where every consumer agrees.\n *\n * A declaration names one group of hooks and carries no per-instance state, so\n * it belongs at module scope, shared by every instance the page connects. A\n * consumer writes as many as it has groups — hooks it drops under different\n * conditions cannot share one. The exception is a hook whose name is only known\n * at runtime, such as one built from the registered identifier: that\n * declaration is a field, because the name is.\n *\n * **A drop is not always right.** The population splits on one question: can the\n * state a hook describes outlive the instance that wrote it? Where it cannot — a\n * drag ends with the pointer, a subscription dies with its socket — dropping on\n * every connection is correct. Where it can — a submission in flight is not\n * interrupted by an in-page move — only a connection that follows a real restore\n * may drop, and the consumer answers \"did a session survive?\" with the signal it\n * already holds for its own teardown (a live-session field, a `DetachGate`\n * probe). The drop then sits behind that answer, as the second example shows.\n *\n * @example Unconditional — the state cannot outlive the instance.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-sortable-dragging\"] });\n *\n * connect(): void {\n * TRANSIENT.reset(this.element);\n * }\n * ```\n *\n * @example Gated — an in-page move must not drop a live state.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-optimistic\"] });\n *\n * connect(): void {\n * const restored = !this.#connected && !this.#gate.pending;\n * this.#gate.cancel();\n * this.#connected = true;\n * if (restored) TRANSIENT.reset(this.element);\n * }\n * ```\n */\n\n/** What a consumer declares once about the hooks its interaction owns. */\nexport interface TransientHooksOptions {\n /**\n * The attribute names a connection may find written by an earlier one. Hooks\n * a consumer drops under different conditions belong to different\n * declarations, so each one can be reset on its own.\n */\n readonly attributes: readonly string[];\n}\n\n/**\n * One consumer's declaration of the hooks its interaction owns, dropped from an\n * element by {@link TransientHooks.reset}.\n */\nexport class TransientHooks {\n readonly #attributes: readonly string[];\n\n constructor(options: TransientHooksOptions) {\n this.#attributes = options.attributes;\n }\n\n /** Drops the declared hooks from `element`; what it carries besides stays. */\n reset(element: Element): void {\n for (const attribute of this.#attributes) element.removeAttribute(attribute);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { DetachGate } from \"../utils/detach_gate\";\nimport { ListenerSet } from \"../utils/listener_set\";\nimport { TransientHooks } from \"../utils/transient_hooks\";\n\n/** The hook a connection may find written by an earlier, now-gone one. */\nconst TRANSIENT = new TransientHooks({ attributes: [\"data-optimistic\"] });\n\n/** Records that this controller wrote `hidden`, and the authored value to put back. */\nconst HIDDEN_MARKER = \"data-optimistic-toggled\";\n\n/** The same record for `aria-busy` on the controller element. */\nconst BUSY_MARKER = \"data-optimistic-busy\";\n\n/** Marker value standing for \"the attribute was not there\". */\nconst AUTHORED_ABSENT = \"absent\";\n\n/** Marker prefix carrying the authored attribute value that follows it. */\nconst AUTHORED_VALUE = \"value:\";\n\n/**\n * Stands in for the submission a synthetic lifecycle event does not name. Turbo\n * puts a `formSubmission` on both events, so this only ever pairs hand-dispatched\n * events with each other — one shared instance, so a start and its end match.\n */\nconst SYNTHETIC_SUBMISSION = {};\n\n/** The submission a Turbo lifecycle event belongs to. */\nfunction submissionOf(event: Event): unknown {\n const detail = (event as CustomEvent<{ formSubmission?: unknown }>).detail;\n return detail?.formSubmission ?? SYNTHETIC_SUBMISSION;\n}\n\n/**\n * Headless **optimistic UI** for Turbo form submissions — the one server-bound\n * behavior that needs no Action Cable: it wraps a Turbo form\n * and applies a *declared* optimistic state the moment the submission starts,\n * keeps it when the server confirms, and **rolls it back** when the submission\n * fails. The pattern: flip the like button instantly; the success response (a\n * Turbo Stream) replaces the fragment with the server truth anyway, so the\n * client only needs the instant flip and the failure rollback. Core (zero\n * dependencies — it rides Turbo's own `turbo:submit-*` events).\n *\n * Markup contract (identifier: `stimeo--optimistic`):\n * <form data-controller=\"stimeo--optimistic\" method=\"post\" action=\"/likes\">\n * <button type=\"submit\" aria-label=\"Like\">\n * <span data-stimeo--optimistic-target=\"hide\">♡</span>\n * <span hidden data-stimeo--optimistic-target=\"show\">♥</span>\n * </button>\n * </form>\n *\n * On `turbo:submit-start`, every `show` target is unhidden and every `hide`\n * target hidden, the element gains `data-optimistic=\"true\"` + `aria-busy=\"true\"`,\n * and the submission named by the event takes ownership of that state. On\n * `turbo:submit-end`: the terminal of any *other* submission is ignored; the\n * owner's terminal clears the pending hook and, on success, dispatches `commit`\n * (the toggled state stays — the server response owns the final DOM), or on\n * failure restores the authored markup and dispatches `rollback`.\n *\n * **Every write records what it displaced.** A target this controller hides or\n * reveals carries the authored `hidden` value in `data-optimistic-toggled`, and\n * the element carries the authored `aria-busy` in `data-optimistic-busy`, so a\n * rollback puts back exactly what the author wrote — not the inverse of whatever\n * the attribute says at that moment. The record lives in the DOM rather than in\n * memory because a Turbo cache snapshot has to carry it: `connect()` is what\n * rewinds a restored page.\n *\n * @remarks\n * Behavior only — what \"optimistic\" looks like is the author's markup (the\n * show/hide pair) and CSS (`[data-optimistic=\"true\"]`). Pairs with\n * `stimeo--submit-once` (double-submit guard) and `stimeo--live-counter`\n * (optimistic numbers). The listeners are delegated on the element, so a form\n * nested under it (or swapped for a new one) keeps working, and the submission\n * ownership is what keeps a sibling form's terminal from resolving this state.\n * With several submissions in flight the newest start owns the optimistic state,\n * so a submission whose terminal never arrives — its form replaced mid-flight —\n * cannot strand the element. `connect()` is idempotent: a Turbo cache snapshot\n * taken mid-submit is rewound, since no submission can be in flight across a\n * restore, while an in-page move (where the submission *does* survive) keeps it.\n * On a real detach the listeners go with the element.\n */\nexport class OptimisticController extends Controller<HTMLElement> {\n static override targets = [\"show\", \"hide\"];\n static events = [\"commit\", \"rollback\"] as const;\n\n declare readonly showTargets: HTMLElement[];\n declare readonly hideTargets: HTMLElement[];\n\n /** The submission that owns the applied optimistic state, if any. */\n #pending: unknown = null;\n\n readonly #gate = new DetachGate();\n readonly #listeners = new ListenerSet();\n\n /** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */\n #connected = false;\n\n override connect(): void {\n // Only a page this instance did not apply the state to can be holding a stale\n // one: a restored snapshot. An in-page move and a repeat connect both leave a\n // live submission behind, so neither may rewind — the gate names the first and\n // the connected flag the second.\n const restored = !this.#connected && !this.#gate.pending;\n this.#gate.cancel();\n this.#connected = true;\n if (restored && this.element.hasAttribute(\"data-optimistic\")) this.#revert();\n this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n this.#listeners.add(this.element, \"turbo:submit-end\", this.#onSubmitEnd);\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#listeners.dispose();\n // Ownership outlives an in-page move: only a real detach forgets it.\n this.#gate.disconnected(this, () => {\n this.#pending = null;\n });\n }\n\n readonly #onSubmitStart = (event: Event): void => {\n this.#pending = submissionOf(event);\n // Turbo owns `aria-busy` on a form and marks it before this event, so what is\n // there now is Turbo's, not the author's. Recording it would put Turbo's value\n // back on a page restored mid-submit, where no terminal ever arrives to clear it.\n if (this.element instanceof HTMLFormElement && !this.element.hasAttribute(BUSY_MARKER)) {\n this.element.setAttribute(BUSY_MARKER, AUTHORED_ABSENT);\n }\n this.#write(this.element, \"aria-busy\", \"true\", BUSY_MARKER);\n this.element.setAttribute(\"data-optimistic\", \"true\");\n for (const target of this.showTargets) this.#setHidden(target, false);\n for (const target of this.hideTargets) this.#setHidden(target, true);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n // A terminal always names a submission, so an element holding none (nothing\n // started, or this one already resolved) falls out here too.\n if (submissionOf(event) !== this.#pending) return;\n this.#pending = null;\n\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success === true;\n if (success) {\n // Keep the toggled faces (the server response owns the final DOM) but drop\n // their records, so a later failure cannot revert a confirmed state.\n this.element.removeAttribute(\"data-optimistic\");\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n for (const target of [...this.showTargets, ...this.hideTargets]) {\n target.removeAttribute(HIDDEN_MARKER);\n }\n this.dispatch(\"commit\");\n } else {\n this.#revert();\n this.dispatch(\"rollback\");\n }\n };\n\n /** Hides or reveals a target, recording the authored `hidden` on first write. */\n #setHidden(target: HTMLElement, hidden: boolean): void {\n if (target.hasAttribute(\"hidden\") === hidden) return;\n this.#write(target, \"hidden\", hidden ? \"\" : null, HIDDEN_MARKER);\n }\n\n /** Writes an attribute, recording what it displaced under `marker` once. */\n #write(element: Element, attribute: string, value: string | null, marker: string): void {\n if (!element.hasAttribute(marker)) {\n const authored = element.getAttribute(attribute);\n element.setAttribute(\n marker,\n authored === null ? AUTHORED_ABSENT : `${AUTHORED_VALUE}${authored}`,\n );\n }\n if (value === null) element.removeAttribute(attribute);\n else element.setAttribute(attribute, value);\n }\n\n /**\n * Puts back the value `marker` recorded and drops the record.\n *\n * `written` is what this controller last put in the attribute. Anything else\n * there now belongs to whoever wrote it — Turbo owns `aria-busy` on a form and\n * clears it before the terminal arrives, and a consumer may have moved a face\n * mid-flight — so the record is dropped without touching the attribute. A\n * marker this build did not write names no authored value, so the attribute\n * goes back to being absent.\n */\n #restore(element: Element, attribute: string, marker: string, written: string | null): void {\n const recorded = element.getAttribute(marker);\n if (recorded === null) return;\n element.removeAttribute(marker);\n if (element.getAttribute(attribute) !== written) return;\n if (recorded.startsWith(AUTHORED_VALUE)) {\n element.setAttribute(attribute, recorded.slice(AUTHORED_VALUE.length));\n return;\n }\n element.removeAttribute(attribute);\n }\n\n /**\n * Puts back exactly the markup this controller displaced (record-owned only).\n *\n * A face can be registered on both sides, and the hide pass runs last, so what\n * this controller wrote there is `hidden` — matching it against the reveal's\n * absent value instead would read as someone else's write and leave the face\n * hidden. Each target is therefore restored once, against the write it last\n * received.\n */\n #revert(): void {\n TRANSIENT.reset(this.element);\n this.#restore(this.element, \"aria-busy\", BUSY_MARKER, \"true\");\n const hidden = new Set(this.hideTargets);\n for (const target of new Set([...this.showTargets, ...this.hideTargets])) {\n this.#restore(target, \"hidden\", HIDDEN_MARKER, hidden.has(target) ? \"\" : null);\n }\n }\n}\n"]}
@@ -58,10 +58,9 @@ import { Controller } from '@hotwired/stimulus';
58
58
  * Controller-owned output: `data-filled` on each entered field, `data-state`
59
59
  * (`empty` / `partial` / `complete`) on the root, and — while input is being
60
60
  * reported invalid — `aria-invalid`, `aria-errormessage`, `aria-describedby`,
61
- * and the `error` target's `hidden`. Authored values return on teardown and
62
- * before the page is cached: the three ARIA attributes keep their lease on the
63
- * field itself, so a connection that adopts a restored DOM can give them back
64
- * too, and the `hidden` is leased.
61
+ * and the `error` target's `hidden`. Authored values return on teardown: the
62
+ * three ARIA attributes keep their lease on the field itself, so a connection
63
+ * that adopts a restored DOM can give them back too, and the `hidden` is leased.
65
64
  *
66
65
  * @remarks
67
66
  * Behavior only. `connect()` reads the fields back as the source of truth, which
@@ -85,6 +84,7 @@ declare class OtpController extends Controller<HTMLElement> {
85
84
  readonly fieldTargets: HTMLInputElement[];
86
85
  readonly valueTarget: HTMLInputElement;
87
86
  readonly errorTarget: HTMLElement;
87
+ readonly errorTargets: HTMLElement[];
88
88
  readonly hasValueTarget: boolean;
89
89
  readonly hasErrorTarget: boolean;
90
90
  patternValue: string;
@@ -98,6 +98,22 @@ declare class OtpController extends Controller<HTMLElement> {
98
98
  fieldTargetConnected(element: HTMLInputElement): void;
99
99
  /** Releases a dropped field's listeners and leases, then reconciles the rest. */
100
100
  fieldTargetDisconnected(element: HTMLInputElement): void;
101
+ /** Writes the combined value into a value field that arrives, in the next reconciliation. */
102
+ valueTargetConnected(): void;
103
+ /** Writes the combined value into the value field that stays when an earlier one leaves. */
104
+ valueTargetDisconnected(): void;
105
+ /**
106
+ * Moves rejected input still being reported onto the first error target as one arrives,
107
+ * and hides the first one while nothing is reported.
108
+ */
109
+ errorTargetConnected(): void;
110
+ /**
111
+ * Gives an error target that no longer resolves as one its own `hidden` back — after
112
+ * `disconnect()` too, since dropping the identifier leaves the element on the page —
113
+ * and shows rejected input still being reported on the error target that stays, or
114
+ * hides it while nothing is reported.
115
+ */
116
+ errorTargetDisconnected(element: HTMLElement): void;
101
117
  /**
102
118
  * Re-validates a changed `pattern` declaration once and drops any entered digit
103
119
  * the new pattern no longer accepts, so the combined value stays interpretable.