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
@@ -25,8 +25,41 @@ function toFiniteNumber(raw) {
25
25
  return Number.isFinite(value) ? value : null;
26
26
  }
27
27
 
28
+ // src/utils/microtask_coalescer.ts
29
+ var MicrotaskCoalescer = class {
30
+ #run;
31
+ #queued = false;
32
+ #active = false;
33
+ #generation = 0;
34
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
35
+ constructor(run) {
36
+ this.#run = run;
37
+ }
38
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
39
+ activate() {
40
+ this.#active = true;
41
+ }
42
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
43
+ cancel() {
44
+ this.#active = false;
45
+ this.#queued = false;
46
+ this.#generation += 1;
47
+ }
48
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
49
+ schedule() {
50
+ if (!this.#active || this.#queued) return;
51
+ this.#queued = true;
52
+ const generation = this.#generation;
53
+ queueMicrotask(() => {
54
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
55
+ this.#queued = false;
56
+ this.#run();
57
+ });
58
+ }
59
+ };
60
+
28
61
  // src/controllers/color_picker_controller.ts
29
- var COLOR_PROPERTY = "--stimeo-color";
62
+ var COLOR_PROPERTY = "--stimeo--color";
30
63
  var CHANNEL_RANGE = {
31
64
  hue: [0, 360],
32
65
  saturation: [0, 100],
@@ -51,16 +84,29 @@ var ColorPickerController = class extends Controller {
51
84
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
52
85
  #dragAbort = null;
53
86
  /** Seeds the model from the initial hex value and renders every surface. */
87
+ /**
88
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
89
+ * pass Stimulus delivers before `connect()`.
90
+ */
91
+ #repaint = new MicrotaskCoalescer(() => {
92
+ this.#render();
93
+ });
54
94
  connect() {
95
+ this.#repaint.activate();
55
96
  const parsed = hexToHsla(this.valueValue);
56
97
  if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
57
98
  this.#render();
58
99
  }
59
100
  /** Cancels any active pointer drag so document listeners never leak. */
60
101
  disconnect() {
102
+ this.#repaint.cancel();
61
103
  this.#dragAbort?.abort();
62
104
  this.#dragAbort = null;
63
105
  }
106
+ /** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
107
+ alphaValueChanged() {
108
+ this.#repaint.schedule();
109
+ }
64
110
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
65
111
  onKeydown(event) {
66
112
  if (isReservedArrowChord(event)) return;
@@ -142,7 +188,11 @@ var ColorPickerController = class extends Controller {
142
188
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
143
189
  this.#render();
144
190
  }
145
- /** Reflects the model onto sliders, the hex input, preview, and form field. */
191
+ /**
192
+ * Reflects the model onto sliders, the hex input, preview, and form field.
193
+ *
194
+ * @stimeoRenderRoot
195
+ */
146
196
  #render() {
147
197
  for (const slider of this.sliderTargets) {
148
198
  const channel = this.#channelOf(slider);
@@ -67,6 +67,35 @@ var CompositionTracker = class {
67
67
  };
68
68
  };
69
69
 
70
+ // src/utils/before_cache_reset.ts
71
+ var BeforeCacheReset = class _BeforeCacheReset {
72
+ /** Every subscribed instance, iterated by the one shared document listener. */
73
+ static #subscribers = /* @__PURE__ */ new Set();
74
+ /** The shared listener; installed while at least one instance is subscribed. */
75
+ static #onBeforeCache = () => {
76
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
77
+ };
78
+ #rewind;
79
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
80
+ constructor(rewind) {
81
+ this.#rewind = rewind;
82
+ }
83
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
84
+ activate() {
85
+ const first = _BeforeCacheReset.#subscribers.size === 0;
86
+ _BeforeCacheReset.#subscribers.add(this);
87
+ if (first) {
88
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
89
+ }
90
+ }
91
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
92
+ deactivate() {
93
+ _BeforeCacheReset.#subscribers.delete(this);
94
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
95
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
96
+ }
97
+ };
98
+
70
99
  // src/utils/escape_layer.ts
71
100
  var EscapeLayer = class _EscapeLayer {
72
101
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -200,7 +229,7 @@ var FocusTrap = class {
200
229
  }
201
230
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
202
231
  document.addEventListener("keydown", this.#onKeydown);
203
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
232
+ this.#beforeCache.activate();
204
233
  const onEscape = this.#options.onEscape;
205
234
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
206
235
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -217,7 +246,7 @@ var FocusTrap = class {
217
246
  this.#activeState = false;
218
247
  this.#escapeLayer.deactivate();
219
248
  document.removeEventListener("keydown", this.#onKeydown);
220
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
249
+ this.#beforeCache.deactivate();
221
250
  if (this.#scrollLocked) {
222
251
  document.body.style.overflow = this.#previousBodyOverflow;
223
252
  this.#scrollLocked = false;
@@ -241,9 +270,7 @@ var FocusTrap = class {
241
270
  * untouched (restore-open designs reopen against a clean baseline), and focus
242
271
  * is left alone mid-navigation. The listener lives only while active.
243
272
  */
244
- #onBeforeCache = () => {
245
- this.deactivate({ restoreFocus: false });
246
- };
273
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
247
274
  /**
248
275
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
249
276
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/confirm_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/escape_layer.ts
6
35
  var EscapeLayer = class _EscapeLayer {
7
36
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -135,7 +164,7 @@ var FocusTrap = class {
135
164
  }
136
165
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
166
  document.addEventListener("keydown", this.#onKeydown);
138
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
167
+ this.#beforeCache.activate();
139
168
  const onEscape = this.#options.onEscape;
140
169
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
170
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -152,7 +181,7 @@ var FocusTrap = class {
152
181
  this.#activeState = false;
153
182
  this.#escapeLayer.deactivate();
154
183
  document.removeEventListener("keydown", this.#onKeydown);
155
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
184
+ this.#beforeCache.deactivate();
156
185
  if (this.#scrollLocked) {
157
186
  document.body.style.overflow = this.#previousBodyOverflow;
158
187
  this.#scrollLocked = false;
@@ -176,9 +205,7 @@ var FocusTrap = class {
176
205
  * untouched (restore-open designs reopen against a clean baseline), and focus
177
206
  * is left alone mid-navigation. The listener lives only while active.
178
207
  */
179
- #onBeforeCache = () => {
180
- this.deactivate({ restoreFocus: false });
181
- };
208
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
182
209
  /**
183
210
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
211
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -237,8 +237,8 @@ var ContextMenuController = class extends Controller {
237
237
  onDismiss: () => this.#closeAndRestore(),
238
238
  claims: claimsWhileFocusWithin(this.element)
239
239
  });
240
- this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
241
- this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
240
+ this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
241
+ this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
242
242
  this.menuTarget.hidden = false;
243
243
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
244
244
  this.#navigableItems[0]?.focus();
@@ -2,6 +2,56 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/countdown_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/microtask_coalescer.ts
23
+ var MicrotaskCoalescer = class {
24
+ #run;
25
+ #queued = false;
26
+ #active = false;
27
+ #generation = 0;
28
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
29
+ constructor(run) {
30
+ this.#run = run;
31
+ }
32
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
33
+ activate() {
34
+ this.#active = true;
35
+ }
36
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
37
+ cancel() {
38
+ this.#active = false;
39
+ this.#queued = false;
40
+ this.#generation += 1;
41
+ }
42
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
43
+ schedule() {
44
+ if (!this.#active || this.#queued) return;
45
+ this.#queued = true;
46
+ const generation = this.#generation;
47
+ queueMicrotask(() => {
48
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
49
+ this.#queued = false;
50
+ this.#run();
51
+ });
52
+ }
53
+ };
54
+
5
55
  // src/utils/safe_timeout.ts
6
56
  var TimerRegistry = class {
7
57
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -48,6 +98,7 @@ var SafeInterval = class extends TimerRegistry {
48
98
  };
49
99
 
50
100
  // src/controllers/countdown_controller.ts
101
+ var SECOND_MS = 1e3;
51
102
  var CountdownController = class extends Controller {
52
103
  static targets = ["days", "hours", "minutes", "seconds", "status"];
53
104
  static values = {
@@ -55,34 +106,79 @@ var CountdownController = class extends Controller {
55
106
  interval: { type: Number, default: 1e3 },
56
107
  direction: { type: String, default: "down" },
57
108
  autostart: { type: Boolean, default: true },
58
- completeLabel: { type: String, default: "" }
109
+ completeLabel: { type: String, default: "" },
110
+ announceText: { type: String, default: "" }
59
111
  };
60
112
  static actions = ["pause", "reset", "resume", "start"];
61
113
  static events = ["complete", "tick"];
62
114
  #intervals = new SafeInterval();
63
115
  #intervalId = null;
116
+ /** Collapses a morph that swaps several render inputs at once into one re-derive. */
117
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
64
118
  /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
65
119
  #reference = 0;
66
120
  /** Amount (ms) captured at pause, so resume can restore the same display. */
67
121
  #pausedAmount = 0;
122
+ /**
123
+ * The amount the slots are currently showing, floored to the second they render.
124
+ * It lags {@link currentAmount} by up to one tick, and it — not the live reading —
125
+ * is what a pause has to preserve: storing the fraction behind the display instead
126
+ * makes the first tick after a resume step by two units.
127
+ */
128
+ #renderedAmount = 0;
68
129
  connect() {
130
+ this.#resync.activate();
69
131
  this.#initReference();
70
- this.#render(this.#currentAmount());
71
- if (this.autostartValue && this.#isValidDeadline) {
72
- this.start();
73
- } else {
132
+ const amount = this.#currentAmount();
133
+ this.#render(amount);
134
+ this.#pausedAmount = this.#renderedAmount;
135
+ const authored = this.element.getAttribute("data-state");
136
+ if (authored === null) {
137
+ if (this.autostartValue && this.#isValidDeadline) {
138
+ this.start();
139
+ return;
140
+ }
74
141
  this.element.setAttribute("data-state", "paused");
142
+ return;
75
143
  }
144
+ if (authored === "complete" && this.#isDown && amount <= 0) {
145
+ return;
146
+ }
147
+ const wasRunning = authored === "running";
148
+ this.element.setAttribute("data-state", "paused");
149
+ if (wasRunning) this.start();
76
150
  }
77
151
  disconnect() {
152
+ this.#resync.cancel();
78
153
  this.#intervals.clearAll();
79
154
  this.#intervalId = null;
80
155
  }
156
+ /** Re-derives the display when a morph swaps the deadline in place. */
157
+ deadlineValueChanged() {
158
+ this.#resync.schedule();
159
+ }
160
+ /** Re-derives the display when a morph flips the counting direction in place. */
161
+ directionValueChanged() {
162
+ this.#resync.schedule();
163
+ }
164
+ /**
165
+ * Points the anchor at the current `deadline` / `direction` and repaints.
166
+ *
167
+ * Render only: it starts no interval and emits no event, so a morph cannot make a
168
+ * paused timer run or replay a milestone. A running one needs no restart either —
169
+ * every tick reads the anchor, so moving it is enough. While paused the stored
170
+ * amount follows the new reading, or resume would continue from the old deadline.
171
+ */
172
+ #resyncToValues() {
173
+ this.#initReference();
174
+ this.#render(this.#currentAmount());
175
+ if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
176
+ }
81
177
  /** Starts (or restarts after pause) ticking toward the deadline. */
82
178
  start() {
83
179
  if (this.#state === "running" || !this.#isValidDeadline) return;
84
180
  if (this.#isDown && this.#currentAmount() <= 0) {
85
- this.#complete();
181
+ if (this.#state !== "complete") this.#complete();
86
182
  return;
87
183
  }
88
184
  this.#runInterval();
@@ -90,7 +186,7 @@ var CountdownController = class extends Controller {
90
186
  /** Pauses ticking, preserving the currently displayed amount. */
91
187
  pause() {
92
188
  if (this.#state !== "running") return;
93
- this.#pausedAmount = this.#currentAmount();
189
+ this.#pausedAmount = this.#renderedAmount;
94
190
  this.#teardownInterval();
95
191
  this.element.setAttribute("data-state", "paused");
96
192
  }
@@ -106,8 +202,8 @@ var CountdownController = class extends Controller {
106
202
  * run state**: a running timer keeps counting down from the reset amount, while a
107
203
  * paused (or completed) one resets the displayed amount but stays paused until the
108
204
  * user resumes — it never silently restarts. The run state is read from the DOM,
109
- * not re-derived from the declarative `autostart` Value (which only governs the
110
- * initial state on connect); re-deriving it would override a user's pause —
205
+ * not re-derived from the declarative `autostart` Value (which governs only markup
206
+ * that states no run state at all); re-deriving it would override a user's pause —
111
207
  * the DOM, not a re-run of declarative config, is the source of truth.
112
208
  */
113
209
  reset() {
@@ -116,13 +212,15 @@ var CountdownController = class extends Controller {
116
212
  this.#initReference();
117
213
  const amount = this.#currentAmount();
118
214
  this.#render(amount);
119
- if (this.hasStatusTarget) this.statusTarget.textContent = "";
215
+ if (this.hasStatusTarget && this.statusTarget.textContent === this.completeLabelValue) {
216
+ this.statusTarget.textContent = "";
217
+ }
120
218
  this.element.setAttribute("data-state", "paused");
121
219
  if (wasRunning && this.#isValidDeadline) {
122
220
  this.#pausedAmount = 0;
123
221
  this.start();
124
222
  } else {
125
- this.#pausedAmount = amount;
223
+ this.#pausedAmount = this.#renderedAmount;
126
224
  }
127
225
  }
128
226
  /** Schedules the repeating tick and marks the timer running. */
@@ -157,6 +255,7 @@ var CountdownController = class extends Controller {
157
255
  this.statusTarget.textContent = this.completeLabelValue;
158
256
  }
159
257
  this.dispatch("complete", { detail: {} });
258
+ announce(fillTemplate(this.announceTextValue, {}));
160
259
  }
161
260
  /** Sets the time anchor from the `deadline` value. */
162
261
  #initReference() {
@@ -169,9 +268,14 @@ var CountdownController = class extends Controller {
169
268
  const raw = this.#isDown ? this.#reference - now : now - this.#reference;
170
269
  return Math.max(0, raw);
171
270
  }
172
- /** Writes the amount into the day/hour/minute/second slots. */
271
+ /**
272
+ * Writes the amount into the day/hour/minute/second slots.
273
+ *
274
+ * @stimeoRenderRoot
275
+ */
173
276
  #render(amount) {
174
- const totalSeconds = Math.floor(amount / 1e3);
277
+ const totalSeconds = Math.floor(amount / SECOND_MS);
278
+ this.#renderedAmount = totalSeconds * SECOND_MS;
175
279
  const days = Math.floor(totalSeconds / 86400);
176
280
  const hours = Math.floor(totalSeconds % 86400 / 3600);
177
281
  const minutes = Math.floor(totalSeconds % 3600 / 60);
@@ -52,6 +52,39 @@ function toISOMonthString(date) {
52
52
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
53
53
  }
54
54
 
55
+ // src/utils/microtask_coalescer.ts
56
+ var MicrotaskCoalescer = class {
57
+ #run;
58
+ #queued = false;
59
+ #active = false;
60
+ #generation = 0;
61
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
62
+ constructor(run) {
63
+ this.#run = run;
64
+ }
65
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
66
+ activate() {
67
+ this.#active = true;
68
+ }
69
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
70
+ cancel() {
71
+ this.#active = false;
72
+ this.#queued = false;
73
+ this.#generation += 1;
74
+ }
75
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
76
+ schedule() {
77
+ if (!this.#active || this.#queued) return;
78
+ this.#queued = true;
79
+ const generation = this.#generation;
80
+ queueMicrotask(() => {
81
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
82
+ this.#queued = false;
83
+ this.#run();
84
+ });
85
+ }
86
+ };
87
+
55
88
  // src/utils/safe_timeout.ts
56
89
  var TimerRegistry = class {
57
90
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -129,7 +162,15 @@ var DateRangePickerController = class extends Controller {
129
162
  /** Deferred focus after an async month transition (cancelled on teardown). */
130
163
  #focusTimer = new SafeTimeout();
131
164
  /** Seeds the range from any pre-filled hidden fields and renders the grid. */
165
+ /**
166
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
167
+ * pass Stimulus delivers before `connect()`.
168
+ */
169
+ #repaint = new MicrotaskCoalescer(() => {
170
+ this.#render();
171
+ });
132
172
  connect() {
173
+ this.#repaint.activate();
133
174
  this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
134
175
  this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
135
176
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
@@ -140,8 +181,17 @@ var DateRangePickerController = class extends Controller {
140
181
  }
141
182
  /** Cancels any pending deferred focus so it never fires on a detached element. */
142
183
  disconnect() {
184
+ this.#repaint.cancel();
143
185
  this.#focusTimer.clearAll();
144
186
  }
187
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
188
+ minValueChanged() {
189
+ this.#repaint.schedule();
190
+ }
191
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
192
+ maxValueChanged() {
193
+ this.#repaint.schedule();
194
+ }
145
195
  /** Navigates to the previous month. */
146
196
  prev(event) {
147
197
  event?.preventDefault();
@@ -305,7 +355,11 @@ var DateRangePickerController = class extends Controller {
305
355
  this.#focusedDate = target;
306
356
  this.#render();
307
357
  }
308
- /** Builds the six-week grid and binds range/roving/disabled state per cell. */
358
+ /**
359
+ * Builds the six-week grid and binds range/roving/disabled state per cell.
360
+ *
361
+ * @stimeoRenderRoot
362
+ */
309
363
  #render() {
310
364
  const info = parseISOMonthString(this.#viewMonth);
311
365
  if (!info) return;
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/dialog_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/escape_layer.ts
6
35
  var EscapeLayer = class _EscapeLayer {
7
36
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -135,7 +164,7 @@ var FocusTrap = class {
135
164
  }
136
165
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
166
  document.addEventListener("keydown", this.#onKeydown);
138
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
167
+ this.#beforeCache.activate();
139
168
  const onEscape = this.#options.onEscape;
140
169
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
170
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -152,7 +181,7 @@ var FocusTrap = class {
152
181
  this.#activeState = false;
153
182
  this.#escapeLayer.deactivate();
154
183
  document.removeEventListener("keydown", this.#onKeydown);
155
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
184
+ this.#beforeCache.deactivate();
156
185
  if (this.#scrollLocked) {
157
186
  document.body.style.overflow = this.#previousBodyOverflow;
158
187
  this.#scrollLocked = false;
@@ -176,9 +205,7 @@ var FocusTrap = class {
176
205
  * untouched (restore-open designs reopen against a clean baseline), and focus
177
206
  * is left alone mid-navigation. The listener lives only while active.
178
207
  */
179
- #onBeforeCache = () => {
180
- this.deactivate({ restoreFocus: false });
181
- };
208
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
182
209
  /**
183
210
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
211
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -110,7 +110,7 @@ var DirectUploadController = class extends Controller {
110
110
  const clamped = Math.max(0, Math.min(100, percent));
111
111
  row.setAttribute("aria-valuenow", String(clamped));
112
112
  row.setAttribute("aria-valuetext", `${clamped}%`);
113
- row.style.setProperty("--stimeo-upload-progress", `${clamped}%`);
113
+ row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
114
114
  this.#setField(row, "percent", `${clamped}%`);
115
115
  this.#syncAggregate();
116
116
  this.dispatch("progress", { detail: { id, percent: clamped } });
@@ -148,7 +148,7 @@ var DirectUploadController = class extends Controller {
148
148
  }
149
149
  clone.setAttribute("aria-valuenow", "0");
150
150
  clone.setAttribute("data-upload-state", "uploading");
151
- clone.style.setProperty("--stimeo-upload-progress", "0%");
151
+ clone.style.setProperty("--stimeo--upload-progress", "0%");
152
152
  this.listTarget.appendChild(clone);
153
153
  this.#rows.set(key, clone);
154
154
  return clone;
@@ -172,7 +172,7 @@ var DirectUploadController = class extends Controller {
172
172
  }
173
173
  const overall = Math.round(total / this.#rows.size);
174
174
  this.element.setAttribute("data-upload-progress", String(overall));
175
- this.element.style.setProperty("--stimeo-upload-progress", `${overall}%`);
175
+ this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
176
176
  }
177
177
  /** Writes a consumer label (with `%{name}` substituted) to the status region. */
178
178
  #announce(label, row) {
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/drawer_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/escape_layer.ts
6
35
  var EscapeLayer = class _EscapeLayer {
7
36
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -135,7 +164,7 @@ var FocusTrap = class {
135
164
  }
136
165
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
166
  document.addEventListener("keydown", this.#onKeydown);
138
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
167
+ this.#beforeCache.activate();
139
168
  const onEscape = this.#options.onEscape;
140
169
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
170
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -152,7 +181,7 @@ var FocusTrap = class {
152
181
  this.#activeState = false;
153
182
  this.#escapeLayer.deactivate();
154
183
  document.removeEventListener("keydown", this.#onKeydown);
155
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
184
+ this.#beforeCache.deactivate();
156
185
  if (this.#scrollLocked) {
157
186
  document.body.style.overflow = this.#previousBodyOverflow;
158
187
  this.#scrollLocked = false;
@@ -176,9 +205,7 @@ var FocusTrap = class {
176
205
  * untouched (restore-open designs reopen against a clean baseline), and focus
177
206
  * is left alone mid-navigation. The listener lives only while active.
178
207
  */
179
- #onBeforeCache = () => {
180
- this.deactivate({ restoreFocus: false });
181
- };
208
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
182
209
  /**
183
210
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
211
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of