stimeo-ui 0.4.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/dist/controllers/aspect_ratio_controller.js +1 -1
  4. data/dist/controllers/breadcrumb_controller.js +5 -1
  5. data/dist/controllers/carousel_controller.js +5 -1
  6. data/dist/controllers/clipboard_controller.js +8 -3
  7. data/dist/controllers/collapsible_controller.js +4 -1
  8. data/dist/controllers/color_picker_controller.js +6 -2
  9. data/dist/controllers/context_menu_controller.js +2 -2
  10. data/dist/controllers/countdown_controller.js +5 -1
  11. data/dist/controllers/date_range_picker_controller.js +5 -1
  12. data/dist/controllers/direct_upload_controller.js +3 -3
  13. data/dist/controllers/empty_state_controller.js +107 -16
  14. data/dist/controllers/flash_controller.js +161 -21
  15. data/dist/controllers/form_validation_controller.js +8 -2
  16. data/dist/controllers/frame_loading_controller.js +94 -22
  17. data/dist/controllers/highlight_controller.js +38 -1
  18. data/dist/controllers/idle_controller.js +13 -2
  19. data/dist/controllers/local_time_controller.js +2 -0
  20. data/dist/controllers/masonry_controller.js +1 -1
  21. data/dist/controllers/meter_controller.js +3 -1
  22. data/dist/controllers/network_status_controller.js +1 -3
  23. data/dist/controllers/number_input_controller.js +191 -24
  24. data/dist/controllers/overflow_menu_controller.js +1 -1
  25. data/dist/controllers/pagination_controller.js +5 -1
  26. data/dist/controllers/password_strength_controller.js +1 -1
  27. data/dist/controllers/pointer_drag_controller.js +10 -0
  28. data/dist/controllers/portal_controller.js +10 -0
  29. data/dist/controllers/progress_controller.js +7 -3
  30. data/dist/controllers/range_slider_controller.js +385 -94
  31. data/dist/controllers/rating_controller.js +2 -0
  32. data/dist/controllers/relative_time_controller.js +2 -0
  33. data/dist/controllers/scroll_area_controller.js +1 -1
  34. data/dist/controllers/separator_controller.js +13 -17
  35. data/dist/controllers/skeleton_controller.js +71 -3
  36. data/dist/controllers/slider_controller.js +325 -48
  37. data/dist/controllers/spinner_controller.js +18 -3
  38. data/dist/controllers/step_indicator_controller.js +3 -1
  39. data/dist/controllers/stepper_controller.js +2 -0
  40. data/dist/controllers/switch_controller.js +162 -18
  41. data/dist/controllers/textarea_autosize_controller.js +1 -1
  42. data/dist/controllers/time_picker_controller.js +6 -3
  43. data/dist/controllers/tree_view_controller.js +19 -1
  44. data/dist/index.js +1214 -307
  45. data/lib/stimeo/ui/version.rb +1 -1
  46. metadata +2 -2
@@ -19,6 +19,61 @@ function fillTemplate(template, values) {
19
19
  });
20
20
  }
21
21
 
22
+ // src/utils/detach_gate.ts
23
+ var DetachGate = class _DetachGate {
24
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
25
+ #pending = false;
26
+ /**
27
+ * True while a probe is queued — the last disconnect was ambiguous and no
28
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
29
+ * reconnect half of an in-page move from a first connect: a controller whose
30
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
31
+ * counter) must skip it for the move, where nothing actually restarted.
32
+ */
33
+ get pending() {
34
+ return this.#pending;
35
+ }
36
+ /**
37
+ * True when the disconnect is definitely a real detach — the element left
38
+ * the document, or `data-controller` no longer lists the identifier. False
39
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
40
+ */
41
+ static isDetached(host) {
42
+ if (!host.element.isConnected) return true;
43
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
44
+ return !tokens.includes(host.identifier);
45
+ }
46
+ /**
47
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
48
+ * detach (fast path), otherwise defers it one microtask — a reconnect
49
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
50
+ * One microtask is the whole probe window: Stimulus reconnects a moved
51
+ * element within the same mutation batch, before the checkpoint drains.
52
+ */
53
+ disconnected(host, teardown) {
54
+ if (_DetachGate.isDetached(host)) {
55
+ this.#pending = false;
56
+ teardown();
57
+ return;
58
+ }
59
+ this.#pending = true;
60
+ queueMicrotask(() => {
61
+ if (!this.#pending) return;
62
+ this.#pending = false;
63
+ teardown();
64
+ });
65
+ }
66
+ /**
67
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
68
+ * an in-page move) and from the head of any teardown path not routed through
69
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
70
+ * never run the teardown a second time.
71
+ */
72
+ cancel() {
73
+ this.#pending = false;
74
+ }
75
+ };
76
+
22
77
  // src/utils/min_duration_floor.ts
23
78
  var MinDurationFloor = class {
24
79
  #timers;
@@ -131,14 +186,16 @@ var SkeletonController = class extends Controller {
131
186
  static events = ["ready"];
132
187
  #timers = new SafeTimeout();
133
188
  #floor = new MinDurationFloor(this.#timers);
189
+ #gate = new DetachGate();
134
190
  connect() {
135
- if (this.#state !== "ready") {
191
+ const moved = this.#gate.pending;
192
+ this.#gate.cancel();
193
+ if (!moved && this.#state !== "ready") {
136
194
  this.#enterLoading();
137
195
  }
138
196
  }
139
197
  disconnect() {
140
- this.#timers.clearAll();
141
- this.#floor.cancel();
198
+ this.#gate.disconnected(this, () => this.#teardown());
142
199
  }
143
200
  /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
144
201
  ready() {
@@ -167,6 +224,17 @@ var SkeletonController = class extends Controller {
167
224
  this.dispatch("ready", { detail: {} });
168
225
  announce(fillTemplate(this.announceReadyTextValue, {}));
169
226
  }
227
+ /**
228
+ * Drops the held reveal on a real detach. The markup keeps whatever it last
229
+ * held: an element on its way out of the document has no reader left, and one
230
+ * whose `data-controller` dropped the identifier no longer resolves its own
231
+ * targets, so the rollback could only ever be partial.
232
+ */
233
+ #teardown() {
234
+ this.#gate.cancel();
235
+ this.#timers.clearAll();
236
+ this.#floor.cancel();
237
+ }
170
238
  /** Current lifecycle phase as reflected on `data-state`. */
171
239
  get #state() {
172
240
  return this.element.getAttribute("data-state") ?? "loading";
@@ -18,6 +18,92 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/microtask_coalescer.ts
22
+ var MicrotaskCoalescer = class {
23
+ #run;
24
+ #queued = false;
25
+ #active = false;
26
+ #generation = 0;
27
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
28
+ constructor(run) {
29
+ this.#run = run;
30
+ }
31
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
32
+ activate() {
33
+ this.#active = true;
34
+ }
35
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
36
+ cancel() {
37
+ this.#active = false;
38
+ this.#queued = false;
39
+ this.#generation += 1;
40
+ }
41
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
42
+ schedule() {
43
+ if (!this.#active || this.#queued) return;
44
+ this.#queued = true;
45
+ const generation = this.#generation;
46
+ queueMicrotask(() => {
47
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
48
+ this.#queued = false;
49
+ this.#run();
50
+ });
51
+ }
52
+ };
53
+
54
+ // src/utils/owned_pointer_session.ts
55
+ var OwnedPointerSession = class {
56
+ pointerId;
57
+ #owner;
58
+ #handlers;
59
+ #abort = new AbortController();
60
+ #active = true;
61
+ constructor(start, owner, handlers) {
62
+ this.pointerId = start.pointerId;
63
+ this.#owner = owner;
64
+ this.#handlers = handlers;
65
+ const { signal } = this.#abort;
66
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
67
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
68
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
69
+ owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
70
+ try {
71
+ owner.setPointerCapture?.(this.pointerId);
72
+ } catch {
73
+ }
74
+ }
75
+ /** Whether this session still owns its pointer and listeners. */
76
+ get active() {
77
+ return this.#active;
78
+ }
79
+ /** Whether `event` belongs to the initiating pointer of the live session. */
80
+ owns(event) {
81
+ return this.#active && event.pointerId === this.pointerId;
82
+ }
83
+ /** Releases capture/listeners and invokes the end callback exactly once. */
84
+ end() {
85
+ if (!this.#active) return;
86
+ this.#active = false;
87
+ this.#abort.abort();
88
+ try {
89
+ this.#owner.releasePointerCapture?.(this.pointerId);
90
+ } catch {
91
+ }
92
+ this.#handlers.end?.();
93
+ }
94
+ #onMove = (event) => {
95
+ if (this.owns(event)) this.#handlers.move(event);
96
+ };
97
+ #onEndEvent = (event) => {
98
+ if (this.owns(event)) this.end();
99
+ };
100
+ #onLostCapture = (event) => {
101
+ const pointerId = event.pointerId;
102
+ if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
103
+ this.end();
104
+ };
105
+ };
106
+
21
107
  // src/utils/range.ts
22
108
  function rangeFraction(value, min, max) {
23
109
  const span = max - min;
@@ -34,6 +120,112 @@ function rangeFraction(value, min, max) {
34
120
  return Math.min(1, Math.max(0, fraction));
35
121
  }
36
122
 
123
+ // src/utils/stepped_value.ts
124
+ function effectiveStep(step) {
125
+ return Number.isFinite(step) && step > 0 ? step : 1;
126
+ }
127
+ function snapSteppedValue(raw, range) {
128
+ if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
129
+ const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
130
+ const clamped = Math.min(range.max, Math.max(range.min, input));
131
+ const step = effectiveStep(range.step);
132
+ const base = stepBase(range);
133
+ const candidates = [];
134
+ if (Number.isFinite(range.min)) candidates.push(range.min);
135
+ if (Number.isFinite(range.max)) candidates.push(range.max);
136
+ const gridPosition = (clamped - base) / step;
137
+ if (Number.isFinite(gridPosition)) {
138
+ addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
139
+ addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
140
+ }
141
+ if (candidates.length === 0) return clamped;
142
+ let nearest = candidates[0];
143
+ let nearestDistance = Math.abs(clamped - nearest);
144
+ for (const candidate of candidates.slice(1)) {
145
+ const distance = Math.abs(clamped - candidate);
146
+ if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
147
+ nearest = candidate;
148
+ nearestDistance = distance;
149
+ }
150
+ }
151
+ return nearest;
152
+ }
153
+ function stepSteppedValue(current, count, range) {
154
+ const value = snapSteppedValue(current, range);
155
+ const distance = Math.abs(Math.trunc(count));
156
+ if (!Number.isFinite(distance) || distance === 0) return value;
157
+ const direction = Math.sign(count);
158
+ const adjacent = adjacentSteppedValue(value, direction, range);
159
+ const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
160
+ return snapSteppedValue(raw, range);
161
+ }
162
+ function adjacentSteppedValue(current, direction, range) {
163
+ const step = effectiveStep(range.step);
164
+ const base = stepBase(range);
165
+ const candidates = [];
166
+ if (direction > 0) {
167
+ const position2 = (current - base) / step;
168
+ if (Number.isFinite(position2)) {
169
+ let gridIndex = Math.floor(position2) + 1;
170
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
171
+ if (candidate < current || nearlyEqual(candidate, current)) {
172
+ gridIndex += 1;
173
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
174
+ }
175
+ if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
176
+ candidates.push(clamp(candidate, range));
177
+ }
178
+ }
179
+ return clamp(Math.min(...candidates), range);
180
+ }
181
+ const position = (current - base) / step;
182
+ if (Number.isFinite(position)) {
183
+ let gridIndex = Math.ceil(position) - 1;
184
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
185
+ if (candidate > current || nearlyEqual(candidate, current)) {
186
+ gridIndex -= 1;
187
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
188
+ }
189
+ if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
190
+ candidates.push(clamp(candidate, range));
191
+ }
192
+ }
193
+ return clamp(Math.max(...candidates), range);
194
+ }
195
+ function addGridCandidate(candidates, index, range, base, step) {
196
+ const candidate = cleanGridValue(base + index * step, base, step);
197
+ if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
198
+ }
199
+ function stepBase(range) {
200
+ if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
201
+ return Number.isFinite(range.min) ? range.min : 0;
202
+ }
203
+ function cleanGridValue(value, base, step) {
204
+ const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
205
+ return precision <= 100 ? Number(value.toFixed(precision)) : value;
206
+ }
207
+ function decimalPlaces(value) {
208
+ const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
209
+ const fractionLength = coefficient.split(".")[1]?.length ?? 0;
210
+ const exponent = exponentText === void 0 ? 0 : Number(exponentText);
211
+ return Math.max(0, fractionLength - exponent);
212
+ }
213
+ function within(value, min, max) {
214
+ return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
215
+ }
216
+ function clamp(value, range) {
217
+ return Math.min(range.max, Math.max(range.min, value));
218
+ }
219
+ function nearlyEqual(left, right) {
220
+ const scale = Math.max(Math.abs(left), Math.abs(right));
221
+ return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
222
+ }
223
+ function finiteFallback(min, max) {
224
+ if (Number.isFinite(min)) return min;
225
+ if (Number.isFinite(max)) return max;
226
+ return 0;
227
+ }
228
+
37
229
  // src/controllers/slider_controller.ts
38
230
  var FRACTION_PROPERTY = "--stimeo--slider-fraction";
39
231
  var SliderController = class extends Controller {
@@ -47,40 +239,79 @@ var SliderController = class extends Controller {
47
239
  };
48
240
  static actions = ["onKeydown", "onPointerDown"];
49
241
  static events = ["change"];
50
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
51
- #dragAbort = null;
242
+ /** One initiating pointer owns each live drag and its stable target snapshot. */
243
+ #drag = null;
244
+ /** Collapses a morph that swaps several render Values into one silent repaint. */
245
+ #repaint = new MicrotaskCoalescer(() => this.#render());
52
246
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
53
247
  get #mirrored() {
54
248
  return this.logicalTrackValue && isRtl(this.element);
55
249
  }
56
250
  /** Clamps the initial value and renders the starting position. */
57
251
  connect() {
58
- this.#setValue(this.valueValue, { silent: true });
252
+ this.#repaint.activate();
253
+ this.#commit(this.valueValue, false);
59
254
  }
60
255
  /** Cancels any active pointer drag so document listeners never leak. */
61
256
  disconnect() {
62
- this.#dragAbort?.abort();
63
- this.#dragAbort = null;
257
+ this.#repaint.cancel();
258
+ this.#endDrag();
259
+ }
260
+ /** Silently repaints a minimum changed by application code or a Turbo morph. */
261
+ minValueChanged() {
262
+ this.#repaint.schedule();
263
+ }
264
+ /** Silently repaints a maximum changed by application code or a Turbo morph. */
265
+ maxValueChanged() {
266
+ this.#repaint.schedule();
267
+ }
268
+ /** Silently repaints a step changed by application code or a Turbo morph. */
269
+ stepValueChanged() {
270
+ this.#repaint.schedule();
271
+ }
272
+ /** Silently repaints a value changed by application code or a Turbo morph. */
273
+ valueValueChanged() {
274
+ this.#repaint.schedule();
275
+ }
276
+ /** Hydrates a thumb inserted or replaced at runtime with the current ARIA state. */
277
+ thumbTargetConnected(thumb) {
278
+ const value = this.#currentValue();
279
+ this.#renderThumb(thumb, value);
280
+ this.#renderFraction(value);
281
+ if (this.#drag && this.#drag.thumb === null) {
282
+ this.#drag.thumb = thumb;
283
+ thumb.focus();
284
+ }
285
+ this.#repaint.schedule();
286
+ }
287
+ /** Drops the stale focus owner while allowing a live track gesture to continue. */
288
+ thumbTargetDisconnected(thumb) {
289
+ if (this.#drag?.thumb === thumb) this.#drag.thumb = null;
290
+ }
291
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
292
+ trackTargetDisconnected(track) {
293
+ if (this.#drag?.track === track) this.#endDrag();
64
294
  }
65
295
  /** Handles keyboard stepping per the APG slider model. */
66
296
  onKeydown(event) {
67
297
  if (isReservedArrowChord(event)) return;
68
- const big = this.stepValue * 10;
69
298
  let next = null;
299
+ const current = this.#currentValue();
300
+ const range = this.#steppedRange;
70
301
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
71
302
  case "ArrowRight":
72
303
  case "ArrowUp":
73
- next = this.valueValue + this.stepValue;
304
+ next = stepSteppedValue(current, 1, range);
74
305
  break;
75
306
  case "ArrowLeft":
76
307
  case "ArrowDown":
77
- next = this.valueValue - this.stepValue;
308
+ next = stepSteppedValue(current, -1, range);
78
309
  break;
79
310
  case "PageUp":
80
- next = this.valueValue + big;
311
+ next = stepSteppedValue(current, 10, range);
81
312
  break;
82
313
  case "PageDown":
83
- next = this.valueValue - big;
314
+ next = stepSteppedValue(current, -10, range);
84
315
  break;
85
316
  case "Home":
86
317
  next = this.minValue;
@@ -92,56 +323,102 @@ var SliderController = class extends Controller {
92
323
  return;
93
324
  }
94
325
  event.preventDefault();
95
- this.#setValue(next);
326
+ this.#commit(next, true);
96
327
  }
97
328
  /** Begins a pointer drag: sets the value and tracks subsequent movement. */
98
329
  onPointerDown(event) {
99
- if (!this.hasTrackTarget) return;
100
- event.preventDefault();
330
+ if (event.button !== 0 || this.#drag || !this.hasTrackTarget || !this.hasThumbTarget) return;
331
+ const track = this.trackTarget;
332
+ const thumb = this.thumbTarget;
101
333
  const mirrored = this.#mirrored;
102
- this.#updateFromClientX(event.clientX, mirrored);
103
- if (this.hasThumbTarget) this.thumbTarget.focus();
104
- this.#dragAbort?.abort();
105
- const abort = new AbortController();
106
- this.#dragAbort = abort;
107
- const onMove = (move) => this.#updateFromClientX(move.clientX, mirrored);
108
- const onUp = () => {
109
- abort.abort();
110
- this.#dragAbort = null;
111
- };
112
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
113
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
114
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
115
- }
116
- /** Maps a pointer X coordinate to a value using the track's geometry. */
117
- #updateFromClientX(clientX, mirrored) {
118
- const rect = this.trackTarget.getBoundingClientRect();
119
- if (rect.width === 0) return;
334
+ const value = this.#valueFromClientX(event.clientX, mirrored, track);
335
+ if (value === null) return;
336
+ event.preventDefault();
337
+ this.#commit(value, true);
338
+ thumb.focus();
339
+ const drag = { pointer: null, track, thumb };
340
+ drag.pointer = new OwnedPointerSession(event, track, {
341
+ move: (move) => {
342
+ if (!track.isConnected) {
343
+ this.#endDrag();
344
+ return;
345
+ }
346
+ const moved = this.#valueFromClientX(move.clientX, mirrored, track);
347
+ if (moved !== null) this.#commit(moved, true);
348
+ },
349
+ end: () => {
350
+ if (this.#drag === drag) this.#drag = null;
351
+ }
352
+ });
353
+ this.#drag = drag;
354
+ }
355
+ /** Maps a pointer X coordinate to a raw value using a stable track snapshot. */
356
+ #valueFromClientX(clientX, mirrored, track) {
357
+ const rect = track.getBoundingClientRect();
358
+ if (rect.width === 0) return null;
120
359
  const offset = (clientX - rect.left) / rect.width;
121
360
  const fraction = mirrored ? 1 - offset : offset;
122
- this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
361
+ return this.minValue + fraction * (this.maxValue - this.minValue);
362
+ }
363
+ /**
364
+ * Stores a normalized value and renders synchronously for responsive input.
365
+ * Morph callbacks use {@link #render} instead, so they never write Values back
366
+ * or dispatch a user-facing change event.
367
+ */
368
+ #commit(raw, notify) {
369
+ const previous = this.#currentValue();
370
+ const value = snapSteppedValue(raw, this.#steppedRange);
371
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
372
+ this.#renderValue(value);
373
+ if (notify && value !== previous) this.dispatch("change", { detail: { value } });
123
374
  }
124
375
  /**
125
- * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and
126
- * reflects the new state on the thumb's ARIA attributes and the fraction
127
- * custom property. Dispatches `change` (detail `{ value }`) on a real value
128
- * change — symmetric with `range-slider` — unless `silent` (the initial
129
- * connect render, which is not a user edit).
376
+ * Reflects the normalized current Value without mutating or dispatching it.
377
+ *
378
+ * @stimeoRenderRoot
130
379
  */
131
- #setValue(raw, { silent = false } = {}) {
132
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
133
- const stepped = this.stepValue > 0 ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue : clamped;
134
- const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));
135
- const changed = value !== this.valueValue;
136
- this.valueValue = value;
380
+ #render() {
381
+ this.#renderValue(this.#currentValue());
382
+ }
383
+ /** Reflects one normalized value on the current target and CSS output. */
384
+ #renderValue(value) {
137
385
  if (this.hasThumbTarget) {
138
- this.thumbTarget.setAttribute("aria-valuemin", String(this.minValue));
139
- this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
140
- this.thumbTarget.setAttribute("aria-valuenow", String(value));
386
+ this.#renderThumb(this.thumbTarget, value);
141
387
  }
388
+ this.#renderFraction(value);
389
+ }
390
+ /** Writes only ARIA values that differ, including on a replacement target. */
391
+ #renderThumb(thumb, value) {
392
+ const attributes = {
393
+ "aria-valuemin": String(this.minValue),
394
+ "aria-valuemax": String(this.maxValue),
395
+ "aria-valuenow": String(value)
396
+ };
397
+ for (const [name, next] of Object.entries(attributes)) {
398
+ if (thumb.getAttribute(name) !== next) thumb.setAttribute(name, next);
399
+ }
400
+ }
401
+ /** Writes the behavior-only positioning hook only when its value changed. */
402
+ #renderFraction(value) {
142
403
  const fraction = rangeFraction(value, this.minValue, this.maxValue);
143
- this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
144
- if (changed && !silent) this.dispatch("change", { detail: { value } });
404
+ const next = String(fraction);
405
+ if (this.element.style.getPropertyValue(FRACTION_PROPERTY) !== next) {
406
+ this.element.style.setProperty(FRACTION_PROPERTY, next);
407
+ }
408
+ }
409
+ /** Current normalized value derived from the live declarative inputs. */
410
+ #currentValue() {
411
+ return snapSteppedValue(this.valueValue, this.#steppedRange);
412
+ }
413
+ /** Shared range configuration; finite endpoints remain allowed off the grid. */
414
+ get #steppedRange() {
415
+ return { min: this.minValue, max: this.maxValue, step: this.stepValue };
416
+ }
417
+ /** Ends the current pointer session without dispatching another change. */
418
+ #endDrag() {
419
+ const drag = this.#drag;
420
+ this.#drag = null;
421
+ drag?.pointer?.end();
145
422
  }
146
423
  };
147
424
 
@@ -48,10 +48,27 @@ var BeforeCacheReset = class _BeforeCacheReset {
48
48
  }
49
49
  };
50
50
 
51
+ // src/utils/default_attribute.ts
52
+ function setDefaultAttribute(element, name, value) {
53
+ if (element.hasAttribute(name)) return false;
54
+ element.setAttribute(name, value);
55
+ return true;
56
+ }
57
+
51
58
  // src/utils/detach_gate.ts
52
59
  var DetachGate = class _DetachGate {
53
60
  /** Set while a probe is queued, waiting for a reconnect to cancel it. */
54
61
  #pending = false;
62
+ /**
63
+ * True while a probe is queued — the last disconnect was ambiguous and no
64
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
65
+ * reconnect half of an in-page move from a first connect: a controller whose
66
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
67
+ * counter) must skip it for the move, where nothing actually restarted.
68
+ */
69
+ get pending() {
70
+ return this.#pending;
71
+ }
55
72
  /**
56
73
  * True when the disconnect is definitely a real detach — the element left
57
74
  * the document, or `data-controller` no longer lists the identifier. False
@@ -222,9 +239,7 @@ var SpinnerController = class extends Controller {
222
239
  this.element.setAttribute("data-state", "idle");
223
240
  return;
224
241
  }
225
- if (!this.element.hasAttribute("data-state")) {
226
- this.element.setAttribute("data-state", "idle");
227
- }
242
+ setDefaultAttribute(this.element, "data-state", "idle");
228
243
  }
229
244
  /**
230
245
  * Re-applies the current phase to an indicator that arrived after `connect()`.
@@ -99,6 +99,8 @@ var StepIndicatorController = class extends Controller {
99
99
  * A pure function of the step set and `current`, so running it again writes the
100
100
  * same values — which is what lets the action path paint synchronously (the event
101
101
  * goes out after the DOM is updated) while a coalesced pass may still follow.
102
+ *
103
+ * @stimeoRenderRoot
102
104
  */
103
105
  #render() {
104
106
  const total = this.stepTargets.length;
@@ -112,7 +114,7 @@ var StepIndicatorController = class extends Controller {
112
114
  }
113
115
  });
114
116
  const ratio = total > 1 ? current / (total - 1) : 0;
115
- this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
117
+ this.element.style.setProperty("--stimeo--step-indicator-ratio", String(ratio));
116
118
  }
117
119
  /**
118
120
  * Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
@@ -68,6 +68,8 @@ var StepperController = class extends Controller {
68
68
  * `aria-current="step"` is placed on the step's **first** `<button>`; the markup
69
69
  * contract assumes one operable button per step. If a step needs multiple
70
70
  * buttons, mark the navigational one first (or this would target the wrong one).
71
+ *
72
+ * @stimeoRenderRoot
71
73
  */
72
74
  #render(current) {
73
75
  this.stepTargets.forEach((step, index) => {