stimeo-ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/dist/controllers/aspect_ratio_controller.js +1 -1
  4. data/dist/controllers/breadcrumb_controller.js +5 -1
  5. data/dist/controllers/carousel_controller.js +5 -1
  6. data/dist/controllers/clipboard_controller.js +8 -3
  7. data/dist/controllers/collapsible_controller.js +4 -1
  8. data/dist/controllers/color_picker_controller.js +6 -2
  9. data/dist/controllers/context_menu_controller.js +2 -2
  10. data/dist/controllers/countdown_controller.js +5 -1
  11. data/dist/controllers/date_range_picker_controller.js +5 -1
  12. data/dist/controllers/direct_upload_controller.js +3 -3
  13. data/dist/controllers/empty_state_controller.js +107 -16
  14. data/dist/controllers/flash_controller.js +161 -21
  15. data/dist/controllers/form_validation_controller.js +8 -2
  16. data/dist/controllers/frame_loading_controller.js +94 -22
  17. data/dist/controllers/highlight_controller.js +38 -1
  18. data/dist/controllers/idle_controller.js +13 -2
  19. data/dist/controllers/local_time_controller.js +2 -0
  20. data/dist/controllers/masonry_controller.js +1 -1
  21. data/dist/controllers/meter_controller.js +3 -1
  22. data/dist/controllers/network_status_controller.js +1 -3
  23. data/dist/controllers/number_input_controller.js +191 -24
  24. data/dist/controllers/overflow_menu_controller.js +1 -1
  25. data/dist/controllers/pagination_controller.js +5 -1
  26. data/dist/controllers/password_strength_controller.js +1 -1
  27. data/dist/controllers/pointer_drag_controller.js +10 -0
  28. data/dist/controllers/portal_controller.js +10 -0
  29. data/dist/controllers/progress_controller.js +7 -3
  30. data/dist/controllers/range_slider_controller.js +385 -94
  31. data/dist/controllers/rating_controller.js +2 -0
  32. data/dist/controllers/relative_time_controller.js +2 -0
  33. data/dist/controllers/scroll_area_controller.js +1 -1
  34. data/dist/controllers/separator_controller.js +13 -17
  35. data/dist/controllers/skeleton_controller.js +71 -3
  36. data/dist/controllers/slider_controller.js +325 -48
  37. data/dist/controllers/spinner_controller.js +18 -3
  38. data/dist/controllers/step_indicator_controller.js +3 -1
  39. data/dist/controllers/stepper_controller.js +2 -0
  40. data/dist/controllers/switch_controller.js +162 -18
  41. data/dist/controllers/textarea_autosize_controller.js +1 -1
  42. data/dist/controllers/time_picker_controller.js +6 -3
  43. data/dist/controllers/tree_view_controller.js +19 -1
  44. data/dist/index.js +1214 -307
  45. data/lib/stimeo/ui/version.rb +1 -1
  46. metadata +2 -2
data/dist/index.js CHANGED
@@ -857,7 +857,7 @@ var AspectRatioController = class extends Controller {
857
857
  };
858
858
  /** Applies the ratio on connect and whenever the value changes. */
859
859
  ratioValueChanged() {
860
- this.element.style.setProperty("--stimeo-aspect-ratio", this.#normalizeRatio(this.ratioValue));
860
+ this.element.style.setProperty("--stimeo--aspect-ratio", this.#normalizeRatio(this.ratioValue));
861
861
  }
862
862
  /**
863
863
  * Normalizes a ratio string to a valid CSS `<ratio>`:
@@ -1346,7 +1346,11 @@ var BreadcrumbController = class extends Controller {
1346
1346
  this.ellipsisTarget.hidden = true;
1347
1347
  return this.listTarget.scrollWidth > this.listTarget.clientWidth + OVERFLOW_EPSILON;
1348
1348
  }
1349
- /** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
1349
+ /**
1350
+ * Applies the collapsed/expanded state to the items, ellipsis, and trigger.
1351
+ *
1352
+ * @stimeoRenderRoot
1353
+ */
1350
1354
  #render() {
1351
1355
  const collapsed = this.#overflowing && !this.#expanded;
1352
1356
  const showEllipsis = this.#overflowing && this.collapsibleTargets.length > 0;
@@ -2078,7 +2082,11 @@ var CarouselController = class extends Controller {
2078
2082
  this.#syncTimer();
2079
2083
  if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
2080
2084
  }
2081
- /** Reflects `this.#index` onto slides and pickers (state hooks + roving). */
2085
+ /**
2086
+ * Reflects `this.#index` onto slides and pickers (state hooks + roving).
2087
+ *
2088
+ * @stimeoRenderRoot
2089
+ */
2082
2090
  #render({ focus }) {
2083
2091
  this.slideTargets.forEach((slide, i) => {
2084
2092
  const active = i === this.#index;
@@ -2287,6 +2295,15 @@ var CheckboxController = class extends Controller {
2287
2295
  return "partial";
2288
2296
  }
2289
2297
  };
2298
+
2299
+ // src/utils/default_attribute.ts
2300
+ function setDefaultAttribute(element, name, value) {
2301
+ if (element.hasAttribute(name)) return false;
2302
+ element.setAttribute(name, value);
2303
+ return true;
2304
+ }
2305
+
2306
+ // src/controllers/clipboard_controller.ts
2290
2307
  var ClipboardController = class extends Controller {
2291
2308
  static targets = ["source", "button", "feedback"];
2292
2309
  static values = {
@@ -2306,9 +2323,7 @@ var ClipboardController = class extends Controller {
2306
2323
  */
2307
2324
  #resetTimerId = null;
2308
2325
  connect() {
2309
- if (!this.element.hasAttribute("data-state")) {
2310
- this.element.setAttribute("data-state", "idle");
2311
- }
2326
+ setDefaultAttribute(this.element, "data-state", "idle");
2312
2327
  }
2313
2328
  disconnect() {
2314
2329
  this.#timers.clearAll();
@@ -2615,7 +2630,10 @@ var CollapsibleController = class extends Controller {
2615
2630
  #applyContent(content, open, waitForCloseTransition) {
2616
2631
  if (open) {
2617
2632
  content.hidden = false;
2618
- content.style.setProperty("--stimeo-collapsible-content-height", `${content.scrollHeight}px`);
2633
+ content.style.setProperty(
2634
+ "--stimeo--collapsible-content-height",
2635
+ `${content.scrollHeight}px`
2636
+ );
2619
2637
  content.setAttribute("data-state", "open");
2620
2638
  return;
2621
2639
  }
@@ -2648,7 +2666,7 @@ function toFiniteNumber(raw) {
2648
2666
  }
2649
2667
 
2650
2668
  // src/controllers/color_picker_controller.ts
2651
- var COLOR_PROPERTY = "--stimeo-color";
2669
+ var COLOR_PROPERTY = "--stimeo--color";
2652
2670
  var CHANNEL_RANGE = {
2653
2671
  hue: [0, 360],
2654
2672
  saturation: [0, 100],
@@ -2777,7 +2795,11 @@ var ColorPickerController = class extends Controller {
2777
2795
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
2778
2796
  this.#render();
2779
2797
  }
2780
- /** Reflects the model onto sliders, the hex input, preview, and form field. */
2798
+ /**
2799
+ * Reflects the model onto sliders, the hex input, preview, and form field.
2800
+ *
2801
+ * @stimeoRenderRoot
2802
+ */
2781
2803
  #render() {
2782
2804
  for (const slider of this.sliderTargets) {
2783
2805
  const channel = this.#channelOf(slider);
@@ -3904,8 +3926,8 @@ var ContextMenuController = class extends Controller {
3904
3926
  onDismiss: () => this.#closeAndRestore(),
3905
3927
  claims: claimsWhileFocusWithin(this.element)
3906
3928
  });
3907
- this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
3908
- this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
3929
+ this.menuTarget.style.setProperty("--stimeo--context-menu-x", `${x}px`);
3930
+ this.menuTarget.style.setProperty("--stimeo--context-menu-y", `${y}px`);
3909
3931
  this.menuTarget.hidden = false;
3910
3932
  if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "open");
3911
3933
  this.#navigableItems[0]?.focus();
@@ -4248,7 +4270,11 @@ var CountdownController = class extends Controller {
4248
4270
  const raw = this.#isDown ? this.#reference - now : now - this.#reference;
4249
4271
  return Math.max(0, raw);
4250
4272
  }
4251
- /** Writes the amount into the day/hour/minute/second slots. */
4273
+ /**
4274
+ * Writes the amount into the day/hour/minute/second slots.
4275
+ *
4276
+ * @stimeoRenderRoot
4277
+ */
4252
4278
  #render(amount) {
4253
4279
  const totalSeconds = Math.floor(amount / SECOND_MS);
4254
4280
  this.#renderedAmount = totalSeconds * SECOND_MS;
@@ -4853,7 +4879,11 @@ var DateRangePickerController = class extends Controller {
4853
4879
  this.#focusedDate = target;
4854
4880
  this.#render();
4855
4881
  }
4856
- /** Builds the six-week grid and binds range/roving/disabled state per cell. */
4882
+ /**
4883
+ * Builds the six-week grid and binds range/roving/disabled state per cell.
4884
+ *
4885
+ * @stimeoRenderRoot
4886
+ */
4857
4887
  #render() {
4858
4888
  const info = parseISOMonthString(this.#viewMonth);
4859
4889
  if (!info) return;
@@ -5079,7 +5109,7 @@ var DirectUploadController = class extends Controller {
5079
5109
  const clamped = Math.max(0, Math.min(100, percent));
5080
5110
  row.setAttribute("aria-valuenow", String(clamped));
5081
5111
  row.setAttribute("aria-valuetext", `${clamped}%`);
5082
- row.style.setProperty("--stimeo-upload-progress", `${clamped}%`);
5112
+ row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
5083
5113
  this.#setField(row, "percent", `${clamped}%`);
5084
5114
  this.#syncAggregate();
5085
5115
  this.dispatch("progress", { detail: { id, percent: clamped } });
@@ -5117,7 +5147,7 @@ var DirectUploadController = class extends Controller {
5117
5147
  }
5118
5148
  clone.setAttribute("aria-valuenow", "0");
5119
5149
  clone.setAttribute("data-upload-state", "uploading");
5120
- clone.style.setProperty("--stimeo-upload-progress", "0%");
5150
+ clone.style.setProperty("--stimeo--upload-progress", "0%");
5121
5151
  this.listTarget.appendChild(clone);
5122
5152
  this.#rows.set(key, clone);
5123
5153
  return clone;
@@ -5141,7 +5171,7 @@ var DirectUploadController = class extends Controller {
5141
5171
  }
5142
5172
  const overall = Math.round(total / this.#rows.size);
5143
5173
  this.element.setAttribute("data-upload-progress", String(overall));
5144
- this.element.style.setProperty("--stimeo-upload-progress", `${overall}%`);
5174
+ this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
5145
5175
  }
5146
5176
  /** Writes a consumer label (with `%{name}` substituted) to the status region. */
5147
5177
  #announce(label, row) {
@@ -5787,23 +5817,120 @@ var EmptyStateController = class extends Controller {
5787
5817
  };
5788
5818
  static events = ["change"];
5789
5819
  #observer = null;
5820
+ /** Whether the controller is between `connect()` and `disconnect()`. */
5821
+ #connected = false;
5790
5822
  /** Last applied empty state; `null` until the first sync so connect emits nothing. */
5791
5823
  #empty = null;
5792
5824
  connect() {
5793
- if (!this.hasListTarget) return;
5794
- if (typeof MutationObserver !== "undefined") {
5795
- this.#observer = new MutationObserver(() => this.#apply());
5796
- this.#observer.observe(this.listTarget, { childList: true });
5797
- }
5798
- this.#apply();
5825
+ this.#connected = true;
5826
+ this.#syncObservation();
5827
+ this.#update();
5799
5828
  }
5800
5829
  disconnect() {
5830
+ this.#connected = false;
5831
+ this.#stopObserving();
5832
+ }
5833
+ /** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */
5834
+ listTargetConnected() {
5835
+ this.#resync();
5836
+ }
5837
+ /** Releases the observation when the `list` element leaves the target set. */
5838
+ listTargetDisconnected() {
5839
+ this.#resync();
5840
+ }
5841
+ /** Syncs an `empty` element that arrives — or is replaced — at runtime. */
5842
+ emptyTargetConnected() {
5843
+ this.#resync();
5844
+ }
5845
+ /**
5846
+ * Syncs the `empty` element that remains when one leaves the target set. A
5847
+ * single-target getter resolves to the first `empty` element in document order,
5848
+ * so a swap that inserts the replacement *before* removing the original (Turbo
5849
+ * Stream `after` / `before` / `append` followed by `remove`) leaves the
5850
+ * replacement untouched until the original goes — this callback is that moment.
5851
+ */
5852
+ emptyTargetDisconnected() {
5853
+ this.#resync();
5854
+ }
5855
+ /** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */
5856
+ itemSelectorValueChanged() {
5857
+ this.#resync();
5858
+ }
5859
+ /**
5860
+ * Re-points the observation and re-renders after a target or selector change.
5861
+ * The `#connected` guard is load-bearing: Stimulus runs value and target
5862
+ * callbacks for the initial markup *before* `connect()` and runs target
5863
+ * callbacks during teardown *after* `disconnect()`, and re-observing there
5864
+ * would outlive the controller.
5865
+ */
5866
+ #resync() {
5867
+ if (!this.#connected) return;
5868
+ this.#syncObservation();
5869
+ this.#update();
5870
+ }
5871
+ /**
5872
+ * Points the mutation observation at the current `list` target — re-resolved on
5873
+ * every sync rather than captured at connect, so an element swapped in at
5874
+ * runtime is observed instead of the detached original.
5875
+ *
5876
+ * The observation covers exactly what the count predicate reads. With no
5877
+ * `itemSelector` the count is the child element count, which only `childList`
5878
+ * can change. With one, the predicate reads the children themselves, so
5879
+ * attribute and descendant mutations are watched too — and the controller's own
5880
+ * writes are filtered back out, or toggling `hidden` would re-enter the render.
5881
+ */
5882
+ #syncObservation() {
5883
+ this.#stopObserving();
5884
+ if (!this.hasListTarget || typeof MutationObserver === "undefined") return;
5885
+ const watchesItems = this.itemSelectorValue.length > 0;
5886
+ this.#observer = new MutationObserver((records) => {
5887
+ if (records.some((record) => this.#affectsCount(record))) this.#update();
5888
+ });
5889
+ this.#observer.observe(this.listTarget, {
5890
+ childList: true,
5891
+ subtree: watchesItems,
5892
+ attributes: watchesItems
5893
+ });
5894
+ }
5895
+ #stopObserving() {
5801
5896
  this.#observer?.disconnect();
5802
5897
  this.#observer = null;
5803
5898
  }
5804
- /** Recomputes the count and syncs visibility, hooks, and the change event. */
5805
- #apply() {
5806
- if (!this.hasListTarget) return;
5899
+ /**
5900
+ * Whether a mutation can change the item count. An attribute written on a
5901
+ * target this controller owns is its own echo — `hidden` on `list` / `empty`,
5902
+ * and the hooks on the controller element when the list *is* that element.
5903
+ */
5904
+ #affectsCount(record) {
5905
+ if (record.type !== "attributes") return true;
5906
+ const own = record.target === this.listTarget || this.hasEmptyTarget && record.target === this.emptyTarget;
5907
+ return !own;
5908
+ }
5909
+ /**
5910
+ * Renders the count, then reports a crossed boundary. The announcement copy is
5911
+ * read here rather than while rendering: it is the wording of the report, not
5912
+ * an input to what is displayed.
5913
+ */
5914
+ #update() {
5915
+ const count = this.#render();
5916
+ if (count === null) return;
5917
+ const empty = count === 0;
5918
+ const crossed = this.#empty !== null && empty !== this.#empty;
5919
+ this.#empty = empty;
5920
+ if (!crossed) return;
5921
+ this.dispatch("change", { detail: { count, empty } });
5922
+ announce(
5923
+ fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
5924
+ );
5925
+ }
5926
+ /**
5927
+ * Syncs visibility and the state hooks to the current item count, and returns
5928
+ * it. `null` when there is no `list` target to count.
5929
+ *
5930
+ * @stimeoRenderRoot
5931
+ */
5932
+ #render() {
5933
+ if (!this.hasListTarget) return null;
5807
5934
  const count = this.#count();
5808
5935
  const empty = count === 0;
5809
5936
  this.element.setAttribute("data-count", String(count));
@@ -5814,13 +5941,7 @@ var EmptyStateController = class extends Controller {
5814
5941
  }
5815
5942
  this.listTarget.hidden = empty;
5816
5943
  if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
5817
- if (this.#empty !== null && empty !== this.#empty) {
5818
- this.dispatch("change", { detail: { count, empty } });
5819
- announce(
5820
- fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
5821
- );
5822
- }
5823
- this.#empty = empty;
5944
+ return count;
5824
5945
  }
5825
5946
  /** Item count: element children matching `itemSelector`, or all element children. */
5826
5947
  #count() {
@@ -6083,29 +6204,119 @@ var FlashController = class extends Controller {
6083
6204
  static events = ["show", "dismiss"];
6084
6205
  #timers = new SafeTimeout();
6085
6206
  #observer = null;
6207
+ /** Whether the controller is between `connect()` and `disconnect()`. */
6208
+ #connected = false;
6086
6209
  /** Auto-dismiss timer state keyed by message element. */
6087
6210
  #state = /* @__PURE__ */ new Map();
6088
6211
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
6089
6212
  #order = [];
6090
- #onEnter = (event) => this.#pause(event.currentTarget);
6091
- #onLeave = (event) => this.#resume(event.currentTarget);
6213
+ /**
6214
+ * Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}
6215
+ * releases the bookkeeping above *before* the transition wait, so for that window the
6216
+ * element is in the DOM but in neither collection — without this set a re-scan would
6217
+ * read it as a brand-new flash and show it a second time.
6218
+ */
6219
+ #leaving = /* @__PURE__ */ new Set();
6220
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
6221
+ #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
6222
+ #onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
6092
6223
  connect() {
6093
- if (!this.hasRegionTarget) return;
6224
+ this.#connected = true;
6094
6225
  for (const message of this.messageTargets) {
6095
- this.#process(message, true);
6096
- }
6097
- if (typeof MutationObserver !== "undefined") {
6098
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
6099
- this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
6226
+ if (this.#owns(message)) this.#process(message, true);
6100
6227
  }
6228
+ this.#syncObservation();
6229
+ this.#beforeCache.activate();
6101
6230
  }
6102
6231
  disconnect() {
6103
- this.#observer?.disconnect();
6104
- this.#observer = null;
6232
+ this.#connected = false;
6233
+ this.#beforeCache.deactivate();
6234
+ this.#stopObserving();
6105
6235
  this.#timers.clearAll();
6106
6236
  for (const message of this.#order) this.#unbindPause(message);
6107
6237
  this.#state.clear();
6108
6238
  this.#order.length = 0;
6239
+ this.#leaving.clear();
6240
+ }
6241
+ /**
6242
+ * Takes the managed flashes out of the page just before Turbo freezes it, so a
6243
+ * restored snapshot carries no notification the visitor has already received: the
6244
+ * fresh `connect()` there reads a leftover flash as a brand-new one and announces it
6245
+ * a second time. A message that never auto-dismisses (`duration: 0`) is one of these
6246
+ * too — that value governs the timer, not what belongs in a cached page. Removal
6247
+ * only: `dismiss` reports a dismissal, and freezing the page is not one.
6248
+ */
6249
+ #rewindForCache() {
6250
+ for (const message of [...this.#order]) {
6251
+ message.remove();
6252
+ this.#forget(message);
6253
+ }
6254
+ for (const message of this.#leaving) message.remove();
6255
+ this.#leaving.clear();
6256
+ }
6257
+ /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
6258
+ regionTargetConnected() {
6259
+ this.#resync();
6260
+ }
6261
+ /** Releases the observation when the `region` element leaves the target set. */
6262
+ regionTargetDisconnected() {
6263
+ this.#resync();
6264
+ }
6265
+ /**
6266
+ * Whether this controller owns `message`. Ownership is the current `region`'s
6267
+ * subtree: a message target anywhere else in the controller's scope is the
6268
+ * consumer's, and so is one in a region that has gone away. The initial scan, a
6269
+ * re-scan after a `region` swap, and a departure from the target set all resolve
6270
+ * ownership through this one test; the observation gets it structurally, by watching
6271
+ * that subtree and nothing else.
6272
+ */
6273
+ #owns(message) {
6274
+ return this.hasRegionTarget && this.regionTarget.contains(message);
6275
+ }
6276
+ /**
6277
+ * Releases a message that left the target set (a Turbo Stream `remove`, the consumer
6278
+ * detaching the node, or a morph that rewrote the target attribute in place): it
6279
+ * stops occupying a `max` slot, and both its pending auto-dismiss and an already
6280
+ * scheduled removal are cancelled. A move *within* the region keeps all of them —
6281
+ * which is why the element must still be a message to be treated as one: ownership
6282
+ * alone reads an in-place attribute rewrite as a move, and a node outside the target
6283
+ * set belongs to the consumer, so nothing here may dismiss it.
6284
+ */
6285
+ messageTargetDisconnected(message) {
6286
+ if (!this.#connected) return;
6287
+ const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
6288
+ if (moved) return;
6289
+ this.#forget(message);
6290
+ this.#leaving.delete(message);
6291
+ }
6292
+ /**
6293
+ * Re-points the observation after a `region` swap and picks up the messages the
6294
+ * new element brought with it (dynamic inserts, so their own `role` announces
6295
+ * them). The `#connected` guard is load-bearing: Stimulus runs target callbacks
6296
+ * for the initial markup *before* `connect()` and again during teardown *after*
6297
+ * `disconnect()`, and re-observing there would outlive the controller.
6298
+ */
6299
+ #resync() {
6300
+ if (!this.#connected) return;
6301
+ this.#syncObservation();
6302
+ for (const message of this.messageTargets) {
6303
+ if (this.#owns(message)) this.#process(message, false);
6304
+ }
6305
+ }
6306
+ /**
6307
+ * Points the mutation observation at the current `region` target, re-resolved on
6308
+ * every sync rather than captured at connect, so an element swapped in at runtime
6309
+ * is observed instead of the detached original.
6310
+ */
6311
+ #syncObservation() {
6312
+ this.#stopObserving();
6313
+ if (!this.hasRegionTarget || typeof MutationObserver === "undefined") return;
6314
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
6315
+ this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
6316
+ }
6317
+ #stopObserving() {
6318
+ this.#observer?.disconnect();
6319
+ this.#observer = null;
6109
6320
  }
6110
6321
  /**
6111
6322
  * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
@@ -6151,6 +6362,7 @@ var FlashController = class extends Controller {
6151
6362
  */
6152
6363
  #process(message, bridge) {
6153
6364
  if (this.#state.has(message) || this.#order.includes(message)) return;
6365
+ if (this.#leaving.has(message)) return;
6154
6366
  const type = message.getAttribute("data-flash-type") ?? "";
6155
6367
  const assertive = ASSERTIVE_TYPES.has(type);
6156
6368
  if (!message.hasAttribute("role")) {
@@ -6183,33 +6395,53 @@ var FlashController = class extends Controller {
6183
6395
  const existing = this.#state.get(message);
6184
6396
  if (existing?.id) this.#timers.clear(existing.id);
6185
6397
  const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
6186
- this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });
6398
+ this.#state.set(message, {
6399
+ id,
6400
+ startedAt: Date.now(),
6401
+ remaining: duration,
6402
+ paused: existing?.paused ?? /* @__PURE__ */ new Set()
6403
+ });
6187
6404
  }
6188
- /** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */
6189
- #pause(message) {
6405
+ /**
6406
+ * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
6407
+ * Hover and focus are independent reasons: the remaining time is banked on the
6408
+ * first of them, and {@link FlashController.#resume} waits for the last one.
6409
+ */
6410
+ #pause(message, reason) {
6190
6411
  const timer = this.#state.get(message);
6191
- if (!timer || timer.id === 0) return;
6412
+ if (!timer) return;
6413
+ timer.paused.add(reason);
6414
+ if (timer.id === 0) return;
6192
6415
  this.#timers.clear(timer.id);
6193
- const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
6194
- this.#state.set(message, { id: 0, startedAt: 0, remaining });
6416
+ const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
6417
+ this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
6195
6418
  }
6196
6419
  /** Resumes a paused message's auto-dismiss with the banked time. */
6197
- #resume(message) {
6420
+ #resume(message, reason) {
6198
6421
  const timer = this.#state.get(message);
6199
6422
  if (!timer) return;
6200
- if (timer.id !== 0 || timer.remaining <= 0) return;
6423
+ timer.paused.delete(reason);
6424
+ if (timer.paused.size > 0) return;
6425
+ if (timer.id !== 0) return;
6201
6426
  this.#startTimer(message, timer.remaining);
6202
6427
  }
6203
- /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
6204
- #beginDismiss(message, reason) {
6428
+ /** Releases every per-message resource: timer, stacking slot, pause listeners. */
6429
+ #forget(message) {
6205
6430
  const timer = this.#state.get(message);
6206
6431
  if (timer?.id) this.#timers.clear(timer.id);
6207
6432
  this.#state.delete(message);
6208
6433
  const index = this.#order.indexOf(message);
6209
6434
  if (index !== -1) this.#order.splice(index, 1);
6435
+ this.#unbindPause(message);
6436
+ }
6437
+ /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
6438
+ #beginDismiss(message, reason) {
6439
+ if (!this.#state.has(message) && !this.#order.includes(message)) return;
6440
+ this.#forget(message);
6441
+ this.#leaving.add(message);
6210
6442
  message.setAttribute("data-flash-state", "leaving");
6211
6443
  const finalize = () => {
6212
- this.#unbindPause(message);
6444
+ if (!this.#leaving.delete(message)) return;
6213
6445
  message.remove();
6214
6446
  this.dispatch("dismiss", { detail: { element: message, reason } });
6215
6447
  };
@@ -6478,8 +6710,7 @@ var FormValidationController = class _FormValidationController extends Controlle
6478
6710
  };
6479
6711
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
6480
6712
  connect() {
6481
- if (!this.element.hasAttribute("novalidate")) {
6482
- this.element.setAttribute("novalidate", "");
6713
+ if (setDefaultAttribute(this.element, "novalidate", "")) {
6483
6714
  this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
6484
6715
  }
6485
6716
  document.addEventListener("submit", this.#onSubmit, true);
@@ -6661,6 +6892,16 @@ var FormValidationController = class _FormValidationController extends Controlle
6661
6892
  var DetachGate = class _DetachGate {
6662
6893
  /** Set while a probe is queued, waiting for a reconnect to cancel it. */
6663
6894
  #pending = false;
6895
+ /**
6896
+ * True while a probe is queued — the last disconnect was ambiguous and no
6897
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
6898
+ * reconnect half of an in-page move from a first connect: a controller whose
6899
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
6900
+ * counter) must skip it for the move, where nothing actually restarted.
6901
+ */
6902
+ get pending() {
6903
+ return this.#pending;
6904
+ }
6664
6905
  /**
6665
6906
  * True when the disconnect is definitely a real detach — the element left
6666
6907
  * the document, or `data-controller` no longer lists the identifier. False
@@ -6765,7 +7006,17 @@ var FrameLoadingController = class extends Controller {
6765
7006
  #gate = new DetachGate();
6766
7007
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
6767
7008
  #loading = false;
6768
- #inertApplied = false;
7009
+ /**
7010
+ * The optional targets this controller revealed, and the content it marked inert.
7011
+ * Held as references rather than re-resolved on the way out: a detach that keeps
7012
+ * the element takes the identifier off `data-controller` first, and a scope
7013
+ * without its identifier stops resolving targets — the elements to tidy would be
7014
+ * unreachable exactly when the tidying matters. They double as the ownership
7015
+ * marker, so a `hidden` or an `inert` the consumer wrote is never taken over.
7016
+ */
7017
+ #revealedSkeleton = null;
7018
+ #revealedOverlay = null;
7019
+ #inertTarget = null;
6769
7020
  #previousFocus = null;
6770
7021
  /** The id of the retreated element, used to re-find it if the load replaced it. */
6771
7022
  #previousFocusId = "";
@@ -6792,31 +7043,88 @@ var FrameLoadingController = class extends Controller {
6792
7043
  this.#gate.disconnected(this, () => this.#teardown());
6793
7044
  }
6794
7045
  /**
6795
- * Drops the held finish and the loading bookkeeping on a real detach. The markup
6796
- * keeps whatever it last held: the page being cached is rewound at
6797
- * `turbo:before-cache` instead, where the frame is still whole.
7046
+ * Drops the held finish and the loading bookkeeping on a real detach, returning
7047
+ * the frame to its idle form. No reconnect is coming, so nothing is left that
7048
+ * could finish the load and clear the hooks — a detach that keeps the element
7049
+ * (a morph dropping the identifier, an exit from a scoped observed root) would
7050
+ * otherwise strand it busy and inert. Focus is left where it is: the element is
7051
+ * leaving this controller's care, and moving it now would be an unexplained jump.
6798
7052
  */
6799
7053
  #teardown() {
6800
7054
  this.#gate.cancel();
6801
7055
  this.#timeouts.clearAll();
6802
7056
  this.#floor.cancel();
7057
+ if (this.#loading) this.#rewindHooks();
6803
7058
  this.#loading = false;
6804
7059
  this.#previousFocus = null;
6805
7060
  }
6806
7061
  /**
6807
- * Returns the frame to its resting hooks for the snapshot Turbo is about to
6808
- * take, so a page reached with the Back button does not restore a frame that is
6809
- * busy and inert with nothing left to finish it. State only — no `end` event and
6810
- * no focus move, because the load did not actually complete. The live page keeps
6811
- * its held finish, so a navigation that never completes still ends properly.
7062
+ * Returns the frame to its idle form for the snapshot Turbo is about to take, so
7063
+ * a page reached with the Back button does not restore a frame that is busy and
7064
+ * inert with nothing left to finish it. State only — no `end` event and no focus
7065
+ * move, because the load did not actually complete.
7066
+ *
7067
+ * The load is abandoned rather than paused, so the flag and any finish the floor
7068
+ * still holds drop along with the hooks. A kept finish would surface after the
7069
+ * rewind as exactly the three things this pass exists to avoid — an `end`, a
7070
+ * completion announcement, and a focus move — and a kept flag would leave the
7071
+ * next fetch on a page that survives a cancelled visit skipping the loading
7072
+ * state, its idempotence guard already satisfied.
6812
7073
  */
6813
7074
  #rewindForCache() {
6814
7075
  if (!this.#loading) return;
7076
+ this.#loading = false;
7077
+ this.#floor.cancel();
7078
+ this.#rewindHooks();
7079
+ }
7080
+ /**
7081
+ * Clears every hook the loading state writes. Shared by the three ways a load can
7082
+ * stop — completion, detach, snapshot — so none of them can drift into tidying
7083
+ * only part of it.
7084
+ */
7085
+ #rewindHooks() {
6815
7086
  this.element.removeAttribute("aria-busy");
6816
7087
  this.element.removeAttribute("data-frame-loading");
6817
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
6818
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
7088
+ if (this.#revealedSkeleton) this.#revealedSkeleton.hidden = true;
7089
+ if (this.#revealedOverlay) this.#revealedOverlay.hidden = true;
7090
+ this.#revealedSkeleton = null;
7091
+ this.#revealedOverlay = null;
7092
+ this.#clearInert();
7093
+ }
7094
+ /**
7095
+ * Re-shows a `skeleton` that arrived mid-load. Turbo's frame renderer empties the
7096
+ * frame and re-inserts the response's children, so a response's authored (hidden)
7097
+ * skeleton can land while a later fetch is still running, and only the controller
7098
+ * knows the frame is still busy.
7099
+ */
7100
+ skeletonTargetConnected() {
7101
+ if (this.#loading) this.#revealSkeleton();
7102
+ }
7103
+ /** Re-shows an `overlay` that arrived mid-load — the same swap as the skeleton. */
7104
+ overlayTargetConnected() {
7105
+ if (this.#loading) this.#revealOverlay();
7106
+ }
7107
+ /**
7108
+ * Re-blocks a `content` that arrived mid-load, so the stale copy stays unusable.
7109
+ * The element that left is released first and ownership is then decided afresh, so
7110
+ * an `inert` the replacement authored stays the consumer's.
7111
+ */
7112
+ contentTargetConnected() {
7113
+ if (!this.#loading) return;
6819
7114
  this.#clearInert();
7115
+ this.#applyInert();
7116
+ }
7117
+ /** Reveals the optional `skeleton`, noting it as this controller's to hide again. */
7118
+ #revealSkeleton() {
7119
+ if (!this.hasSkeletonTarget) return;
7120
+ this.#revealedSkeleton = this.skeletonTarget;
7121
+ this.skeletonTarget.hidden = false;
7122
+ }
7123
+ /** Reveals the optional `overlay`, noting it as this controller's to hide again. */
7124
+ #revealOverlay() {
7125
+ if (!this.hasOverlayTarget) return;
7126
+ this.#revealedOverlay = this.overlayTarget;
7127
+ this.overlayTarget.hidden = false;
6820
7128
  }
6821
7129
  /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
6822
7130
  #begin() {
@@ -6824,8 +7132,8 @@ var FrameLoadingController = class extends Controller {
6824
7132
  this.#floor.begin();
6825
7133
  this.element.setAttribute("aria-busy", "true");
6826
7134
  this.element.setAttribute("data-frame-loading", "true");
6827
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
6828
- if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
7135
+ this.#revealSkeleton();
7136
+ this.#revealOverlay();
6829
7137
  this.#applyInert();
6830
7138
  this.#retreatFocus();
6831
7139
  this.dispatch("start", { detail: {} });
@@ -6834,11 +7142,7 @@ var FrameLoadingController = class extends Controller {
6834
7142
  /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
6835
7143
  #finish() {
6836
7144
  this.#loading = false;
6837
- this.element.removeAttribute("aria-busy");
6838
- this.element.removeAttribute("data-frame-loading");
6839
- if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
6840
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
6841
- this.#clearInert();
7145
+ this.#rewindHooks();
6842
7146
  this.#restoreFocus();
6843
7147
  this.dispatch("end", { detail: {} });
6844
7148
  announce(fillTemplate(this.announceReadyTextValue, {}));
@@ -6847,12 +7151,11 @@ var FrameLoadingController = class extends Controller {
6847
7151
  #applyInert() {
6848
7152
  if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
6849
7153
  this.contentTarget.setAttribute("inert", "");
6850
- this.#inertApplied = true;
7154
+ this.#inertTarget = this.contentTarget;
6851
7155
  }
6852
7156
  #clearInert() {
6853
- if (!this.#inertApplied) return;
6854
- this.#inertApplied = false;
6855
- if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
7157
+ this.#inertTarget?.removeAttribute("inert");
7158
+ this.#inertTarget = null;
6856
7159
  }
6857
7160
  /** Saves and blurs focus if it sits inside the frame about to go stale. */
6858
7161
  #retreatFocus() {
@@ -6889,6 +7192,7 @@ var FrameLoadingController = class extends Controller {
6889
7192
  }
6890
7193
  }
6891
7194
  };
7195
+ var hookOwners = /* @__PURE__ */ new WeakMap();
6892
7196
  var HighlightController = class extends Controller {
6893
7197
  static values = {
6894
7198
  duration: { type: Number, default: 1500 },
@@ -6896,9 +7200,18 @@ var HighlightController = class extends Controller {
6896
7200
  };
6897
7201
  static events = ["start", "end"];
6898
7202
  #timeouts = new SafeTimeout();
7203
+ /**
7204
+ * The removal timer this connection has outstanding for an element. Held weakly so
7205
+ * a row that leaves the DOM is not retained, and dropped wholesale on `disconnect()`
7206
+ * so a cleared id can never be matched against a recycled one. Which connection owns
7207
+ * an element's hook is answered by the shared owner registry above.
7208
+ */
7209
+ #pending = /* @__PURE__ */ new WeakMap();
6899
7210
  #observer = null;
6900
7211
  connect() {
7212
+ this.#clearArrivedHook(this.element);
6901
7213
  if (this.observeValue) {
7214
+ for (const child of this.element.children) this.#clearArrivedHook(child);
6902
7215
  if (typeof MutationObserver !== "undefined") {
6903
7216
  this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
6904
7217
  this.#observer.observe(this.element, { childList: true });
@@ -6911,6 +7224,12 @@ var HighlightController = class extends Controller {
6911
7224
  this.#observer?.disconnect();
6912
7225
  this.#observer = null;
6913
7226
  this.#timeouts.clearAll();
7227
+ this.#pending = /* @__PURE__ */ new WeakMap();
7228
+ }
7229
+ /** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
7230
+ #clearArrivedHook(el) {
7231
+ if (hookOwners.get(el) === this) hookOwners.delete(el);
7232
+ el.removeAttribute("data-highlight");
6914
7233
  }
6915
7234
  /** Highlights every element child added by a childList mutation. */
6916
7235
  #onMutations(mutations) {
@@ -6923,12 +7242,33 @@ var HighlightController = class extends Controller {
6923
7242
  /** Flags `el` with `data-highlight` and schedules its removal (unless reduced-motion). */
6924
7243
  #highlight(el) {
6925
7244
  if (prefersReducedMotion()) return;
7245
+ this.#releasePending(el);
6926
7246
  el.setAttribute("data-highlight", "true");
6927
7247
  this.dispatch("start", { target: el, detail: { element: el } });
6928
- this.#timeouts.set(() => {
7248
+ const id = this.#timeouts.set(() => {
7249
+ this.#pending.delete(el);
7250
+ hookOwners.delete(el);
6929
7251
  el.removeAttribute("data-highlight");
6930
7252
  this.dispatch("end", { target: el, detail: { element: el } });
6931
7253
  }, this.durationValue);
7254
+ this.#pending.set(el, id);
7255
+ hookOwners.set(el, this);
7256
+ }
7257
+ /**
7258
+ * Releases whichever removal timer holds `el`'s hook. The row may have been
7259
+ * highlighted inside a different watched container before it moved here, and that
7260
+ * container's timer is reachable only through the shared owner registry.
7261
+ */
7262
+ #releasePending(el) {
7263
+ const owner = hookOwners.get(el);
7264
+ if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
7265
+ this.#cancelPending(el);
7266
+ }
7267
+ /** Releases `el`'s pending removal timer, if it has one. */
7268
+ #cancelPending(el) {
7269
+ this.#timeouts.clear(this.#pending.get(el) ?? -1);
7270
+ this.#pending.delete(el);
7271
+ hookOwners.delete(el);
6932
7272
  }
6933
7273
  };
6934
7274
 
@@ -7078,6 +7418,12 @@ var IdleController = class extends Controller {
7078
7418
  #prompted = false;
7079
7419
  /** Timestamp of the last activity; the timers self-reschedule against it. */
7080
7420
  #lastActivity = 0;
7421
+ /**
7422
+ * Activity types actually registered on `document`, so `disconnect()` unbinds the
7423
+ * same set even when `events` changed while connected (a Turbo morph can rewrite
7424
+ * the Value in place, and the removal must match the registration, not the Value).
7425
+ */
7426
+ #boundEvents = [];
7081
7427
  #onActivity = () => {
7082
7428
  this.#lastActivity = Date.now();
7083
7429
  if (this.#idle || this.#prompted) {
@@ -7092,16 +7438,21 @@ var IdleController = class extends Controller {
7092
7438
  if (document.visibilityState === "visible") this.#onActivity();
7093
7439
  };
7094
7440
  connect() {
7095
- for (const type of this.eventsValue) {
7441
+ this.#idle = false;
7442
+ this.#prompted = false;
7443
+ this.element.removeAttribute("data-idle");
7444
+ this.#boundEvents = [...this.eventsValue];
7445
+ for (const type of this.#boundEvents) {
7096
7446
  document.addEventListener(type, this.#onActivity, { passive: true, capture: true });
7097
7447
  }
7098
7448
  document.addEventListener("visibilitychange", this.#onVisibility);
7099
7449
  this.#arm();
7100
7450
  }
7101
7451
  disconnect() {
7102
- for (const type of this.eventsValue) {
7452
+ for (const type of this.#boundEvents) {
7103
7453
  document.removeEventListener(type, this.#onActivity, { capture: true });
7104
7454
  }
7455
+ this.#boundEvents = [];
7105
7456
  document.removeEventListener("visibilitychange", this.#onVisibility);
7106
7457
  this.#timeouts.clearAll();
7107
7458
  }
@@ -7992,6 +8343,8 @@ var LocalTimeController = class extends Controller {
7992
8343
  * its condition is that formatting was applied, and a repaint applies it with a
7993
8344
  * new result. A pass that cannot format writes nothing and emits nothing, so the
7994
8345
  * authored absolute text stays as the fallback.
8346
+ *
8347
+ * @stimeoRenderRoot
7995
8348
  */
7996
8349
  #render() {
7997
8350
  const date = this.#parse();
@@ -8067,7 +8420,7 @@ var LocalTimeController = class extends Controller {
8067
8420
  return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
8068
8421
  }
8069
8422
  };
8070
- var COLUMNS_PROPERTY = "--stimeo-masonry-columns";
8423
+ var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
8071
8424
  var MasonryController = class extends Controller {
8072
8425
  static targets = ["item"];
8073
8426
  static values = {
@@ -9026,6 +9379,8 @@ var MeterController = class extends Controller {
9026
9379
  * Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio.
9027
9380
  * The reading is derived once and returned, so the `change` detail reports the
9028
9381
  * same numbers the DOM just received.
9382
+ *
9383
+ * @stimeoRenderRoot
9029
9384
  */
9030
9385
  #render() {
9031
9386
  const value = this.#clamp(this.valueValue);
@@ -9037,7 +9392,7 @@ var MeterController = class extends Controller {
9037
9392
  this.element.setAttribute("aria-valuemin", String(this.minValue));
9038
9393
  this.element.setAttribute("aria-valuemax", String(this.maxValue));
9039
9394
  this.element.setAttribute("aria-valuenow", String(reading.value));
9040
- this.element.style.setProperty("--stimeo-meter-ratio", String(reading.ratio));
9395
+ this.element.style.setProperty("--stimeo--meter-ratio", String(reading.ratio));
9041
9396
  this.element.setAttribute("data-state", reading.state);
9042
9397
  this.#applyValueText(reading);
9043
9398
  return reading;
@@ -10175,9 +10530,7 @@ var NetworkStatusController = class extends Controller {
10175
10530
  #showOffline() {
10176
10531
  this.#timers.clearAll();
10177
10532
  if (this.hasOnlineTarget) this.onlineTarget.hidden = true;
10178
- if (this.hasOfflineTarget) {
10179
- this.offlineTarget.hidden = false;
10180
- }
10533
+ if (this.hasOfflineTarget) this.offlineTarget.hidden = false;
10181
10534
  }
10182
10535
  /** Shows the recovery banner, optionally auto-hiding it after `onlineAutoHide`. */
10183
10536
  #showOnline() {
@@ -10191,6 +10544,114 @@ var NetworkStatusController = class extends Controller {
10191
10544
  }
10192
10545
  }
10193
10546
  };
10547
+
10548
+ // src/utils/stepped_value.ts
10549
+ function effectiveStep(step) {
10550
+ return Number.isFinite(step) && step > 0 ? step : 1;
10551
+ }
10552
+ function snapSteppedValue(raw, range) {
10553
+ if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
10554
+ const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
10555
+ const clamped = Math.min(range.max, Math.max(range.min, input));
10556
+ const step = effectiveStep(range.step);
10557
+ const base = stepBase(range);
10558
+ const candidates = [];
10559
+ if (Number.isFinite(range.min)) candidates.push(range.min);
10560
+ if (Number.isFinite(range.max)) candidates.push(range.max);
10561
+ const gridPosition = (clamped - base) / step;
10562
+ if (Number.isFinite(gridPosition)) {
10563
+ addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
10564
+ addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
10565
+ }
10566
+ if (candidates.length === 0) return clamped;
10567
+ let nearest = candidates[0];
10568
+ let nearestDistance = Math.abs(clamped - nearest);
10569
+ for (const candidate of candidates.slice(1)) {
10570
+ const distance = Math.abs(clamped - candidate);
10571
+ if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
10572
+ nearest = candidate;
10573
+ nearestDistance = distance;
10574
+ }
10575
+ }
10576
+ return nearest;
10577
+ }
10578
+ function stepSteppedValue(current, count, range) {
10579
+ const value = snapSteppedValue(current, range);
10580
+ const distance = Math.abs(Math.trunc(count));
10581
+ if (!Number.isFinite(distance) || distance === 0) return value;
10582
+ const direction = Math.sign(count);
10583
+ const adjacent = adjacentSteppedValue(value, direction, range);
10584
+ const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
10585
+ return snapSteppedValue(raw, range);
10586
+ }
10587
+ function adjacentSteppedValue(current, direction, range) {
10588
+ const step = effectiveStep(range.step);
10589
+ const base = stepBase(range);
10590
+ const candidates = [];
10591
+ if (direction > 0) {
10592
+ const position2 = (current - base) / step;
10593
+ if (Number.isFinite(position2)) {
10594
+ let gridIndex = Math.floor(position2) + 1;
10595
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
10596
+ if (candidate < current || nearlyEqual(candidate, current)) {
10597
+ gridIndex += 1;
10598
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
10599
+ }
10600
+ if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
10601
+ candidates.push(clamp(candidate, range));
10602
+ }
10603
+ }
10604
+ return clamp(Math.min(...candidates), range);
10605
+ }
10606
+ const position = (current - base) / step;
10607
+ if (Number.isFinite(position)) {
10608
+ let gridIndex = Math.ceil(position) - 1;
10609
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
10610
+ if (candidate > current || nearlyEqual(candidate, current)) {
10611
+ gridIndex -= 1;
10612
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
10613
+ }
10614
+ if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
10615
+ candidates.push(clamp(candidate, range));
10616
+ }
10617
+ }
10618
+ return clamp(Math.max(...candidates), range);
10619
+ }
10620
+ function addGridCandidate(candidates, index, range, base, step) {
10621
+ const candidate = cleanGridValue(base + index * step, base, step);
10622
+ if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
10623
+ }
10624
+ function stepBase(range) {
10625
+ if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
10626
+ return Number.isFinite(range.min) ? range.min : 0;
10627
+ }
10628
+ function cleanGridValue(value, base, step) {
10629
+ const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
10630
+ return precision <= 100 ? Number(value.toFixed(precision)) : value;
10631
+ }
10632
+ function decimalPlaces(value) {
10633
+ const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
10634
+ const fractionLength = coefficient.split(".")[1]?.length ?? 0;
10635
+ const exponent = exponentText === void 0 ? 0 : Number(exponentText);
10636
+ return Math.max(0, fractionLength - exponent);
10637
+ }
10638
+ function within(value, min, max) {
10639
+ return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
10640
+ }
10641
+ function clamp(value, range) {
10642
+ return Math.min(range.max, Math.max(range.min, value));
10643
+ }
10644
+ function nearlyEqual(left, right) {
10645
+ const scale = Math.max(Math.abs(left), Math.abs(right));
10646
+ return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
10647
+ }
10648
+ function finiteFallback(min, max) {
10649
+ if (Number.isFinite(min)) return min;
10650
+ if (Number.isFinite(max)) return max;
10651
+ return 0;
10652
+ }
10653
+
10654
+ // src/controllers/number_input_controller.ts
10194
10655
  var NumberInputController = class _NumberInputController extends Controller {
10195
10656
  static targets = ["input", "increment", "decrement"];
10196
10657
  static values = {
@@ -10224,24 +10685,24 @@ var NumberInputController = class _NumberInputController extends Controller {
10224
10685
  #repeatedDuringHold = false;
10225
10686
  /** True when the next `click` is the trailing one after a hold and must be ignored. */
10226
10687
  #suppressNextClick = false;
10688
+ /** Collapses runtime range/step changes into one silent input reconciliation. */
10689
+ #repaint = new MicrotaskCoalescer(() => this.#reconcile());
10227
10690
  /** Normalizes any initial value and wires the focus/hold pointer guards. */
10228
10691
  connect() {
10692
+ this.#repaint.activate();
10229
10693
  if (!this.hasInputTarget) return;
10230
- if (this.inputTarget.value.trim() !== "") {
10231
- this.#write(this.#normalize(this.#currentValue()));
10232
- } else {
10233
- this.#updateButtons(this.#currentValue());
10234
- }
10694
+ this.#reconcile();
10235
10695
  this.#guards = new AbortController();
10236
10696
  const { signal } = this.#guards;
10237
- if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, this.stepValue, signal);
10238
- if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -this.stepValue, signal);
10697
+ if (this.hasIncrementTarget) this.#wireButton(this.incrementTarget, 1, signal);
10698
+ if (this.hasDecrementTarget) this.#wireButton(this.decrementTarget, -1, signal);
10239
10699
  window.addEventListener("pointerup", this.#stopHold, { signal });
10240
10700
  window.addEventListener("pointercancel", this.#stopHold, { signal });
10241
10701
  window.addEventListener("blur", this.#stopHold, { signal });
10242
10702
  }
10243
10703
  /** Releases the pointer guards and tears down every pending timer and hold state. */
10244
10704
  disconnect() {
10705
+ this.#repaint.cancel();
10245
10706
  this.#guards?.abort();
10246
10707
  this.#guards = null;
10247
10708
  this.#holdActive = false;
@@ -10250,16 +10711,28 @@ var NumberInputController = class _NumberInputController extends Controller {
10250
10711
  this.#holdTimeouts.clearAll();
10251
10712
  this.#holdIntervals.clearAll();
10252
10713
  }
10714
+ /** Silently reconciles a minimum changed by application code or a Turbo morph. */
10715
+ minValueChanged() {
10716
+ this.#repaint.schedule();
10717
+ }
10718
+ /** Silently reconciles a maximum changed by application code or a Turbo morph. */
10719
+ maxValueChanged() {
10720
+ this.#repaint.schedule();
10721
+ }
10722
+ /** Silently reconciles a step changed by application code or a Turbo morph. */
10723
+ stepValueChanged() {
10724
+ this.#repaint.schedule();
10725
+ }
10253
10726
  /** Increases by one step. Bound via `data-action` (click). */
10254
10727
  increment() {
10255
10728
  if (this.#consumeSuppressedClick()) return;
10256
- this.#commit(this.#currentValue() + this.stepValue);
10729
+ this.#commitStep(1);
10257
10730
  this.inputTarget.focus();
10258
10731
  }
10259
10732
  /** Decreases by one step. Bound via `data-action` (click). */
10260
10733
  decrement() {
10261
10734
  if (this.#consumeSuppressedClick()) return;
10262
- this.#commit(this.#currentValue() - this.stepValue);
10735
+ this.#commitStep(-1);
10263
10736
  this.inputTarget.focus();
10264
10737
  }
10265
10738
  /** Clamps and snaps a typed value. Bound via `data-action` (change). */
@@ -10270,20 +10743,19 @@ var NumberInputController = class _NumberInputController extends Controller {
10270
10743
  /** Keyboard stepping per the APG spinbutton model. */
10271
10744
  onKeydown(event) {
10272
10745
  if (isReservedArrowChord(event)) return;
10273
- const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
10274
10746
  let next = null;
10275
10747
  switch (event.key) {
10276
10748
  case "ArrowUp":
10277
- next = this.#currentValue() + this.stepValue;
10749
+ next = stepSteppedValue(this.#currentValue(), 1, this.#steppedRange);
10278
10750
  break;
10279
10751
  case "ArrowDown":
10280
- next = this.#currentValue() - this.stepValue;
10752
+ next = stepSteppedValue(this.#currentValue(), -1, this.#steppedRange);
10281
10753
  break;
10282
10754
  case "PageUp":
10283
- next = this.#currentValue() + page;
10755
+ next = this.pageStepValue > 0 ? this.#currentValue() + this.pageStepValue : stepSteppedValue(this.#currentValue(), 10, this.#steppedRange);
10284
10756
  break;
10285
10757
  case "PageDown":
10286
- next = this.#currentValue() - page;
10758
+ next = this.pageStepValue > 0 ? this.#currentValue() - this.pageStepValue : stepSteppedValue(this.#currentValue(), -10, this.#steppedRange);
10287
10759
  break;
10288
10760
  case "Home":
10289
10761
  if (!Number.isFinite(this.minValue)) return;
@@ -10304,14 +10776,14 @@ var NumberInputController = class _NumberInputController extends Controller {
10304
10776
  * hold; leaving the button while held stops it (the global listeners cover
10305
10777
  * release/cancel/blur).
10306
10778
  */
10307
- #wireButton(button, delta, signal) {
10308
- button.addEventListener("pointerdown", (event) => this.#armHold(event, button, delta), {
10779
+ #wireButton(button, direction, signal) {
10780
+ button.addEventListener("pointerdown", (event) => this.#armHold(event, button, direction), {
10309
10781
  signal
10310
10782
  });
10311
10783
  button.addEventListener("pointerleave", this.#stopHold, { signal });
10312
10784
  }
10313
10785
  /** Starts a hold: focus the input, then schedule the first repeat after a delay. */
10314
- #armHold(event, button, delta) {
10786
+ #armHold(event, button, direction) {
10315
10787
  const pointerButton = event.button;
10316
10788
  if (typeof pointerButton === "number" && pointerButton !== 0) return;
10317
10789
  if (button.disabled) return;
@@ -10322,13 +10794,13 @@ var NumberInputController = class _NumberInputController extends Controller {
10322
10794
  this.#repeatedDuringHold = false;
10323
10795
  this.#suppressNextClick = false;
10324
10796
  this.#holdTimeouts.set(() => {
10325
- if (!this.#commit(this.#currentValue() + delta)) {
10797
+ if (!this.#commitStep(direction)) {
10326
10798
  this.#stopHold();
10327
10799
  return;
10328
10800
  }
10329
10801
  this.#repeatedDuringHold = true;
10330
10802
  this.#holdIntervals.set(() => {
10331
- if (!this.#commit(this.#currentValue() + delta)) this.#stopHold();
10803
+ if (!this.#commitStep(direction)) this.#stopHold();
10332
10804
  }, _NumberInputController.#HOLD_REPEAT_MS);
10333
10805
  }, _NumberInputController.#HOLD_DELAY_MS);
10334
10806
  }
@@ -10369,6 +10841,23 @@ var NumberInputController = class _NumberInputController extends Controller {
10369
10841
  if (changed) this.dispatch("change", { detail: { value } });
10370
10842
  return changed;
10371
10843
  }
10844
+ /** Commits an adjacent endpoint/grid value and reports whether it moved. */
10845
+ #commitStep(count) {
10846
+ return this.#commit(stepSteppedValue(this.#currentValue(), count, this.#steppedRange));
10847
+ }
10848
+ /**
10849
+ * Silently reflects the current value after a range or step morph.
10850
+ *
10851
+ * @stimeoRenderRoot
10852
+ */
10853
+ #reconcile() {
10854
+ if (!this.hasInputTarget) return;
10855
+ if (this.inputTarget.value.trim() !== "") {
10856
+ this.#write(this.#normalize(this.#currentValue()));
10857
+ } else {
10858
+ this.#updateButtons(this.#currentValue());
10859
+ }
10860
+ }
10372
10861
  /** Reflects `value` on the input (and ARIA for non-native hosts) and the buttons. */
10373
10862
  #write(value) {
10374
10863
  this.inputTarget.value = String(value);
@@ -10415,11 +10904,11 @@ var NumberInputController = class _NumberInputController extends Controller {
10415
10904
  }
10416
10905
  /** Clamps to `[min, max]` and snaps to the step grid anchored at a finite min (else 0). */
10417
10906
  #normalize(raw) {
10418
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
10419
- if (this.stepValue <= 0) return clamped;
10420
- const base = Number.isFinite(this.minValue) ? this.minValue : 0;
10421
- const stepped = Math.round((clamped - base) / this.stepValue) * this.stepValue + base;
10422
- return Math.min(this.maxValue, Math.max(this.minValue, stepped));
10907
+ return snapSteppedValue(raw, this.#steppedRange);
10908
+ }
10909
+ /** Shared range configuration; finite endpoints remain allowed off the grid. */
10910
+ get #steppedRange() {
10911
+ return { min: this.minValue, max: this.maxValue, step: this.stepValue };
10423
10912
  }
10424
10913
  };
10425
10914
  var TOGGLED_MARKER = "data-optimistic-toggled";
@@ -10929,7 +11418,7 @@ var INDEX = "data-stimeo--overflow-menu-index";
10929
11418
  var BOUNDARY = "data-stimeo--overflow-menu-boundary";
10930
11419
  var SAVED_ROLE = "data-stimeo--overflow-menu-role";
10931
11420
  var SAVED_TABINDEX = "data-stimeo--overflow-menu-tabindex";
10932
- var SAVED_MENU_TARGET = "data-stimeo--overflow-menu-menu-target";
11421
+ var SAVED_MENU_TARGET = "data-stimeo--overflow-menu-saved-menu";
10933
11422
  var MENU_TARGET = "data-stimeo--menu-target";
10934
11423
  var BOOKKEEPING = [BANKED, INDEX, SAVED_ROLE, SAVED_TABINDEX, SAVED_MENU_TARGET];
10935
11424
  var OverflowMenuController = class extends Controller {
@@ -11443,7 +11932,11 @@ var PaginationController = class _PaginationController extends Controller {
11443
11932
  if (!Object.is(page, this.pageValue)) this.pageValue = page;
11444
11933
  this.#render();
11445
11934
  }
11446
- /** Syncs `aria-current` on the page buttons and the prev/next `disabled` state. */
11935
+ /**
11936
+ * Syncs `aria-current` on the page buttons and the prev/next `disabled` state.
11937
+ *
11938
+ * @stimeoRenderRoot
11939
+ */
11447
11940
  #render() {
11448
11941
  const page = this.#page;
11449
11942
  for (const button of this.pageTargets) {
@@ -11650,7 +12143,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
11650
12143
  this.#toggle("data-strength", band, band.length > 0);
11651
12144
  this.#toggle("data-below-min", "true", score > 0 && score < this.minScoreValue);
11652
12145
  const ratio = max > 0 ? score / max : 0;
11653
- this.element.style.setProperty("--stimeo-password-strength", String(ratio));
12146
+ this.element.style.setProperty("--stimeo--password-strength", String(ratio));
11654
12147
  }
11655
12148
  #writeLabel(label) {
11656
12149
  if (this.hasLabelTarget) this.labelTarget.textContent = label;
@@ -12548,20 +13041,24 @@ var ProgressController = class extends Controller {
12548
13041
  get #ratio() {
12549
13042
  return rangeFraction(this.valueValue, this.minValue, this.maxValue);
12550
13043
  }
12551
- /** Reflects value/range/indeterminate onto ARIA, `data-state`, and the ratio. */
13044
+ /**
13045
+ * Reflects value/range/indeterminate onto ARIA, `data-state`, and the ratio.
13046
+ *
13047
+ * @stimeoRenderRoot
13048
+ */
12552
13049
  #render() {
12553
13050
  this.element.setAttribute("aria-valuemin", String(this.minValue));
12554
13051
  this.element.setAttribute("aria-valuemax", String(this.maxValue));
12555
13052
  if (this.indeterminateValue) {
12556
13053
  this.element.removeAttribute("aria-valuenow");
12557
13054
  this.#clearOwnValueText();
12558
- this.element.style.removeProperty("--stimeo-progress-ratio");
13055
+ this.element.style.removeProperty("--stimeo--progress-ratio");
12559
13056
  this.element.setAttribute("data-state", "indeterminate");
12560
13057
  return;
12561
13058
  }
12562
13059
  const value = this.#clamp(this.valueValue);
12563
13060
  this.element.setAttribute("aria-valuenow", String(value));
12564
- this.element.style.setProperty("--stimeo-progress-ratio", String(this.#ratio));
13061
+ this.element.style.setProperty("--stimeo--progress-ratio", String(this.#ratio));
12565
13062
  this.element.setAttribute("data-state", "determinate");
12566
13063
  this.#applyValueText(value);
12567
13064
  }
@@ -12671,13 +13168,70 @@ var RadioGroupController = class extends Controller {
12671
13168
  return radio.getAttribute("data-value") ?? "";
12672
13169
  }
12673
13170
  };
12674
- var START_PROPERTY = "--stimeo-range-start";
12675
- var END_PROPERTY = "--stimeo-range-end";
13171
+
13172
+ // src/utils/owned_pointer_session.ts
13173
+ var OwnedPointerSession = class {
13174
+ pointerId;
13175
+ #owner;
13176
+ #handlers;
13177
+ #abort = new AbortController();
13178
+ #active = true;
13179
+ constructor(start, owner, handlers) {
13180
+ this.pointerId = start.pointerId;
13181
+ this.#owner = owner;
13182
+ this.#handlers = handlers;
13183
+ const { signal } = this.#abort;
13184
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
13185
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
13186
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
13187
+ owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
13188
+ try {
13189
+ owner.setPointerCapture?.(this.pointerId);
13190
+ } catch {
13191
+ }
13192
+ }
13193
+ /** Whether this session still owns its pointer and listeners. */
13194
+ get active() {
13195
+ return this.#active;
13196
+ }
13197
+ /** Whether `event` belongs to the initiating pointer of the live session. */
13198
+ owns(event) {
13199
+ return this.#active && event.pointerId === this.pointerId;
13200
+ }
13201
+ /** Releases capture/listeners and invokes the end callback exactly once. */
13202
+ end() {
13203
+ if (!this.#active) return;
13204
+ this.#active = false;
13205
+ this.#abort.abort();
13206
+ try {
13207
+ this.#owner.releasePointerCapture?.(this.pointerId);
13208
+ } catch {
13209
+ }
13210
+ this.#handlers.end?.();
13211
+ }
13212
+ #onMove = (event) => {
13213
+ if (this.owns(event)) this.#handlers.move(event);
13214
+ };
13215
+ #onEndEvent = (event) => {
13216
+ if (this.owns(event)) this.end();
13217
+ };
13218
+ #onLostCapture = (event) => {
13219
+ const pointerId = event.pointerId;
13220
+ if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
13221
+ this.end();
13222
+ };
13223
+ };
13224
+
13225
+ // src/controllers/range_slider_controller.ts
13226
+ var START_PROPERTY = "--stimeo--range-slider-start";
13227
+ var END_PROPERTY = "--stimeo--range-slider-end";
13228
+ var DEFAULT_MIN = 0;
13229
+ var DEFAULT_MAX = 100;
12676
13230
  var RangeSliderController = class extends Controller {
12677
13231
  static targets = ["track", "startThumb", "endThumb"];
12678
13232
  static values = {
12679
- min: { type: Number, default: 0 },
12680
- max: { type: Number, default: 100 },
13233
+ min: { type: Number, default: DEFAULT_MIN },
13234
+ max: { type: Number, default: DEFAULT_MAX },
12681
13235
  step: { type: Number, default: 1 },
12682
13236
  start: { type: Number, default: 0 },
12683
13237
  end: { type: Number, default: 100 },
@@ -12685,31 +13239,27 @@ var RangeSliderController = class extends Controller {
12685
13239
  };
12686
13240
  static actions = ["onKeydown", "onPointerDown"];
12687
13241
  static events = ["change"];
12688
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
12689
- #dragAbort = null;
13242
+ /** One initiating pointer owns each live drag and its stable target snapshot. */
13243
+ #drag = null;
12690
13244
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
12691
13245
  get #mirrored() {
12692
13246
  return this.logicalTrackValue && isRtl(this.element);
12693
13247
  }
12694
- /** Normalizes the initial pair (clamped, snapped, ordered) and renders. */
12695
13248
  /**
12696
13249
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
12697
13250
  * pass Stimulus delivers before `connect()`.
12698
13251
  */
12699
- #repaint = new MicrotaskCoalescer(() => {
12700
- this.#render(this.startValue, this.endValue);
12701
- });
13252
+ #repaint = new MicrotaskCoalescer(() => this.#render());
12702
13253
  connect() {
12703
13254
  this.#repaint.activate();
12704
- const lo = Math.min(this.startValue, this.endValue);
12705
- const hi = Math.max(this.startValue, this.endValue);
12706
- this.#commit(lo, hi, false);
13255
+ const range = this.#effectiveRange;
13256
+ const pair = this.#currentPair(range);
13257
+ this.#commit(pair.start, pair.end, null, false);
12707
13258
  }
12708
13259
  /** Cancels any active pointer drag so document listeners never leak. */
12709
13260
  disconnect() {
12710
13261
  this.#repaint.cancel();
12711
- this.#dragAbort?.abort();
12712
- this.#dragAbort = null;
13262
+ this.#endDrag();
12713
13263
  }
12714
13264
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
12715
13265
  minValueChanged() {
@@ -12719,141 +13269,275 @@ var RangeSliderController = class extends Controller {
12719
13269
  maxValueChanged() {
12720
13270
  this.#repaint.schedule();
12721
13271
  }
13272
+ /** Repaints when application code (or a Turbo morph) changes `step` at runtime. */
13273
+ stepValueChanged() {
13274
+ this.#repaint.schedule();
13275
+ }
13276
+ /** Repaints when application code (or a Turbo morph) changes `start` at runtime. */
13277
+ startValueChanged() {
13278
+ this.#repaint.schedule();
13279
+ }
13280
+ /** Repaints when application code (or a Turbo morph) changes `end` at runtime. */
13281
+ endValueChanged() {
13282
+ this.#repaint.schedule();
13283
+ }
13284
+ /** Hydrates a replacement start thumb and restores live-drag focus ownership. */
13285
+ startThumbTargetConnected(thumb) {
13286
+ const range = this.#effectiveRange;
13287
+ const pair = this.#currentPair(range);
13288
+ this.#renderStartThumb(thumb, pair, range);
13289
+ this.#renderFractions(pair, range);
13290
+ if (this.#drag?.kind === "start" && this.#drag.thumb === null) {
13291
+ this.#drag.thumb = thumb;
13292
+ thumb.focus();
13293
+ }
13294
+ this.#repaint.schedule();
13295
+ }
13296
+ /** Drops a stale start-thumb reference without orphaning the track gesture. */
13297
+ startThumbTargetDisconnected(thumb) {
13298
+ if (this.#drag?.kind === "start" && this.#drag.thumb === thumb) this.#drag.thumb = null;
13299
+ }
13300
+ /** Hydrates a replacement end thumb and restores live-drag focus ownership. */
13301
+ endThumbTargetConnected(thumb) {
13302
+ const range = this.#effectiveRange;
13303
+ const pair = this.#currentPair(range);
13304
+ this.#renderEndThumb(thumb, pair, range);
13305
+ this.#renderFractions(pair, range);
13306
+ if (this.#drag?.kind === "end" && this.#drag.thumb === null) {
13307
+ this.#drag.thumb = thumb;
13308
+ thumb.focus();
13309
+ }
13310
+ this.#repaint.schedule();
13311
+ }
13312
+ /** Drops a stale end-thumb reference without orphaning the track gesture. */
13313
+ endThumbTargetDisconnected(thumb) {
13314
+ if (this.#drag?.kind === "end" && this.#drag.thumb === thumb) this.#drag.thumb = null;
13315
+ }
13316
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
13317
+ trackTargetDisconnected(track) {
13318
+ if (this.#drag?.track === track) this.#endDrag();
13319
+ }
12722
13320
  /** Keyboard stepping for whichever thumb is focused (the action's element). */
12723
13321
  onKeydown(event) {
12724
13322
  if (isReservedArrowChord(event)) return;
12725
13323
  const thumb = event.currentTarget;
12726
13324
  const isStart = this.hasStartThumbTarget && thumb === this.startThumbTarget;
12727
- const current = isStart ? this.startValue : this.endValue;
12728
- const lower = isStart ? this.minValue : this.startValue;
12729
- const upper = isStart ? this.endValue : this.maxValue;
12730
- const big = this.stepValue * 10;
13325
+ const isEnd = this.hasEndThumbTarget && thumb === this.endThumbTarget;
13326
+ const effectiveRange = this.#effectiveRange;
13327
+ const pair = this.#currentPair(effectiveRange);
13328
+ let kind;
13329
+ let current;
13330
+ let range;
13331
+ if (isStart) {
13332
+ kind = "start";
13333
+ current = pair.start;
13334
+ range = {
13335
+ min: effectiveRange.min,
13336
+ max: pair.end,
13337
+ step: effectiveRange.step,
13338
+ base: effectiveRange.min
13339
+ };
13340
+ } else {
13341
+ if (!isEnd) return;
13342
+ kind = "end";
13343
+ current = pair.end;
13344
+ range = {
13345
+ min: pair.start,
13346
+ max: effectiveRange.max,
13347
+ step: effectiveRange.step,
13348
+ base: effectiveRange.min
13349
+ };
13350
+ }
12731
13351
  let next = null;
12732
13352
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
12733
13353
  case "ArrowRight":
12734
13354
  case "ArrowUp":
12735
- next = current + this.stepValue;
13355
+ next = stepSteppedValue(current, 1, range);
12736
13356
  break;
12737
13357
  case "ArrowLeft":
12738
13358
  case "ArrowDown":
12739
- next = current - this.stepValue;
13359
+ next = stepSteppedValue(current, -1, range);
12740
13360
  break;
12741
13361
  case "PageUp":
12742
- next = current + big;
13362
+ next = stepSteppedValue(current, 10, range);
12743
13363
  break;
12744
13364
  case "PageDown":
12745
- next = current - big;
13365
+ next = stepSteppedValue(current, -10, range);
12746
13366
  break;
12747
13367
  case "Home":
12748
- next = lower;
13368
+ next = range.min;
12749
13369
  break;
12750
13370
  case "End":
12751
- next = upper;
13371
+ next = range.max;
12752
13372
  break;
12753
13373
  default:
12754
13374
  return;
12755
13375
  }
12756
13376
  event.preventDefault();
12757
- this.#moveThumb(isStart, next);
13377
+ this.#moveThumb(kind, next);
12758
13378
  }
12759
13379
  /** Begins a pointer drag on the track, moving the thumb nearest the press. */
12760
13380
  onPointerDown(event) {
12761
- if (!this.hasTrackTarget) return;
13381
+ if (event.button !== 0 || this.#drag || !this.hasTrackTarget || !this.hasStartThumbTarget || !this.hasEndThumbTarget) {
13382
+ return;
13383
+ }
13384
+ const track = this.trackTarget;
12762
13385
  const mirrored = this.#mirrored;
12763
- const value = this.#valueFromClientX(event.clientX, mirrored);
13386
+ const value = this.#valueFromClientX(event.clientX, mirrored, track);
12764
13387
  if (value === null) return;
12765
13388
  event.preventDefault();
12766
- const useStart = Math.abs(value - this.startValue) <= Math.abs(value - this.endValue);
12767
- if (useStart) {
12768
- if (this.hasStartThumbTarget) this.startThumbTarget.focus();
12769
- } else if (this.hasEndThumbTarget) {
12770
- this.endThumbTarget.focus();
12771
- }
12772
- this.#moveThumb(useStart, value);
12773
- this.#dragAbort?.abort();
12774
- const abort = new AbortController();
12775
- this.#dragAbort = abort;
12776
- const onMove = (move) => {
12777
- const moved = this.#valueFromClientX(move.clientX, mirrored);
12778
- if (moved !== null) this.#moveThumb(useStart, moved);
12779
- };
12780
- const onUp = () => {
12781
- abort.abort();
12782
- this.#dragAbort = null;
12783
- };
12784
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
12785
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
12786
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
13389
+ const pair = this.#currentPair(this.#effectiveRange);
13390
+ const kind = this.#nearestThumb(value, pair);
13391
+ let thumb;
13392
+ if (kind === "start") thumb = this.startThumbTarget;
13393
+ else thumb = this.endThumbTarget;
13394
+ this.#moveThumb(kind, value);
13395
+ thumb.focus();
13396
+ const drag = { pointer: null, track, thumb, kind };
13397
+ drag.pointer = new OwnedPointerSession(event, track, {
13398
+ move: (move) => {
13399
+ if (!track.isConnected) {
13400
+ this.#endDrag();
13401
+ return;
13402
+ }
13403
+ const moved = this.#valueFromClientX(move.clientX, mirrored, track);
13404
+ if (moved !== null) this.#moveThumb(kind, moved);
13405
+ },
13406
+ end: () => {
13407
+ if (this.#drag === drag) this.#drag = null;
13408
+ }
13409
+ });
13410
+ this.#drag = drag;
12787
13411
  }
12788
13412
  /** Maps a pointer X coordinate to a raw value using the track geometry. */
12789
- #valueFromClientX(clientX, mirrored) {
12790
- const rect = this.trackTarget.getBoundingClientRect();
13413
+ #valueFromClientX(clientX, mirrored, track) {
13414
+ const rect = track.getBoundingClientRect();
12791
13415
  if (rect.width === 0) return null;
12792
13416
  const offset = (clientX - rect.left) / rect.width;
12793
13417
  const fraction = mirrored ? 1 - offset : offset;
12794
- return this.minValue + fraction * (this.maxValue - this.minValue);
13418
+ const range = this.#effectiveRange;
13419
+ return range.min + fraction * (range.max - range.min);
13420
+ }
13421
+ /**
13422
+ * Chooses the closest thumb while keeping an overlapped pair expandable.
13423
+ * Ordinary midpoint ties stay deterministic on `start`; at an overlap, a
13424
+ * press above the shared value selects `end` and a press below selects
13425
+ * `start`.
13426
+ */
13427
+ #nearestThumb(value, pair) {
13428
+ const startDistance = Math.abs(value - pair.start);
13429
+ const endDistance = Math.abs(value - pair.end);
13430
+ if (endDistance < startDistance) return "end";
13431
+ if (pair.start === pair.end && value > pair.start) return "end";
13432
+ return "start";
12795
13433
  }
12796
13434
  /** Moves one thumb to a new raw value, keeping the pair ordered. */
12797
- #moveThumb(isStart, raw) {
12798
- if (isStart) {
12799
- this.#commit(raw, this.endValue, true);
13435
+ #moveThumb(kind, raw) {
13436
+ const pair = this.#currentPair(this.#effectiveRange);
13437
+ if (kind === "start") {
13438
+ this.#commit(raw, pair.end, "start", true);
12800
13439
  } else {
12801
- this.#commit(this.startValue, raw, true);
13440
+ this.#commit(pair.start, raw, "end", true);
12802
13441
  }
12803
13442
  }
12804
13443
  /**
12805
13444
  * Clamps and snaps `start`/`end`, enforces `start ≤ end`, stores the pair, and
12806
13445
  * reflects it onto the thumbs' ARIA attributes and the range custom
12807
- * properties. Dispatches `change` only on user-driven updates (`notify`).
12808
- */
12809
- #commit(start, end, notify) {
12810
- const prevStart = this.startValue;
12811
- const prevEnd = this.endValue;
12812
- let nextStart = this.#snap(start);
12813
- let nextEnd = this.#snap(end);
13446
+ * properties. Dispatches `stimeo--range-slider:change` only when a
13447
+ * user-driven update changes the normalized pair, with
13448
+ * `{ start: number, end: number }` in `detail`.
13449
+ */
13450
+ #commit(start, end, moving, notify) {
13451
+ const range = this.#effectiveRange;
13452
+ const previous = this.#currentPair(range);
13453
+ let nextStart = snapSteppedValue(start, range);
13454
+ let nextEnd = snapSteppedValue(end, range);
12814
13455
  if (nextStart > nextEnd) {
12815
- if (isUserMovingStart(start, prevStart, end, prevEnd)) nextStart = nextEnd;
12816
- else nextEnd = nextStart;
12817
- }
12818
- this.startValue = nextStart;
12819
- this.endValue = nextEnd;
12820
- this.#render(nextStart, nextEnd);
12821
- if (notify && (nextStart !== prevStart || nextEnd !== prevEnd)) {
13456
+ if (moving === "start") nextStart = nextEnd;
13457
+ else if (moving === "end") nextEnd = nextStart;
13458
+ else [nextStart, nextEnd] = [nextEnd, nextStart];
13459
+ }
13460
+ if (!Object.is(this.startValue, nextStart)) this.startValue = nextStart;
13461
+ if (!Object.is(this.endValue, nextEnd)) this.endValue = nextEnd;
13462
+ const pair = { start: nextStart, end: nextEnd };
13463
+ this.#renderPair(pair, range);
13464
+ if (notify && (nextStart !== previous.start || nextEnd !== previous.end)) {
12822
13465
  this.dispatch("change", { detail: { start: nextStart, end: nextEnd } });
12823
13466
  }
12824
13467
  }
12825
- /** Reflects the current pair onto thumb ARIA attributes and CSS properties. */
12826
- #render(start, end) {
13468
+ /**
13469
+ * Reflects morph-supplied Values without writing them back or dispatching.
13470
+ *
13471
+ * @stimeoRenderRoot
13472
+ */
13473
+ #render() {
13474
+ const range = this.#effectiveRange;
13475
+ this.#renderPair(this.#currentPair(range), range);
13476
+ }
13477
+ /** Reflects one normalized pair onto both thumbs and CSS properties. */
13478
+ #renderPair(pair, range) {
12827
13479
  if (this.hasStartThumbTarget) {
12828
- this.startThumbTarget.setAttribute("aria-valuemin", String(this.minValue));
12829
- this.startThumbTarget.setAttribute("aria-valuemax", String(end));
12830
- this.startThumbTarget.setAttribute("aria-valuenow", String(start));
13480
+ this.#renderStartThumb(this.startThumbTarget, pair, range);
12831
13481
  }
12832
13482
  if (this.hasEndThumbTarget) {
12833
- this.endThumbTarget.setAttribute("aria-valuemin", String(start));
12834
- this.endThumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
12835
- this.endThumbTarget.setAttribute("aria-valuenow", String(end));
12836
- }
12837
- this.element.style.setProperty(
12838
- START_PROPERTY,
12839
- String(rangeFraction(start, this.minValue, this.maxValue))
12840
- );
12841
- this.element.style.setProperty(
12842
- END_PROPERTY,
12843
- String(rangeFraction(end, this.minValue, this.maxValue))
12844
- );
12845
- }
12846
- /** Clamps `raw` to `[min, max]` and snaps it to the nearest step from `min`. */
12847
- #snap(raw) {
12848
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
12849
- if (this.stepValue <= 0) return clamped;
12850
- const stepped = Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue;
12851
- return Math.min(this.maxValue, Math.max(this.minValue, stepped));
13483
+ this.#renderEndThumb(this.endThumbTarget, pair, range);
13484
+ }
13485
+ this.#renderFractions(pair, range);
13486
+ }
13487
+ /** Writes only changed ARIA for the lower thumb. */
13488
+ #renderStartThumb(thumb, pair, range) {
13489
+ this.#setAria(thumb, "aria-valuemin", range.min);
13490
+ this.#setAria(thumb, "aria-valuemax", pair.end);
13491
+ this.#setAria(thumb, "aria-valuenow", pair.start);
13492
+ }
13493
+ /** Writes only changed ARIA for the upper thumb. */
13494
+ #renderEndThumb(thumb, pair, range) {
13495
+ this.#setAria(thumb, "aria-valuemin", pair.start);
13496
+ this.#setAria(thumb, "aria-valuemax", range.max);
13497
+ this.#setAria(thumb, "aria-valuenow", pair.end);
13498
+ }
13499
+ /** Writes one numeric ARIA attribute only when its serialized value changed. */
13500
+ #setAria(thumb, name, value) {
13501
+ const next = String(value);
13502
+ if (thumb.getAttribute(name) !== next) thumb.setAttribute(name, next);
13503
+ }
13504
+ /** Writes the two behavior-only fraction hooks only when they changed. */
13505
+ #renderFractions(pair, range) {
13506
+ const start = String(rangeFraction(pair.start, range.min, range.max));
13507
+ const end = String(rangeFraction(pair.end, range.min, range.max));
13508
+ if (this.element.style.getPropertyValue(START_PROPERTY) !== start) {
13509
+ this.element.style.setProperty(START_PROPERTY, start);
13510
+ }
13511
+ if (this.element.style.getPropertyValue(END_PROPERTY) !== end) {
13512
+ this.element.style.setProperty(END_PROPERTY, end);
13513
+ }
13514
+ }
13515
+ /** Current normalized and ordered pair derived from live declarative Values. */
13516
+ #currentPair(range) {
13517
+ const rawStart = Number.isFinite(this.startValue) ? this.startValue : range.min;
13518
+ const rawEnd = Number.isFinite(this.endValue) ? this.endValue : range.max;
13519
+ const start = snapSteppedValue(rawStart, range);
13520
+ const end = snapSteppedValue(rawEnd, range);
13521
+ if (start <= end) return { start, end };
13522
+ return { start: end, end: start };
13523
+ }
13524
+ /**
13525
+ * Finite ordered range used by ARIA, keyboard, pointer, and CSS reflection.
13526
+ * Invalid endpoints fall back to the public defaults; an authored `max`
13527
+ * below `min` collapses to the finite minimum.
13528
+ */
13529
+ get #effectiveRange() {
13530
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN;
13531
+ const authoredMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX;
13532
+ return { min, max: Math.max(min, authoredMax), step: this.stepValue, base: min };
13533
+ }
13534
+ /** Ends the current pointer session without dispatching another change. */
13535
+ #endDrag() {
13536
+ const drag = this.#drag;
13537
+ this.#drag = null;
13538
+ drag?.pointer?.end();
12852
13539
  }
12853
13540
  };
12854
- function isUserMovingStart(start, prevStart, end, prevEnd) {
12855
- return start !== prevStart && end === prevEnd;
12856
- }
12857
13541
  var RatingController = class extends Controller {
12858
13542
  static targets = ["symbol", "field"];
12859
13543
  static values = {
@@ -12949,6 +13633,8 @@ var RatingController = class extends Controller {
12949
13633
  * Applies `value` (already clamped) everywhere, then dispatches `change`.
12950
13634
  * Use for user-driven changes; on connect call `#apply` directly so
12951
13635
  * initialization mirrors state without emitting an event.
13636
+ *
13637
+ * @stimeoRenderRoot
12952
13638
  */
12953
13639
  #render(value, { focus }) {
12954
13640
  this.#apply(value, { focus });
@@ -13264,6 +13950,8 @@ var RelativeTimeController = class extends Controller {
13264
13950
  * polling can stop: a *past* timestamp that fell back to the absolute text can
13265
13951
  * never leave it again, and a locale the runtime rejects has nothing to render
13266
13952
  * until that value is corrected.
13953
+ *
13954
+ * @stimeoRenderRoot
13267
13955
  */
13268
13956
  #applyAndComputeDelay() {
13269
13957
  const deltaMs = this.#targetMs - Date.now();
@@ -13678,7 +14366,7 @@ var ScrollAreaController = class extends Controller {
13678
14366
  this.#syncKeyboardReach(vp, overflowing);
13679
14367
  const { position, progress } = this.#measurePosition(vp);
13680
14368
  this.element.setAttribute("data-scroll", position);
13681
- this.element.style.setProperty("--stimeo-scroll-progress", String(progress));
14369
+ this.element.style.setProperty("--stimeo--scroll-progress", String(progress));
13682
14370
  const edge = position === "start" ? "start" : position === "end" ? "end" : null;
13683
14371
  if (overflowing && edge && edge !== this.#lastEdge) {
13684
14372
  this.#lastEdge = edge;
@@ -14372,19 +15060,13 @@ var SeparatorController = class extends Controller {
14372
15060
  static actions = ["onKeydown"];
14373
15061
  static events = ["change"];
14374
15062
  connect() {
14375
- if (!this.element.hasAttribute("role")) {
14376
- this.element.setAttribute("role", "separator");
14377
- }
14378
- if (!this.element.hasAttribute("aria-orientation")) {
14379
- this.element.setAttribute("aria-orientation", this.orientationValue);
14380
- }
15063
+ setDefaultAttribute(this.element, "role", "separator");
15064
+ setDefaultAttribute(this.element, "aria-orientation", this.orientationValue);
14381
15065
  if (this.focusableValue) {
14382
- if (!this.element.hasAttribute("tabindex")) {
14383
- this.element.setAttribute("tabindex", "0");
14384
- }
14385
- this.#setDefault("aria-valuemin", "0");
14386
- this.#setDefault("aria-valuemax", "100");
14387
- this.#setDefault("aria-valuenow", String(this.#clamp(this.#value)));
15066
+ setDefaultAttribute(this.element, "tabindex", "0");
15067
+ setDefaultAttribute(this.element, "aria-valuemin", "0");
15068
+ setDefaultAttribute(this.element, "aria-valuemax", "100");
15069
+ setDefaultAttribute(this.element, "aria-valuenow", String(this.#clamp(this.#value)));
14388
15070
  }
14389
15071
  }
14390
15072
  /** Adjusts the value on arrow / Home / End keys (focusable variant only). */
@@ -14438,11 +15120,6 @@ var SeparatorController = class extends Controller {
14438
15120
  const parsed = Number.parseFloat(this.element.getAttribute(name) ?? "");
14439
15121
  return Number.isNaN(parsed) ? fallback : parsed;
14440
15122
  }
14441
- #setDefault(name, value) {
14442
- if (!this.element.hasAttribute(name)) {
14443
- this.element.setAttribute(name, value);
14444
- }
14445
- }
14446
15123
  };
14447
15124
 
14448
15125
  // src/utils/safe_storage.ts
@@ -14761,14 +15438,16 @@ var SkeletonController = class extends Controller {
14761
15438
  static events = ["ready"];
14762
15439
  #timers = new SafeTimeout();
14763
15440
  #floor = new MinDurationFloor(this.#timers);
15441
+ #gate = new DetachGate();
14764
15442
  connect() {
14765
- if (this.#state !== "ready") {
15443
+ const moved = this.#gate.pending;
15444
+ this.#gate.cancel();
15445
+ if (!moved && this.#state !== "ready") {
14766
15446
  this.#enterLoading();
14767
15447
  }
14768
15448
  }
14769
15449
  disconnect() {
14770
- this.#timers.clearAll();
14771
- this.#floor.cancel();
15450
+ this.#gate.disconnected(this, () => this.#teardown());
14772
15451
  }
14773
15452
  /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
14774
15453
  ready() {
@@ -14797,6 +15476,17 @@ var SkeletonController = class extends Controller {
14797
15476
  this.dispatch("ready", { detail: {} });
14798
15477
  announce(fillTemplate(this.announceReadyTextValue, {}));
14799
15478
  }
15479
+ /**
15480
+ * Drops the held reveal on a real detach. The markup keeps whatever it last
15481
+ * held: an element on its way out of the document has no reader left, and one
15482
+ * whose `data-controller` dropped the identifier no longer resolves its own
15483
+ * targets, so the rollback could only ever be partial.
15484
+ */
15485
+ #teardown() {
15486
+ this.#gate.cancel();
15487
+ this.#timers.clearAll();
15488
+ this.#floor.cancel();
15489
+ }
14800
15490
  /** Current lifecycle phase as reflected on `data-state`. */
14801
15491
  get #state() {
14802
15492
  return this.element.getAttribute("data-state") ?? "loading";
@@ -14814,40 +15504,79 @@ var SliderController = class extends Controller {
14814
15504
  };
14815
15505
  static actions = ["onKeydown", "onPointerDown"];
14816
15506
  static events = ["change"];
14817
- /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
14818
- #dragAbort = null;
15507
+ /** One initiating pointer owns each live drag and its stable target snapshot. */
15508
+ #drag = null;
15509
+ /** Collapses a morph that swaps several render Values into one silent repaint. */
15510
+ #repaint = new MicrotaskCoalescer(() => this.#render());
14819
15511
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
14820
15512
  get #mirrored() {
14821
15513
  return this.logicalTrackValue && isRtl(this.element);
14822
15514
  }
14823
15515
  /** Clamps the initial value and renders the starting position. */
14824
15516
  connect() {
14825
- this.#setValue(this.valueValue, { silent: true });
15517
+ this.#repaint.activate();
15518
+ this.#commit(this.valueValue, false);
14826
15519
  }
14827
15520
  /** Cancels any active pointer drag so document listeners never leak. */
14828
15521
  disconnect() {
14829
- this.#dragAbort?.abort();
14830
- this.#dragAbort = null;
15522
+ this.#repaint.cancel();
15523
+ this.#endDrag();
15524
+ }
15525
+ /** Silently repaints a minimum changed by application code or a Turbo morph. */
15526
+ minValueChanged() {
15527
+ this.#repaint.schedule();
15528
+ }
15529
+ /** Silently repaints a maximum changed by application code or a Turbo morph. */
15530
+ maxValueChanged() {
15531
+ this.#repaint.schedule();
15532
+ }
15533
+ /** Silently repaints a step changed by application code or a Turbo morph. */
15534
+ stepValueChanged() {
15535
+ this.#repaint.schedule();
15536
+ }
15537
+ /** Silently repaints a value changed by application code or a Turbo morph. */
15538
+ valueValueChanged() {
15539
+ this.#repaint.schedule();
15540
+ }
15541
+ /** Hydrates a thumb inserted or replaced at runtime with the current ARIA state. */
15542
+ thumbTargetConnected(thumb) {
15543
+ const value = this.#currentValue();
15544
+ this.#renderThumb(thumb, value);
15545
+ this.#renderFraction(value);
15546
+ if (this.#drag && this.#drag.thumb === null) {
15547
+ this.#drag.thumb = thumb;
15548
+ thumb.focus();
15549
+ }
15550
+ this.#repaint.schedule();
15551
+ }
15552
+ /** Drops the stale focus owner while allowing a live track gesture to continue. */
15553
+ thumbTargetDisconnected(thumb) {
15554
+ if (this.#drag?.thumb === thumb) this.#drag.thumb = null;
15555
+ }
15556
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
15557
+ trackTargetDisconnected(track) {
15558
+ if (this.#drag?.track === track) this.#endDrag();
14831
15559
  }
14832
15560
  /** Handles keyboard stepping per the APG slider model. */
14833
15561
  onKeydown(event) {
14834
15562
  if (isReservedArrowChord(event)) return;
14835
- const big = this.stepValue * 10;
14836
15563
  let next = null;
15564
+ const current = this.#currentValue();
15565
+ const range = this.#steppedRange;
14837
15566
  switch (this.#mirrored ? logicalArrowKey(event.key, this.element) : event.key) {
14838
15567
  case "ArrowRight":
14839
15568
  case "ArrowUp":
14840
- next = this.valueValue + this.stepValue;
15569
+ next = stepSteppedValue(current, 1, range);
14841
15570
  break;
14842
15571
  case "ArrowLeft":
14843
15572
  case "ArrowDown":
14844
- next = this.valueValue - this.stepValue;
15573
+ next = stepSteppedValue(current, -1, range);
14845
15574
  break;
14846
15575
  case "PageUp":
14847
- next = this.valueValue + big;
15576
+ next = stepSteppedValue(current, 10, range);
14848
15577
  break;
14849
15578
  case "PageDown":
14850
- next = this.valueValue - big;
15579
+ next = stepSteppedValue(current, -10, range);
14851
15580
  break;
14852
15581
  case "Home":
14853
15582
  next = this.minValue;
@@ -14859,56 +15588,102 @@ var SliderController = class extends Controller {
14859
15588
  return;
14860
15589
  }
14861
15590
  event.preventDefault();
14862
- this.#setValue(next);
15591
+ this.#commit(next, true);
14863
15592
  }
14864
15593
  /** Begins a pointer drag: sets the value and tracks subsequent movement. */
14865
15594
  onPointerDown(event) {
14866
- if (!this.hasTrackTarget) return;
14867
- event.preventDefault();
15595
+ if (event.button !== 0 || this.#drag || !this.hasTrackTarget || !this.hasThumbTarget) return;
15596
+ const track = this.trackTarget;
15597
+ const thumb = this.thumbTarget;
14868
15598
  const mirrored = this.#mirrored;
14869
- this.#updateFromClientX(event.clientX, mirrored);
14870
- if (this.hasThumbTarget) this.thumbTarget.focus();
14871
- this.#dragAbort?.abort();
14872
- const abort = new AbortController();
14873
- this.#dragAbort = abort;
14874
- const onMove = (move) => this.#updateFromClientX(move.clientX, mirrored);
14875
- const onUp = () => {
14876
- abort.abort();
14877
- this.#dragAbort = null;
14878
- };
14879
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
14880
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
14881
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
15599
+ const value = this.#valueFromClientX(event.clientX, mirrored, track);
15600
+ if (value === null) return;
15601
+ event.preventDefault();
15602
+ this.#commit(value, true);
15603
+ thumb.focus();
15604
+ const drag = { pointer: null, track, thumb };
15605
+ drag.pointer = new OwnedPointerSession(event, track, {
15606
+ move: (move) => {
15607
+ if (!track.isConnected) {
15608
+ this.#endDrag();
15609
+ return;
15610
+ }
15611
+ const moved = this.#valueFromClientX(move.clientX, mirrored, track);
15612
+ if (moved !== null) this.#commit(moved, true);
15613
+ },
15614
+ end: () => {
15615
+ if (this.#drag === drag) this.#drag = null;
15616
+ }
15617
+ });
15618
+ this.#drag = drag;
14882
15619
  }
14883
- /** Maps a pointer X coordinate to a value using the track's geometry. */
14884
- #updateFromClientX(clientX, mirrored) {
14885
- const rect = this.trackTarget.getBoundingClientRect();
14886
- if (rect.width === 0) return;
15620
+ /** Maps a pointer X coordinate to a raw value using a stable track snapshot. */
15621
+ #valueFromClientX(clientX, mirrored, track) {
15622
+ const rect = track.getBoundingClientRect();
15623
+ if (rect.width === 0) return null;
14887
15624
  const offset = (clientX - rect.left) / rect.width;
14888
15625
  const fraction = mirrored ? 1 - offset : offset;
14889
- this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
15626
+ return this.minValue + fraction * (this.maxValue - this.minValue);
14890
15627
  }
14891
15628
  /**
14892
- * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and
14893
- * reflects the new state on the thumb's ARIA attributes and the fraction
14894
- * custom property. Dispatches `change` (detail `{ value }`) on a real value
14895
- * change — symmetric with `range-slider` — unless `silent` (the initial
14896
- * connect render, which is not a user edit).
15629
+ * Stores a normalized value and renders synchronously for responsive input.
15630
+ * Morph callbacks use {@link #render} instead, so they never write Values back
15631
+ * or dispatch a user-facing change event.
14897
15632
  */
14898
- #setValue(raw, { silent = false } = {}) {
14899
- const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
14900
- const stepped = this.stepValue > 0 ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue : clamped;
14901
- const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));
14902
- const changed = value !== this.valueValue;
14903
- this.valueValue = value;
15633
+ #commit(raw, notify) {
15634
+ const previous = this.#currentValue();
15635
+ const value = snapSteppedValue(raw, this.#steppedRange);
15636
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
15637
+ this.#renderValue(value);
15638
+ if (notify && value !== previous) this.dispatch("change", { detail: { value } });
15639
+ }
15640
+ /**
15641
+ * Reflects the normalized current Value without mutating or dispatching it.
15642
+ *
15643
+ * @stimeoRenderRoot
15644
+ */
15645
+ #render() {
15646
+ this.#renderValue(this.#currentValue());
15647
+ }
15648
+ /** Reflects one normalized value on the current target and CSS output. */
15649
+ #renderValue(value) {
14904
15650
  if (this.hasThumbTarget) {
14905
- this.thumbTarget.setAttribute("aria-valuemin", String(this.minValue));
14906
- this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
14907
- this.thumbTarget.setAttribute("aria-valuenow", String(value));
15651
+ this.#renderThumb(this.thumbTarget, value);
15652
+ }
15653
+ this.#renderFraction(value);
15654
+ }
15655
+ /** Writes only ARIA values that differ, including on a replacement target. */
15656
+ #renderThumb(thumb, value) {
15657
+ const attributes = {
15658
+ "aria-valuemin": String(this.minValue),
15659
+ "aria-valuemax": String(this.maxValue),
15660
+ "aria-valuenow": String(value)
15661
+ };
15662
+ for (const [name, next] of Object.entries(attributes)) {
15663
+ if (thumb.getAttribute(name) !== next) thumb.setAttribute(name, next);
14908
15664
  }
15665
+ }
15666
+ /** Writes the behavior-only positioning hook only when its value changed. */
15667
+ #renderFraction(value) {
14909
15668
  const fraction = rangeFraction(value, this.minValue, this.maxValue);
14910
- this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
14911
- if (changed && !silent) this.dispatch("change", { detail: { value } });
15669
+ const next = String(fraction);
15670
+ if (this.element.style.getPropertyValue(FRACTION_PROPERTY) !== next) {
15671
+ this.element.style.setProperty(FRACTION_PROPERTY, next);
15672
+ }
15673
+ }
15674
+ /** Current normalized value derived from the live declarative inputs. */
15675
+ #currentValue() {
15676
+ return snapSteppedValue(this.valueValue, this.#steppedRange);
15677
+ }
15678
+ /** Shared range configuration; finite endpoints remain allowed off the grid. */
15679
+ get #steppedRange() {
15680
+ return { min: this.minValue, max: this.maxValue, step: this.stepValue };
15681
+ }
15682
+ /** Ends the current pointer session without dispatching another change. */
15683
+ #endDrag() {
15684
+ const drag = this.#drag;
15685
+ this.#drag = null;
15686
+ drag?.pointer?.end();
14912
15687
  }
14913
15688
  };
14914
15689
  var SmartStickyHeaderController = class extends Controller {
@@ -15191,9 +15966,7 @@ var SpinnerController = class extends Controller {
15191
15966
  this.element.setAttribute("data-state", "idle");
15192
15967
  return;
15193
15968
  }
15194
- if (!this.element.hasAttribute("data-state")) {
15195
- this.element.setAttribute("data-state", "idle");
15196
- }
15969
+ setDefaultAttribute(this.element, "data-state", "idle");
15197
15970
  }
15198
15971
  /**
15199
15972
  * Re-applies the current phase to an indicator that arrived after `connect()`.
@@ -15391,6 +16164,8 @@ var StepIndicatorController = class extends Controller {
15391
16164
  * A pure function of the step set and `current`, so running it again writes the
15392
16165
  * same values — which is what lets the action path paint synchronously (the event
15393
16166
  * goes out after the DOM is updated) while a coalesced pass may still follow.
16167
+ *
16168
+ * @stimeoRenderRoot
15394
16169
  */
15395
16170
  #render() {
15396
16171
  const total = this.stepTargets.length;
@@ -15404,7 +16179,7 @@ var StepIndicatorController = class extends Controller {
15404
16179
  }
15405
16180
  });
15406
16181
  const ratio = total > 1 ? current / (total - 1) : 0;
15407
- this.element.style.setProperty("--stimeo-step-indicator-ratio", String(ratio));
16182
+ this.element.style.setProperty("--stimeo--step-indicator-ratio", String(ratio));
15408
16183
  }
15409
16184
  /**
15410
16185
  * Constrains an index to `[0, total-1]` (or `0` when there are no steps). A
@@ -15485,6 +16260,8 @@ var StepperController = class extends Controller {
15485
16260
  * `aria-current="step"` is placed on the step's **first** `<button>`; the markup
15486
16261
  * contract assumes one operable button per step. If a step needs multiple
15487
16262
  * buttons, mark the navigational one first (or this would target the wrong one).
16263
+ *
16264
+ * @stimeoRenderRoot
15488
16265
  */
15489
16266
  #render(current) {
15490
16267
  this.stepTargets.forEach((step, index) => {
@@ -15889,39 +16666,163 @@ var SubmitOnceController = class extends Controller {
15889
16666
  }
15890
16667
  }
15891
16668
  };
16669
+
16670
+ // src/utils/interactive_host.ts
16671
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
16672
+ function isInteractiveHost(element) {
16673
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
16674
+ let current = element;
16675
+ while (current) {
16676
+ const raw = current.getAttribute("contenteditable");
16677
+ if (raw !== null) {
16678
+ const value = raw.trim().toLowerCase();
16679
+ if (value === "false") return false;
16680
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
16681
+ }
16682
+ current = current.parentElement;
16683
+ }
16684
+ return false;
16685
+ }
16686
+
16687
+ // src/controllers/switch_controller.ts
16688
+ var OBSERVED_ATTRIBUTES = [
16689
+ "role",
16690
+ "aria-checked",
16691
+ "tabindex",
16692
+ "type",
16693
+ "href",
16694
+ "contenteditable",
16695
+ "controls"
16696
+ ];
16697
+ var OBSERVED_ANCESTOR_ATTRIBUTES = ["contenteditable"];
15892
16698
  var SwitchController = class extends Controller {
15893
16699
  static actions = ["onKeydown", "toggle"];
15894
16700
  static events = ["changed"];
16701
+ /** Defaults this instance introduced and may therefore remove safely. */
16702
+ #ownedDefaults = /* @__PURE__ */ new Set();
16703
+ /** Controller writes that must not be mistaken for authored morph changes. */
16704
+ #internalAttributeValues = /* @__PURE__ */ new Map();
16705
+ #attributeObserver = new MutationObserver((records) => {
16706
+ this.#releaseAuthoredDefaults(records);
16707
+ this.#attributeObserver.disconnect();
16708
+ this.#reconcileDefaults();
16709
+ this.#observeAttributes();
16710
+ });
16711
+ /** Blocks disabled pointer/native-key activation before consumer click handlers. */
16712
+ #onClickCapture = (event) => {
16713
+ if (!this.#isSupportedHost || !this.#isActivationDisabled) return;
16714
+ event.preventDefault();
16715
+ event.stopImmediatePropagation();
16716
+ };
15895
16717
  /** Ensures the switch exposes a role and is keyboard-reachable. */
15896
16718
  connect() {
15897
- if (!this.element.hasAttribute("role")) {
15898
- this.element.setAttribute("role", "switch");
15899
- }
15900
- if (!this.element.hasAttribute("aria-checked")) {
15901
- this.element.setAttribute("aria-checked", "false");
15902
- }
15903
- if (!(this.element instanceof HTMLButtonElement) && !this.element.hasAttribute("tabindex")) {
15904
- this.element.setAttribute("tabindex", "0");
15905
- }
16719
+ this.#reconcileDefaults();
16720
+ this.element.addEventListener("click", this.#onClickCapture, true);
16721
+ this.#observeAttributes();
16722
+ }
16723
+ /** Releases the explicit click guard and retained-element attribute observer. */
16724
+ disconnect() {
16725
+ this.element.removeEventListener("click", this.#onClickCapture, true);
16726
+ this.#attributeObserver.disconnect();
16727
+ this.#internalAttributeValues.clear();
15906
16728
  }
15907
16729
  /** Toggles the checked state. Bound via `data-action` (click). */
15908
16730
  toggle() {
16731
+ if (!this.#isSupportedHost || this.#isActivationDisabled) return;
15909
16732
  this.#checked = !this.#checked;
15910
16733
  }
15911
16734
  /**
15912
- * Activates the switch on Space/Enter for non-native hosts and prevents the
15913
- * default Space scroll. Bound via `data-action` (keydown). Native `<button>`
15914
- * hosts are skipped because the browser already turns Space/Enter into a click,
15915
- * which would otherwise toggle the switch twice.
16735
+ * Activates the switch on Space/Enter for non-native hosts and suppresses key
16736
+ * repeat. Bound via `data-action` (keydown). A native `<button type="button">`
16737
+ * owns the initial key-to-click synthesis; repeated keydowns are canceled before
16738
+ * the browser can synthesize additional clicks.
15916
16739
  */
15917
16740
  onKeydown(event) {
15918
16741
  if (event.defaultPrevented) return;
15919
- if (this.element instanceof HTMLButtonElement) return;
15920
- if (event.repeat) return;
15921
- if (event.key === " " || event.key === "Enter") {
16742
+ if (!this.#isSupportedHost) return;
16743
+ if (event.key !== " " && event.key !== "Enter") return;
16744
+ if (this.#isActivationDisabled) {
15922
16745
  event.preventDefault();
15923
- this.toggle();
16746
+ event.stopImmediatePropagation();
16747
+ return;
16748
+ }
16749
+ if (this.element instanceof HTMLButtonElement) {
16750
+ if (event.repeat) event.preventDefault();
16751
+ return;
16752
+ }
16753
+ event.preventDefault();
16754
+ if (event.repeat) return;
16755
+ this.toggle();
16756
+ }
16757
+ /** Re-adds only missing defaults, preserving every authored attribute value. */
16758
+ #reconcileDefaults() {
16759
+ if (!this.#isSupportedHost) {
16760
+ this.#removeOwnedDefaults();
16761
+ return;
16762
+ }
16763
+ this.#setOwnedDefault("role", "switch");
16764
+ this.#setOwnedDefault("aria-checked", "false");
16765
+ if (!(this.element instanceof HTMLButtonElement)) {
16766
+ this.#setOwnedDefault("tabindex", "0");
16767
+ }
16768
+ }
16769
+ /** Records a newly introduced default without claiming authored markup. */
16770
+ #setOwnedDefault(name, value) {
16771
+ if (setDefaultAttribute(this.element, name, value)) this.#ownedDefaults.add(name);
16772
+ }
16773
+ /**
16774
+ * Gives ownership back when a retained-element morph authors a value. Missing
16775
+ * owned attributes stay owned so reconciliation can restore them.
16776
+ */
16777
+ #releaseAuthoredDefaults(records) {
16778
+ for (const record of records) {
16779
+ if (record.target === this.element) {
16780
+ const name = record.attributeName;
16781
+ if (name && this.#ownedDefaults.has(name)) {
16782
+ const value = this.element.getAttribute(name);
16783
+ if (value !== null && this.#internalAttributeValues.get(name) !== value) {
16784
+ this.#ownedDefaults.delete(name);
16785
+ }
16786
+ }
16787
+ }
16788
+ }
16789
+ this.#internalAttributeValues.clear();
16790
+ }
16791
+ /** Removes only defaults introduced by this instance when a host becomes invalid. */
16792
+ #removeOwnedDefaults() {
16793
+ for (const name of this.#ownedDefaults) this.element.removeAttribute(name);
16794
+ this.#ownedDefaults.clear();
16795
+ this.#internalAttributeValues.clear();
16796
+ }
16797
+ /** Watches retained host attributes that Turbo can morph without reconnecting. */
16798
+ #observeAttributes() {
16799
+ this.#attributeObserver.observe(this.element, {
16800
+ attributes: true,
16801
+ attributeFilter: OBSERVED_ATTRIBUTES
16802
+ });
16803
+ let ancestor = this.element.parentElement;
16804
+ while (ancestor) {
16805
+ this.#attributeObserver.observe(ancestor, {
16806
+ attributes: true,
16807
+ attributeFilter: OBSERVED_ANCESTOR_ATTRIBUTES
16808
+ });
16809
+ ancestor = ancestor.parentElement;
16810
+ }
16811
+ }
16812
+ /** Whether the host has one activation model that this controller can own. */
16813
+ get #isSupportedHost() {
16814
+ if (this.element instanceof HTMLButtonElement) return this.element.type === "button";
16815
+ return !isInteractiveHost(this.element);
16816
+ }
16817
+ /** Whether ARIA or native HTML semantics make the supported switch inoperable. */
16818
+ get #isActivationDisabled() {
16819
+ let current = this.element;
16820
+ while (current) {
16821
+ if (current.getAttribute("aria-disabled") === "true") return true;
16822
+ current = current.parentElement;
15924
16823
  }
16824
+ if (!(this.element instanceof HTMLButtonElement)) return false;
16825
+ return this.element.disabled || inheritsFieldsetDisabled(this.element);
15925
16826
  }
15926
16827
  /** Whether the switch is currently on. */
15927
16828
  get #checked() {
@@ -15929,7 +16830,9 @@ var SwitchController = class extends Controller {
15929
16830
  }
15930
16831
  /** Reflects the new state on `aria-checked` and notifies listeners. */
15931
16832
  set #checked(value) {
15932
- this.element.setAttribute("aria-checked", value ? "true" : "false");
16833
+ const reflected = value ? "true" : "false";
16834
+ this.#internalAttributeValues.set("aria-checked", reflected);
16835
+ this.element.setAttribute("aria-checked", reflected);
15933
16836
  this.dispatch("changed", { detail: { checked: value } });
15934
16837
  }
15935
16838
  };
@@ -16240,7 +17143,7 @@ var TextareaAutosizeController = class extends Controller {
16240
17143
  } else {
16241
17144
  el.removeAttribute("data-at-max-rows");
16242
17145
  }
16243
- el.style.setProperty("--stimeo-textarea-rows", String(rows));
17146
+ el.style.setProperty("--stimeo--textarea-rows", String(rows));
16244
17147
  if (height !== this.#lastHeight) {
16245
17148
  this.#lastHeight = height;
16246
17149
  this.dispatch("resize", { detail: { height, rows } });
@@ -16497,7 +17400,6 @@ var TimePickerController = class extends Controller {
16497
17400
  }
16498
17401
  /** Propagates a wrap carry from `kind` into the next larger segment. */
16499
17402
  #carry(kind, amount) {
16500
- if (amount === 0) return;
16501
17403
  if (kind === "second") this.#step("minute", amount);
16502
17404
  else if (kind === "minute") this.#step("hour", amount);
16503
17405
  }
@@ -16559,15 +17461,19 @@ var TimePickerController = class extends Controller {
16559
17461
  segment.setAttribute("aria-valuemax", String(max));
16560
17462
  segment.textContent = text;
16561
17463
  }
16562
- /** Composes `HH:MM[:SS]` (24-hour) into the hidden field; dispatches `change`. */
17464
+ /** Composes `HH:MM[:SS]` and notifies both native-form and widget consumers. */
16563
17465
  #syncField(notify) {
16564
17466
  const h24 = this.#hours24();
16565
17467
  const parts = [pad(h24), pad(this.#state.minute)];
16566
17468
  if (this.secondsValue) parts.push(pad(this.#state.second));
16567
17469
  const value = parts.join(":");
16568
- if (this.hasFieldTarget && this.fieldTarget.value !== value) {
17470
+ const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;
17471
+ if (fieldChanged) {
16569
17472
  this.fieldTarget.value = value;
16570
17473
  }
17474
+ if (notify && fieldChanged) {
17475
+ this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
17476
+ }
16571
17477
  if (notify && value !== this.#lastValue) this.dispatch("change", { detail: { value } });
16572
17478
  this.#lastValue = value;
16573
17479
  }
@@ -17461,7 +18367,7 @@ var TransitionController = class extends Controller {
17461
18367
  else window.clearTimeout(id);
17462
18368
  }
17463
18369
  };
17464
- var NESTED_INTERACTIVE = 'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"])';
18370
+ var NESTED_INTERACTIVE = INTERACTIVE_HOST_SELECTOR;
17465
18371
  var TreeViewController = class extends Controller {
17466
18372
  static targets = ["item", "group"];
17467
18373
  static actions = ["onClick", "onKeydown", "toggle"];
@@ -17722,6 +18628,7 @@ var TreeViewController = class extends Controller {
17722
18628
  if (target.closest('[role="treeitem"]') !== item) return null;
17723
18629
  const control = target.closest(NESTED_INTERACTIVE);
17724
18630
  if (control && item.contains(control)) return null;
18631
+ if (isInteractiveHost(target)) return null;
17725
18632
  return item;
17726
18633
  }
17727
18634
  /**