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,6 +18,214 @@ 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/slider_controller.ts
22
230
  var FRACTION_PROPERTY = "--stimeo--slider-fraction";
23
231
  var SliderController = class extends Controller {
@@ -31,40 +239,79 @@ var SliderController = class extends Controller {
31
239
  };
32
240
  static actions = ["onKeydown", "onPointerDown"];
33
241
  static events = ["change"];
34
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
35
- #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());
36
246
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
37
247
  get #mirrored() {
38
248
  return this.logicalTrackValue && isRtl(this.element);
39
249
  }
40
250
  /** Clamps the initial value and renders the starting position. */
41
251
  connect() {
42
- this.#setValue(this.valueValue, { silent: true });
252
+ this.#repaint.activate();
253
+ this.#commit(this.valueValue, false);
43
254
  }
44
255
  /** Cancels any active pointer drag so document listeners never leak. */
45
256
  disconnect() {
46
- this.#dragAbort?.abort();
47
- 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();
48
294
  }
49
295
  /** Handles keyboard stepping per the APG slider model. */
50
296
  onKeydown(event) {
51
297
  if (isReservedArrowChord(event)) return;
52
- const big = this.stepValue * 10;
53
298
  let next = null;
299
+ const current = this.#currentValue();
300
+ const range = this.#steppedRange;
54
301
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
55
302
  case "ArrowRight":
56
303
  case "ArrowUp":
57
- next = this.valueValue + this.stepValue;
304
+ next = stepSteppedValue(current, 1, range);
58
305
  break;
59
306
  case "ArrowLeft":
60
307
  case "ArrowDown":
61
- next = this.valueValue - this.stepValue;
308
+ next = stepSteppedValue(current, -1, range);
62
309
  break;
63
310
  case "PageUp":
64
- next = this.valueValue + big;
311
+ next = stepSteppedValue(current, 10, range);
65
312
  break;
66
313
  case "PageDown":
67
- next = this.valueValue - big;
314
+ next = stepSteppedValue(current, -10, range);
68
315
  break;
69
316
  case "Home":
70
317
  next = this.minValue;
@@ -76,57 +323,102 @@ var SliderController = class extends Controller {
76
323
  return;
77
324
  }
78
325
  event.preventDefault();
79
- this.#setValue(next);
326
+ this.#commit(next, true);
80
327
  }
81
328
  /** Begins a pointer drag: sets the value and tracks subsequent movement. */
82
329
  onPointerDown(event) {
83
- if (!this.hasTrackTarget) return;
84
- event.preventDefault();
330
+ if (event.button !== 0 || this.#drag || !this.hasTrackTarget || !this.hasThumbTarget) return;
331
+ const track = this.trackTarget;
332
+ const thumb = this.thumbTarget;
85
333
  const mirrored = this.#mirrored;
86
- this.#updateFromClientX(event.clientX, mirrored);
87
- if (this.hasThumbTarget) this.thumbTarget.focus();
88
- this.#dragAbort?.abort();
89
- const abort = new AbortController();
90
- this.#dragAbort = abort;
91
- const onMove = (move) => this.#updateFromClientX(move.clientX, mirrored);
92
- const onUp = () => {
93
- abort.abort();
94
- this.#dragAbort = null;
95
- };
96
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
97
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
98
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
99
- }
100
- /** Maps a pointer X coordinate to a value using the track's geometry. */
101
- #updateFromClientX(clientX, mirrored) {
102
- const rect = this.trackTarget.getBoundingClientRect();
103
- 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;
104
359
  const offset = (clientX - rect.left) / rect.width;
105
360
  const fraction = mirrored ? 1 - offset : offset;
106
- this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
361
+ return this.minValue + fraction * (this.maxValue - this.minValue);
107
362
  }
108
363
  /**
109
- * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and
110
- * reflects the new state on the thumb's ARIA attributes and the fraction
111
- * custom property. Dispatches `change` (detail `{ value }`) on a real value
112
- * change — symmetric with `range-slider` — unless `silent` (the initial
113
- * connect render, which is not a user edit).
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.
114
367
  */
115
- #setValue(raw, { silent = false } = {}) {
116
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
117
- const stepped = this.stepValue > 0 ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue : clamped;
118
- const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));
119
- const changed = value !== this.valueValue;
120
- this.valueValue = value;
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 } });
374
+ }
375
+ /**
376
+ * Reflects the normalized current Value without mutating or dispatching it.
377
+ *
378
+ * @stimeoRenderRoot
379
+ */
380
+ #render() {
381
+ this.#renderValue(this.#currentValue());
382
+ }
383
+ /** Reflects one normalized value on the current target and CSS output. */
384
+ #renderValue(value) {
121
385
  if (this.hasThumbTarget) {
122
- this.thumbTarget.setAttribute("aria-valuemin", String(this.minValue));
123
- this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
124
- this.thumbTarget.setAttribute("aria-valuenow", String(value));
386
+ this.#renderThumb(this.thumbTarget, value);
125
387
  }
126
- const span = this.maxValue - this.minValue;
127
- const fraction = span > 0 ? (value - this.minValue) / span : 0;
128
- this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
129
- if (changed && !silent) this.dispatch("change", { detail: { value } });
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) {
403
+ const fraction = rangeFraction(value, this.minValue, this.maxValue);
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();
130
422
  }
131
423
  };
132
424