stimeo-ui 0.15.0 → 0.16.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +54 -10
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +56 -11
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +14 -1
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +305 -46
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -18,6 +18,16 @@ 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/field_mirror.ts
22
+ function writeField(field, value) {
23
+ if (field.value === value) return false;
24
+ field.value = value;
25
+ return true;
26
+ }
27
+ function commitField(target) {
28
+ target.dispatchEvent(new Event("change", { bubbles: true }));
29
+ }
30
+
21
31
  // src/utils/microtask_coalescer.ts
22
32
  var MicrotaskCoalescer = class {
23
33
  #run;
@@ -53,54 +63,46 @@ var MicrotaskCoalescer = class {
53
63
 
54
64
  // src/utils/owned_pointer_session.ts
55
65
  var OwnedPointerSession = class {
56
- pointerId;
66
+ #pointerId;
57
67
  #owner;
58
68
  #handlers;
59
69
  #abort = new AbortController();
60
70
  #active = true;
61
71
  constructor(start, owner, handlers) {
62
- this.pointerId = start.pointerId;
72
+ this.#pointerId = start.pointerId;
63
73
  this.#owner = owner;
64
74
  this.#handlers = handlers;
65
75
  const { signal } = this.#abort;
66
76
  owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
67
77
  owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
68
78
  owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
69
- owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
70
79
  try {
71
- owner.setPointerCapture?.(this.pointerId);
80
+ owner.setPointerCapture?.(this.#pointerId);
72
81
  } catch {
73
82
  }
74
83
  }
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
+ /** Releases capture and listeners, then reports the end exactly once. */
84
85
  end() {
86
+ this.#finish("teardown");
87
+ }
88
+ #finish(kind) {
85
89
  if (!this.#active) return;
86
90
  this.#active = false;
87
91
  this.#abort.abort();
88
92
  try {
89
- this.#owner.releasePointerCapture?.(this.pointerId);
93
+ this.#owner.releasePointerCapture?.(this.#pointerId);
90
94
  } catch {
91
95
  }
92
- this.#handlers.end?.();
96
+ this.#handlers.end?.(kind);
97
+ }
98
+ #owns(event) {
99
+ return this.#active && event.pointerId === this.#pointerId;
93
100
  }
94
101
  #onMove = (event) => {
95
- if (this.owns(event)) this.#handlers.move(event);
102
+ if (this.#owns(event)) this.#handlers.move(event);
96
103
  };
97
104
  #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();
105
+ if (this.#owns(event)) this.#finish(event.type === "pointerup" ? "up" : "cancel");
104
106
  };
105
107
  };
106
108
 
@@ -229,7 +231,7 @@ function finiteFallback(min, max) {
229
231
  // src/controllers/slider_controller.ts
230
232
  var FRACTION_PROPERTY = "--stimeo--slider-fraction";
231
233
  var SliderController = class extends Controller {
232
- static targets = ["track", "thumb"];
234
+ static targets = ["track", "thumb", "field"];
233
235
  static values = {
234
236
  min: { type: Number, default: 0 },
235
237
  max: { type: Number, default: 100 },
@@ -238,38 +240,47 @@ var SliderController = class extends Controller {
238
240
  logicalTrack: { type: Boolean, default: false }
239
241
  };
240
242
  static actions = ["onKeydown", "onPointerDown"];
241
- static events = ["change"];
243
+ static events = ["change", "reconcile"];
242
244
  /** One initiating pointer owns each live drag and its stable target snapshot. */
243
245
  #drag = null;
244
- /** Collapses a morph that swaps several render Values into one silent repaint. */
246
+ /**
247
+ * The value last published: taken on connect, then moved by each user commit
248
+ * and by each repaint that reports a page-driven move.
249
+ */
250
+ #settled = 0;
251
+ /** Collapses a morph that swaps several render Values into one repaint. */
245
252
  #repaint = new MicrotaskCoalescer(() => this.#render());
246
253
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
247
254
  get #mirrored() {
248
255
  return this.logicalTrackValue && isRtl(this.element);
249
256
  }
250
- /** Clamps the initial value and renders the starting position. */
257
+ /**
258
+ * Renders the normalized value as the starting position without writing it
259
+ * back, and takes it as the baseline, so connecting reports nothing.
260
+ */
251
261
  connect() {
252
262
  this.#repaint.activate();
253
- this.#commit(this.valueValue, false);
263
+ this.#settled = this.#currentValue();
264
+ this.#render();
254
265
  }
255
266
  /** Cancels any active pointer drag so document listeners never leak. */
256
267
  disconnect() {
257
268
  this.#repaint.cancel();
258
269
  this.#endDrag();
259
270
  }
260
- /** Silently repaints a minimum changed by application code or a Turbo morph. */
271
+ /** Repaints a minimum changed by application code or a Turbo morph. */
261
272
  minValueChanged() {
262
273
  this.#repaint.schedule();
263
274
  }
264
- /** Silently repaints a maximum changed by application code or a Turbo morph. */
275
+ /** Repaints a maximum changed by application code or a Turbo morph. */
265
276
  maxValueChanged() {
266
277
  this.#repaint.schedule();
267
278
  }
268
- /** Silently repaints a step changed by application code or a Turbo morph. */
279
+ /** Repaints a step changed by application code or a Turbo morph. */
269
280
  stepValueChanged() {
270
281
  this.#repaint.schedule();
271
282
  }
272
- /** Silently repaints a value changed by application code or a Turbo morph. */
283
+ /** Repaints a value changed by application code or a Turbo morph. */
273
284
  valueValueChanged() {
274
285
  this.#repaint.schedule();
275
286
  }
@@ -284,6 +295,10 @@ var SliderController = class extends Controller {
284
295
  }
285
296
  this.#repaint.schedule();
286
297
  }
298
+ /** Fills a form field inserted or replaced at runtime on the next repaint. */
299
+ fieldTargetConnected() {
300
+ this.#repaint.schedule();
301
+ }
287
302
  /** Drops the stale focus owner while allowing a live track gesture to continue. */
288
303
  thumbTargetDisconnected(thumb) {
289
304
  if (this.#drag?.thumb === thumb) this.#drag.thumb = null;
@@ -296,22 +311,22 @@ var SliderController = class extends Controller {
296
311
  onKeydown(event) {
297
312
  if (isReservedArrowChord(event)) return;
298
313
  let next = null;
299
- const current = this.#currentValue();
314
+ const declared = this.valueValue;
300
315
  const range = this.#steppedRange;
301
316
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
302
317
  case "ArrowRight":
303
318
  case "ArrowUp":
304
- next = stepSteppedValue(current, 1, range);
319
+ next = stepSteppedValue(declared, 1, range);
305
320
  break;
306
321
  case "ArrowLeft":
307
322
  case "ArrowDown":
308
- next = stepSteppedValue(current, -1, range);
323
+ next = stepSteppedValue(declared, -1, range);
309
324
  break;
310
325
  case "PageUp":
311
- next = stepSteppedValue(current, 10, range);
326
+ next = stepSteppedValue(declared, 10, range);
312
327
  break;
313
328
  case "PageDown":
314
- next = stepSteppedValue(current, -10, range);
329
+ next = stepSteppedValue(declared, -10, range);
315
330
  break;
316
331
  case "Home":
317
332
  next = this.minValue;
@@ -323,7 +338,7 @@ var SliderController = class extends Controller {
323
338
  return;
324
339
  }
325
340
  event.preventDefault();
326
- this.#commit(next, true);
341
+ this.#commit(next);
327
342
  }
328
343
  /** Begins a pointer drag: sets the value and tracks subsequent movement. */
329
344
  onPointerDown(event) {
@@ -334,7 +349,7 @@ var SliderController = class extends Controller {
334
349
  const value = this.#valueFromClientX(event.clientX, mirrored, track);
335
350
  if (value === null) return;
336
351
  event.preventDefault();
337
- this.#commit(value, true);
352
+ this.#commit(value);
338
353
  thumb.focus();
339
354
  const drag = { pointer: null, track, thumb };
340
355
  drag.pointer = new OwnedPointerSession(event, track, {
@@ -344,7 +359,7 @@ var SliderController = class extends Controller {
344
359
  return;
345
360
  }
346
361
  const moved = this.#valueFromClientX(move.clientX, mirrored, track);
347
- if (moved !== null) this.#commit(moved, true);
362
+ if (moved !== null) this.#commit(moved);
348
363
  },
349
364
  end: () => {
350
365
  if (this.#drag === drag) this.#drag = null;
@@ -361,24 +376,44 @@ var SliderController = class extends Controller {
361
376
  return this.minValue + fraction * (this.maxValue - this.minValue);
362
377
  }
363
378
  /**
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.
379
+ * Stores the normalized value the user moved to, renders it synchronously for
380
+ * responsive input, and reports it as `change` when it differs from the value
381
+ * last published. The comparison is with the value last published, not with
382
+ * the value the move started from, so a page write no repaint has published
383
+ * yet is reported once, as this change, and a move that ends where the value
384
+ * was last published reports nothing. The baseline moves before either report
385
+ * goes out, so the repaint the Value write schedules — and any move a
386
+ * subscriber makes while the report is dispatched — is measured from this
387
+ * value.
367
388
  */
368
- #commit(raw, notify) {
369
- const previous = this.#currentValue();
389
+ #commit(raw) {
370
390
  const value = snapSteppedValue(raw, this.#steppedRange);
391
+ const reported = this.#settled;
392
+ this.#settled = value;
371
393
  if (!Object.is(this.valueValue, value)) this.valueValue = value;
372
394
  this.#renderValue(value);
373
- if (notify && value !== previous) this.dispatch("change", { detail: { value } });
395
+ this.#mirrorField(value, true);
396
+ if (value !== reported) this.dispatch("change", { detail: { value } });
397
+ }
398
+ /** Mirrors `value` into the optional form field, reporting only a user's move. */
399
+ #mirrorField(value, notify) {
400
+ if (!this.hasFieldTarget) return;
401
+ if (writeField(this.fieldTarget, String(value)) && notify) commitField(this.fieldTarget);
374
402
  }
375
403
  /**
376
- * Reflects the normalized current Value without mutating or dispatching it.
404
+ * Reflects the normalized current Value without writing it back. A published
405
+ * value that moved from the last one is reported once as `reconcile`; the
406
+ * field follows without a native `change`.
377
407
  *
378
408
  * @stimeoRenderRoot
379
409
  */
380
410
  #render() {
381
- this.#renderValue(this.#currentValue());
411
+ const value = this.#currentValue();
412
+ this.#renderValue(value);
413
+ this.#mirrorField(value, false);
414
+ if (value === this.#settled) return;
415
+ this.#settled = value;
416
+ this.dispatch("reconcile", { detail: { value } });
382
417
  }
383
418
  /** Reflects one normalized value on the current target and CSS output. */
384
419
  #renderValue(value) {
@@ -121,6 +121,7 @@ var SmartStickyHeaderController = class extends Controller {
121
121
  this.element.removeEventListener("focusin", this.#onFocusin);
122
122
  this.#frames.cancel();
123
123
  }
124
+ /** @stimeoRenderRoot */
124
125
  #measure() {
125
126
  const y = scrollOffset(this.#scrollerEl);
126
127
  if (y <= this.#offset) {
@@ -129,10 +130,19 @@ var SmartStickyHeaderController = class extends Controller {
129
130
  return;
130
131
  }
131
132
  const delta = y - this.#lastY;
132
- if (Math.abs(delta) < this.toleranceValue) return;
133
+ if (this.#isJitter(delta)) return;
133
134
  this.#lastY = y;
134
135
  this.#apply(delta > 0);
135
136
  }
137
+ /**
138
+ * Whether one scroll step is too small to act on.
139
+ *
140
+ * @stimeoRuntimeOnly `tolerance` is the allowance one scroll step is compared against; the
141
+ * header's state at rest does not depend on it.
142
+ */
143
+ #isJitter(delta) {
144
+ return Math.abs(delta) < this.toleranceValue;
145
+ }
136
146
  /**
137
147
  * Reflects the state onto the hook and emits `change` on transitions.
138
148
  *
@@ -57,7 +57,20 @@ var MicrotaskCoalescer = class {
57
57
  }
58
58
  };
59
59
 
60
+ // src/utils/transient_hooks.ts
61
+ var TransientHooks = class {
62
+ #attributes;
63
+ constructor(options) {
64
+ this.#attributes = options.attributes;
65
+ }
66
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
67
+ reset(element) {
68
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
69
+ }
70
+ };
71
+
60
72
  // src/controllers/sortable_controller.ts
73
+ var TRANSIENT = new TransientHooks({ attributes: ["data-sortable-dragging"] });
61
74
  var SortableController = class extends Controller {
62
75
  static targets = ["list", "item"];
63
76
  static values = {
@@ -72,7 +85,7 @@ var SortableController = class extends Controller {
72
85
  /** Defers the lost-item check to after the mutation batch (see below). */
73
86
  #settle = new MicrotaskCoalescer(() => this.#dropLostSession());
74
87
  connect() {
75
- this.element.removeAttribute("data-sortable-dragging");
88
+ TRANSIENT.reset(this.element);
76
89
  this.#settle.activate();
77
90
  this.element.addEventListener("stimeo--pointer-drag:start", this.#onDragStart);
78
91
  this.element.addEventListener("stimeo--pointer-drag:move", this.#onDragMove);
@@ -295,6 +308,8 @@ var SortableController = class extends Controller {
295
308
  * Only transitions reach here — the pickup, a step that actually changed the
296
309
  * landing slot, and the single end of the session — so a pointer crossing the
297
310
  * same slot twice or an arrow clamped at an end stays silent.
311
+ *
312
+ * @stimeoRuntimeOnly The texts word the steps of one reorder, spoken as they happen.
298
313
  */
299
314
  #announce(key, item) {
300
315
  const template = this.#announceTemplate(key);
@@ -323,7 +338,7 @@ var SortableController = class extends Controller {
323
338
  }
324
339
  /** The announced item name: the authored override, else its collapsed text. */
325
340
  #nameOf(item) {
326
- const authored = item.getAttribute("data-stimeo--sortable-name");
341
+ const authored = item.getAttribute(`data-${this.identifier}-name`);
327
342
  if (authored) return authored;
328
343
  return (item.textContent ?? "").replace(/\s+/g, " ").trim();
329
344
  }
@@ -292,7 +292,11 @@ var SpinnerController = class extends Controller {
292
292
  this.#setBusy(false);
293
293
  this.#floor.schedule(this.minDurationValue, () => this.#hide());
294
294
  }
295
- /** Reveals the indicator, marks the moment shown, and announces via the live region. */
295
+ /**
296
+ * Reveals the indicator, marks the moment shown, and announces via the live region.
297
+ *
298
+ * @stimeoRuntimeOnly `announceText` is the wording of the one announcement this show makes.
299
+ */
296
300
  #show() {
297
301
  this.#floor.begin();
298
302
  this.#setBusy(true);
@@ -301,7 +305,11 @@ var SpinnerController = class extends Controller {
301
305
  this.dispatch("show", { detail: {} });
302
306
  announce(fillTemplate(this.announceTextValue, {}));
303
307
  }
304
- /** Hides the indicator and returns to the idle state. */
308
+ /**
309
+ * Hides the indicator and returns to the idle state.
310
+ *
311
+ * @stimeoRuntimeOnly `announceReadyText` is the wording of the one announcement this hide makes.
312
+ */
305
313
  #hide() {
306
314
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
307
315
  this.element.setAttribute("data-state", "idle");
@@ -39,17 +39,17 @@ var MicrotaskCoalescer = class {
39
39
  var StepIndicatorController = class extends Controller {
40
40
  static targets = ["step"];
41
41
  static values = {
42
- current: { type: Number, default: 0 }
42
+ index: { type: Number, default: 0 }
43
43
  };
44
- static actions = ["setCurrent"];
44
+ static actions = ["setIndex"];
45
45
  static events = ["change"];
46
46
  /**
47
- * Collapses a batch of step callbacks — and a morph that swaps `current` with
47
+ * Collapses a batch of step callbacks — and a morph that swaps `index` with
48
48
  * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
49
49
  * each one would otherwise rewrite every step's state.
50
50
  */
51
51
  #repaint = new MicrotaskCoalescer(() => this.#render());
52
- /** Renders the initial state from the `current` value. */
52
+ /** Renders the initial state from the `index` value. */
53
53
  connect() {
54
54
  this.#repaint.activate();
55
55
  this.#render();
@@ -66,32 +66,33 @@ var StepIndicatorController = class extends Controller {
66
66
  stepTargetDisconnected() {
67
67
  this.#repaint.schedule();
68
68
  }
69
- /** Repaints when application code (or a Turbo morph) changes `current` at runtime. */
70
- currentValueChanged() {
69
+ /** Repaints when application code (or a Turbo morph) changes `index` at runtime. */
70
+ indexValueChanged() {
71
71
  this.#repaint.schedule();
72
72
  }
73
73
  /**
74
- * Updates the current step from an external event (`detail.current`, 0-based)
75
- * and dispatches `change`. Out-of-range indices are clamped to the step set,
74
+ * Moves the indicator from an external event (`detail.index`, 0-based) and
75
+ * dispatches `change`. Out-of-range positions are clamped to the step set,
76
76
  * and both sides of the no-op test are clamped, so moving onto the step an
77
- * out-of-range `current` already renders is not reported as a change.
77
+ * out-of-range `index` already renders is not reported as a change.
78
78
  */
79
- setCurrent(event) {
80
- const next = event.detail?.current;
79
+ setIndex(event) {
80
+ const next = event.detail?.index;
81
81
  if (typeof next !== "number" || !Number.isFinite(next)) return;
82
82
  const clamped = this.#clamp(next);
83
- const moved = clamped !== this.#clamp(this.currentValue);
84
- this.currentValue = clamped;
83
+ const previous = this.#clamp(this.indexValue);
84
+ const moved = clamped !== previous;
85
+ this.indexValue = clamped;
85
86
  if (!moved) return;
86
87
  this.#render();
87
88
  this.dispatch("change", {
88
- detail: { current: clamped, total: this.stepTargets.length }
89
+ detail: { index: clamped, previous, total: this.stepTargets.length }
89
90
  });
90
91
  }
91
92
  /**
92
93
  * Applies `data-state`, `aria-current`, and the progress ratio custom property.
93
94
  *
94
- * A pure function of the step set and `current`, so running it again writes the
95
+ * A pure function of the step set and `index`, so running it again writes the
95
96
  * same values — which is what lets the action path paint synchronously (the event
96
97
  * goes out after the DOM is updated) while a coalesced pass may still follow.
97
98
  *
@@ -99,7 +100,7 @@ var StepIndicatorController = class extends Controller {
99
100
  */
100
101
  #render() {
101
102
  const total = this.stepTargets.length;
102
- const current = this.#clamp(this.currentValue);
103
+ const current = this.#clamp(this.indexValue);
103
104
  this.stepTargets.forEach((step, index) => {
104
105
  step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
105
106
  if (index === current) {
@@ -113,7 +114,7 @@ var StepIndicatorController = class extends Controller {
113
114
  }
114
115
  /**
115
116
  * Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
116
- * non-finite index falls back to the first step: `current` is read from markup,
117
+ * non-finite index falls back to the first step: `index` is read from markup,
117
118
  * so an unparsable attribute arrives as `NaN` and would otherwise propagate
118
119
  * into every state hook.
119
120
  */
@@ -1,5 +1,40 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/stepper_controller.ts
4
+
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
3
38
  // src/controllers/stepper_controller.ts
4
39
  var StepperController = class extends Controller {
5
40
  static targets = ["step"];
@@ -8,20 +43,39 @@ var StepperController = class extends Controller {
8
43
  linear: { type: Boolean, default: false }
9
44
  };
10
45
  static actions = ["goto", "next", "prev"];
11
- static events = ["change"];
12
- #isConnected = false;
13
- /** Normalizes an out-of-range initial `index` and renders the initial state. */
46
+ static events = ["change", "reconcile"];
47
+ /**
48
+ * Collapses the Value and target callbacks of one mutation into one pass, and
49
+ * refuses the ones Stimulus delivers before `connect()`, which renders itself.
50
+ */
51
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileStep());
52
+ /**
53
+ * The position shown current last, which the next move is measured from, or
54
+ * `null` before any step has been shown. While no step is present it keeps the
55
+ * last one.
56
+ */
57
+ #shown = null;
58
+ /** Renders the initial state from `index`, clamped into the step range. */
14
59
  connect() {
15
- this.#isConnected = true;
16
- this.#normalizeAndRender();
60
+ this.#repaint.activate();
61
+ this.#shown = this.#currentStep;
62
+ this.#render();
17
63
  }
64
+ /** Drops a pending pass, so nothing renders or reports for a stepper that left. */
18
65
  disconnect() {
19
- this.#isConnected = false;
66
+ this.#repaint.cancel();
20
67
  }
21
68
  /** Re-renders when Turbo Morph or application code changes `index` at runtime. */
22
69
  indexValueChanged() {
23
- if (!this.#isConnected) return;
24
- this.#normalizeAndRender();
70
+ this.#repaint.schedule();
71
+ }
72
+ /** Re-derives every step's state for a step added at runtime. */
73
+ stepTargetConnected() {
74
+ this.#repaint.schedule();
75
+ }
76
+ /** Re-derives every step's state for a step removed at runtime. */
77
+ stepTargetDisconnected() {
78
+ this.#repaint.schedule();
25
79
  }
26
80
  /** Advances to the next step (ignored at the last step). */
27
81
  next() {
@@ -31,9 +85,16 @@ var StepperController = class extends Controller {
31
85
  prev() {
32
86
  this.#moveTo(this.#clampIndex(this.indexValue) - 1);
33
87
  }
34
- /** Jumps to the step carried in the action's `index` param. */
88
+ /**
89
+ * Jumps to the step carried in the action's `index` param. A param that is empty,
90
+ * or that Stimulus parses into anything but a number or a string, is no index,
91
+ * so it jumps nowhere.
92
+ */
35
93
  goto(event) {
36
- const target = Number(event.params.index);
94
+ const { index } = event.params;
95
+ if (index === "") return;
96
+ if (typeof index !== "number" && typeof index !== "string") return;
97
+ const target = Number(index);
37
98
  if (!Number.isFinite(target) || !Number.isInteger(target)) return;
38
99
  this.#moveTo(target);
39
100
  }
@@ -51,19 +112,39 @@ var StepperController = class extends Controller {
51
112
  if (this.linearValue && target > current + 1) return;
52
113
  const previous = current;
53
114
  this.indexValue = target;
54
- this.#render(target);
115
+ this.#shown = target;
116
+ this.#render();
55
117
  this.dispatch("change", {
56
- detail: { index: target, previous, step: this.stepTargets[target] }
118
+ detail: { index: target, previous, total, step: this.stepTargets[target] }
57
119
  });
58
120
  }
59
- /** Normalizes the public Value and reflects it without dispatching an action event. */
60
- #normalizeAndRender() {
61
- const normalized = this.#clampIndex(this.indexValue);
62
- if (!Object.is(normalized, this.indexValue)) this.indexValue = normalized;
63
- this.#render(normalized);
121
+ /**
122
+ * Re-derives one settled batch of Value and target changes, and reports a
123
+ * current position that moved from the one shown before as `reconcile`. With no
124
+ * step present there is nothing to report, and the position shown last stays the
125
+ * one the next move is measured from.
126
+ */
127
+ #reconcileStep() {
128
+ const previous = this.#shown;
129
+ const index = this.#currentStep;
130
+ if (index !== null) this.#shown = index;
131
+ this.#render();
132
+ if (index === null || previous === null || index === previous) return;
133
+ const steps = this.stepTargets;
134
+ this.dispatch("reconcile", {
135
+ detail: { index, previous, total: steps.length, step: steps[index] }
136
+ });
137
+ }
138
+ /** The current position, clamped into the step range, or `null` when there is no step. */
139
+ get #currentStep() {
140
+ return this.stepTargets.length > 0 ? this.#clampIndex(this.indexValue) : null;
64
141
  }
65
142
  /**
66
- * Derives each step's `data-state` and the current button's `aria-current`.
143
+ * Derives each step's `data-state` and the current button's `aria-current` from
144
+ * `index`, clamped into the step range, without dispatching an action event.
145
+ *
146
+ * The clamp decides only what is shown. This body never writes `index`, so an
147
+ * out-of-range declaration stays in the attribute as the page wrote it.
67
148
  *
68
149
  * `aria-current="step"` is placed on the step's **first** `<button>`; the markup
69
150
  * contract assumes one operable button per step. If a step needs multiple
@@ -71,7 +152,8 @@ var StepperController = class extends Controller {
71
152
  *
72
153
  * @stimeoRenderRoot
73
154
  */
74
- #render(current) {
155
+ #render() {
156
+ const current = this.#clampIndex(this.indexValue);
75
157
  this.stepTargets.forEach((step, index) => {
76
158
  step.dataset.state = index < current ? "complete" : index === current ? "current" : "upcoming";
77
159
  const button = step.querySelector("button");