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
@@ -18,14 +18,224 @@ 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
+
107
+ // src/utils/range.ts
108
+ function rangeFraction(value, min, max) {
109
+ const span = max - min;
110
+ if (!(span > 0)) return 0;
111
+ const clamped = Math.min(max, Math.max(min, value));
112
+ let fraction;
113
+ if (Number.isFinite(span)) {
114
+ fraction = (clamped - min) / span;
115
+ } else {
116
+ const scale = Math.max(Math.abs(min), Math.abs(max));
117
+ fraction = (clamped / scale - min / scale) / (max / scale - min / scale);
118
+ }
119
+ if (!Number.isFinite(fraction)) return 0;
120
+ return Math.min(1, Math.max(0, fraction));
121
+ }
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
+
21
229
  // src/controllers/range_slider_controller.ts
22
- var START_PROPERTY = "--stimeo-range-start";
23
- 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;
24
234
  var RangeSliderController = class extends Controller {
25
235
  static targets = ["track", "startThumb", "endThumb"];
26
236
  static values = {
27
- min: { type: Number, default: 0 },
28
- max: { type: Number, default: 100 },
237
+ min: { type: Number, default: DEFAULT_MIN },
238
+ max: { type: Number, default: DEFAULT_MAX },
29
239
  step: { type: Number, default: 1 },
30
240
  start: { type: Number, default: 0 },
31
241
  end: { type: Number, default: 100 },
@@ -33,159 +243,305 @@ var RangeSliderController = class extends Controller {
33
243
  };
34
244
  static actions = ["onKeydown", "onPointerDown"];
35
245
  static events = ["change"];
36
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
37
- #dragAbort = null;
246
+ /** One initiating pointer owns each live drag and its stable target snapshot. */
247
+ #drag = null;
38
248
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
39
249
  get #mirrored() {
40
250
  return this.logicalTrackValue && isRtl(this.element);
41
251
  }
42
- /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
252
+ /**
253
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
254
+ * pass Stimulus delivers before `connect()`.
255
+ */
256
+ #repaint = new MicrotaskCoalescer(() => this.#render());
43
257
  connect() {
44
- const lo = Math.min(this.startValue, this.endValue);
45
- const hi = Math.max(this.startValue, this.endValue);
46
- this.#commit(lo, hi, false);
258
+ this.#repaint.activate();
259
+ const range = this.#effectiveRange;
260
+ const pair = this.#currentPair(range);
261
+ this.#commit(pair.start, pair.end, null, false);
47
262
  }
48
263
  /** Cancels any active pointer drag so document listeners never leak. */
49
264
  disconnect() {
50
- this.#dragAbort?.abort();
51
- this.#dragAbort = null;
265
+ this.#repaint.cancel();
266
+ this.#endDrag();
267
+ }
268
+ /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
269
+ minValueChanged() {
270
+ this.#repaint.schedule();
271
+ }
272
+ /** Repaints when application code (or a Turbo morph) changes `max` at runtime. */
273
+ maxValueChanged() {
274
+ this.#repaint.schedule();
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();
52
323
  }
53
324
  /** Keyboard stepping for whichever thumb is focused (the action's element). */
54
325
  onKeydown(event) {
55
326
  if (isReservedArrowChord(event)) return;
56
327
  const thumb = event.currentTarget;
57
328
  const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
58
- const current = isStart ? this.startValue : this.endValue;
59
- const lower = isStart ? this.minValue : this.startValue;
60
- const upper = isStart ? this.endValue : this.maxValue;
61
- 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
+ }
62
355
  let next = null;
63
356
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
64
357
  case "ArrowRight":
65
358
  case "ArrowUp":
66
- next = current + this.stepValue;
359
+ next = stepSteppedValue(current, 1, range);
67
360
  break;
68
361
  case "ArrowLeft":
69
362
  case "ArrowDown":
70
- next = current - this.stepValue;
363
+ next = stepSteppedValue(current, -1, range);
71
364
  break;
72
365
  case "PageUp":
73
- next = current + big;
366
+ next = stepSteppedValue(current, 10, range);
74
367
  break;
75
368
  case "PageDown":
76
- next = current - big;
369
+ next = stepSteppedValue(current, -10, range);
77
370
  break;
78
371
  case "Home":
79
- next = lower;
372
+ next = range.min;
80
373
  break;
81
374
  case "End":
82
- next = upper;
375
+ next = range.max;
83
376
  break;
84
377
  default:
85
378
  return;
86
379
  }
87
380
  event.preventDefault();
88
- this.#moveThumb(isStart, next);
381
+ this.#moveThumb(kind, next);
89
382
  }
90
383
  /** Begins a pointer drag on the track, moving the thumb nearest the press. */
91
384
  onPointerDown(event) {
92
- 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;
93
389
  const mirrored = this.#mirrored;
94
- const value = this.#valueFromClientX(event.clientX, mirrored);
390
+ const value = this.#valueFromClientX(event.clientX, mirrored, track);
95
391
  if (value === null) return;
96
392
  event.preventDefault();
97
- const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
98
- if (useStart) {
99
- if (this.hasStartThumbTarget) this.startThumbTarget.focus();
100
- } else if (this.hasEndThumbTarget) {
101
- this.endThumbTarget.focus();
102
- }
103
- this.#moveThumb(useStart, value);
104
- this.#dragAbort?.abort();
105
- const abort = new AbortController();
106
- this.#dragAbort = abort;
107
- const onMove = (move) => {
108
- const moved = this.#valueFromClientX(move.clientX, mirrored);
109
- if (moved !== null) this.#moveThumb(useStart, moved);
110
- };
111
- const onUp = () => {
112
- abort.abort();
113
- this.#dragAbort = null;
114
- };
115
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
116
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
117
- 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;
118
415
  }
119
416
  /** Maps a pointer X coordinate to a raw value using the track geometry. */
120
- #valueFromClientX(clientX, mirrored) {
121
- const rect = this.trackTarget.getBoundingClientRect();
417
+ #valueFromClientX(clientX, mirrored, track) {
418
+ const rect = track.getBoundingClientRect();
122
419
  if (rect.width === 0) return null;
123
420
  const offset = (clientX - rect.left) / rect.width;
124
421
  const fraction = mirrored ? 1 - offset : offset;
125
- 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";
126
437
  }
127
438
  /** Moves one thumb to a new raw value, keeping the pair ordered. */
128
- #moveThumb(isStart, raw) {
129
- if (isStart) {
130
- 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);
131
443
  } else {
132
- this.#commit(this.startValue, raw, true);
444
+ this.#commit(pair.start, raw, "end", true);
133
445
  }
134
446
  }
135
447
  /**
136
448
  * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
137
449
  * reflects it onto the thumbs' ARIA attributes and the range custom
138
- * 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`.
139
453
  */
140
- #commit(start, end, notify) {
141
- const prevStart = this.startValue;
142
- const prevEnd = this.endValue;
143
- let nextStart = this.#snap(start);
144
- 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);
145
459
  if (nextStart > nextEnd) {
146
- if (isUserMovingStart(start, prevStart, end, prevEnd)) nextStart = nextEnd;
147
- else nextEnd = nextStart;
460
+ if (moving === "start") nextStart = nextEnd;
461
+ else if (moving === "end") nextEnd = nextStart;
462
+ else [nextStart, nextEnd] = [nextEnd, nextStart];
148
463
  }
149
- this.startValue = nextStart;
150
- this.endValue = nextEnd;
151
- this.#render(nextStart, nextEnd);
152
- 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)) {
153
469
  this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
154
470
  }
155
471
  }
156
- /** Reflects the current pair onto thumb ARIA attributes and CSS properties. */
157
- #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) {
158
483
  if (this.hasStartThumbTarget) {
159
- this.startThumbTarget.setAttribute("aria-valuemin", String(this.minValue));
160
- this.startThumbTarget.setAttribute("aria-valuemax", String(end));
161
- this.startThumbTarget.setAttribute("aria-valuenow", String(start));
484
+ this.#renderStartThumb(this.startThumbTarget, pair, range);
162
485
  }
163
486
  if (this.hasEndThumbTarget) {
164
- this.endThumbTarget.setAttribute("aria-valuemin", String(start));
165
- this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
166
- this.endThumbTarget.setAttribute("aria-valuenow", String(end));
167
- }
168
- const span = this.maxValue - this.minValue;
169
- this.element.style.setProperty(
170
- START_PROPERTY,
171
- String(span > 0 ? (start - this.minValue) / span : 0)
172
- );
173
- this.element.style.setProperty(
174
- END_PROPERTY,
175
- String(span > 0 ? (end - this.minValue) / span : 0)
176
- );
177
- }
178
- /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
179
- #snap(raw) {
180
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
181
- if (this.stepValue <= 0) return clamped;
182
- const stepped = Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue;
183
- return Math.min(this.maxValue, Math.max(this.minValue, stepped));
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);
517
+ }
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();
184
543
  }
185
544
  };
186
- function isUserMovingStart(start, prevStart, end, prevEnd) {
187
- return start !== prevStart && end === prevEnd;
188
- }
189
545
 
190
546
  export { RangeSliderController };
191
547
  //# sourceMappingURL=range_slider_controller.js.map