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
@@ -51,6 +51,59 @@ var MicrotaskCoalescer = class {
51
51
  }
52
52
  };
53
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
+
54
107
  // src/utils/range.ts
55
108
  function rangeFraction(value, min, max) {
56
109
  const span = max - min;
@@ -67,14 +120,122 @@ function rangeFraction(value, min, max) {
67
120
  return Math.min(1, Math.max(0, fraction));
68
121
  }
69
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
+
70
229
  // src/controllers/range_slider_controller.ts
71
- var START_PROPERTY = "--stimeo-range-start";
72
- var END_PROPERTY = "--stimeo-range-end";
230
+ var START_PROPERTY = "--stimeo--range-slider-start";
231
+ var END_PROPERTY = "--stimeo--range-slider-end";
232
+ var DEFAULT_MIN = 0;
233
+ var DEFAULT_MAX = 100;
73
234
  var RangeSliderController = class extends Controller {
74
235
  static targets = ["track", "startThumb", "endThumb"];
75
236
  static values = {
76
- min: { type: Number, default: 0 },
77
- max: { type: Number, default: 100 },
237
+ min: { type: Number, default: DEFAULT_MIN },
238
+ max: { type: Number, default: DEFAULT_MAX },
78
239
  step: { type: Number, default: 1 },
79
240
  start: { type: Number, default: 0 },
80
241
  end: { type: Number, default: 100 },
@@ -82,31 +243,27 @@ var RangeSliderController = class extends Controller {
82
243
  };
83
244
  static actions = ["onKeydown", "onPointerDown"];
84
245
  static events = ["change"];
85
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
86
- #dragAbort = null;
246
+ /** One initiating pointer owns each live drag and its stable target snapshot. */
247
+ #drag = null;
87
248
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
88
249
  get #mirrored() {
89
250
  return this.logicalTrackValue && isRtl(this.element);
90
251
  }
91
- /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
92
252
  /**
93
253
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
94
254
  * pass Stimulus delivers before `connect()`.
95
255
  */
96
- #repaint = new MicrotaskCoalescer(() => {
97
- this.#render(this.startValue, this.endValue);
98
- });
256
+ #repaint = new MicrotaskCoalescer(() => this.#render());
99
257
  connect() {
100
258
  this.#repaint.activate();
101
- const lo = Math.min(this.startValue, this.endValue);
102
- const hi = Math.max(this.startValue, this.endValue);
103
- this.#commit(lo, hi, false);
259
+ const range = this.#effectiveRange;
260
+ const pair = this.#currentPair(range);
261
+ this.#commit(pair.start, pair.end, null, false);
104
262
  }
105
263
  /** Cancels any active pointer drag so document listeners never leak. */
106
264
  disconnect() {
107
265
  this.#repaint.cancel();
108
- this.#dragAbort?.abort();
109
- this.#dragAbort = null;
266
+ this.#endDrag();
110
267
  }
111
268
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
112
269
  minValueChanged() {
@@ -116,141 +273,275 @@ var RangeSliderController = class extends Controller {
116
273
  maxValueChanged() {
117
274
  this.#repaint.schedule();
118
275
  }
276
+ /** Repaints when application code (or a Turbo morph) changes `step` at runtime. */
277
+ stepValueChanged() {
278
+ this.#repaint.schedule();
279
+ }
280
+ /** Repaints when application code (or a Turbo morph) changes `start` at runtime. */
281
+ startValueChanged() {
282
+ this.#repaint.schedule();
283
+ }
284
+ /** Repaints when application code (or a Turbo morph) changes `end` at runtime. */
285
+ endValueChanged() {
286
+ this.#repaint.schedule();
287
+ }
288
+ /** Hydrates a replacement start thumb and restores live-drag focus ownership. */
289
+ startThumbTargetConnected(thumb) {
290
+ const range = this.#effectiveRange;
291
+ const pair = this.#currentPair(range);
292
+ this.#renderStartThumb(thumb, pair, range);
293
+ this.#renderFractions(pair, range);
294
+ if (this.#drag?.kind === "start" && this.#drag.thumb === null) {
295
+ this.#drag.thumb = thumb;
296
+ thumb.focus();
297
+ }
298
+ this.#repaint.schedule();
299
+ }
300
+ /** Drops a stale start-thumb reference without orphaning the track gesture. */
301
+ startThumbTargetDisconnected(thumb) {
302
+ if (this.#drag?.kind === "start" && this.#drag.thumb === thumb) this.#drag.thumb = null;
303
+ }
304
+ /** Hydrates a replacement end thumb and restores live-drag focus ownership. */
305
+ endThumbTargetConnected(thumb) {
306
+ const range = this.#effectiveRange;
307
+ const pair = this.#currentPair(range);
308
+ this.#renderEndThumb(thumb, pair, range);
309
+ this.#renderFractions(pair, range);
310
+ if (this.#drag?.kind === "end" && this.#drag.thumb === null) {
311
+ this.#drag.thumb = thumb;
312
+ thumb.focus();
313
+ }
314
+ this.#repaint.schedule();
315
+ }
316
+ /** Drops a stale end-thumb reference without orphaning the track gesture. */
317
+ endThumbTargetDisconnected(thumb) {
318
+ if (this.#drag?.kind === "end" && this.#drag.thumb === thumb) this.#drag.thumb = null;
319
+ }
320
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
321
+ trackTargetDisconnected(track) {
322
+ if (this.#drag?.track === track) this.#endDrag();
323
+ }
119
324
  /** Keyboard stepping for whichever thumb is focused (the action's element). */
120
325
  onKeydown(event) {
121
326
  if (isReservedArrowChord(event)) return;
122
327
  const thumb = event.currentTarget;
123
328
  const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
124
- const current = isStart ? this.startValue : this.endValue;
125
- const lower = isStart ? this.minValue : this.startValue;
126
- const upper = isStart ? this.endValue : this.maxValue;
127
- const big = this.stepValue * 10;
329
+ const isEnd = this.hasEndThumbTarget && thumb === this.endThumbTarget;
330
+ const effectiveRange = this.#effectiveRange;
331
+ const pair = this.#currentPair(effectiveRange);
332
+ let kind;
333
+ let current;
334
+ let range;
335
+ if (isStart) {
336
+ kind = "start";
337
+ current = pair.start;
338
+ range = {
339
+ min: effectiveRange.min,
340
+ max: pair.end,
341
+ step: effectiveRange.step,
342
+ base: effectiveRange.min
343
+ };
344
+ } else {
345
+ if (!isEnd) return;
346
+ kind = "end";
347
+ current = pair.end;
348
+ range = {
349
+ min: pair.start,
350
+ max: effectiveRange.max,
351
+ step: effectiveRange.step,
352
+ base: effectiveRange.min
353
+ };
354
+ }
128
355
  let next = null;
129
356
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
130
357
  case "ArrowRight":
131
358
  case "ArrowUp":
132
- next = current + this.stepValue;
359
+ next = stepSteppedValue(current, 1, range);
133
360
  break;
134
361
  case "ArrowLeft":
135
362
  case "ArrowDown":
136
- next = current - this.stepValue;
363
+ next = stepSteppedValue(current, -1, range);
137
364
  break;
138
365
  case "PageUp":
139
- next = current + big;
366
+ next = stepSteppedValue(current, 10, range);
140
367
  break;
141
368
  case "PageDown":
142
- next = current - big;
369
+ next = stepSteppedValue(current, -10, range);
143
370
  break;
144
371
  case "Home":
145
- next = lower;
372
+ next = range.min;
146
373
  break;
147
374
  case "End":
148
- next = upper;
375
+ next = range.max;
149
376
  break;
150
377
  default:
151
378
  return;
152
379
  }
153
380
  event.preventDefault();
154
- this.#moveThumb(isStart, next);
381
+ this.#moveThumb(kind, next);
155
382
  }
156
383
  /** Begins a pointer drag on the track, moving the thumb nearest the press. */
157
384
  onPointerDown(event) {
158
- if (!this.hasTrackTarget) return;
385
+ if (event.button !== 0 || this.#drag || !this.hasTrackTarget || !this.hasStartThumbTarget || !this.hasEndThumbTarget) {
386
+ return;
387
+ }
388
+ const track = this.trackTarget;
159
389
  const mirrored = this.#mirrored;
160
- const value = this.#valueFromClientX(event.clientX, mirrored);
390
+ const value = this.#valueFromClientX(event.clientX, mirrored, track);
161
391
  if (value === null) return;
162
392
  event.preventDefault();
163
- const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
164
- if (useStart) {
165
- if (this.hasStartThumbTarget) this.startThumbTarget.focus();
166
- } else if (this.hasEndThumbTarget) {
167
- this.endThumbTarget.focus();
168
- }
169
- this.#moveThumb(useStart, value);
170
- this.#dragAbort?.abort();
171
- const abort = new AbortController();
172
- this.#dragAbort = abort;
173
- const onMove = (move) => {
174
- const moved = this.#valueFromClientX(move.clientX, mirrored);
175
- if (moved !== null) this.#moveThumb(useStart, moved);
176
- };
177
- const onUp = () => {
178
- abort.abort();
179
- this.#dragAbort = null;
180
- };
181
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
182
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
183
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
393
+ const pair = this.#currentPair(this.#effectiveRange);
394
+ const kind = this.#nearestThumb(value, pair);
395
+ let thumb;
396
+ if (kind === "start") thumb = this.startThumbTarget;
397
+ else thumb = this.endThumbTarget;
398
+ this.#moveThumb(kind, value);
399
+ thumb.focus();
400
+ const drag = { pointer: null, track, thumb, kind };
401
+ drag.pointer = new OwnedPointerSession(event, track, {
402
+ move: (move) => {
403
+ if (!track.isConnected) {
404
+ this.#endDrag();
405
+ return;
406
+ }
407
+ const moved = this.#valueFromClientX(move.clientX, mirrored, track);
408
+ if (moved !== null) this.#moveThumb(kind, moved);
409
+ },
410
+ end: () => {
411
+ if (this.#drag === drag) this.#drag = null;
412
+ }
413
+ });
414
+ this.#drag = drag;
184
415
  }
185
416
  /** Maps a pointer X coordinate to a raw value using the track geometry. */
186
- #valueFromClientX(clientX, mirrored) {
187
- const rect = this.trackTarget.getBoundingClientRect();
417
+ #valueFromClientX(clientX, mirrored, track) {
418
+ const rect = track.getBoundingClientRect();
188
419
  if (rect.width === 0) return null;
189
420
  const offset = (clientX - rect.left) / rect.width;
190
421
  const fraction = mirrored ? 1 - offset : offset;
191
- return this.minValue + fraction * (this.maxValue - this.minValue);
422
+ const range = this.#effectiveRange;
423
+ return range.min + fraction * (range.max - range.min);
424
+ }
425
+ /**
426
+ * Chooses the closest thumb while keeping an overlapped pair expandable.
427
+ * Ordinary midpoint ties stay deterministic on `start`; at an overlap, a
428
+ * press above the shared value selects `end` and a press below selects
429
+ * `start`.
430
+ */
431
+ #nearestThumb(value, pair) {
432
+ const startDistance = Math.abs(value - pair.start);
433
+ const endDistance = Math.abs(value - pair.end);
434
+ if (endDistance < startDistance) return "end";
435
+ if (pair.start === pair.end && value > pair.start) return "end";
436
+ return "start";
192
437
  }
193
438
  /** Moves one thumb to a new raw value, keeping the pair ordered. */
194
- #moveThumb(isStart, raw) {
195
- if (isStart) {
196
- this.#commit(raw, this.endValue, true);
439
+ #moveThumb(kind, raw) {
440
+ const pair = this.#currentPair(this.#effectiveRange);
441
+ if (kind === "start") {
442
+ this.#commit(raw, pair.end, "start", true);
197
443
  } else {
198
- this.#commit(this.startValue, raw, true);
444
+ this.#commit(pair.start, raw, "end", true);
199
445
  }
200
446
  }
201
447
  /**
202
448
  * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
203
449
  * reflects it onto the thumbs' ARIA attributes and the range custom
204
- * properties. Dispatches `change` only on user-driven updates (`notify`).
450
+ * properties. Dispatches `stimeo--range-slider:change` only when a
451
+ * user-driven update changes the normalized pair, with
452
+ * `{ start: number, end: number }` in `detail`.
205
453
  */
206
- #commit(start, end, notify) {
207
- const prevStart = this.startValue;
208
- const prevEnd = this.endValue;
209
- let nextStart = this.#snap(start);
210
- let nextEnd = this.#snap(end);
454
+ #commit(start, end, moving, notify) {
455
+ const range = this.#effectiveRange;
456
+ const previous = this.#currentPair(range);
457
+ let nextStart = snapSteppedValue(start, range);
458
+ let nextEnd = snapSteppedValue(end, range);
211
459
  if (nextStart > nextEnd) {
212
- if (isUserMovingStart(start, prevStart, end, prevEnd)) nextStart = nextEnd;
213
- else nextEnd = nextStart;
460
+ if (moving === "start") nextStart = nextEnd;
461
+ else if (moving === "end") nextEnd = nextStart;
462
+ else [nextStart, nextEnd] = [nextEnd, nextStart];
214
463
  }
215
- this.startValue = nextStart;
216
- this.endValue = nextEnd;
217
- this.#render(nextStart, nextEnd);
218
- if (notify && (nextStart !== prevStart || nextEnd !== prevEnd)) {
464
+ if (!Object.is(this.startValue, nextStart)) this.startValue = nextStart;
465
+ if (!Object.is(this.endValue, nextEnd)) this.endValue = nextEnd;
466
+ const pair = { start: nextStart, end: nextEnd };
467
+ this.#renderPair(pair, range);
468
+ if (notify && (nextStart !== previous.start || nextEnd !== previous.end)) {
219
469
  this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
220
470
  }
221
471
  }
222
- /** Reflects the current pair onto thumb ARIA attributes and CSS properties. */
223
- #render(start, end) {
472
+ /**
473
+ * Reflects morph-supplied Values without writing them back or dispatching.
474
+ *
475
+ * @stimeoRenderRoot
476
+ */
477
+ #render() {
478
+ const range = this.#effectiveRange;
479
+ this.#renderPair(this.#currentPair(range), range);
480
+ }
481
+ /** Reflects one normalized pair onto both thumbs and CSS properties. */
482
+ #renderPair(pair, range) {
224
483
  if (this.hasStartThumbTarget) {
225
- this.startThumbTarget.setAttribute("aria-valuemin", String(this.minValue));
226
- this.startThumbTarget.setAttribute("aria-valuemax", String(end));
227
- this.startThumbTarget.setAttribute("aria-valuenow", String(start));
484
+ this.#renderStartThumb(this.startThumbTarget, pair, range);
228
485
  }
229
486
  if (this.hasEndThumbTarget) {
230
- this.endThumbTarget.setAttribute("aria-valuemin", String(start));
231
- this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
232
- this.endThumbTarget.setAttribute("aria-valuenow", String(end));
487
+ this.#renderEndThumb(this.endThumbTarget, pair, range);
488
+ }
489
+ this.#renderFractions(pair, range);
490
+ }
491
+ /** Writes only changed ARIA for the lower thumb. */
492
+ #renderStartThumb(thumb, pair, range) {
493
+ this.#setAria(thumb, "aria-valuemin", range.min);
494
+ this.#setAria(thumb, "aria-valuemax", pair.end);
495
+ this.#setAria(thumb, "aria-valuenow", pair.start);
496
+ }
497
+ /** Writes only changed ARIA for the upper thumb. */
498
+ #renderEndThumb(thumb, pair, range) {
499
+ this.#setAria(thumb, "aria-valuemin", pair.start);
500
+ this.#setAria(thumb, "aria-valuemax", range.max);
501
+ this.#setAria(thumb, "aria-valuenow", pair.end);
502
+ }
503
+ /** Writes one numeric ARIA attribute only when its serialized value changed. */
504
+ #setAria(thumb, name, value) {
505
+ const next = String(value);
506
+ if (thumb.getAttribute(name) !== next) thumb.setAttribute(name, next);
507
+ }
508
+ /** Writes the two behavior-only fraction hooks only when they changed. */
509
+ #renderFractions(pair, range) {
510
+ const start = String(rangeFraction(pair.start, range.min, range.max));
511
+ const end = String(rangeFraction(pair.end, range.min, range.max));
512
+ if (this.element.style.getPropertyValue(START_PROPERTY) !== start) {
513
+ this.element.style.setProperty(START_PROPERTY, start);
514
+ }
515
+ if (this.element.style.getPropertyValue(END_PROPERTY) !== end) {
516
+ this.element.style.setProperty(END_PROPERTY, end);
233
517
  }
234
- this.element.style.setProperty(
235
- START_PROPERTY,
236
- String(rangeFraction(start, this.minValue, this.maxValue))
237
- );
238
- this.element.style.setProperty(
239
- END_PROPERTY,
240
- String(rangeFraction(end, this.minValue, this.maxValue))
241
- );
242
- }
243
- /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
244
- #snap(raw) {
245
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
246
- if (this.stepValue <= 0) return clamped;
247
- const stepped = Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue;
248
- return Math.min(this.maxValue, Math.max(this.minValue, stepped));
518
+ }
519
+ /** Current normalized and ordered pair derived from live declarative Values. */
520
+ #currentPair(range) {
521
+ const rawStart = Number.isFinite(this.startValue) ? this.startValue : range.min;
522
+ const rawEnd = Number.isFinite(this.endValue) ? this.endValue : range.max;
523
+ const start = snapSteppedValue(rawStart, range);
524
+ const end = snapSteppedValue(rawEnd, range);
525
+ if (start <= end) return { start, end };
526
+ return { start: end, end: start };
527
+ }
528
+ /**
529
+ * Finite ordered range used by ARIA, keyboard, pointer, and CSS reflection.
530
+ * Invalid endpoints fall back to the public defaults; an authored `max`
531
+ * below `min` collapses to the finite minimum.
532
+ */
533
+ get #effectiveRange() {
534
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN;
535
+ const authoredMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX;
536
+ return { min, max: Math.max(min, authoredMax), step: this.stepValue, base: min };
537
+ }
538
+ /** Ends the current pointer session without dispatching another change. */
539
+ #endDrag() {
540
+ const drag = this.#drag;
541
+ this.#drag = null;
542
+ drag?.pointer?.end();
249
543
  }
250
544
  };
251
- function isUserMovingStart(start, prevStart, end, prevEnd) {
252
- return start !== prevStart && end === prevEnd;
253
- }
254
545
 
255
546
  export { RangeSliderController };
256
547
  //# sourceMappingURL=range_slider_controller.js.map
@@ -175,6 +175,8 @@ var RatingController = class extends Controller {
175
175
  * Applies `value` (already clamped) everywhere, then dispatches `change`.
176
176
  * Use for user-driven changes; on connect call `#apply` directly so
177
177
  * initialization mirrors state without emitting an event.
178
+ *
179
+ * @stimeoRenderRoot
178
180
  */
179
181
  #render(value, { focus }) {
180
182
  this.#apply(value, { focus });
@@ -208,6 +208,8 @@ var RelativeTimeController = class extends Controller {
208
208
  * polling can stop: a *past* timestamp that fell back to the absolute text can
209
209
  * never leave it again, and a locale the runtime rejects has nothing to render
210
210
  * until that value is corrected.
211
+ *
212
+ * @stimeoRenderRoot
211
213
  */
212
214
  #applyAndComputeDelay() {
213
215
  const deltaMs = this.#targetMs - Date.now();
@@ -165,7 +165,7 @@ var ScrollAreaController = class extends Controller {
165
165
  this.#syncKeyboardReach(vp, overflowing);
166
166
  const { position, progress } = this.#measurePosition(vp);
167
167
  this.element.setAttribute("data-scroll", position);
168
- this.element.style.setProperty("--stimeo-scroll-progress", String(progress));
168
+ this.element.style.setProperty("--stimeo--scroll-progress", String(progress));
169
169
  const edge = position === "start" ? "start" : position === "end" ? "end" : null;
170
170
  if (overflowing && edge && edge !== this.#lastEdge) {
171
171
  this.#lastEdge = edge;
@@ -8,6 +8,13 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/default_attribute.ts
12
+ function setDefaultAttribute(element, name, value) {
13
+ if (element.hasAttribute(name)) return false;
14
+ element.setAttribute(name, value);
15
+ return true;
16
+ }
17
+
11
18
  // src/controllers/separator_controller.ts
12
19
  var SeparatorController = class extends Controller {
13
20
  static values = {
@@ -18,19 +25,13 @@ var SeparatorController = class extends Controller {
18
25
  static actions = ["onKeydown"];
19
26
  static events = ["change"];
20
27
  connect() {
21
- if (!this.element.hasAttribute("role")) {
22
- this.element.setAttribute("role", "separator");
23
- }
24
- if (!this.element.hasAttribute("aria-orientation")) {
25
- this.element.setAttribute("aria-orientation", this.orientationValue);
26
- }
28
+ setDefaultAttribute(this.element, "role", "separator");
29
+ setDefaultAttribute(this.element, "aria-orientation", this.orientationValue);
27
30
  if (this.focusableValue) {
28
- if (!this.element.hasAttribute("tabindex")) {
29
- this.element.setAttribute("tabindex", "0");
30
- }
31
- this.#setDefault("aria-valuemin", "0");
32
- this.#setDefault("aria-valuemax", "100");
33
- this.#setDefault("aria-valuenow", String(this.#clamp(this.#value)));
31
+ setDefaultAttribute(this.element, "tabindex", "0");
32
+ setDefaultAttribute(this.element, "aria-valuemin", "0");
33
+ setDefaultAttribute(this.element, "aria-valuemax", "100");
34
+ setDefaultAttribute(this.element, "aria-valuenow", String(this.#clamp(this.#value)));
34
35
  }
35
36
  }
36
37
  /** Adjusts the value on arrow / Home / End keys (focusable variant only). */
@@ -84,11 +85,6 @@ var SeparatorController = class extends Controller {
84
85
  const parsed = Number.parseFloat(this.element.getAttribute(name) ?? "");
85
86
  return Number.isNaN(parsed) ? fallback : parsed;
86
87
  }
87
- #setDefault(name, value) {
88
- if (!this.element.hasAttribute(name)) {
89
- this.element.setAttribute(name, value);
90
- }
91
- }
92
88
  };
93
89
 
94
90
  export { SeparatorController };