stimeo-ui 0.4.0 → 0.6.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +184 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/breadcrumb_controller.js +5 -1
  6. data/dist/controllers/carousel_controller.js +90 -10
  7. data/dist/controllers/checkbox_controller.js +136 -25
  8. data/dist/controllers/clipboard_controller.js +8 -3
  9. data/dist/controllers/collapsible_controller.js +4 -1
  10. data/dist/controllers/color_picker_controller.js +41 -11
  11. data/dist/controllers/context_menu_controller.js +2 -2
  12. data/dist/controllers/countdown_controller.js +5 -1
  13. data/dist/controllers/date_range_picker_controller.js +162 -31
  14. data/dist/controllers/direct_upload_controller.js +3 -3
  15. data/dist/controllers/empty_state_controller.js +107 -16
  16. data/dist/controllers/file_dropzone_controller.js +26 -3
  17. data/dist/controllers/flash_controller.js +161 -21
  18. data/dist/controllers/form_validation_controller.js +8 -2
  19. data/dist/controllers/frame_loading_controller.js +94 -22
  20. data/dist/controllers/highlight_controller.js +38 -1
  21. data/dist/controllers/idle_controller.js +39 -6
  22. data/dist/controllers/local_time_controller.js +2 -0
  23. data/dist/controllers/masonry_controller.js +1 -1
  24. data/dist/controllers/menubar_controller.js +5 -3
  25. data/dist/controllers/meter_controller.js +3 -1
  26. data/dist/controllers/multi_select_controller.js +460 -151
  27. data/dist/controllers/network_status_controller.js +1 -3
  28. data/dist/controllers/number_input_controller.js +455 -64
  29. data/dist/controllers/overflow_menu_controller.js +5 -1
  30. data/dist/controllers/pagination_controller.js +38 -1
  31. data/dist/controllers/password_strength_controller.js +21 -3
  32. data/dist/controllers/persist_controller.js +24 -5
  33. data/dist/controllers/pointer_drag_controller.js +10 -0
  34. data/dist/controllers/portal_controller.js +10 -0
  35. data/dist/controllers/progress_controller.js +7 -3
  36. data/dist/controllers/radio_group_controller.js +540 -56
  37. data/dist/controllers/range_slider_controller.js +385 -94
  38. data/dist/controllers/rating_controller.js +274 -89
  39. data/dist/controllers/relative_time_controller.js +2 -0
  40. data/dist/controllers/resizable_controller.js +33 -0
  41. data/dist/controllers/roving_controller.js +60 -5
  42. data/dist/controllers/scroll_area_controller.js +155 -23
  43. data/dist/controllers/scroll_visibility_controller.js +33 -0
  44. data/dist/controllers/separator_controller.js +13 -17
  45. data/dist/controllers/skeleton_controller.js +71 -3
  46. data/dist/controllers/slider_controller.js +325 -48
  47. data/dist/controllers/spinner_controller.js +18 -3
  48. data/dist/controllers/step_indicator_controller.js +3 -1
  49. data/dist/controllers/stepper_controller.js +2 -0
  50. data/dist/controllers/switch_controller.js +162 -18
  51. data/dist/controllers/tags_input_controller.js +356 -120
  52. data/dist/controllers/textarea_autosize_controller.js +1 -1
  53. data/dist/controllers/time_picker_controller.js +296 -104
  54. data/dist/controllers/toggle_group_controller.js +378 -55
  55. data/dist/controllers/toolbar_controller.js +5 -3
  56. data/dist/controllers/tree_view_controller.js +24 -4
  57. data/dist/index.js +3811 -1048
  58. data/lib/stimeo/ui/version.rb +1 -1
  59. metadata +2 -2
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/flash_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/safe_timeout.ts
6
35
  var TimerRegistry = class {
7
36
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -103,29 +132,119 @@ var FlashController = class extends Controller {
103
132
  static events = ["show", "dismiss"];
104
133
  #timers = new SafeTimeout();
105
134
  #observer = null;
135
+ /** Whether the controller is between `connect()` and `disconnect()`. */
136
+ #connected = false;
106
137
  /** Auto-dismiss timer state keyed by message element. */
107
138
  #state = /* @__PURE__ */ new Map();
108
139
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
109
140
  #order = [];
110
- #onEnter = (event) => this.#pause(event.currentTarget);
111
- #onLeave = (event) => this.#resume(event.currentTarget);
141
+ /**
142
+ * Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}
143
+ * releases the bookkeeping above *before* the transition wait, so for that window the
144
+ * element is in the DOM but in neither collection — without this set a re-scan would
145
+ * read it as a brand-new flash and show it a second time.
146
+ */
147
+ #leaving = /* @__PURE__ */ new Set();
148
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
149
+ #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
150
+ #onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
112
151
  connect() {
113
- if (!this.hasRegionTarget) return;
152
+ this.#connected = true;
114
153
  for (const message of this.messageTargets) {
115
- this.#process(message, true);
116
- }
117
- if (typeof MutationObserver !== "undefined") {
118
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
119
- this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
154
+ if (this.#owns(message)) this.#process(message, true);
120
155
  }
156
+ this.#syncObservation();
157
+ this.#beforeCache.activate();
121
158
  }
122
159
  disconnect() {
123
- this.#observer?.disconnect();
124
- this.#observer = null;
160
+ this.#connected = false;
161
+ this.#beforeCache.deactivate();
162
+ this.#stopObserving();
125
163
  this.#timers.clearAll();
126
164
  for (const message of this.#order) this.#unbindPause(message);
127
165
  this.#state.clear();
128
166
  this.#order.length = 0;
167
+ this.#leaving.clear();
168
+ }
169
+ /**
170
+ * Takes the managed flashes out of the page just before Turbo freezes it, so a
171
+ * restored snapshot carries no notification the visitor has already received: the
172
+ * fresh `connect()` there reads a leftover flash as a brand-new one and announces it
173
+ * a second time. A message that never auto-dismisses (`duration: 0`) is one of these
174
+ * too — that value governs the timer, not what belongs in a cached page. Removal
175
+ * only: `dismiss` reports a dismissal, and freezing the page is not one.
176
+ */
177
+ #rewindForCache() {
178
+ for (const message of [...this.#order]) {
179
+ message.remove();
180
+ this.#forget(message);
181
+ }
182
+ for (const message of this.#leaving) message.remove();
183
+ this.#leaving.clear();
184
+ }
185
+ /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
186
+ regionTargetConnected() {
187
+ this.#resync();
188
+ }
189
+ /** Releases the observation when the `region` element leaves the target set. */
190
+ regionTargetDisconnected() {
191
+ this.#resync();
192
+ }
193
+ /**
194
+ * Whether this controller owns `message`. Ownership is the current `region`'s
195
+ * subtree: a message target anywhere else in the controller's scope is the
196
+ * consumer's, and so is one in a region that has gone away. The initial scan, a
197
+ * re-scan after a `region` swap, and a departure from the target set all resolve
198
+ * ownership through this one test; the observation gets it structurally, by watching
199
+ * that subtree and nothing else.
200
+ */
201
+ #owns(message) {
202
+ return this.hasRegionTarget && this.regionTarget.contains(message);
203
+ }
204
+ /**
205
+ * Releases a message that left the target set (a Turbo Stream `remove`, the consumer
206
+ * detaching the node, or a morph that rewrote the target attribute in place): it
207
+ * stops occupying a `max` slot, and both its pending auto-dismiss and an already
208
+ * scheduled removal are cancelled. A move *within* the region keeps all of them —
209
+ * which is why the element must still be a message to be treated as one: ownership
210
+ * alone reads an in-place attribute rewrite as a move, and a node outside the target
211
+ * set belongs to the consumer, so nothing here may dismiss it.
212
+ */
213
+ messageTargetDisconnected(message) {
214
+ if (!this.#connected) return;
215
+ const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
216
+ if (moved) return;
217
+ this.#forget(message);
218
+ this.#leaving.delete(message);
219
+ }
220
+ /**
221
+ * Re-points the observation after a `region` swap and picks up the messages the
222
+ * new element brought with it (dynamic inserts, so their own `role` announces
223
+ * them). The `#connected` guard is load-bearing: Stimulus runs target callbacks
224
+ * for the initial markup *before* `connect()` and again during teardown *after*
225
+ * `disconnect()`, and re-observing there would outlive the controller.
226
+ */
227
+ #resync() {
228
+ if (!this.#connected) return;
229
+ this.#syncObservation();
230
+ for (const message of this.messageTargets) {
231
+ if (this.#owns(message)) this.#process(message, false);
232
+ }
233
+ }
234
+ /**
235
+ * Points the mutation observation at the current `region` target, re-resolved on
236
+ * every sync rather than captured at connect, so an element swapped in at runtime
237
+ * is observed instead of the detached original.
238
+ */
239
+ #syncObservation() {
240
+ this.#stopObserving();
241
+ if (!this.hasRegionTarget || typeof MutationObserver === "undefined") return;
242
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
243
+ this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
244
+ }
245
+ #stopObserving() {
246
+ this.#observer?.disconnect();
247
+ this.#observer = null;
129
248
  }
130
249
  /**
131
250
  * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
@@ -171,6 +290,7 @@ var FlashController = class extends Controller {
171
290
  */
172
291
  #process(message, bridge) {
173
292
  if (this.#state.has(message) || this.#order.includes(message)) return;
293
+ if (this.#leaving.has(message)) return;
174
294
  const type = message.getAttribute("data-flash-type") ?? "";
175
295
  const assertive = ASSERTIVE_TYPES.has(type);
176
296
  if (!message.hasAttribute("role")) {
@@ -203,33 +323,53 @@ var FlashController = class extends Controller {
203
323
  const existing = this.#state.get(message);
204
324
  if (existing?.id) this.#timers.clear(existing.id);
205
325
  const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
206
- this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });
326
+ this.#state.set(message, {
327
+ id,
328
+ startedAt: Date.now(),
329
+ remaining: duration,
330
+ paused: existing?.paused ?? /* @__PURE__ */ new Set()
331
+ });
207
332
  }
208
- /** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */
209
- #pause(message) {
333
+ /**
334
+ * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
335
+ * Hover and focus are independent reasons: the remaining time is banked on the
336
+ * first of them, and {@link FlashController.#resume} waits for the last one.
337
+ */
338
+ #pause(message, reason) {
210
339
  const timer = this.#state.get(message);
211
- if (!timer || timer.id === 0) return;
340
+ if (!timer) return;
341
+ timer.paused.add(reason);
342
+ if (timer.id === 0) return;
212
343
  this.#timers.clear(timer.id);
213
- const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
214
- this.#state.set(message, { id: 0, startedAt: 0, remaining });
344
+ const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
345
+ this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
215
346
  }
216
347
  /** Resumes a paused message's auto-dismiss with the banked time. */
217
- #resume(message) {
348
+ #resume(message, reason) {
218
349
  const timer = this.#state.get(message);
219
350
  if (!timer) return;
220
- if (timer.id !== 0 || timer.remaining <= 0) return;
351
+ timer.paused.delete(reason);
352
+ if (timer.paused.size > 0) return;
353
+ if (timer.id !== 0) return;
221
354
  this.#startTimer(message, timer.remaining);
222
355
  }
223
- /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
224
- #beginDismiss(message, reason) {
356
+ /** Releases every per-message resource: timer, stacking slot, pause listeners. */
357
+ #forget(message) {
225
358
  const timer = this.#state.get(message);
226
359
  if (timer?.id) this.#timers.clear(timer.id);
227
360
  this.#state.delete(message);
228
361
  const index = this.#order.indexOf(message);
229
362
  if (index !== -1) this.#order.splice(index, 1);
363
+ this.#unbindPause(message);
364
+ }
365
+ /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
366
+ #beginDismiss(message, reason) {
367
+ if (!this.#state.has(message) && !this.#order.includes(message)) return;
368
+ this.#forget(message);
369
+ this.#leaving.add(message);
230
370
  message.setAttribute("data-flash-state", "leaving");
231
371
  const finalize = () => {
232
- this.#unbindPause(message);
372
+ if (!this.#leaving.delete(message)) return;
233
373
  message.remove();
234
374
  this.dispatch("dismiss", { detail: { element: message, reason } });
235
375
  };
@@ -2,6 +2,13 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/form_validation_controller.ts
4
4
 
5
+ // src/utils/default_attribute.ts
6
+ function setDefaultAttribute(element, name, value) {
7
+ if (element.hasAttribute(name)) return false;
8
+ element.setAttribute(name, value);
9
+ return true;
10
+ }
11
+
5
12
  // src/utils/focus_trap.ts
6
13
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
14
 
@@ -79,8 +86,7 @@ var FormValidationController = class _FormValidationController extends Controlle
79
86
  };
80
87
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
81
88
  connect() {
82
- if (!this.element.hasAttribute("novalidate")) {
83
- this.element.setAttribute("novalidate", "");
89
+ if (setDefaultAttribute(this.element, "novalidate", "")) {
84
90
  this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
85
91
  }
86
92
  document.addEventListener("submit", this.#onSubmit, true);
@@ -52,6 +52,16 @@ var BeforeCacheReset = class _BeforeCacheReset {
52
52
  var DetachGate = class _DetachGate {
53
53
  /** Set while a probe is queued, waiting for a reconnect to cancel it. */
54
54
  #pending = false;
55
+ /**
56
+ * True while a probe is queued — the last disconnect was ambiguous and no
57
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
58
+ * reconnect half of an in-page move from a first connect: a controller whose
59
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
60
+ * counter) must skip it for the move, where nothing actually restarted.
61
+ */
62
+ get pending() {
63
+ return this.#pending;
64
+ }
55
65
  /**
56
66
  * True when the disconnect is definitely a real detach — the element left
57
67
  * the document, or `data-controller` no longer lists the identifier. False
@@ -209,7 +219,17 @@ var FrameLoadingController = class extends Controller {
209
219
  #gate = new DetachGate();
210
220
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
211
221
  #loading = false;
212
- #inertApplied = false;
222
+ /**
223
+ * The optional targets this controller revealed, and the content it marked inert.
224
+ * Held as references rather than re-resolved on the way out: a detach that keeps
225
+ * the element takes the identifier off `data-controller` first, and a scope
226
+ * without its identifier stops resolving targets — the elements to tidy would be
227
+ * unreachable exactly when the tidying matters. They double as the ownership
228
+ * marker, so a `hidden` or an `inert` the consumer wrote is never taken over.
229
+ */
230
+ #revealedSkeleton = null;
231
+ #revealedOverlay = null;
232
+ #inertTarget = null;
213
233
  #previousFocus = null;
214
234
  /** The id of the retreated element, used to re-find it if the load replaced it. */
215
235
  #previousFocusId = "";
@@ -236,40 +256,97 @@ var FrameLoadingController = class extends Controller {
236
256
  this.#gate.disconnected(this, () => this.#teardown());
237
257
  }
238
258
  /**
239
- * Drops the held finish and the loading bookkeeping on a real detach. The markup
240
- * keeps whatever it last held: the page being cached is rewound at
241
- * `turbo:before-cache` instead, where the frame is still whole.
259
+ * Drops the held finish and the loading bookkeeping on a real detach, returning
260
+ * the frame to its idle form. No reconnect is coming, so nothing is left that
261
+ * could finish the load and clear the hooks a detach that keeps the element
262
+ * (a morph dropping the identifier, an exit from a scoped observed root) would
263
+ * otherwise strand it busy and inert. Focus is left where it is: the element is
264
+ * leaving this controller's care, and moving it now would be an unexplained jump.
242
265
  */
243
266
  #teardown() {
244
267
  this.#gate.cancel();
245
268
  this.#timeouts.clearAll();
246
269
  this.#floor.cancel();
270
+ if (this.#loading) this.#rewindHooks();
247
271
  this.#loading = false;
248
272
  this.#previousFocus = null;
249
273
  }
250
274
  /**
251
- * Returns the frame to its resting hooks for the snapshot Turbo is about to
252
- * take, so a page reached with the Back button does not restore a frame that is
253
- * busy and inert with nothing left to finish it. State only — no `end` event and
254
- * no focus move, because the load did not actually complete. The live page keeps
255
- * its held finish, so a navigation that never completes still ends properly.
275
+ * Returns the frame to its idle form for the snapshot Turbo is about to take, so
276
+ * a page reached with the Back button does not restore a frame that is busy and
277
+ * inert with nothing left to finish it. State only — no `end` event and no focus
278
+ * move, because the load did not actually complete.
279
+ *
280
+ * The load is abandoned rather than paused, so the flag and any finish the floor
281
+ * still holds drop along with the hooks. A kept finish would surface after the
282
+ * rewind as exactly the three things this pass exists to avoid — an `end`, a
283
+ * completion announcement, and a focus move — and a kept flag would leave the
284
+ * next fetch on a page that survives a cancelled visit skipping the loading
285
+ * state, its idempotence guard already satisfied.
256
286
  */
257
287
  #rewindForCache() {
258
288
  if (!this.#loading) return;
289
+ this.#loading = false;
290
+ this.#floor.cancel();
291
+ this.#rewindHooks();
292
+ }
293
+ /**
294
+ * Clears every hook the loading state writes. Shared by the three ways a load can
295
+ * stop — completion, detach, snapshot — so none of them can drift into tidying
296
+ * only part of it.
297
+ */
298
+ #rewindHooks() {
259
299
  this.element.removeAttribute("aria-busy");
260
300
  this.element.removeAttribute("data-frame-loading");
261
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
262
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
301
+ if (this.#revealedSkeleton) this.#revealedSkeleton.hidden = true;
302
+ if (this.#revealedOverlay) this.#revealedOverlay.hidden = true;
303
+ this.#revealedSkeleton = null;
304
+ this.#revealedOverlay = null;
263
305
  this.#clearInert();
264
306
  }
307
+ /**
308
+ * Re-shows a `skeleton` that arrived mid-load. Turbo's frame renderer empties the
309
+ * frame and re-inserts the response's children, so a response's authored (hidden)
310
+ * skeleton can land while a later fetch is still running, and only the controller
311
+ * knows the frame is still busy.
312
+ */
313
+ skeletonTargetConnected() {
314
+ if (this.#loading) this.#revealSkeleton();
315
+ }
316
+ /** Re-shows an `overlay` that arrived mid-load — the same swap as the skeleton. */
317
+ overlayTargetConnected() {
318
+ if (this.#loading) this.#revealOverlay();
319
+ }
320
+ /**
321
+ * Re-blocks a `content` that arrived mid-load, so the stale copy stays unusable.
322
+ * The element that left is released first and ownership is then decided afresh, so
323
+ * an `inert` the replacement authored stays the consumer's.
324
+ */
325
+ contentTargetConnected() {
326
+ if (!this.#loading) return;
327
+ this.#clearInert();
328
+ this.#applyInert();
329
+ }
330
+ /** Reveals the optional `skeleton`, noting it as this controller's to hide again. */
331
+ #revealSkeleton() {
332
+ if (!this.hasSkeletonTarget) return;
333
+ this.#revealedSkeleton = this.skeletonTarget;
334
+ this.skeletonTarget.hidden = false;
335
+ }
336
+ /** Reveals the optional `overlay`, noting it as this controller's to hide again. */
337
+ #revealOverlay() {
338
+ if (!this.hasOverlayTarget) return;
339
+ this.#revealedOverlay = this.overlayTarget;
340
+ this.overlayTarget.hidden = false;
341
+ }
265
342
  /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
266
343
  #begin() {
267
344
  this.#loading = true;
268
345
  this.#floor.begin();
269
346
  this.element.setAttribute("aria-busy", "true");
270
347
  this.element.setAttribute("data-frame-loading", "true");
271
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
272
- if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
348
+ this.#revealSkeleton();
349
+ this.#revealOverlay();
273
350
  this.#applyInert();
274
351
  this.#retreatFocus();
275
352
  this.dispatch("start", { detail: {} });
@@ -278,11 +355,7 @@ var FrameLoadingController = class extends Controller {
278
355
  /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
279
356
  #finish() {
280
357
  this.#loading = false;
281
- this.element.removeAttribute("aria-busy");
282
- this.element.removeAttribute("data-frame-loading");
283
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
284
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
285
- this.#clearInert();
358
+ this.#rewindHooks();
286
359
  this.#restoreFocus();
287
360
  this.dispatch("end", { detail: {} });
288
361
  announce(fillTemplate(this.announceReadyTextValue, {}));
@@ -291,12 +364,11 @@ var FrameLoadingController = class extends Controller {
291
364
  #applyInert() {
292
365
  if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
293
366
  this.contentTarget.setAttribute("inert", "");
294
- this.#inertApplied = true;
367
+ this.#inertTarget = this.contentTarget;
295
368
  }
296
369
  #clearInert() {
297
- if (!this.#inertApplied) return;
298
- this.#inertApplied = false;
299
- if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
370
+ this.#inertTarget?.removeAttribute("inert");
371
+ this.#inertTarget = null;
300
372
  }
301
373
  /** Saves and blurs focus if it sits inside the frame about to go stale. */
302
374
  #retreatFocus() {
@@ -61,6 +61,7 @@ var SafeTimeout = class extends TimerRegistry {
61
61
  };
62
62
 
63
63
  // src/controllers/highlight_controller.ts
64
+ var hookOwners = /* @__PURE__ */ new WeakMap();
64
65
  var HighlightController = class extends Controller {
65
66
  static values = {
66
67
  duration: { type: Number, default: 1500 },
@@ -68,9 +69,18 @@ var HighlightController = class extends Controller {
68
69
  };
69
70
  static events = ["start", "end"];
70
71
  #timeouts = new SafeTimeout();
72
+ /**
73
+ * The removal timer this connection has outstanding for an element. Held weakly so
74
+ * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
75
+ * so a cleared id can never be matched against a recycled one. Which connection owns
76
+ * an element's hook is answered by the shared owner registry above.
77
+ */
78
+ #pending = /* @__PURE__ */ new WeakMap();
71
79
  #observer = null;
72
80
  connect() {
81
+ this.#clearArrivedHook(this.element);
73
82
  if (this.observeValue) {
83
+ for (const child of this.element.children) this.#clearArrivedHook(child);
74
84
  if (typeof MutationObserver !== "undefined") {
75
85
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
76
86
  this.#observer.observe(this.element, { childList: true });
@@ -83,6 +93,12 @@ var HighlightController = class extends Controller {
83
93
  this.#observer?.disconnect();
84
94
  this.#observer = null;
85
95
  this.#timeouts.clearAll();
96
+ this.#pending = /* @__PURE__ */ new WeakMap();
97
+ }
98
+ /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
99
+ #clearArrivedHook(el) {
100
+ if (hookOwners.get(el) === this) hookOwners.delete(el);
101
+ el.removeAttribute("data-highlight");
86
102
  }
87
103
  /** Highlights every element child added by a childList mutation. */
88
104
  #onMutations(mutations) {
@@ -95,12 +111,33 @@ var HighlightController = class extends Controller {
95
111
  /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
96
112
  #highlight(el) {
97
113
  if (prefersReducedMotion()) return;
114
+ this.#releasePending(el);
98
115
  el.setAttribute("data-highlight", "true");
99
116
  this.dispatch("start", { target: el, detail: { element: el } });
100
- this.#timeouts.set(() => {
117
+ const id = this.#timeouts.set(() => {
118
+ this.#pending.delete(el);
119
+ hookOwners.delete(el);
101
120
  el.removeAttribute("data-highlight");
102
121
  this.dispatch("end", { target: el, detail: { element: el } });
103
122
  }, this.durationValue);
123
+ this.#pending.set(el, id);
124
+ hookOwners.set(el, this);
125
+ }
126
+ /**
127
+ * Releases whichever removal timer holds `el`'s hook. The row may have been
128
+ * highlighted inside a different watched container before it moved here, and that
129
+ * container's timer is reachable only through the shared owner registry.
130
+ */
131
+ #releasePending(el) {
132
+ const owner = hookOwners.get(el);
133
+ if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
134
+ this.#cancelPending(el);
135
+ }
136
+ /** Releases `el`'s pending removal timer, if it has one. */
137
+ #cancelPending(el) {
138
+ this.#timeouts.clear(this.#pending.get(el) ?? -1);
139
+ this.#pending.delete(el);
140
+ hookOwners.delete(el);
104
141
  }
105
142
  };
106
143
 
@@ -55,15 +55,37 @@ var SafeTimeout = class extends TimerRegistry {
55
55
  }
56
56
  };
57
57
 
58
+ // src/utils/string_list.ts
59
+ function parseStringList(raw, fallback = []) {
60
+ const text = raw.trim();
61
+ if (text.length === 0) return [...fallback];
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(text);
65
+ } catch {
66
+ return [...fallback];
67
+ }
68
+ if (!Array.isArray(parsed)) return [...fallback];
69
+ return parsed.filter((entry) => typeof entry === "string");
70
+ }
71
+
58
72
  // src/controllers/idle_controller.ts
73
+ var DEFAULT_ACTIVITY_EVENTS = [
74
+ "mousemove",
75
+ "mousedown",
76
+ "keydown",
77
+ "wheel",
78
+ "touchstart",
79
+ "scroll"
80
+ ];
59
81
  var IdleController = class extends Controller {
60
82
  static values = {
61
83
  timeout: { type: Number, default: 9e5 },
62
84
  promptBefore: { type: Number, default: 0 },
63
- events: {
64
- type: Array,
65
- default: ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "scroll"]
66
- }
85
+ // A JSON list read through `parseStringList` rather than Stimulus's `Array`
86
+ // type: that reader throws out of the value observer before any callback
87
+ // runs, so one malformed attribute would stop the detector connecting.
88
+ events: { type: String, default: "" }
67
89
  };
68
90
  static events = ["prompt", "idle", "active"];
69
91
  #timeouts = new SafeTimeout();
@@ -71,6 +93,12 @@ var IdleController = class extends Controller {
71
93
  #prompted = false;
72
94
  /** Timestamp of the last activity; the timers self-reschedule against it. */
73
95
  #lastActivity = 0;
96
+ /**
97
+ * Activity types actually registered on `document`, so `disconnect()` unbinds the
98
+ * same set even when `events` changed while connected (a Turbo morph can rewrite
99
+ * the Value in place, and the removal must match the registration, not the Value).
100
+ */
101
+ #boundEvents = [];
74
102
  #onActivity = () => {
75
103
  this.#lastActivity = Date.now();
76
104
  if (this.#idle || this.#prompted) {
@@ -85,16 +113,21 @@ var IdleController = class extends Controller {
85
113
  if (document.visibilityState === "visible") this.#onActivity();
86
114
  };
87
115
  connect() {
88
- for (const type of this.eventsValue) {
116
+ this.#idle = false;
117
+ this.#prompted = false;
118
+ this.element.removeAttribute("data-idle");
119
+ this.#boundEvents = parseStringList(this.eventsValue, DEFAULT_ACTIVITY_EVENTS);
120
+ for (const type of this.#boundEvents) {
89
121
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
90
122
  }
91
123
  document.addEventListener("visibilitychange", this.#onVisibility);
92
124
  this.#arm();
93
125
  }
94
126
  disconnect() {
95
- for (const type of this.eventsValue) {
127
+ for (const type of this.#boundEvents) {
96
128
  document.removeEventListener(type, this.#onActivity, { capture: true });
97
129
  }
130
+ this.#boundEvents = [];
98
131
  document.removeEventListener("visibilitychange", this.#onVisibility);
99
132
  this.#timeouts.clearAll();
100
133
  }
@@ -94,6 +94,8 @@ var LocalTimeController = class extends Controller {
94
94
  * its condition is that formatting was applied, and a repaint applies it with a
95
95
  * new result. A pass that cannot format writes nothing and emits nothing, so the
96
96
  * authored absolute text stays as the fallback.
97
+ *
98
+ * @stimeoRenderRoot
97
99
  */
98
100
  #render() {
99
101
  const date = this.#parse();
@@ -59,7 +59,7 @@ var LayoutObserver = class {
59
59
  };
60
60
 
61
61
  // src/controllers/masonry_controller.ts
62
- var COLUMNS_PROPERTY = "--stimeo-masonry-columns";
62
+ var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
63
63
  var MasonryController = class extends Controller {
64
64
  static targets = ["item"];
65
65
  static values = {
@@ -125,10 +125,12 @@ var RovingTabindex = class {
125
125
  * "nothing is currently tabbable".
126
126
  *
127
127
  * @param index - Position of the item to make tabbable.
128
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
128
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
129
+ * and `items` to reuse an event-scoped collection snapshot.
129
130
  */
130
- setActive(index, { focus = false } = {}) {
131
- const items = this.#getItems();
131
+ setActive(index, options = {}) {
132
+ const { focus = false } = options;
133
+ const items = options.items ?? this.#getItems();
132
134
  items.forEach((item, i) => {
133
135
  item.tabIndex = i === index ? 0 : -1;
134
136
  });
@@ -173,6 +173,8 @@ var MeterController = class extends Controller {
173
173
  * Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
174
174
  * The reading is derived once and returned, so the `change` detail reports the
175
175
  * same numbers the DOM just received.
176
+ *
177
+ * @stimeoRenderRoot
176
178
  */
177
179
  #render() {
178
180
  const value = this.#clamp(this.valueValue);
@@ -184,7 +186,7 @@ var MeterController = class extends Controller {
184
186
  this.element.setAttribute("aria-valuemin", String(this.minValue));
185
187
  this.element.setAttribute("aria-valuemax", String(this.maxValue));
186
188
  this.element.setAttribute("aria-valuenow", String(reading.value));
187
- this.element.style.setProperty("--stimeo-meter-ratio", String(reading.ratio));
189
+ this.element.style.setProperty("--stimeo--meter-ratio", String(reading.ratio));
188
190
  this.element.setAttribute("data-state", reading.state);
189
191
  this.#applyValueText(reading);
190
192
  return reading;