stimeo-ui 0.3.0 → 0.5.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -0
  3. data/dist/controllers/alert_dialog_controller.js +32 -5
  4. data/dist/controllers/announcer_controller.js +255 -20
  5. data/dist/controllers/aspect_ratio_controller.js +1 -1
  6. data/dist/controllers/breadcrumb_controller.js +5 -1
  7. data/dist/controllers/carousel_controller.js +5 -1
  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 +52 -2
  11. data/dist/controllers/command_palette_controller.js +32 -5
  12. data/dist/controllers/confirm_controller.js +32 -5
  13. data/dist/controllers/context_menu_controller.js +2 -2
  14. data/dist/controllers/countdown_controller.js +117 -13
  15. data/dist/controllers/date_range_picker_controller.js +55 -1
  16. data/dist/controllers/dialog_controller.js +32 -5
  17. data/dist/controllers/direct_upload_controller.js +3 -3
  18. data/dist/controllers/drawer_controller.js +32 -5
  19. data/dist/controllers/empty_state_controller.js +128 -23
  20. data/dist/controllers/flash_controller.js +161 -21
  21. data/dist/controllers/focus_controller.js +32 -5
  22. data/dist/controllers/form_validation_controller.js +8 -2
  23. data/dist/controllers/frame_loading_controller.js +261 -27
  24. data/dist/controllers/highlight_controller.js +38 -1
  25. data/dist/controllers/idle_controller.js +13 -2
  26. data/dist/controllers/local_time_controller.js +102 -6
  27. data/dist/controllers/masonry_controller.js +1 -1
  28. data/dist/controllers/meter_controller.js +147 -26
  29. data/dist/controllers/network_status_controller.js +29 -11
  30. data/dist/controllers/number_input_controller.js +191 -24
  31. data/dist/controllers/overflow_menu_controller.js +34 -7
  32. data/dist/controllers/pagination_controller.js +5 -1
  33. data/dist/controllers/password_strength_controller.js +1 -1
  34. data/dist/controllers/pointer_drag_controller.js +10 -0
  35. data/dist/controllers/portal_controller.js +10 -0
  36. data/dist/controllers/progress_controller.js +123 -12
  37. data/dist/controllers/range_slider_controller.js +449 -93
  38. data/dist/controllers/rating_controller.js +55 -0
  39. data/dist/controllers/relative_time_controller.js +135 -12
  40. data/dist/controllers/scroll_area_controller.js +1 -1
  41. data/dist/controllers/separator_controller.js +13 -17
  42. data/dist/controllers/sidebar_controller.js +37 -8
  43. data/dist/controllers/skeleton_controller.js +143 -22
  44. data/dist/controllers/slider_controller.js +342 -50
  45. data/dist/controllers/spinner_controller.js +244 -28
  46. data/dist/controllers/step_indicator_controller.js +85 -6
  47. data/dist/controllers/stepper_controller.js +2 -0
  48. data/dist/controllers/stick_to_bottom_controller.js +60 -10
  49. data/dist/controllers/switch_controller.js +162 -18
  50. data/dist/controllers/textarea_autosize_controller.js +1 -1
  51. data/dist/controllers/time_picker_controller.js +6 -3
  52. data/dist/controllers/tree_view_controller.js +19 -1
  53. data/dist/index.js +2278 -596
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. metadata +2 -2
@@ -2,6 +2,155 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/frame_loading_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 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
+ }
65
+ /**
66
+ * True when the disconnect is definitely a real detach — the element left
67
+ * the document, or `data-controller` no longer lists the identifier. False
68
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
69
+ */
70
+ static isDetached(host) {
71
+ if (!host.element.isConnected) return true;
72
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
73
+ return !tokens.includes(host.identifier);
74
+ }
75
+ /**
76
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
77
+ * detach (fast path), otherwise defers it one microtask — a reconnect
78
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
79
+ * One microtask is the whole probe window: Stimulus reconnects a moved
80
+ * element within the same mutation batch, before the checkpoint drains.
81
+ */
82
+ disconnected(host, teardown) {
83
+ if (_DetachGate.isDetached(host)) {
84
+ this.#pending = false;
85
+ teardown();
86
+ return;
87
+ }
88
+ this.#pending = true;
89
+ queueMicrotask(() => {
90
+ if (!this.#pending) return;
91
+ this.#pending = false;
92
+ teardown();
93
+ });
94
+ }
95
+ /**
96
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
97
+ * an in-page move) and from the head of any teardown path not routed through
98
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
99
+ * never run the teardown a second time.
100
+ */
101
+ cancel() {
102
+ this.#pending = false;
103
+ }
104
+ };
105
+
106
+ // src/utils/min_duration_floor.ts
107
+ var MinDurationFloor = class {
108
+ #timers;
109
+ /** Pending finish timer id, or `null` when nothing is held back. */
110
+ #timerId = null;
111
+ /** Epoch ms the floor is measured from. */
112
+ #since = 0;
113
+ /** @param timers - the controller's registry; the floor schedules into it. */
114
+ constructor(timers) {
115
+ this.#timers = timers;
116
+ }
117
+ /** Starts the floor: call when the state being held becomes visible. */
118
+ begin() {
119
+ this.#since = Date.now();
120
+ }
121
+ /** True while a finish is held back waiting for the floor to elapse. */
122
+ get pending() {
123
+ return this.#timerId !== null;
124
+ }
125
+ /**
126
+ * Runs `finish` once the floor has elapsed, immediately when it already has.
127
+ *
128
+ * A held-back finish is **replaced**, never stacked: only the most recently
129
+ * queued id is cancellable, so a second timer would outlive every cancel and
130
+ * end a state that has since restarted. Controllers that want the first signal
131
+ * to win guard on {@link pending} before calling.
132
+ */
133
+ schedule(minDuration, finish) {
134
+ this.cancel();
135
+ const remaining = minDuration - (Date.now() - this.#since);
136
+ if (remaining > 0) {
137
+ this.#timerId = this.#timers.set(() => {
138
+ this.#timerId = null;
139
+ finish();
140
+ }, remaining);
141
+ } else {
142
+ finish();
143
+ }
144
+ }
145
+ /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
146
+ cancel() {
147
+ if (this.#timerId !== null) {
148
+ this.#timers.clear(this.#timerId);
149
+ this.#timerId = null;
150
+ }
151
+ }
152
+ };
153
+
5
154
  // src/utils/safe_timeout.ts
6
155
  var TimerRegistry = class {
7
156
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -59,32 +208,42 @@ var SafeTimeout = class extends TimerRegistry {
59
208
  var FrameLoadingController = class extends Controller {
60
209
  static targets = ["content", "skeleton", "overlay"];
61
210
  static values = {
211
+ announceText: { type: String, default: "" },
212
+ announceReadyText: { type: String, default: "" },
62
213
  minDuration: { type: Number, default: 0 },
63
214
  restoreFocus: { type: Boolean, default: true }
64
215
  };
65
216
  static events = ["start", "end"];
66
217
  #timeouts = new SafeTimeout();
218
+ #floor = new MinDurationFloor(this.#timeouts);
219
+ #gate = new DetachGate();
220
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
67
221
  #loading = false;
68
- #startedAt = 0;
69
- #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;
70
233
  #previousFocus = null;
71
234
  /** The id of the retreated element, used to re-find it if the load replaced it. */
72
235
  #previousFocusId = "";
73
236
  #onStart = () => {
74
- this.#timeouts.clearAll();
237
+ this.#floor.cancel();
75
238
  if (!this.#loading) this.#begin();
76
239
  };
77
240
  #onEnd = () => {
78
241
  if (!this.#loading) return;
79
- const remaining = this.minDurationValue - (Date.now() - this.#startedAt);
80
- if (remaining > 0) {
81
- this.#timeouts.clearAll();
82
- this.#timeouts.set(() => this.#finish(), remaining);
83
- } else {
84
- this.#finish();
85
- }
242
+ this.#floor.schedule(this.minDurationValue, () => this.#finish());
86
243
  };
87
244
  connect() {
245
+ this.#gate.cancel();
246
+ this.#beforeCache.activate();
88
247
  this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
89
248
  this.element.addEventListener("turbo:frame-load", this.#onEnd);
90
249
  this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
@@ -93,48 +252,123 @@ var FrameLoadingController = class extends Controller {
93
252
  this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
94
253
  this.element.removeEventListener("turbo:frame-load", this.#onEnd);
95
254
  this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
255
+ this.#beforeCache.deactivate();
256
+ this.#gate.disconnected(this, () => this.#teardown());
257
+ }
258
+ /**
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.
265
+ */
266
+ #teardown() {
267
+ this.#gate.cancel();
96
268
  this.#timeouts.clearAll();
97
- if (this.#loading) {
98
- this.element.removeAttribute("aria-busy");
99
- this.element.removeAttribute("data-frame-loading");
100
- this.#clearInert();
101
- }
269
+ this.#floor.cancel();
270
+ if (this.#loading) this.#rewindHooks();
102
271
  this.#loading = false;
103
272
  this.#previousFocus = null;
104
273
  }
274
+ /**
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.
286
+ */
287
+ #rewindForCache() {
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() {
299
+ this.element.removeAttribute("aria-busy");
300
+ this.element.removeAttribute("data-frame-loading");
301
+ if (this.#revealedSkeleton) this.#revealedSkeleton.hidden = true;
302
+ if (this.#revealedOverlay) this.#revealedOverlay.hidden = true;
303
+ this.#revealedSkeleton = null;
304
+ this.#revealedOverlay = null;
305
+ this.#clearInert();
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
+ }
105
342
  /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
106
343
  #begin() {
107
344
  this.#loading = true;
108
- this.#startedAt = Date.now();
345
+ this.#floor.begin();
109
346
  this.element.setAttribute("aria-busy", "true");
110
347
  this.element.setAttribute("data-frame-loading", "true");
111
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
112
- if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
348
+ this.#revealSkeleton();
349
+ this.#revealOverlay();
113
350
  this.#applyInert();
114
351
  this.#retreatFocus();
115
352
  this.dispatch("start", { detail: {} });
353
+ announce(fillTemplate(this.announceTextValue, {}));
116
354
  }
117
355
  /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
118
356
  #finish() {
119
357
  this.#loading = false;
120
- this.element.removeAttribute("aria-busy");
121
- this.element.removeAttribute("data-frame-loading");
122
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
123
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
124
- this.#clearInert();
358
+ this.#rewindHooks();
125
359
  this.#restoreFocus();
126
360
  this.dispatch("end", { detail: {} });
361
+ announce(fillTemplate(this.announceReadyTextValue, {}));
127
362
  }
128
363
  /** Marks the content inert to block double-submits while stale (if we own it). */
129
364
  #applyInert() {
130
365
  if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
131
366
  this.contentTarget.setAttribute("inert", "");
132
- this.#inertApplied = true;
367
+ this.#inertTarget = this.contentTarget;
133
368
  }
134
369
  #clearInert() {
135
- if (!this.#inertApplied) return;
136
- this.#inertApplied = false;
137
- if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
370
+ this.#inertTarget?.removeAttribute("inert");
371
+ this.#inertTarget = null;
138
372
  }
139
373
  /** Saves and blurs focus if it sits inside the frame about to go stale. */
140
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
 
@@ -71,6 +71,12 @@ var IdleController = class extends Controller {
71
71
  #prompted = false;
72
72
  /** Timestamp of the last activity; the timers self-reschedule against it. */
73
73
  #lastActivity = 0;
74
+ /**
75
+ * Activity types actually registered on `document`, so `disconnect()` unbinds the
76
+ * same set even when `events` changed while connected (a Turbo morph can rewrite
77
+ * the Value in place, and the removal must match the registration, not the Value).
78
+ */
79
+ #boundEvents = [];
74
80
  #onActivity = () => {
75
81
  this.#lastActivity = Date.now();
76
82
  if (this.#idle || this.#prompted) {
@@ -85,16 +91,21 @@ var IdleController = class extends Controller {
85
91
  if (document.visibilityState === "visible") this.#onActivity();
86
92
  };
87
93
  connect() {
88
- for (const type of this.eventsValue) {
94
+ this.#idle = false;
95
+ this.#prompted = false;
96
+ this.element.removeAttribute("data-idle");
97
+ this.#boundEvents = [...this.eventsValue];
98
+ for (const type of this.#boundEvents) {
89
99
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
90
100
  }
91
101
  document.addEventListener("visibilitychange", this.#onVisibility);
92
102
  this.#arm();
93
103
  }
94
104
  disconnect() {
95
- for (const type of this.eventsValue) {
105
+ for (const type of this.#boundEvents) {
96
106
  document.removeEventListener(type, this.#onActivity, { capture: true });
97
107
  }
108
+ this.#boundEvents = [];
98
109
  document.removeEventListener("visibilitychange", this.#onVisibility);
99
110
  this.#timeouts.clearAll();
100
111
  }
@@ -1,5 +1,40 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/local_time_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/local_time_controller.ts
4
39
  var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
5
40
  function toStyle(value) {
@@ -14,7 +49,55 @@ var LocalTimeController = class extends Controller {
14
49
  titleFormat: { type: String, default: "" }
15
50
  };
16
51
  static events = ["format"];
52
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
53
+ #resync = new MicrotaskCoalescer(() => this.#render());
54
+ /**
55
+ * Watches the one render input that is not a Value. Only `datetime` is filtered
56
+ * in, so the text and `title` this controller writes cannot re-enter the pass.
57
+ */
58
+ #datetimeWatch = new MutationObserver(() => {
59
+ this.#resync.schedule();
60
+ });
17
61
  connect() {
62
+ this.#resync.activate();
63
+ this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
64
+ this.#render();
65
+ }
66
+ disconnect() {
67
+ this.#resync.cancel();
68
+ this.#datetimeWatch.disconnect();
69
+ }
70
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
71
+ localeValueChanged() {
72
+ this.#resync.schedule();
73
+ }
74
+ /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
75
+ timeZoneValueChanged() {
76
+ this.#resync.schedule();
77
+ }
78
+ /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
79
+ dateStyleValueChanged() {
80
+ this.#resync.schedule();
81
+ }
82
+ /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
83
+ timeStyleValueChanged() {
84
+ this.#resync.schedule();
85
+ }
86
+ /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
87
+ titleFormatValueChanged() {
88
+ this.#resync.schedule();
89
+ }
90
+ /**
91
+ * Formats the instant in `datetime` against the current Values and writes it out.
92
+ *
93
+ * The `format` event rides with every pass, including a repaint a morph triggers:
94
+ * its condition is that formatting was applied, and a repaint applies it with a
95
+ * new result. A pass that cannot format writes nothing and emits nothing, so the
96
+ * authored absolute text stays as the fallback.
97
+ *
98
+ * @stimeoRenderRoot
99
+ */
100
+ #render() {
18
101
  const date = this.#parse();
19
102
  if (date === null) return;
20
103
  const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
@@ -24,7 +107,10 @@ var LocalTimeController = class extends Controller {
24
107
  if (title !== null) this.element.setAttribute("title", title);
25
108
  this.dispatch("format", { detail: { formatted } });
26
109
  }
27
- /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */
110
+ /**
111
+ * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
112
+ * around the attribute value is tolerated.
113
+ */
28
114
  #parse() {
29
115
  const raw = this.element.getAttribute("datetime");
30
116
  if (!raw) return null;
@@ -37,11 +123,21 @@ var LocalTimeController = class extends Controller {
37
123
  * the *runtime's* local zone, contradicting "the server emits UTC". Values that
38
124
  * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
39
125
  * parsed as UTC) are returned unchanged.
126
+ *
127
+ * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
128
+ * left to each engine, so a whole value shaped that way is normalized to the `T`
129
+ * separator first. The pattern is anchored: a value trailing anything else — a
130
+ * zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
131
+ * authored instead of being turned into a string nothing can parse.
40
132
  */
41
133
  #asUtc(value) {
42
- const hasTime = /T\d{2}:\d{2}/.test(value);
43
- const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(value);
44
- return hasTime && !hasZone ? `${value}Z` : value;
134
+ const isoLike = value.replace(
135
+ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
136
+ "$1T$2"
137
+ );
138
+ const hasTime = /T\d{2}:\d{2}/.test(isoLike);
139
+ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
140
+ return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
45
141
  }
46
142
  /**
47
143
  * Builds the optional detailed `title`. `titleFormat` is an `Intl` style
@@ -70,9 +166,9 @@ var LocalTimeController = class extends Controller {
70
166
  return null;
71
167
  }
72
168
  }
73
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
169
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
74
170
  get #locale() {
75
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
171
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
76
172
  }
77
173
  };
78
174
 
@@ -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 = {