stimeo-ui 0.14.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -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 +81 -12
  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 +85 -17
  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 +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  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 +46 -13
  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 +432 -71
  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 +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  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/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -192,6 +192,16 @@ function isScrollable(element) {
192
192
  return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
193
193
  }
194
194
 
195
+ // src/utils/state_reason.ts
196
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
197
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
198
+ function stateReasonFor(event) {
199
+ if (!event) return "api";
200
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
201
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
202
+ return "user";
203
+ }
204
+
195
205
  // src/controllers/popover_controller.ts
196
206
  var PopoverController = class extends Controller {
197
207
  static targets = ["trigger", "panel"];
@@ -199,15 +209,19 @@ var PopoverController = class extends Controller {
199
209
  closeOnScroll: { type: Boolean, default: false }
200
210
  };
201
211
  static actions = ["close", "open", "toggle"];
212
+ static events = ["close", "open"];
202
213
  /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
203
214
  #stopScrollDismiss = null;
204
215
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
205
216
  #escapeLayer = new EscapeLayer();
217
+ /** Whether state moves are reported: set once `connect()` settled the baseline. */
218
+ #reporting = false;
206
219
  /** Starts closed and registers the standing dismissal listeners. */
207
220
  connect() {
208
- this.close();
221
+ this.#close("api");
209
222
  document.addEventListener("click", this.#onOutsideClick, true);
210
223
  this.element.addEventListener("focusout", this.#onFocusOut);
224
+ this.#reporting = true;
211
225
  }
212
226
  /**
213
227
  * Removes every standing listener registered in {@link connect} plus any active
@@ -216,6 +230,7 @@ var PopoverController = class extends Controller {
216
230
  * the element after a Turbo navigation.
217
231
  */
218
232
  disconnect() {
233
+ this.#reporting = false;
219
234
  this.#escapeLayer.deactivate();
220
235
  document.removeEventListener("click", this.#onOutsideClick, true);
221
236
  this.element.removeEventListener("focusout", this.#onFocusOut);
@@ -223,34 +238,51 @@ var PopoverController = class extends Controller {
223
238
  this.#stopScrollDismiss = null;
224
239
  }
225
240
  /** Toggles the popover. Bound via `data-action` (click on the trigger). */
226
- toggle() {
241
+ toggle(event) {
227
242
  if (this.#isOpen) {
228
- this.close();
243
+ this.close(event);
229
244
  } else {
230
- this.open();
245
+ this.open(event);
231
246
  }
232
247
  }
233
248
  /** Opens the panel, reflects state, and moves focus inside it. */
234
- open() {
249
+ open(event) {
250
+ this.#open(stateReasonFor(event));
251
+ }
252
+ /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
253
+ close(event) {
254
+ this.#close(stateReasonFor(event));
255
+ }
256
+ /**
257
+ * Opens the panel, reflects state, reports a move, and moves focus inside it.
258
+ *
259
+ * @stimeoRuntimeOnly `closeOnScroll` decides whether this opening wires the scroll dismissal;
260
+ * what is shown does not depend on it.
261
+ */
262
+ #open(reason) {
235
263
  if (!this.hasPanelTarget || this.#isOpen) return;
236
264
  this.panelTarget.hidden = false;
237
265
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
266
+ if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
267
+ if (!this.#isOpen) return;
238
268
  this.#escapeLayer.activate(document, {
239
269
  onDismiss: () => this.#closeAndRestore(),
240
270
  claims: claimsWhileFocusWithin(this.element)
241
271
  });
242
272
  if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
243
- this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
273
+ this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#close("scroll"));
244
274
  }
245
275
  this.#focusFirst();
246
276
  }
247
- /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
248
- close() {
277
+ /** Closes the panel, reflects the collapsed state, and reports a move. */
278
+ #close(reason) {
279
+ const was = this.#isOpen;
249
280
  this.#escapeLayer.deactivate();
250
281
  this.#stopScrollDismiss?.();
251
282
  this.#stopScrollDismiss = null;
252
283
  if (this.hasPanelTarget) this.panelTarget.hidden = true;
253
284
  if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
285
+ if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
254
286
  }
255
287
  /** Moves focus to the first focusable element in the panel, or the panel itself. */
256
288
  #focusFirst() {
@@ -264,13 +296,15 @@ var PopoverController = class extends Controller {
264
296
  }
265
297
  /** Closes and restores focus to the trigger for explicit keyboard dismissal. */
266
298
  #closeAndRestore() {
267
- this.close();
299
+ this.#close("escape");
268
300
  if (this.hasTriggerTarget) this.triggerTarget.focus();
269
301
  }
270
302
  /** Closes without moving focus when a click lands outside the controller element. */
271
303
  #onOutsideClick = (event) => {
272
304
  const target = event.target;
273
- if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();
305
+ if (this.#isOpen && target instanceof Node && !this.element.contains(target)) {
306
+ this.#close("outside");
307
+ }
274
308
  };
275
309
  /**
276
310
  * Closes when focus leaves the controller for a known external destination
@@ -284,7 +318,7 @@ var PopoverController = class extends Controller {
284
318
  if (!this.#isOpen) return;
285
319
  const next = event.relatedTarget;
286
320
  if (!(next instanceof Node) || this.element.contains(next)) return;
287
- this.close();
321
+ this.#close("focus");
288
322
  };
289
323
  /** Whether the panel is currently visible. */
290
324
  get #isOpen() {
@@ -122,7 +122,7 @@ var PortalController = class extends Controller {
122
122
  destination.appendChild(node);
123
123
  }
124
124
  node.setAttribute("data-portaled", "true");
125
- this.dispatch("mount", { detail: { target: destination } });
125
+ this.dispatch("mount", { detail: { destination } });
126
126
  }
127
127
  disconnect() {
128
128
  const state = portalState.get(this.element);
@@ -137,7 +137,11 @@ var PortalController = class extends Controller {
137
137
  if (current?.owner === this) this.#restore(current);
138
138
  });
139
139
  }
140
- /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
140
+ /**
141
+ * Returns the node to its placeholder (or removes it) and clears the bookkeeping.
142
+ *
143
+ * @stimeoRuntimeOnly `restore` decides whether this one unmount puts the node back or drops it.
144
+ */
141
145
  #restore(state) {
142
146
  portalState.delete(this.element);
143
147
  const { node, placeholder } = state;
@@ -83,7 +83,20 @@ var StylePropertyLease = class {
83
83
  }
84
84
  };
85
85
 
86
+ // src/utils/transient_hooks.ts
87
+ var TransientHooks = class {
88
+ #attributes;
89
+ constructor(options) {
90
+ this.#attributes = options.attributes;
91
+ }
92
+ /** Drops the declared hooks from `element`; what it carries besides stays. */
93
+ reset(element) {
94
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
95
+ }
96
+ };
97
+
86
98
  // src/controllers/preview_guard_controller.ts
99
+ var TRANSIENT = new TransientHooks({ attributes: ["data-preview-hidden"] });
87
100
  var PreviewGuardController = class extends Controller {
88
101
  static values = {
89
102
  placeholder: { type: String, default: "" }
@@ -101,7 +114,7 @@ var PreviewGuardController = class extends Controller {
101
114
  connect() {
102
115
  this.#connected = true;
103
116
  if (this.#hidden) this.#reguard();
104
- else this.element.removeAttribute("data-preview-hidden");
117
+ else TRANSIENT.reset(this.element);
105
118
  this.#beforeCache.activate();
106
119
  if (typeof MutationObserver !== "undefined") {
107
120
  this.#observer = new MutationObserver(() => this.#sync());
@@ -161,6 +174,8 @@ var PreviewGuardController = class extends Controller {
161
174
  * first would reconnect the whole subtree for an instant. Only a change of *form* —
162
175
  * to or from the empty placeholder — has to revert, and the focus the guard is holding
163
176
  * carries across it rather than being handed back and taken again.
177
+ *
178
+ * @stimeoRenderRoot
164
179
  */
165
180
  #reguard() {
166
181
  if (this.#savedNodes && this.placeholderValue !== "") {
@@ -76,8 +76,12 @@ function rangeFraction(value, min, max) {
76
76
  }
77
77
 
78
78
  // src/controllers/progress_controller.ts
79
- var OWNED_VALUE_TEXT = "data-stimeo--progress-owns-valuetext";
79
+ var OWNED_VALUE_TEXT = "owns-valuetext";
80
80
  var ProgressController = class extends Controller {
81
+ /** The marker above, in the namespace this controller is registered under. */
82
+ get #ownedValueText() {
83
+ return `data-${this.identifier}-${OWNED_VALUE_TEXT}`;
84
+ }
81
85
  static targets = ["bar"];
82
86
  static values = {
83
87
  value: { type: Number, default: 0 },
@@ -189,13 +193,13 @@ var ProgressController = class extends Controller {
189
193
  const percent = Math.round(this.#ratio * 100);
190
194
  const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent));
191
195
  this.element.setAttribute("aria-valuetext", text);
192
- this.element.setAttribute(OWNED_VALUE_TEXT, "");
196
+ this.element.setAttribute(this.#ownedValueText, "");
193
197
  }
194
198
  /** Removes `aria-valuetext` only when this controller is the one that wrote it. */
195
199
  #clearOwnValueText() {
196
- if (!this.element.hasAttribute(OWNED_VALUE_TEXT)) return;
200
+ if (!this.element.hasAttribute(this.#ownedValueText)) return;
197
201
  this.element.removeAttribute("aria-valuetext");
198
- this.element.removeAttribute(OWNED_VALUE_TEXT);
202
+ this.element.removeAttribute(this.#ownedValueText);
199
203
  }
200
204
  };
201
205
 
@@ -29,6 +29,16 @@ function ownerOf(candidates, node) {
29
29
  return candidates[ownerIndex(candidates, node)] ?? null;
30
30
  }
31
31
 
32
+ // src/utils/field_mirror.ts
33
+ function writeField(field, value) {
34
+ if (field.value === value) return false;
35
+ field.value = value;
36
+ return true;
37
+ }
38
+ function commitField(target) {
39
+ target.dispatchEvent(new Event("change", { bubbles: true }));
40
+ }
41
+
32
42
  // src/utils/focus_candidate.ts
33
43
  function inheritsFieldsetDisabled(control) {
34
44
  let fieldset = control.closest("fieldset[disabled]");
@@ -131,6 +141,7 @@ function rovingMove(current, length, delta, wrap) {
131
141
  }
132
142
 
133
143
  // src/controllers/radio_group_controller.ts
144
+ var NO_SELECTION = { radio: null, value: "" };
134
145
  var OBSERVED_ATTRIBUTES = [
135
146
  "aria-checked",
136
147
  "aria-disabled",
@@ -158,7 +169,8 @@ var RadioGroupController = class extends Controller {
158
169
  #internalCheckedValues = /* @__PURE__ */ new Map();
159
170
  #internalTabindexValues = /* @__PURE__ */ new Map();
160
171
  #observer = null;
161
- #committedRadio = null;
172
+ /** The selection last published: read on connect, then committed or reported. */
173
+ #committed = NO_SELECTION;
162
174
  #connected = false;
163
175
  #lastOrder = [];
164
176
  #focusedRadio = null;
@@ -177,7 +189,7 @@ var RadioGroupController = class extends Controller {
177
189
  this.#normalizeSelection();
178
190
  this.#ensureTabStop(true);
179
191
  this.#reflectField(this.#selectedRadio, { silent: true });
180
- this.#committedRadio = this.#selectedRadio ?? null;
192
+ this.#committed = this.#settled;
181
193
  this.#lastOrder = this.#managedTargets;
182
194
  this.element.addEventListener("click", this.#onClickCapture, true);
183
195
  this.element.addEventListener("keydown", this.#onKeydownCapture, true);
@@ -208,7 +220,7 @@ var RadioGroupController = class extends Controller {
208
220
  this.#internalTabindexValues.clear();
209
221
  this.#focusedRadio = null;
210
222
  this.#pendingFocusIndex = null;
211
- this.#committedRadio = null;
223
+ this.#committed = NO_SELECTION;
212
224
  this.#preferChecked = false;
213
225
  }
214
226
  /** Removes a newly connected radio from the Tab sequence before batch reconciliation. */
@@ -334,17 +346,23 @@ var RadioGroupController = class extends Controller {
334
346
  if (this.#isActivationDisabled(destination)) this.#setActive(destination, true);
335
347
  else this.#selectRadio(destination, { focus: true });
336
348
  }
337
- /** Applies one user selection and emits only when selected identity changes. */
349
+ /**
350
+ * Applies one user selection and emits only when selected identity changes. A
351
+ * new selection is taken as published before either report goes out, so a
352
+ * listener that selects again is measured from it. Activating the radio that is
353
+ * already checked publishes nothing: if the page checked it, the next pass
354
+ * reports that move.
355
+ */
338
356
  #selectRadio(radio, { focus }) {
339
357
  if (!this.#isSupportedHost(radio) || this.#isActivationDisabled(radio)) return;
340
358
  const previous = this.#selectedRadio;
341
359
  const changed = previous !== radio;
342
360
  if (changed) {
343
361
  for (const item of this.#managedTargets) this.#setChecked(item, item === radio);
362
+ this.#committed = { radio, value: this.#radioValue(radio) };
344
363
  }
345
364
  this.#setActive(radio, focus);
346
365
  this.#reflectField(radio, { silent: !changed });
347
- this.#committedRadio = radio;
348
366
  if (changed) {
349
367
  this.dispatch("change", { detail: { value: this.#radioValue(radio), radio } });
350
368
  }
@@ -372,16 +390,19 @@ var RadioGroupController = class extends Controller {
372
390
  this.#reportReconciledSelection();
373
391
  }
374
392
  /**
375
- * Announces a selection this pass decided rather than the user. Dispatched after
376
- * observation resumes so a consumer's own DOM edits are seen by the next pass.
393
+ * Announces a selection that moved since the one this group last published —
394
+ * another radio, or the same radio submitting another value. Dispatched after
395
+ * observation resumes so a consumer's own DOM edits are seen by the next pass,
396
+ * and only while connected: moving focus inside the pass runs listeners that
397
+ * can disconnect the group.
377
398
  */
378
399
  #reportReconciledSelection() {
379
- const settled = this.#selectedRadio ?? null;
380
- if (settled === this.#committedRadio) return;
381
- this.#committedRadio = settled;
382
- this.dispatch("reconcile", {
383
- detail: { value: settled ? this.#radioValue(settled) : "", radio: settled }
384
- });
400
+ if (!this.#connected) return;
401
+ const settled = this.#settled;
402
+ const committed = this.#committed;
403
+ if (settled.radio === committed.radio && settled.value === committed.value) return;
404
+ this.#committed = settled;
405
+ this.dispatch("reconcile", { detail: { value: settled.value, radio: settled.radio } });
385
406
  }
386
407
  /** Begins or ends ownership according to a radio's current host semantics. */
387
408
  #reconcileHost(radio, dropFromTabSequence) {
@@ -493,7 +514,7 @@ var RadioGroupController = class extends Controller {
493
514
  }
494
515
  /** Whether the closest Radio Group scope around a target is this instance. */
495
516
  #ownsEventTarget(target) {
496
- return target instanceof Element && target.closest('[data-controller~="stimeo--radio-group"]') === this.element;
517
+ return target instanceof Element && target.closest(`[data-controller~="${this.identifier}"]`) === this.element;
497
518
  }
498
519
  /** Hosts whose activation model can be owned without conflicting native behavior. */
499
520
  #isSupportedHost(radio) {
@@ -603,6 +624,11 @@ var RadioGroupController = class extends Controller {
603
624
  get #selectedRadio() {
604
625
  return this.#managedTargets.find((radio) => this.#isChecked(radio));
605
626
  }
627
+ /** The current selection as the group publishes it. */
628
+ get #settled() {
629
+ const radio = this.#selectedRadio;
630
+ return radio ? { radio, value: this.#radioValue(radio) } : NO_SELECTION;
631
+ }
606
632
  /** Whether a radio is currently checked. */
607
633
  #isChecked(radio) {
608
634
  return radio.getAttribute("aria-checked") === "true";
@@ -624,9 +650,8 @@ var RadioGroupController = class extends Controller {
624
650
  #reflectField(radio, { silent = false } = {}) {
625
651
  if (!this.hasFieldTarget) return;
626
652
  const value = radio ? this.#radioValue(radio) : "";
627
- if (this.fieldTarget.value === value) return;
628
- this.fieldTarget.value = value;
629
- if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
653
+ if (!writeField(this.fieldTarget, value)) return;
654
+ if (!silent) commitField(this.fieldTarget);
630
655
  }
631
656
  /** A radio's submitted value (`data-value`, defaulting to empty). */
632
657
  #radioValue(radio) {
@@ -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
 
@@ -232,7 +234,7 @@ var END_PROPERTY = "--stimeo--range-slider-end";
232
234
  var DEFAULT_MIN = 0;
233
235
  var DEFAULT_MAX = 100;
234
236
  var RangeSliderController = class extends Controller {
235
- static targets = ["track", "startThumb", "endThumb"];
237
+ static targets = ["track", "startThumb", "endThumb", "startField", "endField"];
236
238
  static values = {
237
239
  min: { type: Number, default: DEFAULT_MIN },
238
240
  max: { type: Number, default: DEFAULT_MAX },
@@ -242,9 +244,14 @@ var RangeSliderController = class extends Controller {
242
244
  logicalTrack: { type: Boolean, default: false }
243
245
  };
244
246
  static actions = ["onKeydown", "onPointerDown"];
245
- static events = ["change"];
247
+ static events = ["change", "reconcile"];
246
248
  /** One initiating pointer owns each live drag and its stable target snapshot. */
247
249
  #drag = null;
250
+ /**
251
+ * The pair last published: taken on connect, then moved by each user commit
252
+ * and by each repaint that reports a page-driven move.
253
+ */
254
+ #settled = { start: DEFAULT_MIN, end: DEFAULT_MAX };
248
255
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
249
256
  get #mirrored() {
250
257
  return this.logicalTrackValue && isRtl(this.element);
@@ -254,11 +261,14 @@ var RangeSliderController = class extends Controller {
254
261
  * pass Stimulus delivers before `connect()`.
255
262
  */
256
263
  #repaint = new MicrotaskCoalescer(() => this.#render());
264
+ /**
265
+ * Renders the normalized, ordered pair without writing it back, and takes it
266
+ * as the baseline, so connecting reports nothing.
267
+ */
257
268
  connect() {
258
269
  this.#repaint.activate();
259
- const range = this.#effectiveRange;
260
- const pair = this.#currentPair(range);
261
- this.#commit(pair.start, pair.end, null, false);
270
+ this.#settled = this.#currentPair(this.#effectiveRange);
271
+ this.#render();
262
272
  }
263
273
  /** Cancels any active pointer drag so document listeners never leak. */
264
274
  disconnect() {
@@ -313,6 +323,14 @@ var RangeSliderController = class extends Controller {
313
323
  }
314
324
  this.#repaint.schedule();
315
325
  }
326
+ /** Fills a start field inserted or replaced at runtime on the next repaint. */
327
+ startFieldTargetConnected() {
328
+ this.#repaint.schedule();
329
+ }
330
+ /** Fills an end field inserted or replaced at runtime on the next repaint. */
331
+ endFieldTargetConnected() {
332
+ this.#repaint.schedule();
333
+ }
316
334
  /** Drops a stale end-thumb reference without orphaning the track gesture. */
317
335
  endThumbTargetDisconnected(thumb) {
318
336
  if (this.#drag?.kind === "end" && this.#drag.thumb === thumb) this.#drag.thumb = null;
@@ -439,44 +457,72 @@ var RangeSliderController = class extends Controller {
439
457
  #moveThumb(kind, raw) {
440
458
  const pair = this.#currentPair(this.#effectiveRange);
441
459
  if (kind === "start") {
442
- this.#commit(raw, pair.end, "start", true);
460
+ this.#commit(raw, pair.end, "start");
443
461
  } else {
444
- this.#commit(pair.start, raw, "end", true);
462
+ this.#commit(pair.start, raw, "end");
445
463
  }
446
464
  }
447
465
  /**
448
- * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
449
- * reflects it onto the thumbs' ARIA attributes and the range custom
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`.
466
+ * Clamps and snaps the pair a user's move of the `moving` thumb produced,
467
+ * stops that thumb at its partner, stores the pair, and reflects it onto the
468
+ * thumbs' ARIA attributes and the range custom properties. The pair is
469
+ * reported as `change` when it differs from the pair last published — not from
470
+ * the pair the move started from — so a page write no repaint has published
471
+ * yet is reported once, as this change, and a move that ends on the pair last
472
+ * published reports nothing. The baseline moves before any report goes out,
473
+ * so the repaint the Value writes schedule — and any move a subscriber makes
474
+ * while the report is dispatched — is measured from this pair.
453
475
  */
454
- #commit(start, end, moving, notify) {
476
+ #commit(start, end, moving) {
455
477
  const range = this.#effectiveRange;
456
- const previous = this.#currentPair(range);
457
478
  let nextStart = snapSteppedValue(start, range);
458
479
  let nextEnd = snapSteppedValue(end, range);
459
480
  if (nextStart > nextEnd) {
460
481
  if (moving === "start") nextStart = nextEnd;
461
- else if (moving === "end") nextEnd = nextStart;
462
- else [nextStart, nextEnd] = [nextEnd, nextStart];
482
+ else nextEnd = nextStart;
463
483
  }
484
+ const pair = { start: nextStart, end: nextEnd };
485
+ const reported = this.#settled;
486
+ this.#settled = pair;
464
487
  if (!Object.is(this.startValue, nextStart)) this.startValue = nextStart;
465
488
  if (!Object.is(this.endValue, nextEnd)) this.endValue = nextEnd;
466
- const pair = { start: nextStart, end: nextEnd };
467
489
  this.#renderPair(pair, range);
468
- if (notify && (nextStart !== previous.start || nextEnd !== previous.end)) {
490
+ this.#mirrorFields(pair, true);
491
+ if (nextStart !== reported.start || nextEnd !== reported.end) {
469
492
  this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
470
493
  }
471
494
  }
472
495
  /**
473
- * Reflects morph-supplied Values without writing them back or dispatching.
496
+ * Reflects the Values the page supplied without writing them back. A published
497
+ * pair that moved from the last one is reported once as `reconcile`; the
498
+ * fields follow without a native `change`.
474
499
  *
475
500
  * @stimeoRenderRoot
476
501
  */
477
502
  #render() {
478
503
  const range = this.#effectiveRange;
479
- this.#renderPair(this.#currentPair(range), range);
504
+ const pair = this.#currentPair(range);
505
+ this.#renderPair(pair, range);
506
+ this.#mirrorFields(pair, false);
507
+ const settled = this.#settled;
508
+ if (pair.start === settled.start && pair.end === settled.end) return;
509
+ this.#settled = pair;
510
+ this.dispatch("reconcile", { detail: { start: pair.start, end: pair.end } });
511
+ }
512
+ /**
513
+ * Mirrors the pair into the optional form fields, reporting only a user's
514
+ * move. Each end is compared on its own, so dragging one thumb never reports
515
+ * a commit from the field the other thumb owns.
516
+ */
517
+ #mirrorFields(pair, notify) {
518
+ if (this.hasStartFieldTarget) {
519
+ const field = this.startFieldTarget;
520
+ if (writeField(field, String(pair.start)) && notify) commitField(field);
521
+ }
522
+ if (this.hasEndFieldTarget) {
523
+ const field = this.endFieldTarget;
524
+ if (writeField(field, String(pair.end)) && notify) commitField(field);
525
+ }
480
526
  }
481
527
  /** Reflects one normalized pair onto both thumbs and CSS properties. */
482
528
  #renderPair(pair, range) {