stimeo-ui 0.2.1 → 0.4.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 (219) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/cable/index.d.ts +16 -16
  3. package/dist/cable/index.js.map +1 -1
  4. package/dist/controllers/accordion_controller.js +10 -0
  5. package/dist/controllers/accordion_controller.js.map +1 -1
  6. package/dist/controllers/alert_dialog_controller.js +32 -5
  7. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  8. package/dist/controllers/announcer_controller.d.ts +32 -6
  9. package/dist/controllers/announcer_controller.js +255 -20
  10. package/dist/controllers/announcer_controller.js.map +1 -1
  11. package/dist/controllers/breadcrumb_controller.d.ts +97 -10
  12. package/dist/controllers/breadcrumb_controller.js +225 -13
  13. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  14. package/dist/controllers/calendar_controller.d.ts +11 -1
  15. package/dist/controllers/calendar_controller.js +89 -22
  16. package/dist/controllers/calendar_controller.js.map +1 -1
  17. package/dist/controllers/carousel_controller.d.ts +16 -1
  18. package/dist/controllers/carousel_controller.js +47 -6
  19. package/dist/controllers/carousel_controller.js.map +1 -1
  20. package/dist/controllers/collapsible_controller.js +2 -2
  21. package/dist/controllers/collapsible_controller.js.map +1 -1
  22. package/dist/controllers/color_picker_controller.d.ts +14 -2
  23. package/dist/controllers/color_picker_controller.js +92 -7
  24. package/dist/controllers/color_picker_controller.js.map +1 -1
  25. package/dist/controllers/combobox_controller.d.ts +14 -1
  26. package/dist/controllers/combobox_controller.js +162 -23
  27. package/dist/controllers/combobox_controller.js.map +1 -1
  28. package/dist/controllers/command_palette_controller.d.ts +2 -0
  29. package/dist/controllers/command_palette_controller.js +226 -22
  30. package/dist/controllers/command_palette_controller.js.map +1 -1
  31. package/dist/controllers/confirm_controller.js +32 -5
  32. package/dist/controllers/confirm_controller.js.map +1 -1
  33. package/dist/controllers/context_menu_controller.d.ts +7 -6
  34. package/dist/controllers/context_menu_controller.js +32 -10
  35. package/dist/controllers/context_menu_controller.js.map +1 -1
  36. package/dist/controllers/count_up_controller.js.map +1 -1
  37. package/dist/controllers/countdown_controller.d.ts +19 -5
  38. package/dist/controllers/countdown_controller.js +112 -12
  39. package/dist/controllers/countdown_controller.js.map +1 -1
  40. package/dist/controllers/data_grid_controller.d.ts +19 -1
  41. package/dist/controllers/data_grid_controller.js +82 -4
  42. package/dist/controllers/data_grid_controller.js.map +1 -1
  43. package/dist/controllers/date_range_picker_controller.d.ts +5 -1
  44. package/dist/controllers/date_range_picker_controller.js +77 -3
  45. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  46. package/dist/controllers/dialog_controller.js +32 -5
  47. package/dist/controllers/dialog_controller.js.map +1 -1
  48. package/dist/controllers/dismissible_controller.js.map +1 -1
  49. package/dist/controllers/drawer_controller.js +32 -5
  50. package/dist/controllers/drawer_controller.js.map +1 -1
  51. package/dist/controllers/dropdown_controller.d.ts +1 -2
  52. package/dist/controllers/dropdown_controller.js.map +1 -1
  53. package/dist/controllers/editable_controller.js +1 -0
  54. package/dist/controllers/editable_controller.js.map +1 -1
  55. package/dist/controllers/empty_state_controller.d.ts +9 -4
  56. package/dist/controllers/empty_state_controller.js +24 -10
  57. package/dist/controllers/empty_state_controller.js.map +1 -1
  58. package/dist/controllers/focus_controller.d.ts +1 -1
  59. package/dist/controllers/focus_controller.js +32 -5
  60. package/dist/controllers/focus_controller.js.map +1 -1
  61. package/dist/controllers/form_field_controller.js.map +1 -1
  62. package/dist/controllers/form_validation_controller.js +1 -1
  63. package/dist/controllers/form_validation_controller.js.map +1 -1
  64. package/dist/controllers/frame_loading_controller.d.ts +14 -3
  65. package/dist/controllers/frame_loading_controller.js +177 -15
  66. package/dist/controllers/frame_loading_controller.js.map +1 -1
  67. package/dist/controllers/highlight_controller.js.map +1 -1
  68. package/dist/controllers/hover_card_controller.d.ts +1 -1
  69. package/dist/controllers/hover_card_controller.js.map +1 -1
  70. package/dist/controllers/intersection_controller.js +36 -11
  71. package/dist/controllers/intersection_controller.js.map +1 -1
  72. package/dist/controllers/lazy_frame_controller.js +31 -10
  73. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  74. package/dist/controllers/listbox_controller.d.ts +9 -2
  75. package/dist/controllers/listbox_controller.js +257 -53
  76. package/dist/controllers/listbox_controller.js.map +1 -1
  77. package/dist/controllers/local_time_controller.d.ts +19 -3
  78. package/dist/controllers/local_time_controller.js +102 -8
  79. package/dist/controllers/local_time_controller.js.map +1 -1
  80. package/dist/controllers/menu_controller.d.ts +29 -11
  81. package/dist/controllers/menu_controller.js +104 -17
  82. package/dist/controllers/menu_controller.js.map +1 -1
  83. package/dist/controllers/menubar_controller.d.ts +91 -11
  84. package/dist/controllers/menubar_controller.js +415 -63
  85. package/dist/controllers/menubar_controller.js.map +1 -1
  86. package/dist/controllers/meter_controller.d.ts +22 -2
  87. package/dist/controllers/meter_controller.js +145 -26
  88. package/dist/controllers/meter_controller.js.map +1 -1
  89. package/dist/controllers/multi_select_controller.d.ts +31 -5
  90. package/dist/controllers/multi_select_controller.js +312 -29
  91. package/dist/controllers/multi_select_controller.js.map +1 -1
  92. package/dist/controllers/navigation_menu_controller.d.ts +65 -18
  93. package/dist/controllers/navigation_menu_controller.js +154 -27
  94. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  95. package/dist/controllers/nested_form_controller.d.ts +4 -5
  96. package/dist/controllers/nested_form_controller.js.map +1 -1
  97. package/dist/controllers/network_status_controller.d.ts +13 -1
  98. package/dist/controllers/network_status_controller.js +28 -8
  99. package/dist/controllers/network_status_controller.js.map +1 -1
  100. package/dist/controllers/number_input_controller.d.ts +1 -1
  101. package/dist/controllers/number_input_controller.js +7 -0
  102. package/dist/controllers/number_input_controller.js.map +1 -1
  103. package/dist/controllers/otp_controller.js +18 -1
  104. package/dist/controllers/otp_controller.js.map +1 -1
  105. package/dist/controllers/overflow_indicator_controller.d.ts +3 -2
  106. package/dist/controllers/overflow_indicator_controller.js +81 -13
  107. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  108. package/dist/controllers/overflow_menu_controller.d.ts +78 -19
  109. package/dist/controllers/overflow_menu_controller.js +408 -57
  110. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  111. package/dist/controllers/pagination_controller.d.ts +36 -5
  112. package/dist/controllers/pagination_controller.js +163 -32
  113. package/dist/controllers/pagination_controller.js.map +1 -1
  114. package/dist/controllers/persist_controller.js +6 -6
  115. package/dist/controllers/persist_controller.js.map +1 -1
  116. package/dist/controllers/pointer_drag_controller.d.ts +2 -3
  117. package/dist/controllers/pointer_drag_controller.js +9 -1
  118. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  119. package/dist/controllers/popover_controller.d.ts +1 -2
  120. package/dist/controllers/popover_controller.js +2 -2
  121. package/dist/controllers/popover_controller.js.map +1 -1
  122. package/dist/controllers/portal_controller.d.ts +4 -4
  123. package/dist/controllers/portal_controller.js.map +1 -1
  124. package/dist/controllers/progress_controller.d.ts +16 -1
  125. package/dist/controllers/progress_controller.js +116 -9
  126. package/dist/controllers/progress_controller.js.map +1 -1
  127. package/dist/controllers/radio_group_controller.js +22 -3
  128. package/dist/controllers/radio_group_controller.js.map +1 -1
  129. package/dist/controllers/range_slider_controller.d.ts +18 -5
  130. package/dist/controllers/range_slider_controller.js +100 -9
  131. package/dist/controllers/range_slider_controller.js.map +1 -1
  132. package/dist/controllers/rating_controller.d.ts +4 -1
  133. package/dist/controllers/rating_controller.js +69 -2
  134. package/dist/controllers/rating_controller.js.map +1 -1
  135. package/dist/controllers/read_more_controller.js +63 -19
  136. package/dist/controllers/read_more_controller.js.map +1 -1
  137. package/dist/controllers/reading_progress_controller.js.map +1 -1
  138. package/dist/controllers/relative_time_controller.d.ts +13 -0
  139. package/dist/controllers/relative_time_controller.js +133 -12
  140. package/dist/controllers/relative_time_controller.js.map +1 -1
  141. package/dist/controllers/resizable_controller.d.ts +3 -1
  142. package/dist/controllers/resizable_controller.js +65 -1
  143. package/dist/controllers/resizable_controller.js.map +1 -1
  144. package/dist/controllers/roving_controller.js +17 -2
  145. package/dist/controllers/roving_controller.js.map +1 -1
  146. package/dist/controllers/scroll_area_controller.js +86 -12
  147. package/dist/controllers/scroll_area_controller.js.map +1 -1
  148. package/dist/controllers/scroll_restore_controller.js +1 -1
  149. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  150. package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
  151. package/dist/controllers/scroll_visibility_controller.js +33 -3
  152. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  153. package/dist/controllers/scrollspy_controller.d.ts +64 -15
  154. package/dist/controllers/scrollspy_controller.js +346 -73
  155. package/dist/controllers/scrollspy_controller.js.map +1 -1
  156. package/dist/controllers/separator_controller.d.ts +20 -5
  157. package/dist/controllers/separator_controller.js +9 -0
  158. package/dist/controllers/separator_controller.js.map +1 -1
  159. package/dist/controllers/sidebar_controller.d.ts +2 -12
  160. package/dist/controllers/sidebar_controller.js +37 -8
  161. package/dist/controllers/sidebar_controller.js.map +1 -1
  162. package/dist/controllers/skeleton_controller.d.ts +7 -2
  163. package/dist/controllers/skeleton_controller.js +73 -20
  164. package/dist/controllers/skeleton_controller.js.map +1 -1
  165. package/dist/controllers/slider_controller.d.ts +11 -1
  166. package/dist/controllers/slider_controller.js +49 -8
  167. package/dist/controllers/slider_controller.js.map +1 -1
  168. package/dist/controllers/sortable_controller.d.ts +3 -1
  169. package/dist/controllers/sortable_controller.js +34 -3
  170. package/dist/controllers/sortable_controller.js.map +1 -1
  171. package/dist/controllers/spinner_controller.d.ts +37 -4
  172. package/dist/controllers/spinner_controller.js +228 -27
  173. package/dist/controllers/spinner_controller.js.map +1 -1
  174. package/dist/controllers/step_indicator_controller.d.ts +12 -1
  175. package/dist/controllers/step_indicator_controller.js +82 -5
  176. package/dist/controllers/step_indicator_controller.js.map +1 -1
  177. package/dist/controllers/stick_to_bottom_controller.d.ts +31 -8
  178. package/dist/controllers/stick_to_bottom_controller.js +61 -10
  179. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  180. package/dist/controllers/sticky_observer_controller.js +32 -11
  181. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  182. package/dist/controllers/submit_once_controller.d.ts +2 -2
  183. package/dist/controllers/submit_once_controller.js.map +1 -1
  184. package/dist/controllers/switch_controller.js +1 -0
  185. package/dist/controllers/switch_controller.js.map +1 -1
  186. package/dist/controllers/tabs_controller.d.ts +11 -3
  187. package/dist/controllers/tabs_controller.js +26 -3
  188. package/dist/controllers/tabs_controller.js.map +1 -1
  189. package/dist/controllers/tags_input_controller.js +22 -2
  190. package/dist/controllers/tags_input_controller.js.map +1 -1
  191. package/dist/controllers/theme_controller.d.ts +1 -1
  192. package/dist/controllers/theme_controller.js +22 -3
  193. package/dist/controllers/theme_controller.js.map +1 -1
  194. package/dist/controllers/time_picker_controller.js +20 -1
  195. package/dist/controllers/time_picker_controller.js.map +1 -1
  196. package/dist/controllers/toast_controller.d.ts +4 -5
  197. package/dist/controllers/toast_controller.js +4 -5
  198. package/dist/controllers/toast_controller.js.map +1 -1
  199. package/dist/controllers/toggle_group_controller.js +23 -2
  200. package/dist/controllers/toggle_group_controller.js.map +1 -1
  201. package/dist/controllers/toolbar_controller.d.ts +53 -13
  202. package/dist/controllers/toolbar_controller.js +230 -31
  203. package/dist/controllers/toolbar_controller.js.map +1 -1
  204. package/dist/controllers/tooltip_controller.d.ts +1 -1
  205. package/dist/controllers/tooltip_controller.js.map +1 -1
  206. package/dist/controllers/transition_controller.js.map +1 -1
  207. package/dist/controllers/tree_view_controller.d.ts +87 -6
  208. package/dist/controllers/tree_view_controller.js +467 -51
  209. package/dist/controllers/tree_view_controller.js.map +1 -1
  210. package/dist/index.js +4507 -907
  211. package/dist/index.js.map +1 -1
  212. package/dist/inspector/cli.d.ts +375 -39
  213. package/dist/inspector/cli.js +1009 -86
  214. package/dist/inspector/cli.js.map +1 -1
  215. package/dist/inspector/cli_bin.js +1200 -120
  216. package/dist/inspector/cli_bin.js.map +1 -1
  217. package/dist/inspector/examples.json +28 -28
  218. package/dist/inspector/manifest.json +1060 -132
  219. package/package.json +5 -6
@@ -2,6 +2,145 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/spinner_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
22
+ // src/utils/before_cache_reset.ts
23
+ var BeforeCacheReset = class _BeforeCacheReset {
24
+ /** Every subscribed instance, iterated by the one shared document listener. */
25
+ static #subscribers = /* @__PURE__ */ new Set();
26
+ /** The shared listener; installed while at least one instance is subscribed. */
27
+ static #onBeforeCache = () => {
28
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
29
+ };
30
+ #rewind;
31
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
32
+ constructor(rewind) {
33
+ this.#rewind = rewind;
34
+ }
35
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
36
+ activate() {
37
+ const first = _BeforeCacheReset.#subscribers.size === 0;
38
+ _BeforeCacheReset.#subscribers.add(this);
39
+ if (first) {
40
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
41
+ }
42
+ }
43
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
44
+ deactivate() {
45
+ _BeforeCacheReset.#subscribers.delete(this);
46
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
47
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
48
+ }
49
+ };
50
+
51
+ // src/utils/detach_gate.ts
52
+ var DetachGate = class _DetachGate {
53
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
54
+ #pending = false;
55
+ /**
56
+ * True when the disconnect is definitely a real detach — the element left
57
+ * the document, or `data-controller` no longer lists the identifier. False
58
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
59
+ */
60
+ static isDetached(host) {
61
+ if (!host.element.isConnected) return true;
62
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
63
+ return !tokens.includes(host.identifier);
64
+ }
65
+ /**
66
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
67
+ * detach (fast path), otherwise defers it one microtask — a reconnect
68
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
69
+ * One microtask is the whole probe window: Stimulus reconnects a moved
70
+ * element within the same mutation batch, before the checkpoint drains.
71
+ */
72
+ disconnected(host, teardown) {
73
+ if (_DetachGate.isDetached(host)) {
74
+ this.#pending = false;
75
+ teardown();
76
+ return;
77
+ }
78
+ this.#pending = true;
79
+ queueMicrotask(() => {
80
+ if (!this.#pending) return;
81
+ this.#pending = false;
82
+ teardown();
83
+ });
84
+ }
85
+ /**
86
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
87
+ * an in-page move) and from the head of any teardown path not routed through
88
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
89
+ * never run the teardown a second time.
90
+ */
91
+ cancel() {
92
+ this.#pending = false;
93
+ }
94
+ };
95
+
96
+ // src/utils/min_duration_floor.ts
97
+ var MinDurationFloor = class {
98
+ #timers;
99
+ /** Pending finish timer id, or `null` when nothing is held back. */
100
+ #timerId = null;
101
+ /** Epoch ms the floor is measured from. */
102
+ #since = 0;
103
+ /** @param timers - the controller's registry; the floor schedules into it. */
104
+ constructor(timers) {
105
+ this.#timers = timers;
106
+ }
107
+ /** Starts the floor: call when the state being held becomes visible. */
108
+ begin() {
109
+ this.#since = Date.now();
110
+ }
111
+ /** True while a finish is held back waiting for the floor to elapse. */
112
+ get pending() {
113
+ return this.#timerId !== null;
114
+ }
115
+ /**
116
+ * Runs `finish` once the floor has elapsed, immediately when it already has.
117
+ *
118
+ * A held-back finish is **replaced**, never stacked: only the most recently
119
+ * queued id is cancellable, so a second timer would outlive every cancel and
120
+ * end a state that has since restarted. Controllers that want the first signal
121
+ * to win guard on {@link pending} before calling.
122
+ */
123
+ schedule(minDuration, finish) {
124
+ this.cancel();
125
+ const remaining = minDuration - (Date.now() - this.#since);
126
+ if (remaining > 0) {
127
+ this.#timerId = this.#timers.set(() => {
128
+ this.#timerId = null;
129
+ finish();
130
+ }, remaining);
131
+ } else {
132
+ finish();
133
+ }
134
+ }
135
+ /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
136
+ cancel() {
137
+ if (this.#timerId !== null) {
138
+ this.#timers.clear(this.#timerId);
139
+ this.#timerId = null;
140
+ }
141
+ }
142
+ };
143
+
5
144
  // src/utils/safe_timeout.ts
6
145
  var TimerRegistry = class {
7
146
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -59,38 +198,61 @@ var SafeTimeout = class extends TimerRegistry {
59
198
  var SpinnerController = class extends Controller {
60
199
  static targets = ["indicator", "region", "message"];
61
200
  static values = {
201
+ announceText: { type: String, default: "" },
202
+ announceReadyText: { type: String, default: "" },
62
203
  delay: { type: Number, default: 0 },
63
- minDuration: { type: Number, default: 0 }
204
+ minDuration: { type: Number, default: 0 },
205
+ timeout: { type: Number, default: 0 }
64
206
  };
65
207
  static actions = ["start", "stop"];
66
- static events = ["hide", "show"];
208
+ static events = ["hide", "show", "timeout"];
67
209
  #timers = new SafeTimeout();
210
+ #floor = new MinDurationFloor(this.#timers);
211
+ #gate = new DetachGate();
212
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
68
213
  /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */
69
214
  #delayTimerId = null;
70
- /** Pending min-duration hide timer id, or `null` when none is scheduled. */
71
- #hideTimerId = null;
72
- /** Epoch ms when the spinner became visible, used to enforce `minDuration`. */
73
- #shownAt = 0;
215
+ /** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */
216
+ #timeoutTimerId = null;
74
217
  connect() {
218
+ this.#gate.cancel();
219
+ this.#beforeCache.activate();
220
+ if (this.#state === "pending" && this.#delayTimerId === null) {
221
+ this.#setBusy(false);
222
+ this.element.setAttribute("data-state", "idle");
223
+ return;
224
+ }
75
225
  if (!this.element.hasAttribute("data-state")) {
76
226
  this.element.setAttribute("data-state", "idle");
77
227
  }
78
228
  }
229
+ /**
230
+ * Re-applies the current phase to an indicator that arrived after `connect()`.
231
+ *
232
+ * A Turbo Stream can swap the indicator for a fresh node mid-load, and that node
233
+ * carries the markup contract's `hidden`. Without this the spinner would vanish
234
+ * while `data-state` still says `loading`, and nothing but the next cycle would
235
+ * bring it back.
236
+ */
237
+ indicatorTargetConnected(target) {
238
+ target.hidden = this.#state !== "loading";
239
+ }
79
240
  disconnect() {
80
- this.#timers.clearAll();
81
- this.#delayTimerId = null;
82
- this.#hideTimerId = null;
241
+ this.#beforeCache.deactivate();
242
+ this.#gate.disconnected(this, () => this.#teardown());
83
243
  }
84
244
  /** Begins loading. Honors `delay` before the spinner actually appears. */
85
245
  start() {
86
246
  if (this.#state === "loading") {
87
247
  this.#setBusy(true);
88
- this.#cancelHide();
248
+ this.#floor.cancel();
249
+ this.#armTimeout();
89
250
  return;
90
251
  }
91
252
  if (this.#state !== "idle") return;
92
253
  this.#setBusy(true);
93
- this.#cancelHide();
254
+ this.#floor.cancel();
255
+ this.#armTimeout();
94
256
  if (this.delayValue > 0) {
95
257
  this.element.setAttribute("data-state", "pending");
96
258
  this.#delayTimerId = this.#timers.set(() => {
@@ -104,6 +266,7 @@ var SpinnerController = class extends Controller {
104
266
  /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */
105
267
  stop() {
106
268
  const state = this.#state;
269
+ this.#cancelTimeout();
107
270
  if (state === "pending") {
108
271
  this.#cancelDelay();
109
272
  this.#setBusy(false);
@@ -112,28 +275,52 @@ var SpinnerController = class extends Controller {
112
275
  }
113
276
  if (state !== "loading") return;
114
277
  this.#setBusy(false);
115
- const remaining = this.minDurationValue - (Date.now() - this.#shownAt);
116
- if (remaining > 0) {
117
- this.#hideTimerId = this.#timers.set(() => {
118
- this.#hideTimerId = null;
119
- this.#hide();
120
- }, remaining);
121
- } else {
122
- this.#hide();
123
- }
278
+ this.#floor.schedule(this.minDurationValue, () => this.#hide());
124
279
  }
125
280
  /** Reveals the indicator, marks the moment shown, and announces via the live region. */
126
281
  #show() {
127
- this.#shownAt = Date.now();
282
+ this.#floor.begin();
283
+ this.#setBusy(true);
128
284
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;
129
285
  this.element.setAttribute("data-state", "loading");
130
286
  this.dispatch("show", { detail: {} });
287
+ announce(fillTemplate(this.announceTextValue, {}));
131
288
  }
132
289
  /** Hides the indicator and returns to the idle state. */
133
290
  #hide() {
134
291
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
135
292
  this.element.setAttribute("data-state", "idle");
136
293
  this.dispatch("hide", { detail: {} });
294
+ announce(fillTemplate(this.announceReadyTextValue, {}));
295
+ }
296
+ /**
297
+ * Drops both timers on a real detach. The markup keeps whatever it last held: an
298
+ * element on its way out of the document has no reader left, and one whose
299
+ * `data-controller` dropped the identifier no longer resolves its own targets, so
300
+ * the rollback could only ever be partial. The snapshot is rewound where it is
301
+ * still whole, on `turbo:before-cache`.
302
+ */
303
+ #teardown() {
304
+ this.#gate.cancel();
305
+ this.#timers.clearAll();
306
+ this.#delayTimerId = null;
307
+ this.#timeoutTimerId = null;
308
+ this.#floor.cancel();
309
+ }
310
+ /**
311
+ * Returns the loading state to idle for the snapshot Turbo is about to take,
312
+ * so a page reached with the Back button is not restored mid-load with a
313
+ * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
314
+ * and `aria-busy`. No `hide` is dispatched — the load was never observed to
315
+ * finish, and a snapshot rewind is not a lifecycle event the consumer can act
316
+ * on. The live page keeps its timers, so a navigation that never completes
317
+ * leaves the running cycle intact.
318
+ */
319
+ #rewindForCache() {
320
+ this.#cancelTimeout();
321
+ this.#setBusy(false);
322
+ if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
323
+ this.element.setAttribute("data-state", "idle");
137
324
  }
138
325
  /** Reflects busy state onto the controlled region (if present). */
139
326
  #setBusy(busy) {
@@ -141,18 +328,32 @@ var SpinnerController = class extends Controller {
141
328
  this.regionTarget.setAttribute("aria-busy", String(busy));
142
329
  }
143
330
  }
331
+ /**
332
+ * Arms the safety net so a `stop` that never arrives cannot strand the spinner.
333
+ * Off by default: the consumer owns the async work, so only it knows whether a
334
+ * ceiling makes sense. Re-arming on a restart measures from the newest start.
335
+ */
336
+ #armTimeout() {
337
+ this.#cancelTimeout();
338
+ if (this.timeoutValue <= 0) return;
339
+ this.#timeoutTimerId = this.#timers.set(() => {
340
+ this.#timeoutTimerId = null;
341
+ this.dispatch("timeout", { detail: {} });
342
+ this.stop();
343
+ }, this.timeoutValue);
344
+ }
345
+ #cancelTimeout() {
346
+ if (this.#timeoutTimerId !== null) {
347
+ this.#timers.clear(this.#timeoutTimerId);
348
+ this.#timeoutTimerId = null;
349
+ }
350
+ }
144
351
  #cancelDelay() {
145
352
  if (this.#delayTimerId !== null) {
146
353
  this.#timers.clear(this.#delayTimerId);
147
354
  this.#delayTimerId = null;
148
355
  }
149
356
  }
150
- #cancelHide() {
151
- if (this.#hideTimerId !== null) {
152
- this.#timers.clear(this.#hideTimerId);
153
- this.#hideTimerId = null;
154
- }
155
- }
156
357
  /** Current lifecycle phase as reflected on `data-state`. */
157
358
  get #state() {
158
359
  return this.element.getAttribute("data-state") ?? "idle";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/spinner_controller.ts"],"names":[],"mappings":";;;;;AAwBA,IAAe,gBAAf,MAA6B;AAAA;AAAA,EAER,GAAA,uBAAU,GAAA,EAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAczC,MAAM,EAAA,EAAkB;AACtB,IAAA,IAAI,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,EAAE,CAAA,EAAG;AACvB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAA,GAAiB;AACf,IAAA,KAAA,MAAW,EAAA,IAAM,KAAK,GAAA,EAAK;AACzB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AACA,IAAA,IAAA,CAAK,IAAI,KAAA,EAAM;AAAA,EACjB;AAAA;AAAA,EAGA,IAAI,IAAA,GAAe;AACjB,IAAA,OAAO,KAAK,GAAA,CAAI,IAAA;AAAA,EAClB;AACF,CAAA;AAmBO,IAAM,WAAA,GAAN,cAA0B,aAAA,CAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO7C,GAAA,CAAI,UAAsB,KAAA,EAAuB;AAC/C,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,QAAA,CAAS,MAAM;AAC7B,MAAA,IAAA,CAAK,GAAA,CAAI,OAAO,EAAE,CAAA;AAClB,MAAA,QAAA,EAAS;AAAA,IACX,GAAG,KAAK,CAAA;AACR,IAAA,IAAA,CAAK,GAAA,CAAI,IAAI,EAAE,CAAA;AACf,IAAA,OAAO,EAAA;AAAA,EACT;AAAA,EAEU,QAAA,CAAS,UAAsB,KAAA,EAAuB;AAC9D,IAAA,OAAO,MAAA,CAAO,UAAA,CAAW,QAAA,EAAU,KAAK,CAAA;AAAA,EAC1C;AAAA,EAEU,OAAO,EAAA,EAAkB;AACjC,IAAA,MAAA,CAAO,aAAa,EAAE,CAAA;AAAA,EACxB;AACF,CAAA;;;ACxEO,IAAM,iBAAA,GAAN,cAAgC,UAAA,CAAwB;AAAA,EAC7D,OAAgB,OAAA,GAAU,CAAC,WAAA,EAAa,UAAU,SAAS,CAAA;AAAA,EAC3D,OAAgB,MAAA,GAAS;AAAA,IACvB,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAClC,WAAA,EAAa,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA;AAAE,GAC1C;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,OAAA,EAAS,MAAM,CAAA;AAAA,EACjC,OAAO,MAAA,GAAS,CAAC,MAAA,EAAQ,MAAM,CAAA;AAAA,EAYtB,OAAA,GAAU,IAAI,WAAA,EAAY;AAAA;AAAA,EAGnC,aAAA,GAA+B,IAAA;AAAA;AAAA,EAE/B,YAAA,GAA8B,IAAA;AAAA;AAAA,EAE9B,QAAA,GAAW,CAAA;AAAA,EAEF,OAAA,GAAgB;AACvB,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAY,CAAA,EAAG;AAC5C,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,MAAM,CAAA;AAAA,IAChD;AAAA,EACF;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AACtB,IAAA,IAAA,CAAK,aAAA,GAAgB,IAAA;AACrB,IAAA,IAAA,CAAK,YAAA,GAAe,IAAA;AAAA,EACtB;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,IAAI,IAAA,CAAK,WAAW,SAAA,EAAW;AAK7B,MAAA,IAAA,CAAK,SAAS,IAAI,CAAA;AAClB,MAAA,IAAA,CAAK,WAAA,EAAY;AACjB,MAAA;AAAA,IACF;AACA,IAAA,IAAI,IAAA,CAAK,WAAW,MAAA,EAAQ;AAC5B,IAAA,IAAA,CAAK,SAAS,IAAI,CAAA;AAElB,IAAA,IAAA,CAAK,WAAA,EAAY;AACjB,IAAA,IAAI,IAAA,CAAK,aAAa,CAAA,EAAG;AACvB,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,SAAS,CAAA;AACjD,MAAA,IAAA,CAAK,aAAA,GAAgB,IAAA,CAAK,OAAA,CAAQ,GAAA,CAAI,MAAM;AAC1C,QAAA,IAAA,CAAK,aAAA,GAAgB,IAAA;AACrB,QAAA,IAAA,CAAK,KAAA,EAAM;AAAA,MACb,CAAA,EAAG,KAAK,UAAU,CAAA;AAAA,IACpB,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,KAAA,EAAM;AAAA,IACb;AAAA,EACF;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,MAAM,QAAQ,IAAA,CAAK,MAAA;AACnB,IAAA,IAAI,UAAU,SAAA,EAAW;AAEvB,MAAA,IAAA,CAAK,YAAA,EAAa;AAClB,MAAA,IAAA,CAAK,SAAS,KAAK,CAAA;AACnB,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,MAAM,CAAA;AAC9C,MAAA;AAAA,IACF;AACA,IAAA,IAAI,UAAU,SAAA,EAAW;AAEzB,IAAA,IAAA,CAAK,SAAS,KAAK,CAAA;AACnB,IAAA,MAAM,YAAY,IAAA,CAAK,gBAAA,IAAoB,IAAA,CAAK,GAAA,KAAQ,IAAA,CAAK,QAAA,CAAA;AAC7D,IAAA,IAAI,YAAY,CAAA,EAAG;AACjB,MAAA,IAAA,CAAK,YAAA,GAAe,IAAA,CAAK,OAAA,CAAQ,GAAA,CAAI,MAAM;AACzC,QAAA,IAAA,CAAK,YAAA,GAAe,IAAA;AACpB,QAAA,IAAA,CAAK,KAAA,EAAM;AAAA,MACb,GAAG,SAAS,CAAA;AAAA,IACd,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,KAAA,EAAM;AAAA,IACb;AAAA,EACF;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,IAAA,CAAK,QAAA,GAAW,KAAK,GAAA,EAAI;AACzB,IAAA,IAAI,IAAA,CAAK,kBAAA,EAAoB,IAAA,CAAK,eAAA,CAAgB,MAAA,GAAS,KAAA;AAC3D,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,SAAS,CAAA;AACjD,IAAA,IAAA,CAAK,SAAS,MAAA,EAAQ,EAAE,MAAA,EAAQ,IAAI,CAAA;AAAA,EACtC;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,IAAI,IAAA,CAAK,kBAAA,EAAoB,IAAA,CAAK,eAAA,CAAgB,MAAA,GAAS,IAAA;AAC3D,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,MAAM,CAAA;AAC9C,IAAA,IAAA,CAAK,SAAS,MAAA,EAAQ,EAAE,MAAA,EAAQ,IAAI,CAAA;AAAA,EACtC;AAAA;AAAA,EAGA,SAAS,IAAA,EAAqB;AAC5B,IAAA,IAAI,KAAK,eAAA,EAAiB;AACxB,MAAA,IAAA,CAAK,YAAA,CAAa,YAAA,CAAa,WAAA,EAAa,MAAA,CAAO,IAAI,CAAC,CAAA;AAAA,IAC1D;AAAA,EACF;AAAA,EAEA,YAAA,GAAqB;AACnB,IAAA,IAAI,IAAA,CAAK,kBAAkB,IAAA,EAAM;AAC/B,MAAA,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,IAAA,CAAK,aAAa,CAAA;AACrC,MAAA,IAAA,CAAK,aAAA,GAAgB,IAAA;AAAA,IACvB;AAAA,EACF;AAAA,EAEA,WAAA,GAAoB;AAClB,IAAA,IAAI,IAAA,CAAK,iBAAiB,IAAA,EAAM;AAC9B,MAAA,IAAA,CAAK,OAAA,CAAQ,KAAA,CAAM,IAAA,CAAK,YAAY,CAAA;AACpC,MAAA,IAAA,CAAK,YAAA,GAAe,IAAA;AAAA,IACtB;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,MAAA,GAAiB;AACnB,IAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAY,CAAA,IAAK,MAAA;AAAA,EACpD;AACF","file":"spinner_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless loading-indicator behavior built on the live-region + `aria-busy`\n * practice (no dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--spinner`):\n * <div data-controller=\"stimeo--spinner\"\n * data-stimeo--spinner-delay-value=\"150\"\n * data-stimeo--spinner-min-duration-value=\"500\"\n * data-action=\"loading:start->stimeo--spinner#start\n * loading:stop->stimeo--spinner#stop\">\n * <div role=\"status\" aria-live=\"polite\" hidden\n * data-stimeo--spinner-target=\"indicator\">\n * <span data-stimeo--spinner-target=\"message\">Loading…</span>\n * </div>\n * <div aria-busy=\"false\" data-stimeo--spinner-target=\"region\"></div>\n * </div>\n *\n * The indicator is a `role=\"status\"` live region carrying *text* (never an icon\n * alone) so screen readers announce loading; the controlled `region` mirrors the\n * busy state via `aria-busy`. Two timers tame flicker: `delay` suppresses the\n * spinner for fast operations, and `minDuration` keeps it visible long enough to\n * be perceived once shown.\n *\n * @remarks\n * Behavior only — the visual spinner is the consumer's, alongside the text and\n * `aria-hidden=\"true\"`. Both timers are owned by {@link SafeTimeout} and torn\n * down on `disconnect()` (Turbo navigation included).\n */\nexport class SpinnerController extends Controller<HTMLElement> {\n static override targets = [\"indicator\", \"region\", \"message\"];\n static override values = {\n delay: { type: Number, default: 0 },\n minDuration: { type: Number, default: 0 },\n };\n static actions = [\"start\", \"stop\"] as const;\n static events = [\"hide\", \"show\"] as const;\n\n declare readonly indicatorTarget: HTMLElement;\n declare readonly regionTarget: HTMLElement;\n declare readonly messageTarget: HTMLElement;\n declare readonly hasIndicatorTarget: boolean;\n declare readonly hasRegionTarget: boolean;\n declare readonly hasMessageTarget: boolean;\n\n declare delayValue: number;\n declare minDurationValue: number;\n\n readonly #timers = new SafeTimeout();\n\n /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */\n #delayTimerId: number | null = null;\n /** Pending min-duration hide timer id, or `null` when none is scheduled. */\n #hideTimerId: number | null = null;\n /** Epoch ms when the spinner became visible, used to enforce `minDuration`. */\n #shownAt = 0;\n\n override connect(): void {\n if (!this.element.hasAttribute(\"data-state\")) {\n this.element.setAttribute(\"data-state\", \"idle\");\n }\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n this.#delayTimerId = null;\n this.#hideTimerId = null;\n }\n\n /** Begins loading. Honors `delay` before the spinner actually appears. */\n start(): void {\n if (this.#state === \"loading\") {\n // Already shown (possibly waiting out `minDuration` after a stop): a quick\n // stop→start within that window must keep the spinner visible. Restore the\n // busy state and cancel the pending hide instead of returning a no-op, which\n // would let the stale hide fire and flicker the spinner away mid-load.\n this.#setBusy(true);\n this.#cancelHide();\n return;\n }\n if (this.#state !== \"idle\") return;\n this.#setBusy(true);\n // A pending hide from a previous cycle is now stale.\n this.#cancelHide();\n if (this.delayValue > 0) {\n this.element.setAttribute(\"data-state\", \"pending\");\n this.#delayTimerId = this.#timers.set(() => {\n this.#delayTimerId = null;\n this.#show();\n }, this.delayValue);\n } else {\n this.#show();\n }\n }\n\n /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */\n stop(): void {\n const state = this.#state;\n if (state === \"pending\") {\n // The delay never elapsed — the spinner never appeared, so just cancel.\n this.#cancelDelay();\n this.#setBusy(false);\n this.element.setAttribute(\"data-state\", \"idle\");\n return;\n }\n if (state !== \"loading\") return;\n\n this.#setBusy(false);\n const remaining = this.minDurationValue - (Date.now() - this.#shownAt);\n if (remaining > 0) {\n this.#hideTimerId = this.#timers.set(() => {\n this.#hideTimerId = null;\n this.#hide();\n }, remaining);\n } else {\n this.#hide();\n }\n }\n\n /** Reveals the indicator, marks the moment shown, and announces via the live region. */\n #show(): void {\n this.#shownAt = Date.now();\n if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;\n this.element.setAttribute(\"data-state\", \"loading\");\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Hides the indicator and returns to the idle state. */\n #hide(): void {\n if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"idle\");\n this.dispatch(\"hide\", { detail: {} });\n }\n\n /** Reflects busy state onto the controlled region (if present). */\n #setBusy(busy: boolean): void {\n if (this.hasRegionTarget) {\n this.regionTarget.setAttribute(\"aria-busy\", String(busy));\n }\n }\n\n #cancelDelay(): void {\n if (this.#delayTimerId !== null) {\n this.#timers.clear(this.#delayTimerId);\n this.#delayTimerId = null;\n }\n }\n\n #cancelHide(): void {\n if (this.#hideTimerId !== null) {\n this.#timers.clear(this.#hideTimerId);\n this.#hideTimerId = null;\n }\n }\n\n /** Current lifecycle phase as reflected on `data-state`. */\n get #state(): string {\n return this.element.getAttribute(\"data-state\") ?? \"idle\";\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/before_cache_reset.ts","../../src/utils/detach_gate.ts","../../src/utils/min_duration_floor.ts","../../src/utils/safe_timeout.ts","../../src/controllers/spinner_controller.ts"],"names":[],"mappings":"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","file":"spinner_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way (the `MicrotaskCoalescer` split).\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\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 #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/**\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 (the `SafeTimeout` / `FocusTrap` lineage —\n * 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 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","import type { SafeTimeout } from \"./safe_timeout\";\n\n/**\n * Holds a transient state visible for a minimum time before letting it end.\n *\n * A loading indicator that appears and disappears within a few frames reads as a\n * flicker rather than as feedback, so several controllers keep one up for a floor\n * of `minDuration` milliseconds measured from the moment it became visible. The\n * shape is always the same: note the moment, and when the end signal arrives\n * either run it now, because the floor has already passed, or hold it on a timer\n * for whatever is left.\n *\n * The floor **borrows** a {@link SafeTimeout} instead of owning one, so a\n * controller that also schedules other work (a show delay) keeps every timer in a\n * single registry and one `clearAll()` in its teardown still drops them all.\n * Cancelling after such a bulk clear is safe: the registry no-ops on an id it no\n * longer holds.\n *\n * Scope is the floor itself. **When an end signal is allowed to arrive at all\n * stays with the controller**, because the answers differ — one keeps the first\n * queued finish and ignores repeats, another lets the newest replace it — and\n * flattening that into a shared default would silently change what the consumers\n * already promise.\n *\n * @example\n * ```ts\n * readonly #timers = new SafeTimeout();\n * readonly #floor = new MinDurationFloor(this.#timers);\n *\n * #show() { this.#floor.begin(); … }\n * stop() { this.#floor.schedule(this.minDurationValue, () => this.#hide()); }\n * ```\n */\nexport class MinDurationFloor {\n readonly #timers: SafeTimeout;\n\n /** Pending finish timer id, or `null` when nothing is held back. */\n #timerId: number | null = null;\n\n /** Epoch ms the floor is measured from. */\n #since = 0;\n\n /** @param timers - the controller's registry; the floor schedules into it. */\n constructor(timers: SafeTimeout) {\n this.#timers = timers;\n }\n\n /** Starts the floor: call when the state being held becomes visible. */\n begin(): void {\n this.#since = Date.now();\n }\n\n /** True while a finish is held back waiting for the floor to elapse. */\n get pending(): boolean {\n return this.#timerId !== null;\n }\n\n /**\n * Runs `finish` once the floor has elapsed, immediately when it already has.\n *\n * A held-back finish is **replaced**, never stacked: only the most recently\n * queued id is cancellable, so a second timer would outlive every cancel and\n * end a state that has since restarted. Controllers that want the first signal\n * to win guard on {@link pending} before calling.\n */\n schedule(minDuration: number, finish: () => void): void {\n this.cancel();\n const remaining = minDuration - (Date.now() - this.#since);\n if (remaining > 0) {\n this.#timerId = this.#timers.set(() => {\n this.#timerId = null;\n finish();\n }, remaining);\n } else {\n finish();\n }\n }\n\n /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */\n cancel(): void {\n if (this.#timerId !== null) {\n this.#timers.clear(this.#timerId);\n this.#timerId = null;\n }\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\nimport { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { DetachGate } from \"../utils/detach_gate\";\nimport { MinDurationFloor } from \"../utils/min_duration_floor\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless loading-indicator behavior built on the live-region + `aria-busy`\n * practice (no dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--spinner`):\n * <div data-controller=\"stimeo--spinner\"\n * data-stimeo--spinner-delay-value=\"150\"\n * data-stimeo--spinner-min-duration-value=\"500\"\n * data-stimeo--spinner-timeout-value=\"0\"\n * data-action=\"loading:start->stimeo--spinner#start\n * loading:stop->stimeo--spinner#stop\">\n * <div role=\"status\" aria-live=\"polite\" hidden\n * data-stimeo--spinner-target=\"indicator\">\n * <span data-stimeo--spinner-target=\"message\">Loading…</span>\n * </div>\n * <div aria-busy=\"false\" data-stimeo--spinner-target=\"region\"></div>\n * </div>\n *\n * The indicator is a `role=\"status\"` live region carrying *text* (never an icon\n * alone) so screen readers announce loading; the controlled `region` mirrors the\n * busy state via `aria-busy`. Two timers tame flicker: `delay` suppresses the\n * spinner for fast operations, and `minDuration` keeps it visible long enough to\n * be perceived once shown. `timeout` is the opt-in safety net for the case the\n * consumer's `stop` never arrives.\n *\n * Events (all with an empty `detail`):\n * - `stimeo--spinner:show` — the indicator became visible, after `delay`.\n * - `stimeo--spinner:hide` — the indicator went away, after `minDuration`.\n * - `stimeo--spinner:timeout` — `timeout` elapsed with the load still running;\n * the controller then ends it exactly as `stop` would, so a `hide` follows.\n *\n * @remarks\n * Behavior only — the visual spinner is the consumer's, alongside the text and\n * `aria-hidden=\"true\"`. Both timers are owned by {@link SafeTimeout}, kept across\n * an in-page move and dropped on a real detach via {@link DetachGate}, while the\n * loading state a cached page would freeze is rewound by {@link BeforeCacheReset}.\n */\nexport class SpinnerController extends Controller<HTMLElement> {\n static override targets = [\"indicator\", \"region\", \"message\"];\n static override values = {\n announceText: { type: String, default: \"\" },\n announceReadyText: { type: String, default: \"\" },\n delay: { type: Number, default: 0 },\n minDuration: { type: Number, default: 0 },\n timeout: { type: Number, default: 0 },\n };\n static actions = [\"start\", \"stop\"] as const;\n static events = [\"hide\", \"show\", \"timeout\"] as const;\n\n declare readonly indicatorTarget: HTMLElement;\n declare readonly regionTarget: HTMLElement;\n declare readonly messageTarget: HTMLElement;\n declare readonly hasIndicatorTarget: boolean;\n declare readonly hasRegionTarget: boolean;\n declare readonly hasMessageTarget: boolean;\n\n declare delayValue: number;\n declare minDurationValue: number;\n declare timeoutValue: number;\n declare announceTextValue: string;\n declare announceReadyTextValue: string;\n\n readonly #timers = new SafeTimeout();\n readonly #floor = new MinDurationFloor(this.#timers);\n readonly #gate = new DetachGate();\n readonly #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());\n\n /** Pending show-delay timer id, or `null` when no start is awaiting its delay. */\n #delayTimerId: number | null = null;\n /** Pending safety-net timer id, or `null` when `timeout` is off or not armed. */\n #timeoutTimerId: number | null = null;\n\n override connect(): void {\n this.#gate.cancel();\n this.#beforeCache.activate();\n if (this.#state === \"pending\" && this.#delayTimerId === null) {\n // `pending` lives exactly as long as its show-delay timer. Reading it back with\n // no timer of this instance behind it means the markup outlived the timer — a\n // restored snapshot, or an element re-attached too late to count as a move — so\n // nothing is left to advance it and `start()` would refuse every later load.\n // Fall back to idle, dropping the busy flag the interrupted `start()` set.\n this.#setBusy(false);\n this.element.setAttribute(\"data-state\", \"idle\");\n return;\n }\n if (!this.element.hasAttribute(\"data-state\")) {\n this.element.setAttribute(\"data-state\", \"idle\");\n }\n }\n\n /**\n * Re-applies the current phase to an indicator that arrived after `connect()`.\n *\n * A Turbo Stream can swap the indicator for a fresh node mid-load, and that node\n * carries the markup contract's `hidden`. Without this the spinner would vanish\n * while `data-state` still says `loading`, and nothing but the next cycle would\n * bring it back.\n */\n indicatorTargetConnected(target: HTMLElement): void {\n target.hidden = this.#state !== \"loading\";\n }\n\n override disconnect(): void {\n // Symmetric with `connect()` regardless of why the disconnect came: an in-page\n // move re-subscribes, and only the timers are held back for the reconnect.\n this.#beforeCache.deactivate();\n this.#gate.disconnected(this, () => this.#teardown());\n }\n\n /** Begins loading. Honors `delay` before the spinner actually appears. */\n start(): void {\n if (this.#state === \"loading\") {\n // Already shown (possibly waiting out `minDuration` after a stop): a quick\n // stop→start within that window must keep the spinner visible. Restore the\n // busy state and cancel the pending hide instead of returning a no-op, which\n // would let the stale hide fire and flicker the spinner away mid-load.\n this.#setBusy(true);\n this.#floor.cancel();\n this.#armTimeout();\n return;\n }\n if (this.#state !== \"idle\") return;\n this.#setBusy(true);\n // A hide held back from a previous cycle is now stale.\n this.#floor.cancel();\n this.#armTimeout();\n if (this.delayValue > 0) {\n this.element.setAttribute(\"data-state\", \"pending\");\n this.#delayTimerId = this.#timers.set(() => {\n this.#delayTimerId = null;\n this.#show();\n }, this.delayValue);\n } else {\n this.#show();\n }\n }\n\n /** Ends loading. Honors `minDuration` so a shown spinner does not flicker. */\n stop(): void {\n const state = this.#state;\n this.#cancelTimeout();\n if (state === \"pending\") {\n // The delay never elapsed — the spinner never appeared, so just cancel.\n this.#cancelDelay();\n this.#setBusy(false);\n this.element.setAttribute(\"data-state\", \"idle\");\n return;\n }\n if (state !== \"loading\") return;\n\n this.#setBusy(false);\n this.#floor.schedule(this.minDurationValue, () => this.#hide());\n }\n\n /** Reveals the indicator, marks the moment shown, and announces via the live region. */\n #show(): void {\n this.#floor.begin();\n // A visible spinner always means a busy region. Re-asserting it costs nothing on\n // the direct path and closes the one where a snapshot rewind cleared the flag\n // while the show-delay timer it deliberately spared was still armed.\n this.#setBusy(true);\n if (this.hasIndicatorTarget) this.indicatorTarget.hidden = false;\n this.element.setAttribute(\"data-state\", \"loading\");\n this.dispatch(\"show\", { detail: {} });\n // loading ↔ ready is the transition worth reading; the spinner itself is\n // visual and carries no words.\n announce(fillTemplate(this.announceTextValue, {}));\n }\n\n /** Hides the indicator and returns to the idle state. */\n #hide(): void {\n if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"idle\");\n this.dispatch(\"hide\", { detail: {} });\n announce(fillTemplate(this.announceReadyTextValue, {}));\n }\n\n /**\n * Drops both timers on a real detach. The markup keeps whatever it last held: an\n * element on its way out of the document has no reader left, and one whose\n * `data-controller` dropped the identifier no longer resolves its own targets, so\n * the rollback could only ever be partial. The snapshot is rewound where it is\n * still whole, on `turbo:before-cache`.\n */\n #teardown(): void {\n this.#gate.cancel();\n this.#timers.clearAll();\n this.#delayTimerId = null;\n this.#timeoutTimerId = null;\n this.#floor.cancel();\n }\n\n /**\n * Returns the loading state to idle for the snapshot Turbo is about to take,\n * so a page reached with the Back button is not restored mid-load with a\n * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,\n * and `aria-busy`. No `hide` is dispatched — the load was never observed to\n * finish, and a snapshot rewind is not a lifecycle event the consumer can act\n * on. The live page keeps its timers, so a navigation that never completes\n * leaves the running cycle intact.\n */\n #rewindForCache(): void {\n this.#cancelTimeout();\n this.#setBusy(false);\n if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;\n this.element.setAttribute(\"data-state\", \"idle\");\n }\n\n /** Reflects busy state onto the controlled region (if present). */\n #setBusy(busy: boolean): void {\n if (this.hasRegionTarget) {\n this.regionTarget.setAttribute(\"aria-busy\", String(busy));\n }\n }\n\n /**\n * Arms the safety net so a `stop` that never arrives cannot strand the spinner.\n * Off by default: the consumer owns the async work, so only it knows whether a\n * ceiling makes sense. Re-arming on a restart measures from the newest start.\n */\n #armTimeout(): void {\n this.#cancelTimeout();\n if (this.timeoutValue <= 0) return;\n this.#timeoutTimerId = this.#timers.set(() => {\n this.#timeoutTimerId = null;\n this.dispatch(\"timeout\", { detail: {} });\n this.stop();\n }, this.timeoutValue);\n }\n\n #cancelTimeout(): void {\n if (this.#timeoutTimerId !== null) {\n this.#timers.clear(this.#timeoutTimerId);\n this.#timeoutTimerId = null;\n }\n }\n\n #cancelDelay(): void {\n if (this.#delayTimerId !== null) {\n this.#timers.clear(this.#delayTimerId);\n this.#delayTimerId = null;\n }\n }\n\n /** Current lifecycle phase as reflected on `data-state`. */\n get #state(): string {\n return this.element.getAttribute(\"data-state\") ?? \"idle\";\n }\n}\n"]}
@@ -25,6 +25,7 @@ import { Controller } from '@hotwired/stimulus';
25
25
  *
26
26
  * Behavior provided:
27
27
  * - Reflects `current` onto each step's `data-state` and `aria-current`.
28
+ * - Re-derives every step when the step set changes at runtime.
28
29
  * - `setCurrent` (bound to an event whose `detail.current` is the 0-based index)
29
30
  * updates the current step and dispatches `stimeo--step-indicator:change`.
30
31
  */
@@ -43,9 +44,19 @@ declare class StepIndicatorController extends Controller<HTMLElement> {
43
44
  currentValue: number;
44
45
  /** Renders the initial state from the `current` value. */
45
46
  connect(): void;
47
+ /** Closes the window in which a queued repaint may still run. */
48
+ disconnect(): void;
49
+ /** Syncs a step appended or replaced at runtime (the consumer owns the list). */
50
+ stepTargetConnected(): void;
51
+ /** Re-derives the remaining steps when one is removed at runtime. */
52
+ stepTargetDisconnected(): void;
53
+ /** Repaints when application code (or a Turbo morph) changes `current` at runtime. */
54
+ currentValueChanged(): void;
46
55
  /**
47
56
  * Updates the current step from an external event (`detail.current`, 0-based)
48
- * and dispatches `change`. Out-of-range indices are clamped to the step set.
57
+ * and dispatches `change`. Out-of-range indices are clamped to the step set,
58
+ * and both sides of the no-op test are clamped, so moving onto the step an
59
+ * out-of-range `current` already renders is not reported as a change.
49
60
  */
50
61
  setCurrent(event: CustomEvent<{
51
62
  current?: number;
@@ -1,5 +1,40 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/step_indicator_controller.ts
4
+
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
3
38
  // src/controllers/step_indicator_controller.ts
4
39
  var StepIndicatorController = class extends Controller {
5
40
  static targets = ["step"];
@@ -8,26 +43,63 @@ var StepIndicatorController = class extends Controller {
8
43
  };
9
44
  static actions = ["setCurrent"];
10
45
  static events = ["change"];
46
+ /**
47
+ * Whether the target callbacks may render. Stimulus reports the authored steps
48
+ * as connected before `connect()` and the remaining ones as disconnected after
49
+ * `disconnect()`, so this keeps a connect at one render pass, not one per step.
50
+ */
51
+ /**
52
+ * Collapses a batch of step callbacks — and a morph that swaps `current` with
53
+ * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
54
+ * each one would otherwise rewrite every step's state.
55
+ */
56
+ #repaint = new MicrotaskCoalescer(() => this.#render());
11
57
  /** Renders the initial state from the `current` value. */
12
58
  connect() {
59
+ this.#repaint.activate();
13
60
  this.#render();
14
61
  }
62
+ /** Closes the window in which a queued repaint may still run. */
63
+ disconnect() {
64
+ this.#repaint.cancel();
65
+ }
66
+ /** Syncs a step appended or replaced at runtime (the consumer owns the list). */
67
+ stepTargetConnected() {
68
+ this.#repaint.schedule();
69
+ }
70
+ /** Re-derives the remaining steps when one is removed at runtime. */
71
+ stepTargetDisconnected() {
72
+ this.#repaint.schedule();
73
+ }
74
+ /** Repaints when application code (or a Turbo morph) changes `current` at runtime. */
75
+ currentValueChanged() {
76
+ this.#repaint.schedule();
77
+ }
15
78
  /**
16
79
  * Updates the current step from an external event (`detail.current`, 0-based)
17
- * and dispatches `change`. Out-of-range indices are clamped to the step set.
80
+ * and dispatches `change`. Out-of-range indices are clamped to the step set,
81
+ * and both sides of the no-op test are clamped, so moving onto the step an
82
+ * out-of-range `current` already renders is not reported as a change.
18
83
  */
19
84
  setCurrent(event) {
20
85
  const next = event.detail?.current;
21
86
  if (typeof next !== "number" || !Number.isFinite(next)) return;
22
87
  const clamped = this.#clamp(next);
23
- if (clamped === this.currentValue) return;
88
+ const moved = clamped !== this.#clamp(this.currentValue);
24
89
  this.currentValue = clamped;
90
+ if (!moved) return;
25
91
  this.#render();
26
92
  this.dispatch("change", {
27
93
  detail: { current: clamped, total: this.stepTargets.length }
28
94
  });
29
95
  }
30
- /** Applies `data-state`, `aria-current`, and the progress ratio custom property. */
96
+ /**
97
+ * Applies `data-state`, `aria-current`, and the progress ratio custom property.
98
+ *
99
+ * A pure function of the step set and `current`, so running it again writes the
100
+ * same values — which is what lets the action path paint synchronously (the event
101
+ * goes out after the DOM is updated) while a coalesced pass may still follow.
102
+ */
31
103
  #render() {
32
104
  const total = this.stepTargets.length;
33
105
  const current = this.#clamp(this.currentValue);
@@ -42,10 +114,15 @@ var StepIndicatorController = class extends Controller {
42
114
  const ratio = total > 1 ? current / (total - 1) : 0;
43
115
  this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
44
116
  }
45
- /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */
117
+ /**
118
+ * Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
119
+ * non-finite index falls back to the first step: `current` is read from markup,
120
+ * so an unparsable attribute arrives as `NaN` and would otherwise propagate
121
+ * into every state hook.
122
+ */
46
123
  #clamp(index) {
47
124
  const last = this.stepTargets.length - 1;
48
- if (last < 0) return 0;
125
+ if (last < 0 || !Number.isFinite(index)) return 0;
49
126
  return Math.min(last, Math.max(0, Math.trunc(index)));
50
127
  }
51
128
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/step_indicator_controller.ts"],"names":[],"mappings":";;;AA8BO,IAAM,uBAAA,GAAN,cAAsC,UAAA,CAAwB;AAAA,EACnE,OAAgB,OAAA,GAAU,CAAC,MAAM,CAAA;AAAA,EACjC,OAAgB,MAAA,GAAS;AAAA,IACvB,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA;AAAE,GACtC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,YAAY,CAAA;AAAA,EAC9B,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA,EAMhB,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,WAAW,KAAA,EAAgD;AACzD,IAAA,MAAM,IAAA,GAAO,MAAM,MAAA,EAAQ,OAAA;AAC3B,IAAA,IAAI,OAAO,IAAA,KAAS,QAAA,IAAY,CAAC,MAAA,CAAO,QAAA,CAAS,IAAI,CAAA,EAAG;AACxD,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,MAAA,CAAO,IAAI,CAAA;AAChC,IAAA,IAAI,OAAA,KAAY,KAAK,YAAA,EAAc;AACnC,IAAA,IAAA,CAAK,YAAA,GAAe,OAAA;AACpB,IAAA,IAAA,CAAK,OAAA,EAAQ;AACb,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU;AAAA,MACtB,QAAQ,EAAE,OAAA,EAAS,SAAS,KAAA,EAAO,IAAA,CAAK,YAAY,MAAA;AAAO,KAC5D,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,MAAM,KAAA,GAAQ,KAAK,WAAA,CAAY,MAAA;AAC/B,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,MAAA,CAAO,IAAA,CAAK,YAAY,CAAA;AAC7C,IAAA,IAAA,CAAK,WAAA,CAAY,OAAA,CAAQ,CAAC,IAAA,EAAM,KAAA,KAAU;AACxC,MAAA,IAAA,CAAK,QAAQ,KAAA,GACX,KAAA,GAAQ,UAAU,UAAA,GAAa,KAAA,KAAU,UAAU,SAAA,GAAY,UAAA;AACjE,MAAA,IAAI,UAAU,OAAA,EAAS;AACrB,QAAA,IAAA,CAAK,YAAA,CAAa,gBAAgB,MAAM,CAAA;AAAA,MAC1C,CAAA,MAAO;AACL,QAAA,IAAA,CAAK,gBAAgB,cAAc,CAAA;AAAA,MACrC;AAAA,IACF,CAAC,CAAA;AACD,IAAA,MAAM,KAAA,GAAQ,KAAA,GAAQ,CAAA,GAAI,OAAA,IAAW,QAAQ,CAAA,CAAA,GAAK,CAAA;AAClD,IAAA,IAAA,CAAK,QAAQ,KAAA,CAAM,WAAA,CAAY,+BAAA,EAAiC,MAAA,CAAO,KAAK,CAAC,CAAA;AAAA,EAC/E;AAAA;AAAA,EAGA,OAAO,KAAA,EAAuB;AAC5B,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,CAAA;AACvC,IAAA,IAAI,IAAA,GAAO,GAAG,OAAO,CAAA;AACrB,IAAA,OAAO,IAAA,CAAK,GAAA,CAAI,IAAA,EAAM,IAAA,CAAK,GAAA,CAAI,GAAG,IAAA,CAAK,KAAA,CAAM,KAAK,CAAC,CAAC,CAAA;AAAA,EACtD;AACF","file":"step_indicator_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible **read-only** step-progress indicator.\n *\n * Markup contract (identifier: `stimeo--step-indicator`):\n * <ol data-controller=\"stimeo--step-indicator\" aria-label=\"Checkout progress\"\n * data-stimeo--step-indicator-current-value=\"1\"\n * data-action=\"step:set->stimeo--step-indicator#setCurrent\">\n * <li data-stimeo--step-indicator-target=\"step\">Cart</li>\n * <li data-stimeo--step-indicator-target=\"step\">Shipping</li>\n * <li data-stimeo--step-indicator-target=\"step\">Payment</li>\n * </ol>\n *\n * There is no dedicated APG widget; the current position is expressed with\n * `aria-current=\"step\"`. This indicator is **read only** — it never moves focus\n * and the steps are not operable. For an interactive wizard whose steps are\n * `<button>`s, use {@link StepperController | Stepper}.\n *\n * @remarks\n * Behavior only. Each step `<li>` gets a `data-state` (`complete`/`current`/\n * `upcoming`) derived from the current index; the consumer draws the circles,\n * lines, and numbers from those hooks. A `--stimeo-step-indicator-ratio`\n * (0–1) custom property on the root expresses overall progress for CSS.\n *\n * Behavior provided:\n * - Reflects `current` onto each step's `data-state` and `aria-current`.\n * - `setCurrent` (bound to an event whose `detail.current` is the 0-based index)\n * updates the current step and dispatches `stimeo--step-indicator:change`.\n */\nexport class StepIndicatorController extends Controller<HTMLElement> {\n static override targets = [\"step\"];\n static override values = {\n current: { type: Number, default: 0 },\n };\n static actions = [\"setCurrent\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly stepTargets: HTMLElement[];\n declare currentValue: number;\n\n /** Renders the initial state from the `current` value. */\n override connect(): void {\n this.#render();\n }\n\n /**\n * Updates the current step from an external event (`detail.current`, 0-based)\n * and dispatches `change`. Out-of-range indices are clamped to the step set.\n */\n setCurrent(event: CustomEvent<{ current?: number }>): void {\n const next = event.detail?.current;\n if (typeof next !== \"number\" || !Number.isFinite(next)) return;\n const clamped = this.#clamp(next);\n if (clamped === this.currentValue) return;\n this.currentValue = clamped;\n this.#render();\n this.dispatch(\"change\", {\n detail: { current: clamped, total: this.stepTargets.length },\n });\n }\n\n /** Applies `data-state`, `aria-current`, and the progress ratio custom property. */\n #render(): void {\n const total = this.stepTargets.length;\n const current = this.#clamp(this.currentValue);\n this.stepTargets.forEach((step, index) => {\n step.dataset.state =\n index < current ? \"complete\" : index === current ? \"current\" : \"upcoming\";\n if (index === current) {\n step.setAttribute(\"aria-current\", \"step\");\n } else {\n step.removeAttribute(\"aria-current\");\n }\n });\n const ratio = total > 1 ? current / (total - 1) : 0;\n this.element.style.setProperty(\"--stimeo-step-indicator-ratio\", String(ratio));\n }\n\n /** Constrains an index to `[0, total-1]` (or `0` when there are no steps). */\n #clamp(index: number): number {\n const last = this.stepTargets.length - 1;\n if (last < 0) return 0;\n return Math.min(last, Math.max(0, Math.trunc(index)));\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/microtask_coalescer.ts","../../src/controllers/step_indicator_controller.ts"],"names":[],"mappings":"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","file":"step_indicator_controller.js","sourcesContent":["/**\n * Collapses many target callbacks from one DOM mutation into a single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element, so\n * replacing a list of N options delivers N callbacks — but the useful unit of\n * work is \"reconcile against the DOM that resulted\", once, after the batch has\n * settled. Every controller that owns a reconcilable target set needs the same\n * shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers the *initial* target callbacks\n * ahead of `connect()`. Reconciling there would compute a fallback against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\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 #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/**\n * Headless, accessible **read-only** step-progress indicator.\n *\n * Markup contract (identifier: `stimeo--step-indicator`):\n * <ol data-controller=\"stimeo--step-indicator\" aria-label=\"Checkout progress\"\n * data-stimeo--step-indicator-current-value=\"1\"\n * data-action=\"step:set->stimeo--step-indicator#setCurrent\">\n * <li data-stimeo--step-indicator-target=\"step\">Cart</li>\n * <li data-stimeo--step-indicator-target=\"step\">Shipping</li>\n * <li data-stimeo--step-indicator-target=\"step\">Payment</li>\n * </ol>\n *\n * There is no dedicated APG widget; the current position is expressed with\n * `aria-current=\"step\"`. This indicator is **read only** — it never moves focus\n * and the steps are not operable. For an interactive wizard whose steps are\n * `<button>`s, use {@link StepperController | Stepper}.\n *\n * @remarks\n * Behavior only. Each step `<li>` gets a `data-state` (`complete`/`current`/\n * `upcoming`) derived from the current index; the consumer draws the circles,\n * lines, and numbers from those hooks. A `--stimeo-step-indicator-ratio`\n * (0–1) custom property on the root expresses overall progress for CSS.\n *\n * Behavior provided:\n * - Reflects `current` onto each step's `data-state` and `aria-current`.\n * - Re-derives every step when the step set changes at runtime.\n * - `setCurrent` (bound to an event whose `detail.current` is the 0-based index)\n * updates the current step and dispatches `stimeo--step-indicator:change`.\n */\nexport class StepIndicatorController extends Controller<HTMLElement> {\n static override targets = [\"step\"];\n static override values = {\n current: { type: Number, default: 0 },\n };\n static actions = [\"setCurrent\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly stepTargets: HTMLElement[];\n declare currentValue: number;\n\n /**\n * Whether the target callbacks may render. Stimulus reports the authored steps\n * as connected before `connect()` and the remaining ones as disconnected after\n * `disconnect()`, so this keeps a connect at one render pass, not one per step.\n */\n /**\n * Collapses a batch of step callbacks — and a morph that swaps `current` with\n * them — into one repaint. Replacing a list of N steps delivers N callbacks, and\n * each one would otherwise rewrite every step's state.\n */\n readonly #repaint = new MicrotaskCoalescer(() => this.#render());\n\n /** Renders the initial state from the `current` value. */\n override connect(): void {\n this.#repaint.activate();\n this.#render();\n }\n\n /** Closes the window in which a queued repaint may still run. */\n override disconnect(): void {\n this.#repaint.cancel();\n }\n\n /** Syncs a step appended or replaced at runtime (the consumer owns the list). */\n stepTargetConnected(): void {\n this.#repaint.schedule();\n }\n\n /** Re-derives the remaining steps when one is removed at runtime. */\n stepTargetDisconnected(): void {\n this.#repaint.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `current` at runtime. */\n currentValueChanged(): void {\n this.#repaint.schedule();\n }\n\n /**\n * Updates the current step from an external event (`detail.current`, 0-based)\n * and dispatches `change`. Out-of-range indices are clamped to the step set,\n * and both sides of the no-op test are clamped, so moving onto the step an\n * out-of-range `current` already renders is not reported as a change.\n */\n setCurrent(event: CustomEvent<{ current?: number }>): void {\n const next = event.detail?.current;\n if (typeof next !== \"number\" || !Number.isFinite(next)) return;\n const clamped = this.#clamp(next);\n const moved = clamped !== this.#clamp(this.currentValue);\n // Normalise even when the display does not move: an out-of-range `current` left\n // in the markup would otherwise be re-clamped against a later step set and land\n // somewhere the consumer never asked for. Writing it here is the consumer-driven\n // path, the only one that owns the Value.\n this.currentValue = clamped;\n if (!moved) return;\n this.#render();\n this.dispatch(\"change\", {\n detail: { current: clamped, total: this.stepTargets.length },\n });\n }\n\n /**\n * Applies `data-state`, `aria-current`, and the progress ratio custom property.\n *\n * A pure function of the step set and `current`, so running it again writes the\n * same values — which is what lets the action path paint synchronously (the event\n * goes out after the DOM is updated) while a coalesced pass may still follow.\n */\n #render(): void {\n const total = this.stepTargets.length;\n const current = this.#clamp(this.currentValue);\n this.stepTargets.forEach((step, index) => {\n step.dataset.state =\n index < current ? \"complete\" : index === current ? \"current\" : \"upcoming\";\n if (index === current) {\n step.setAttribute(\"aria-current\", \"step\");\n } else {\n step.removeAttribute(\"aria-current\");\n }\n });\n const ratio = total > 1 ? current / (total - 1) : 0;\n this.element.style.setProperty(\"--stimeo-step-indicator-ratio\", String(ratio));\n }\n\n /**\n * Constrains an index to `[0, total-1]` (or `0` when there are no steps). A\n * non-finite index falls back to the first step: `current` is read from markup,\n * so an unparsable attribute arrives as `NaN` and would otherwise propagate\n * into every state hook.\n */\n #clamp(index: number): number {\n const last = this.stepTargets.length - 1;\n if (last < 0 || !Number.isFinite(index)) return 0;\n return Math.min(last, Math.max(0, Math.trunc(index)));\n }\n}\n"]}