stimeo-ui 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/countdown_controller.ts
4
-
5
- // src/utils/announce.ts
6
3
  function announce(message, options = {}) {
7
4
  const text = message.trim();
8
5
  if (text.length === 0) return;
@@ -19,27 +16,33 @@ function fillTemplate(template, values) {
19
16
  });
20
17
  }
21
18
 
22
- // src/utils/microtask_coalescer.ts
19
+ var ListenerSet = class {
20
+ #abort = new AbortController();
21
+ add(target, type, handler, options) {
22
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
23
+ }
24
+ dispose() {
25
+ this.#abort.abort();
26
+ this.#abort = new AbortController();
27
+ }
28
+ };
29
+
23
30
  var MicrotaskCoalescer = class {
24
31
  #run;
25
32
  #queued = false;
26
33
  #active = false;
27
34
  #generation = 0;
28
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
29
35
  constructor(run) {
30
36
  this.#run = run;
31
37
  }
32
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
33
38
  activate() {
34
39
  this.#active = true;
35
40
  }
36
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
37
41
  cancel() {
38
42
  this.#active = false;
39
43
  this.#queued = false;
40
44
  this.#generation += 1;
41
45
  }
42
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
43
46
  schedule() {
44
47
  if (!this.#active || this.#queued) return;
45
48
  this.#queued = true;
@@ -52,38 +55,96 @@ var MicrotaskCoalescer = class {
52
55
  }
53
56
  };
54
57
 
55
- // src/utils/safe_timeout.ts
58
+ var MorphRenderWatcher = class {
59
+ #listeners = new ListenerSet();
60
+ #pass;
61
+ #element = null;
62
+ #onMorph = () => this.#pass.schedule();
63
+ constructor(run) {
64
+ this.#pass = new MicrotaskCoalescer(run);
65
+ }
66
+ observe(element) {
67
+ if (this.#element === element) return;
68
+ this.disconnect();
69
+ this.#element = element;
70
+ this.#pass.activate();
71
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
72
+ }
73
+ schedule() {
74
+ this.#pass.schedule();
75
+ }
76
+ disconnect() {
77
+ this.#listeners.dispose();
78
+ this.#pass.cancel();
79
+ this.#element = null;
80
+ }
81
+ };
82
+
83
+ var MAX_TIMER_DELAY_MS = 2147483647;
84
+
85
+ var NUMBER_BOUNDS = {
86
+ positiveTimer: { finite: true, exclusiveMin: 0, max: MAX_TIMER_DELAY_MS }
87
+ };
88
+ function matchesNumberBounds(value, bounds) {
89
+ if (!Number.isFinite(value)) {
90
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
91
+ if (direction === null) return false;
92
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
93
+ }
94
+ if (bounds.min !== void 0 && value < bounds.min) return false;
95
+ if (bounds.max !== void 0 && value > bounds.max) return false;
96
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
97
+ if (bounds.integer && !Number.isInteger(value)) return false;
98
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
99
+ return true;
100
+ }
101
+
102
+ function readNumber(raw, fallback, bounds) {
103
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
104
+ }
105
+
106
+ var NumberValueReader = class {
107
+ #lastRejected = /* @__PURE__ */ new Map();
108
+ read(owner, name, raw, fallback, bounds) {
109
+ const resolved = readNumber(raw, fallback, bounds);
110
+ if (matchesNumberBounds(raw, bounds)) {
111
+ this.#lastRejected.delete(name);
112
+ return resolved;
113
+ }
114
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
115
+ const literal = owner.element.getAttribute(attribute);
116
+ if (literal === null) {
117
+ this.#lastRejected.delete(name);
118
+ return resolved;
119
+ }
120
+ if (this.#lastRejected.get(name) !== literal) {
121
+ this.#lastRejected.set(name, literal);
122
+ console.warn(
123
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
124
+ );
125
+ }
126
+ return resolved;
127
+ }
128
+ };
129
+
56
130
  var TimerRegistry = class {
57
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
58
131
  ids = /* @__PURE__ */ new Set();
59
- /**
60
- * Cancels a single tracked timer.
61
- *
62
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
63
- * registry), so callers can clear defensively without guarding.
64
- */
65
132
  clear(id) {
66
133
  if (this.ids.delete(id)) {
67
134
  this.cancel(id);
68
135
  }
69
136
  }
70
- /**
71
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
72
- * to guarantee no timer outlives the element.
73
- */
74
137
  clearAll() {
75
138
  for (const id of this.ids) {
76
139
  this.cancel(id);
77
140
  }
78
141
  this.ids.clear();
79
142
  }
80
- /** Number of timers currently tracked (pending). */
81
143
  get size() {
82
144
  return this.ids.size;
83
145
  }
84
146
  };
85
147
  var SafeInterval = class extends TimerRegistry {
86
- /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */
87
148
  set(callback, delay) {
88
149
  const id = this.schedule(callback, delay);
89
150
  this.ids.add(id);
@@ -97,11 +158,10 @@ var SafeInterval = class extends TimerRegistry {
97
158
  }
98
159
  };
99
160
 
100
- // src/controllers/countdown_controller.ts
101
161
  var SECOND_MS = 1e3;
102
162
  var OWNED_STATUS = "owns-status";
103
- var CountdownController = class extends Controller {
104
- /** The status marker, in the namespace this controller is registered under. */
163
+ var CountdownController = class _CountdownController extends Controller {
164
+ #numbers = new NumberValueReader();
105
165
  get #ownedStatus() {
106
166
  return `data-${this.identifier}-${OWNED_STATUS}`;
107
167
  }
@@ -114,114 +174,122 @@ var CountdownController = class extends Controller {
114
174
  completeLabel: { type: String, default: "" },
115
175
  announceText: { type: String, default: "" }
116
176
  };
177
+ static valueConstraints = {
178
+ interval: NUMBER_BOUNDS.positiveTimer
179
+ };
117
180
  static actions = ["pause", "reset", "resume", "start"];
118
181
  static events = ["complete", "tick"];
119
182
  #intervals = new SafeInterval();
120
183
  #intervalId = null;
121
- /** Collapses a morph that swaps several render inputs at once into one re-derive. */
122
- #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
123
- /**
124
- * Whether `connect()` has run for this connection. Stimulus delivers Value
125
- * callbacks ahead of it, while the run state is still the markup's own and not
126
- * yet settled; `connect()` brings the status up to date once it is.
127
- */
184
+ #armedPeriod = 0;
185
+ #resync = new MorphRenderWatcher(() => this.#resyncToValues());
128
186
  #connected = false;
129
- /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */
130
187
  #reference = 0;
131
- /** Amount (ms) captured at pause, so resume can restore the same display. */
132
188
  #pausedAmount = 0;
133
- /**
134
- * The amount the slots are currently showing, floored to the second they render.
135
- * It lags {@link #currentAmount} by up to one tick, and it — not the live reading —
136
- * is what a pause has to preserve: storing the fraction behind the display instead
137
- * makes the first tick after a resume step by two units.
138
- */
139
189
  #renderedAmount = 0;
190
+ #phase = "paused";
191
+ #valuesDirty = false;
192
+ #statusWrites = /* @__PURE__ */ new WeakMap();
140
193
  connect() {
141
194
  this.#connected = true;
142
- this.#resync.activate();
195
+ this.#resync.observe(this.element);
196
+ this.#valuesDirty = false;
143
197
  this.#initReference();
144
198
  const amount = this.#currentAmount();
145
199
  this.#render(amount);
146
200
  this.#pausedAmount = this.#renderedAmount;
147
201
  const authored = this.element.getAttribute("data-state");
202
+ this.#phase = authored ?? "paused";
148
203
  const settled = authored === "complete" && this.#isSettled(amount);
149
204
  if (authored === null) {
150
205
  if (this.autostartValue && this.#isValidDeadline) this.start();
151
- else this.element.setAttribute("data-state", "paused");
206
+ else this.#setPhase("paused");
152
207
  } else if (!settled) {
153
- this.element.setAttribute("data-state", "paused");
208
+ this.#setPhase("paused");
154
209
  if (authored === "running") this.start();
155
210
  }
156
211
  this.#syncStatus();
157
212
  }
158
213
  disconnect() {
159
214
  this.#connected = false;
160
- this.#resync.cancel();
215
+ this.#resync.disconnect();
161
216
  this.#intervals.clearAll();
162
217
  this.#intervalId = null;
163
218
  }
164
- /** Re-derives the display when a morph swaps the deadline in place. */
219
+ daysTargetConnected() {
220
+ this.#resync.schedule();
221
+ }
222
+ daysTargetDisconnected() {
223
+ this.#resync.schedule();
224
+ }
225
+ hoursTargetConnected() {
226
+ this.#resync.schedule();
227
+ }
228
+ hoursTargetDisconnected() {
229
+ this.#resync.schedule();
230
+ }
231
+ minutesTargetConnected() {
232
+ this.#resync.schedule();
233
+ }
234
+ minutesTargetDisconnected() {
235
+ this.#resync.schedule();
236
+ }
237
+ secondsTargetConnected() {
238
+ this.#resync.schedule();
239
+ }
240
+ secondsTargetDisconnected() {
241
+ this.#resync.schedule();
242
+ }
243
+ statusTargetConnected() {
244
+ this.#resync.schedule();
245
+ }
246
+ statusTargetDisconnected(status) {
247
+ if (!this.statusTargets.includes(status) && this.#ownsStatus(status)) {
248
+ status.removeAttribute(this.#ownedStatus);
249
+ this.#statusWrites.delete(status);
250
+ }
251
+ this.#resync.schedule();
252
+ }
165
253
  deadlineValueChanged() {
254
+ this.#valuesDirty = true;
166
255
  this.#resync.schedule();
167
256
  }
168
- /** Re-derives the display when a morph flips the counting direction in place. */
169
257
  directionValueChanged() {
258
+ this.#valuesDirty = true;
170
259
  this.#resync.schedule();
171
260
  }
172
- /**
173
- * Follows a completion label swapped in place by a morph.
174
- *
175
- * Render only: `complete` is neither dispatched nor announced again. The status
176
- * changes only after the countdown has completed, and only while it still shows
177
- * the text this controller wrote there and nothing else, so text or an element
178
- * the consumer put in the slot stays. A change delivered ahead of `connect()` is
179
- * left to `connect()`.
180
- */
181
261
  completeLabelValueChanged() {
182
262
  if (this.#connected) this.#syncStatus();
183
263
  }
184
- /**
185
- * Keeps a status that still shows this controller's text, and nothing else, on
186
- * what the run state calls for: the label in force while complete; otherwise the
187
- * text is taken back, marker and all. A status holding anything else is the
188
- * consumer's and is left alone, marker included.
189
- *
190
- * @stimeoRenderRoot
191
- */
264
+ intervalValueChanged() {
265
+ if (this.#intervalId === null || this.#armedPeriod === this.#safeInterval) return;
266
+ this.#teardownInterval();
267
+ this.#armTick();
268
+ }
192
269
  #syncStatus() {
193
270
  if (!this.hasStatusTarget || !this.#ownsStatus(this.statusTarget)) return;
194
271
  if (this.#state === "complete") this.#writeStatus(this.statusTarget, this.completeLabelValue);
195
272
  else this.#releaseStatus(this.statusTarget);
196
273
  }
197
- /**
198
- * Points the anchor at the current `deadline` / `direction` and repaints.
199
- *
200
- * Render only: it starts no interval and emits no event, so a morph cannot make a
201
- * paused timer run or replay a milestone. A running one needs no restart either —
202
- * every tick reads the anchor, so moving it is enough. While paused the stored
203
- * amount follows the new reading, or resume would continue from the old deadline.
204
- * A completion the new reading no longer settles (a deadline moved forward, or a
205
- * count turned `up`) is handed back paused, as `connect()` hands back a restored
206
- * one, so `resume()` counts again; the completion text this controller wrote goes
207
- * with it, from a status slot that still shows it and nothing else.
208
- *
209
- * @stimeoRenderRoot
210
- */
211
274
  #resyncToValues() {
275
+ this.#setPhase(this.#state);
276
+ if (!this.#valuesDirty) {
277
+ this.#render(this.#renderedAmount);
278
+ this.#syncStatus();
279
+ return;
280
+ }
281
+ this.#valuesDirty = false;
212
282
  this.#initReference();
213
283
  const amount = this.#currentAmount();
214
284
  this.#render(amount);
215
- if (this.#state === "complete" && !this.#isSettled(amount)) {
216
- this.element.setAttribute("data-state", "paused");
217
- this.#syncStatus();
285
+ if (this.#state === "running" && !this.#isValidDeadline) {
286
+ this.#teardownInterval();
287
+ this.#setPhase("paused");
218
288
  }
289
+ if (this.#state === "complete" && !this.#isSettled(amount)) this.#setPhase("paused");
219
290
  if (this.#state !== "running") this.#pausedAmount = this.#renderedAmount;
291
+ this.#syncStatus();
220
292
  }
221
- /**
222
- * Starts (or restarts after pause) ticking toward the deadline. Counting on from a
223
- * completion takes this controller's completion text back out of the status.
224
- */
225
293
  start() {
226
294
  if (this.#state === "running" || !this.#isValidDeadline) return;
227
295
  if (this.#isDown && this.#currentAmount() <= 0) {
@@ -230,36 +298,25 @@ var CountdownController = class extends Controller {
230
298
  }
231
299
  this.#runInterval();
232
300
  }
233
- /** Pauses ticking, preserving the currently displayed amount. */
234
301
  pause() {
235
302
  if (this.#state !== "running") return;
236
303
  this.#pausedAmount = this.#renderedAmount;
237
304
  this.#teardownInterval();
238
- this.element.setAttribute("data-state", "paused");
305
+ this.#setPhase("paused");
239
306
  }
240
- /** Resumes from a pause, continuing from the preserved amount. */
241
307
  resume() {
242
308
  if (this.#state !== "paused" || !this.#isValidDeadline) return;
243
309
  const now = Date.now();
244
310
  this.#reference = this.#isDown ? now + this.#pausedAmount : now - this.#pausedAmount;
245
311
  this.start();
246
312
  }
247
- /**
248
- * Re-syncs to the deadline and clears any pause offset, **preserving the current
249
- * run state**: a running timer keeps counting down from the reset amount, while a
250
- * paused (or completed) one resets the displayed amount but stays paused until the
251
- * user resumes — it never silently restarts. The run state is read from the DOM,
252
- * not re-derived from the declarative `autostart` Value (which governs only markup
253
- * that states no run state at all); re-deriving it would override a user's pause —
254
- * the DOM, not a re-run of declarative config, is the source of truth.
255
- */
256
313
  reset() {
257
314
  const wasRunning = this.#state === "running";
258
315
  this.#teardownInterval();
259
316
  this.#initReference();
260
317
  const amount = this.#currentAmount();
261
318
  this.#render(amount);
262
- this.element.setAttribute("data-state", "paused");
319
+ this.#setPhase("paused");
263
320
  this.#syncStatus();
264
321
  if (wasRunning && this.#isValidDeadline) {
265
322
  this.#pausedAmount = 0;
@@ -268,29 +325,21 @@ var CountdownController = class extends Controller {
268
325
  this.#pausedAmount = this.#renderedAmount;
269
326
  }
270
327
  }
271
- /**
272
- * Schedules the repeating tick and marks the timer running. This is the one way
273
- * into `running`, and a running timer keeps none of this controller's completion
274
- * text in the status, so the status is settled here as on every other way out of
275
- * `complete`: `start()` leaves a completion directly when `direction` flips to
276
- * `up` and `start()` runs in the same task, ahead of the direction callback.
277
- *
278
- * @stimeoRuntimeOnly `interval` is the period of the one timer this call arms; the running state
279
- * it writes does not depend on a Value.
280
- */
281
328
  #runInterval() {
282
- this.element.setAttribute("data-state", "running");
329
+ this.#setPhase("running");
283
330
  this.#syncStatus();
284
- this.#intervalId = this.#intervals.set(() => this.#tick(), this.intervalValue);
331
+ this.#armTick();
332
+ }
333
+ #armTick() {
334
+ this.#armedPeriod = this.#safeInterval;
335
+ this.#intervalId = this.#intervals.set(() => this.#tick(), this.#armedPeriod);
285
336
  }
286
- /** Cancels the repeating tick, if any. */
287
337
  #teardownInterval() {
288
338
  if (this.#intervalId !== null) {
289
339
  this.#intervals.clear(this.#intervalId);
290
340
  this.#intervalId = null;
291
341
  }
292
342
  }
293
- /** Recomputes, renders, emits `tick`, and completes when a countdown hits 0. */
294
343
  #tick() {
295
344
  const amount = this.#currentAmount();
296
345
  this.#render(amount);
@@ -301,26 +350,14 @@ var CountdownController = class extends Controller {
301
350
  this.#complete();
302
351
  }
303
352
  }
304
- /**
305
- * Stops at zero, marks completion, writes the completion label, emits `complete`,
306
- * and announces it.
307
- *
308
- * @stimeoRenderRoot
309
- */
310
353
  #complete() {
311
354
  this.#teardownInterval();
312
355
  this.#render(0);
313
- this.element.setAttribute("data-state", "complete");
356
+ this.#setPhase("complete");
314
357
  this.#claimStatus();
315
358
  this.dispatch("complete", { detail: {} });
316
359
  this.#announceCompletion();
317
360
  }
318
- /**
319
- * Writes the label in force into the status slot as this controller's text. An
320
- * empty label puts no text there: it claims a slot that is already empty — no
321
- * text and no element — empties one that is still this controller's, and leaves
322
- * any other slot, the consumer's, alone.
323
- */
324
361
  #claimStatus() {
325
362
  if (!this.hasStatusTarget) return;
326
363
  const status = this.statusTarget;
@@ -328,54 +365,42 @@ var CountdownController = class extends Controller {
328
365
  if (label === "" && !this.#isEmpty(status) && !this.#ownsStatus(status)) return;
329
366
  this.#writeStatus(status, label);
330
367
  }
331
- /**
332
- * Whether the status slot shows the text this controller wrote there and nothing
333
- * else: the text matches the marker, and the slot holds no element. Writing the
334
- * slot's text replaces every child node, so an element the consumer puts in the
335
- * slot makes it theirs even while the text still matches. An empty marker
336
- * matches the slot whenever it is empty.
337
- */
338
368
  #ownsStatus(status) {
369
+ const held = this.#statusWrites.get(status);
370
+ if (!status.hasAttribute(this.#ownedStatus) && held && status.firstElementChild === null && (status.textContent === held.text || status.textContent === held.original))
371
+ return true;
339
372
  return status.getAttribute(this.#ownedStatus) === status.textContent && status.firstElementChild === null;
340
373
  }
341
- /** Whether the status slot holds nothing at all: no text and no element. */
342
374
  #isEmpty(status) {
343
375
  return status.textContent === "" && status.firstElementChild === null;
344
376
  }
345
- /** Puts `text` into the status slot and records it as this controller's. */
346
377
  #writeStatus(status, text) {
378
+ const original = this.#statusWrites.get(status)?.original ?? status.textContent ?? "";
379
+ this.#statusWrites.set(status, { original, text });
347
380
  if (status.textContent !== text) status.textContent = text;
348
- status.setAttribute(this.#ownedStatus, text);
381
+ if (status.getAttribute(this.#ownedStatus) !== text)
382
+ status.setAttribute(this.#ownedStatus, text);
349
383
  }
350
- /** Takes this controller's text out of the status slot, marker and all. */
351
384
  #releaseStatus(status) {
352
385
  status.textContent = "";
353
386
  status.removeAttribute(this.#ownedStatus);
387
+ this.#statusWrites.delete(status);
354
388
  }
355
- /**
356
- * Reads the completion out through the shared announcer.
357
- *
358
- * @stimeoRuntimeOnly `announceText` words the one announcement a completion makes.
359
- */
360
389
  #announceCompletion() {
361
390
  announce(fillTemplate(this.announceTextValue, {}));
362
391
  }
363
- /** Sets the time anchor from the `deadline` value. */
364
392
  #initReference() {
365
393
  this.#reference = Date.parse(this.deadlineValue);
366
394
  }
367
- /** Whether a reading of `amount` leaves nothing to count: only a countdown at zero. */
368
395
  #isSettled(amount) {
369
396
  return this.#isDown && amount <= 0;
370
397
  }
371
- /** Remaining (down) or elapsed (up) ms, never negative. */
372
398
  #currentAmount() {
373
399
  if (!this.#isValidDeadline) return 0;
374
400
  const now = Date.now();
375
401
  const raw = this.#isDown ? this.#reference - now : now - this.#reference;
376
402
  return Math.max(0, raw);
377
403
  }
378
- /** Writes the amount into the day/hour/minute/second slots. */
379
404
  #render(amount) {
380
405
  const totalSeconds = Math.floor(amount / SECOND_MS);
381
406
  this.#renderedAmount = totalSeconds * SECOND_MS;
@@ -388,7 +413,6 @@ var CountdownController = class extends Controller {
388
413
  if (this.hasMinutesTarget) this.minutesTarget.textContent = this.#pad(minutes);
389
414
  if (this.hasSecondsTarget) this.secondsTarget.textContent = this.#pad(seconds);
390
415
  }
391
- /** Zero-pads a unit to two digits. */
392
416
  #pad(unit) {
393
417
  return String(unit).padStart(2, "0");
394
418
  }
@@ -398,12 +422,23 @@ var CountdownController = class extends Controller {
398
422
  get #isValidDeadline() {
399
423
  return !Number.isNaN(this.#reference);
400
424
  }
401
- /** Current lifecycle phase as reflected on `data-state`. */
425
+ #setPhase(phase) {
426
+ this.#phase = phase;
427
+ this.element.setAttribute("data-state", phase);
428
+ }
402
429
  get #state() {
403
- return this.element.getAttribute("data-state") ?? "paused";
430
+ return this.element.getAttribute("data-state") ?? this.#phase;
431
+ }
432
+ get #safeInterval() {
433
+ return this.#numbers.read(
434
+ this,
435
+ "interval",
436
+ this.intervalValue,
437
+ _CountdownController.values.interval.default,
438
+ _CountdownController.valueConstraints.interval
439
+ );
404
440
  }
405
441
  };
406
442
 
407
443
  export { CountdownController };
408
- //# sourceMappingURL=countdown_controller.js.map
409
444
  //# sourceMappingURL=countdown_controller.js.map