stimeo-ui 0.8.0 → 0.9.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.
data/dist/index.js CHANGED
@@ -1664,72 +1664,156 @@ var BreadcrumbController = class extends Controller {
1664
1664
  return active instanceof HTMLElement && element.contains(active);
1665
1665
  }
1666
1666
  };
1667
+
1668
+ // src/utils/announce.ts
1669
+ function announce(message, options = {}) {
1670
+ const text = message.trim();
1671
+ if (text.length === 0) return;
1672
+ window.dispatchEvent(
1673
+ new CustomEvent("stimeo--announcer:announce", {
1674
+ detail: { message: text, assertive: options.assertive === true }
1675
+ })
1676
+ );
1677
+ }
1678
+ function fillTemplate(template, values) {
1679
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
1680
+ const replacement = values[name];
1681
+ return replacement === void 0 ? match : String(replacement);
1682
+ });
1683
+ }
1684
+
1685
+ // src/controllers/bulk_select_controller.ts
1667
1686
  var BulkSelectController = class extends Controller {
1668
1687
  static targets = ["all", "item", "bar", "count", "selectAllPages"];
1669
1688
  static values = {
1670
1689
  totalCount: { type: Number, default: 0 },
1671
- announce: { type: Boolean, default: true }
1690
+ announceText: { type: String, default: "" }
1672
1691
  };
1673
1692
  static actions = ["clear", "selectAllPages"];
1674
- static events = ["change"];
1675
- /** All-pages mode is a transient UI state; mirrored to `data-all-pages` so it
1676
- * survives a Turbo swap and `connect()` can rehydrate it. */
1693
+ static events = ["change", "reconcile"];
1694
+ /** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
1695
+ * `connect()` over markup that already carries the attribute rehydrates the
1696
+ * mode — a morph, a Turbo Stream, or a server that renders it back. A restore
1697
+ * visit serves the server's markup instead, so the mode does not survive one. */
1677
1698
  #allPagesMode = false;
1678
- /** Last emitted figures, so a recompute dispatches `change` only on real change. */
1699
+ /** Last emitted figures, so a recompute reports only on a real change. */
1679
1700
  #lastCount = -1;
1680
1701
  #lastAllPages = false;
1702
+ /** Collapses every signal from one DOM or Value update into one repair. */
1703
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
1681
1704
  /** Delegated `change` handler covering the select-all box and every row. */
1682
1705
  #onChange = (event) => {
1683
1706
  const target = event.target;
1684
1707
  if (!target) return;
1685
1708
  if (this.hasAllTarget && target === this.allTarget) {
1686
1709
  this.#applyAll();
1687
- } else if (target.matches('[data-stimeo--bulk-select-target="item"]')) {
1710
+ } else if (this.itemTargets.some((item) => item === target)) {
1688
1711
  this.#exitAllPages();
1689
- this.#recompute(true);
1712
+ this.#reportChange(this.#recompute());
1690
1713
  }
1691
1714
  };
1692
1715
  connect() {
1693
1716
  this.#allPagesMode = this.element.dataset.allPages === "true";
1717
+ this.#reconcile.activate();
1694
1718
  this.element.addEventListener("change", this.#onChange);
1695
- this.#recompute(false);
1719
+ this.#recompute();
1696
1720
  }
1697
1721
  disconnect() {
1698
1722
  this.element.removeEventListener("change", this.#onChange);
1723
+ this.#reconcile.cancel();
1724
+ }
1725
+ /** Repairs the figures for a row that arrived at runtime. */
1726
+ itemTargetConnected() {
1727
+ this.#reconcile.schedule();
1728
+ }
1729
+ /** Repairs the figures after a row leaves, so a removed selection stops counting. */
1730
+ itemTargetDisconnected() {
1731
+ this.#reconcile.schedule();
1732
+ }
1733
+ /** Reflects the current selection onto a select-all box added at runtime. */
1734
+ allTargetConnected() {
1735
+ this.#reconcile.schedule();
1736
+ }
1737
+ /** Repairs the figures after the select-all box leaves. */
1738
+ allTargetDisconnected() {
1739
+ this.#reconcile.schedule();
1740
+ }
1741
+ /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
1742
+ totalCountValueChanged() {
1743
+ if (!Number.isFinite(this.totalCountValue)) {
1744
+ this.totalCountValue = 0;
1745
+ return;
1746
+ }
1747
+ this.#reconcile.schedule();
1748
+ }
1749
+ /** Repaints so wording changed at runtime is used by the next announcement. */
1750
+ announceTextValueChanged() {
1751
+ this.#reconcile.schedule();
1699
1752
  }
1700
1753
  /** Clears every selection (rows + select-all) and exits all-pages mode. */
1701
1754
  clear() {
1702
- for (const item of this.#items) item.checked = false;
1755
+ for (const item of this.itemTargets) item.checked = false;
1703
1756
  if (this.hasAllTarget) {
1704
1757
  this.allTarget.checked = false;
1705
1758
  this.allTarget.indeterminate = false;
1706
1759
  }
1707
1760
  this.#exitAllPages();
1708
- this.#recompute(true);
1761
+ this.#reportChange(this.#recompute());
1709
1762
  }
1710
- /** Enters "select all across pages" mode (count shows `totalCount`). */
1763
+ /**
1764
+ * Enters "select all across pages" mode: the count shows `totalCount`, and every
1765
+ * row on this page is checked.
1766
+ *
1767
+ * The mode's claim is that the whole set is selected, so leaving a visible row
1768
+ * unchecked would put the page and the count in open disagreement.
1769
+ */
1711
1770
  selectAllPages() {
1712
1771
  this.#allPagesMode = true;
1713
- this.#recompute(true);
1772
+ this.#checkEveryRow();
1773
+ this.#reportChange(this.#recompute());
1774
+ }
1775
+ /** Marks every row on this page selected. */
1776
+ #checkEveryRow() {
1777
+ for (const item of this.itemTargets) item.checked = true;
1714
1778
  }
1715
1779
  /** Mirrors the select-all box to every row, then recomputes. */
1716
1780
  #applyAll() {
1717
1781
  if (!this.hasAllTarget) return;
1718
1782
  const { checked } = this.allTarget;
1719
- for (const item of this.#items) item.checked = checked;
1783
+ for (const item of this.itemTargets) item.checked = checked;
1720
1784
  this.#exitAllPages();
1721
- this.#recompute(true);
1785
+ this.#reportChange(this.#recompute());
1722
1786
  }
1723
1787
  #exitAllPages() {
1724
1788
  this.#allPagesMode = false;
1725
1789
  }
1790
+ /** Repairs the derived state after the page moved rows or a render input. */
1791
+ #reconcileNow() {
1792
+ if (this.#allPagesMode) this.#checkEveryRow();
1793
+ const detail = this.#recompute();
1794
+ if (!detail) return;
1795
+ this.dispatch("reconcile", { detail });
1796
+ this.#announce(detail);
1797
+ }
1798
+ /** Reports a selection the user moved. */
1799
+ #reportChange(detail) {
1800
+ if (!detail) return;
1801
+ this.dispatch("change", { detail });
1802
+ this.#announce(detail);
1803
+ }
1804
+ /** Hands the count to the shared announcer, worded by the consumer. */
1805
+ #announce(detail) {
1806
+ announce(fillTemplate(this.announceTextValue, { count: detail.count }));
1807
+ }
1726
1808
  /**
1727
1809
  * Recomputes the count, the select-all checked/indeterminate state, and the bar
1728
- * visibility from the current DOM. Dispatches `change` (when `notify`) only if
1729
- * the emitted count or all-pages flag actually changed.
1810
+ * visibility from the current DOM. Returns the figures when the emitted count or
1811
+ * all-pages flag actually moved, and `null` when they did not.
1812
+ *
1813
+ * @stimeoRenderRoot
1730
1814
  */
1731
- #recompute(notify) {
1732
- const items = this.#items;
1815
+ #recompute() {
1816
+ const items = this.itemTargets;
1733
1817
  const total = items.length;
1734
1818
  const checked = items.filter((item) => item.checked).length;
1735
1819
  const allPages = this.#allPagesMode;
@@ -1740,8 +1824,10 @@ var BulkSelectController = class extends Controller {
1740
1824
  const count = allPages ? this.totalCountValue : checked;
1741
1825
  const show = allPages || checked > 0;
1742
1826
  if (this.hasBarTarget) {
1827
+ if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {
1828
+ this.allTarget.focus();
1829
+ }
1743
1830
  this.barTarget.hidden = !show;
1744
- this.barTarget.setAttribute("aria-live", this.announceValue ? "polite" : "off");
1745
1831
  }
1746
1832
  if (this.hasCountTarget) this.countTarget.textContent = String(count);
1747
1833
  this.element.setAttribute("data-selected-count", String(checked));
@@ -1750,15 +1836,7 @@ var BulkSelectController = class extends Controller {
1750
1836
  const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;
1751
1837
  this.#lastCount = count;
1752
1838
  this.#lastAllPages = allPages;
1753
- if (notify && changed) {
1754
- this.dispatch("change", { detail: { count, allPages } });
1755
- }
1756
- }
1757
- /** Live list of row checkboxes, queried from the DOM so dynamic rows count. */
1758
- get #items() {
1759
- return Array.from(
1760
- this.element.querySelectorAll('[data-stimeo--bulk-select-target="item"]')
1761
- );
1839
+ return changed ? { count, allPages } : null;
1762
1840
  }
1763
1841
  };
1764
1842
 
@@ -2718,25 +2796,6 @@ function hits(elements, node) {
2718
2796
  function setAttributeIfChanged(element, name, value) {
2719
2797
  if (element.getAttribute(name) !== value) element.setAttribute(name, value);
2720
2798
  }
2721
-
2722
- // src/utils/announce.ts
2723
- function announce(message, options = {}) {
2724
- const text = message.trim();
2725
- if (text.length === 0) return;
2726
- window.dispatchEvent(
2727
- new CustomEvent("stimeo--announcer:announce", {
2728
- detail: { message: text, assertive: options.assertive === true }
2729
- })
2730
- );
2731
- }
2732
- function fillTemplate(template, values) {
2733
- return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
2734
- const replacement = values[name];
2735
- return replacement === void 0 ? match : String(replacement);
2736
- });
2737
- }
2738
-
2739
- // src/controllers/character_counter_controller.ts
2740
2799
  var CharacterCounterController = class _CharacterCounterController extends Controller {
2741
2800
  static targets = ["input", "output"];
2742
2801
  static values = {
@@ -3150,45 +3209,59 @@ function setDefaultAttribute(element, name, value) {
3150
3209
  }
3151
3210
 
3152
3211
  // src/controllers/clipboard_controller.ts
3212
+ var TRANSIENT_STATES = /* @__PURE__ */ new Set(["copied", "error"]);
3153
3213
  var ClipboardController = class extends Controller {
3154
3214
  static targets = ["source", "button", "feedback"];
3155
3215
  static values = {
3156
3216
  text: { type: String, default: "" },
3157
3217
  feedbackDuration: { type: Number, default: 2e3 },
3158
3218
  copiedLabel: { type: String, default: "Copied" },
3159
- errorLabel: { type: String, default: "Copy failed" }
3219
+ errorLabel: { type: String, default: "Copy failed" },
3220
+ announceCopiedText: { type: String, default: "" },
3221
+ announceErrorText: { type: String, default: "" }
3160
3222
  };
3161
3223
  static actions = ["copy"];
3162
3224
  static events = ["copy"];
3163
- /** Auto-clear timer for the completion notice; torn down on disconnect. */
3225
+ /**
3226
+ * The pending return to idle — the only timer this controller schedules, so
3227
+ * `clearAll()` is exactly "drop the auto-clear" and needs no id of its own.
3228
+ */
3164
3229
  #timers = new SafeTimeout();
3230
+ /** Returns the completion state to idle for the snapshot Turbo takes. */
3231
+ #beforeCache = new BeforeCacheReset(() => this.#rewind());
3165
3232
  /**
3166
- * The pending auto-clear timer id, or `null` when none is scheduled. Tracked so
3167
- * a rapid second copy cancels the first window instead of letting a stale timer
3168
- * reset the freshly-shown notice early.
3233
+ * Whether this connection is still live. `copy()` suspends on the Clipboard API,
3234
+ * and a teardown that lands while it is suspended must win: the continuation
3235
+ * would otherwise write to an element nobody owns and arm a timer past the
3236
+ * `clearAll()` that was supposed to be the last word.
3169
3237
  */
3170
- #resetTimerId = null;
3238
+ #connected = false;
3171
3239
  connect() {
3172
- setDefaultAttribute(this.element, "data-state", "idle");
3240
+ this.#connected = true;
3241
+ this.#adopt();
3242
+ this.#beforeCache.activate();
3173
3243
  }
3174
3244
  disconnect() {
3245
+ this.#connected = false;
3246
+ this.#beforeCache.deactivate();
3175
3247
  this.#timers.clearAll();
3176
3248
  }
3177
3249
  /**
3178
3250
  * Copies the resolved text and reports the outcome. Bound via `data-action`
3179
- * (click). Always dispatches `stimeo--clipboard:copy` with `{ success, text }`
3180
- * — including on failure — so consumers can react either way.
3251
+ * (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
3252
+ * completed attempt — including on failure — so consumers can react either way.
3253
+ * An attempt whose connection ended while it was in flight reports nothing.
3181
3254
  */
3182
3255
  async copy() {
3183
3256
  const text = this.#resolveText();
3184
3257
  let success = false;
3185
3258
  try {
3186
- if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
3187
3259
  await navigator.clipboard.writeText(text);
3188
3260
  success = true;
3189
3261
  } catch {
3190
3262
  success = false;
3191
3263
  }
3264
+ if (!this.#connected) return;
3192
3265
  this.#reportResult(success);
3193
3266
  this.dispatch("copy", { detail: { success, text } });
3194
3267
  }
@@ -3205,24 +3278,52 @@ var ClipboardController = class extends Controller {
3205
3278
  }
3206
3279
  return source.textContent ?? "";
3207
3280
  }
3208
- /** Reflects the result on `data-state`, announces it, and schedules a reset. */
3281
+ /**
3282
+ * Reads the current state back from the DOM.
3283
+ *
3284
+ * A `copied` or `error` found at connect time is this controller's own output
3285
+ * from a connection that is gone, and so is the timer that would have cleared it
3286
+ * — nothing else would ever return the element to `idle`. Any other authored
3287
+ * value belongs to the consumer and only a missing attribute takes the default.
3288
+ */
3289
+ #adopt() {
3290
+ if (this.#inTransientState()) {
3291
+ this.#reset();
3292
+ return;
3293
+ }
3294
+ setDefaultAttribute(this.element, "data-state", "idle");
3295
+ }
3296
+ /** Whether `data-state` currently holds one of the values this controller writes. */
3297
+ #inTransientState() {
3298
+ const state = this.element.getAttribute("data-state");
3299
+ return state !== null && TRANSIENT_STATES.has(state);
3300
+ }
3301
+ /**
3302
+ * Returns the completion state to idle for the snapshot Turbo is about to take,
3303
+ * so a page reached with the Back button does not report a copy that happened
3304
+ * before the navigation. Only a state this controller wrote is rewound — an
3305
+ * authored one is the consumer's and has to survive into the snapshot, exactly as
3306
+ * `connect()` leaves it alone. State only — no `copy` is dispatched, which would
3307
+ * claim a fresh copy ran.
3308
+ */
3309
+ #rewind() {
3310
+ if (!this.#inTransientState()) return;
3311
+ this.#timers.clearAll();
3312
+ this.#reset();
3313
+ }
3314
+ /** Reflects the result, announces it, and schedules the return to idle. */
3209
3315
  #reportResult(success) {
3210
3316
  this.element.setAttribute("data-state", success ? "copied" : "error");
3211
3317
  if (this.hasFeedbackTarget) {
3212
3318
  this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
3213
3319
  }
3214
- if (this.#resetTimerId !== null) {
3215
- this.#timers.clear(this.#resetTimerId);
3216
- this.#resetTimerId = null;
3217
- }
3320
+ announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
3321
+ this.#timers.clearAll();
3218
3322
  if (this.feedbackDurationValue > 0) {
3219
- this.#resetTimerId = this.#timers.set(() => {
3220
- this.#resetTimerId = null;
3221
- this.#reset();
3222
- }, this.feedbackDurationValue);
3323
+ this.#timers.set(() => this.#reset(), this.feedbackDurationValue);
3223
3324
  }
3224
3325
  }
3225
- /** Returns to the idle state and clears the completion notice. */
3326
+ /** Returns to the idle state and empties the completion slot. */
3226
3327
  #reset() {
3227
3328
  this.element.setAttribute("data-state", "idle");
3228
3329
  if (this.hasFeedbackTarget) {
@@ -3511,8 +3612,62 @@ function toFiniteNumber(raw) {
3511
3612
  return Number.isFinite(value) ? value : null;
3512
3613
  }
3513
3614
 
3615
+ // src/utils/owned_pointer_session.ts
3616
+ var OwnedPointerSession = class {
3617
+ pointerId;
3618
+ #owner;
3619
+ #handlers;
3620
+ #abort = new AbortController();
3621
+ #active = true;
3622
+ constructor(start, owner, handlers) {
3623
+ this.pointerId = start.pointerId;
3624
+ this.#owner = owner;
3625
+ this.#handlers = handlers;
3626
+ const { signal } = this.#abort;
3627
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
3628
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
3629
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
3630
+ owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
3631
+ try {
3632
+ owner.setPointerCapture?.(this.pointerId);
3633
+ } catch {
3634
+ }
3635
+ }
3636
+ /** Whether this session still owns its pointer and listeners. */
3637
+ get active() {
3638
+ return this.#active;
3639
+ }
3640
+ /** Whether `event` belongs to the initiating pointer of the live session. */
3641
+ owns(event) {
3642
+ return this.#active && event.pointerId === this.pointerId;
3643
+ }
3644
+ /** Releases capture/listeners and invokes the end callback exactly once. */
3645
+ end() {
3646
+ if (!this.#active) return;
3647
+ this.#active = false;
3648
+ this.#abort.abort();
3649
+ try {
3650
+ this.#owner.releasePointerCapture?.(this.pointerId);
3651
+ } catch {
3652
+ }
3653
+ this.#handlers.end?.();
3654
+ }
3655
+ #onMove = (event) => {
3656
+ if (this.owns(event)) this.#handlers.move(event);
3657
+ };
3658
+ #onEndEvent = (event) => {
3659
+ if (this.owns(event)) this.end();
3660
+ };
3661
+ #onLostCapture = (event) => {
3662
+ const pointerId = event.pointerId;
3663
+ if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
3664
+ this.end();
3665
+ };
3666
+ };
3667
+
3514
3668
  // src/controllers/color_picker_controller.ts
3515
3669
  var COLOR_PROPERTY = "--stimeo--color";
3670
+ var VALUE_TEXT_ATTRIBUTE = "data-value-text";
3516
3671
  var CHANNEL_RANGE = {
3517
3672
  hue: [0, 360],
3518
3673
  saturation: [0, 100],
@@ -3532,10 +3687,10 @@ var ColorPickerController = class extends Controller {
3532
3687
  get #mirrored() {
3533
3688
  return this.logicalTrackValue && isRtl(this.element);
3534
3689
  }
3535
- /** The current color in the editing model. */
3690
+ /** The current color in the editing model; its alpha is 100 while `alpha` is off. */
3536
3691
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
3537
- /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
3538
- #dragAbort = null;
3692
+ /** The pointer that owns the live drag, with the slider whose geometry maps it. */
3693
+ #drag = null;
3539
3694
  /** Color the last repaint settled on, so a configuration-driven move is reported once. */
3540
3695
  #committedHex = null;
3541
3696
  /**
@@ -3546,25 +3701,48 @@ var ColorPickerController = class extends Controller {
3546
3701
  /** Seeds the model from the initial hex value and renders every surface. */
3547
3702
  connect() {
3548
3703
  this.#repaint.activate();
3549
- const parsed = hexToHsla(this.valueValue);
3550
- if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
3704
+ this.#adoptValue();
3551
3705
  this.#render();
3552
3706
  }
3553
3707
  /** Cancels any active pointer drag so document listeners never leak. */
3554
3708
  disconnect() {
3555
3709
  this.#repaint.cancel();
3556
- this.#dragAbort?.abort();
3557
- this.#dragAbort = null;
3710
+ this.#endDrag();
3558
3711
  }
3559
3712
  /** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
3560
3713
  alphaValueChanged() {
3561
3714
  this.#repaint.schedule();
3562
3715
  }
3716
+ /** Adopts a color application code (or a Turbo morph) put in `value` at runtime. */
3717
+ valueValueChanged() {
3718
+ if (this.valueValue === this.#committedHex) return;
3719
+ this.#repaint.schedule();
3720
+ }
3721
+ /** Hydrates a channel slider inserted or replaced at runtime. */
3722
+ sliderTargetConnected(slider) {
3723
+ this.#renderSlider(slider);
3724
+ }
3725
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
3726
+ sliderTargetDisconnected(slider) {
3727
+ if (this.#drag?.slider === slider) this.#endDrag();
3728
+ }
3729
+ /** Fills a hex input inserted or replaced at runtime with the current color. */
3730
+ hexTargetConnected(hex) {
3731
+ this.#mirrorColor(hex, this.#hexString());
3732
+ }
3733
+ /** Fills a form field inserted or replaced at runtime with the current color. */
3734
+ fieldTargetConnected(field) {
3735
+ this.#mirrorColor(field, this.#hexString());
3736
+ }
3737
+ /** Publishes the current color on a preview inserted or replaced at runtime. */
3738
+ previewTargetConnected(preview) {
3739
+ this.#publishColor(preview, this.#hexString());
3740
+ }
3563
3741
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
3564
3742
  onKeydown(event) {
3565
3743
  if (isReservedArrowChord(event)) return;
3566
3744
  const slider = event.currentTarget;
3567
- const channel = this.#channelOf(slider);
3745
+ const channel = this.#editableChannel(slider);
3568
3746
  if (!channel) return;
3569
3747
  const [min, max] = this.#rangeOf(slider, channel);
3570
3748
  const value = this.#color[channel];
@@ -3596,34 +3774,36 @@ var ColorPickerController = class extends Controller {
3596
3774
  event.preventDefault();
3597
3775
  this.#setChannel(channel, next, min, max);
3598
3776
  }
3599
- /** Begins a pointer drag on a channel slider and tracks movement. */
3777
+ /** Begins a primary-button drag on a channel slider, owned by its own pointer. */
3600
3778
  onPointerDown(event) {
3779
+ if (event.button !== 0 || this.#drag) return;
3601
3780
  const slider = event.currentTarget;
3602
- const channel = this.#channelOf(slider);
3781
+ const channel = this.#editableChannel(slider);
3603
3782
  if (!channel) return;
3604
- event.preventDefault();
3605
- slider.focus();
3606
3783
  const [min, max] = this.#rangeOf(slider, channel);
3607
3784
  const mirrored = this.#mirrored;
3608
3785
  const update = (clientX) => {
3609
3786
  const rect = slider.getBoundingClientRect();
3610
- if (rect.width === 0) return;
3787
+ if (rect.width === 0) return false;
3611
3788
  const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
3612
3789
  const fraction = mirrored ? 1 - offset : offset;
3613
3790
  this.#setChannel(channel, min + fraction * (max - min), min, max);
3791
+ return true;
3614
3792
  };
3615
- update(event.clientX);
3616
- this.#dragAbort?.abort();
3617
- const abort = new AbortController();
3618
- this.#dragAbort = abort;
3619
- const onMove = (move) => update(move.clientX);
3620
- const onUp = () => {
3621
- abort.abort();
3622
- this.#dragAbort = null;
3623
- };
3624
- document.addEventListener("pointermove", onMove, { signal: abort.signal });
3625
- document.addEventListener("pointerup", onUp, { signal: abort.signal });
3626
- document.addEventListener("pointercancel", onUp, { signal: abort.signal });
3793
+ if (!update(event.clientX)) return;
3794
+ event.preventDefault();
3795
+ slider.focus();
3796
+ const drag = { pointer: null, slider };
3797
+ drag.pointer = new OwnedPointerSession(event, slider, {
3798
+ move: (move) => {
3799
+ if (slider.isConnected) update(move.clientX);
3800
+ else this.#endDrag();
3801
+ },
3802
+ end: () => {
3803
+ if (this.#drag === drag) this.#drag = null;
3804
+ }
3805
+ });
3806
+ this.#drag = drag;
3627
3807
  }
3628
3808
  /** Parses the hex input on confirm and syncs every channel + surface. */
3629
3809
  onHexInput() {
@@ -3633,9 +3813,22 @@ var ColorPickerController = class extends Controller {
3633
3813
  this.hexTarget.value = this.#hexString();
3634
3814
  return;
3635
3815
  }
3636
- this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
3816
+ this.#color = this.#opaqueUnlessEnabled(parsed);
3637
3817
  this.#commitColor();
3638
3818
  }
3819
+ /** Replaces the model with the color `value` names, leaving an unparsable one alone. */
3820
+ #adoptValue() {
3821
+ const parsed = hexToHsla(this.valueValue);
3822
+ if (parsed) this.#color = this.#opaqueUnlessEnabled(parsed);
3823
+ }
3824
+ /**
3825
+ * The model a parsed color implies: alpha only survives while its channel is
3826
+ * enabled, because `hexString()` would otherwise emit `#RRGGBB` while `change`
3827
+ * reported `rgba.a < 1`.
3828
+ */
3829
+ #opaqueUnlessEnabled(parsed) {
3830
+ return this.alphaValue ? parsed : { ...parsed, alpha: 100 };
3831
+ }
3639
3832
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
3640
3833
  #setChannel(channel, raw, min, max) {
3641
3834
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
@@ -3654,32 +3847,57 @@ var ColorPickerController = class extends Controller {
3654
3847
  }
3655
3848
  }
3656
3849
  /**
3657
- * Reflects the model onto sliders, the hex input, preview, and form field.
3850
+ * Reflects the model onto sliders, the hex input, preview, form field, and the
3851
+ * `value` Value it serializes into.
3658
3852
  *
3659
3853
  * @stimeoRenderRoot
3660
3854
  */
3661
3855
  #render() {
3662
- for (const slider of this.sliderTargets) {
3663
- const channel = this.#channelOf(slider);
3664
- if (!channel) continue;
3665
- const value = this.#color[channel];
3666
- slider.setAttribute("aria-valuenow", String(value));
3667
- slider.setAttribute("aria-valuetext", valueText(channel, value));
3668
- }
3856
+ for (const slider of this.sliderTargets) this.#renderSlider(slider);
3669
3857
  const hex = this.#hexString();
3670
3858
  this.#committedHex = hex;
3671
- if (this.hasHexTarget) this.hexTarget.value = hex;
3672
- for (const field of this.fieldTargets) field.value = hex;
3673
- for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
3674
- this.element.style.setProperty(COLOR_PROPERTY, hex);
3859
+ if (this.valueValue !== hex) this.valueValue = hex;
3860
+ if (this.hasHexTarget) this.#mirrorColor(this.hexTarget, hex);
3861
+ for (const field of this.fieldTargets) this.#mirrorColor(field, hex);
3862
+ for (const preview of this.previewTargets) this.#publishColor(preview, hex);
3863
+ this.#publishColor(this.element, hex);
3864
+ }
3865
+ /** Writes one slider's announced range, value, and value text, skipping equal ones. */
3866
+ #renderSlider(slider) {
3867
+ const channel = this.#channelOf(slider);
3868
+ if (!channel) return;
3869
+ const [min, max] = this.#rangeOf(slider, channel);
3870
+ const value = this.#color[channel];
3871
+ const attributes = {
3872
+ "aria-valuemin": String(min),
3873
+ "aria-valuemax": String(max),
3874
+ "aria-valuenow": String(value),
3875
+ "aria-valuetext": this.#valueText(slider, channel, value)
3876
+ };
3877
+ for (const [name, next] of Object.entries(attributes)) {
3878
+ if (slider.getAttribute(name) !== next) slider.setAttribute(name, next);
3879
+ }
3880
+ }
3881
+ /** Mirrors the color into an input, leaving an already-equal value untouched. */
3882
+ #mirrorColor(input, hex) {
3883
+ if (input.value !== hex) input.value = hex;
3884
+ }
3885
+ /** Publishes the color as the consumer's CSS hook, skipping an equal value. */
3886
+ #publishColor(element, hex) {
3887
+ if (element.style.getPropertyValue(COLOR_PROPERTY) !== hex) {
3888
+ element.style.setProperty(COLOR_PROPERTY, hex);
3889
+ }
3675
3890
  }
3676
3891
  /**
3677
- * Repaints after `alpha` changed at runtime and reports a color this controller
3678
- * settled on. Disabling alpha drops it from the model, so the committed color can
3679
- * move without a user edit; `change` stays reserved for the picker's own actions.
3892
+ * Repaints after a declarative input changed at runtime and reports a color this
3893
+ * controller settled on. Disabling alpha drops it from the model and an outside
3894
+ * `value` names another color, so the committed color can move without a user
3895
+ * edit; `change` stays reserved for the picker's own actions.
3680
3896
  */
3681
3897
  #reconcileColor() {
3682
3898
  const previous = this.#committedHex;
3899
+ if (previous !== null && this.valueValue !== previous) this.#adoptValue();
3900
+ if (!this.alphaValue) this.#color.alpha = 100;
3683
3901
  this.#render();
3684
3902
  if (previous !== null && this.#committedHex !== previous) {
3685
3903
  this.dispatch("reconcile", { detail: this.#settledDetail() });
@@ -3700,7 +3918,27 @@ var ColorPickerController = class extends Controller {
3700
3918
  /** Reads a slider's `data-channel`, if it is a known channel. */
3701
3919
  #channelOf(slider) {
3702
3920
  const channel = slider.getAttribute("data-channel");
3703
- return channel && channel in CHANNEL_RANGE ? channel : null;
3921
+ return channel && Object.hasOwn(CHANNEL_RANGE, channel) ? channel : null;
3922
+ }
3923
+ /**
3924
+ * The channel a slider edits, or null when this picker edits none through it. An
3925
+ * alpha slider authored while `alpha` is off edits nothing: moving it would leave
3926
+ * the model translucent behind an opaque `#RRGGBB`.
3927
+ */
3928
+ #editableChannel(slider) {
3929
+ const channel = this.#channelOf(slider);
3930
+ return channel === "alpha" && !this.alphaValue ? null : channel;
3931
+ }
3932
+ /** The channel's announced text: the slider's template, or the built-in English. */
3933
+ #valueText(slider, channel, value) {
3934
+ const template = slider.getAttribute(VALUE_TEXT_ATTRIBUTE);
3935
+ return template ? template.replaceAll("{value}", String(value)) : defaultValueText(channel, value);
3936
+ }
3937
+ /** Ends the live drag so no further movement of that pointer reaches the model. */
3938
+ #endDrag() {
3939
+ const drag = this.#drag;
3940
+ this.#drag = null;
3941
+ drag?.pointer?.end();
3704
3942
  }
3705
3943
  /**
3706
3944
  * A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
@@ -3716,7 +3954,7 @@ var ColorPickerController = class extends Controller {
3716
3954
  ];
3717
3955
  }
3718
3956
  };
3719
- function valueText(channel, value) {
3957
+ function defaultValueText(channel, value) {
3720
3958
  const label = channel.charAt(0).toUpperCase() + channel.slice(1);
3721
3959
  const unit = channel === "hue" ? "degrees" : "percent";
3722
3960
  return `${label} ${value} ${unit}`;
@@ -5691,6 +5929,25 @@ function round(value, precision) {
5691
5929
  if (!Number.isFinite(rounded)) return value;
5692
5930
  return rounded === 0 ? 0 : rounded;
5693
5931
  }
5932
+
5933
+ // src/utils/interactive_host.ts
5934
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
5935
+ function isInteractiveHost(element) {
5936
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
5937
+ let current = element;
5938
+ while (current) {
5939
+ const raw = current.getAttribute("contenteditable");
5940
+ if (raw !== null) {
5941
+ const value = raw.trim().toLowerCase();
5942
+ if (value === "false") return false;
5943
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
5944
+ }
5945
+ current = current.parentElement;
5946
+ }
5947
+ return false;
5948
+ }
5949
+
5950
+ // src/controllers/data_grid_controller.ts
5694
5951
  var SORT_CYCLE = ["none", "ascending", "descending"];
5695
5952
  function nextSortDirection(current) {
5696
5953
  const index = SORT_CYCLE.indexOf(current);
@@ -5704,8 +5961,15 @@ var DataGridController = class extends Controller {
5704
5961
  };
5705
5962
  static actions = ["onKeydown", "sort", "toggleSelect"];
5706
5963
  static events = ["selectionchange", "sort"];
5707
- /** Gates the row callback so it does not re-walk every row once per authored row on mount. */
5708
- #connected = false;
5964
+ /**
5965
+ * Collapses the per-element target callbacks of one DOM mutation into a single
5966
+ * baseline pass, and refuses to run before `connect()` or after `disconnect()`.
5967
+ *
5968
+ * Stimulus reports every target one at a time, so an ungated pass would re-walk
5969
+ * the whole grid once per authored cell on mount and once per streamed cell
5970
+ * afterwards — quadratic in the cell count both times.
5971
+ */
5972
+ #reconcile = new MicrotaskCoalescer(() => this.#restoreBaseline());
5709
5973
  /**
5710
5974
  * Establishes a single tab stop across all navigable cells/headers and brings
5711
5975
  * the rows to their baseline.
@@ -5716,15 +5980,30 @@ var DataGridController = class extends Controller {
5716
5980
  * attribute, so the Value callback does not fire a second time.
5717
5981
  */
5718
5982
  connect() {
5983
+ this.#restoreBaseline();
5984
+ this.#reconcile.activate();
5985
+ }
5986
+ /** Closes the reconcile window so a queued pass cannot run against a detached tree. */
5987
+ disconnect() {
5988
+ this.#reconcile.cancel();
5989
+ }
5990
+ /**
5991
+ * Rebuilds both DOM-owned baselines from the live grid: exactly one navigable
5992
+ * cell is in the Tab sequence, and every selectable row carries an explicit
5993
+ * `aria-selected`.
5994
+ *
5995
+ * The tab stop keeps whichever cell already holds it, so a rebuild triggered by
5996
+ * an unrelated row arriving does not throw the user's position away; only when
5997
+ * no cell holds it — the grid is fresh, or the holder was removed — does the
5998
+ * first navigable cell take over. Without that fallback a grid whose active row
5999
+ * is removed keeps every cell at `-1` and drops out of the Tab sequence
6000
+ * entirely.
6001
+ */
6002
+ #restoreBaseline() {
5719
6003
  const cells = this.#navigableCells();
5720
6004
  const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
5721
- this.#setActiveCell(active, { focus: false });
6005
+ if (active) this.#setActiveCell(active, { focus: false }, cells);
5722
6006
  this.#normalizeSelection();
5723
- this.#connected = true;
5724
- }
5725
- /** Reopens the row callback for the next mount. */
5726
- disconnect() {
5727
- this.#connected = false;
5728
6007
  }
5729
6008
  /**
5730
6009
  * Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
@@ -5735,16 +6014,25 @@ var DataGridController = class extends Controller {
5735
6014
  this.#syncSelectable();
5736
6015
  this.#normalizeSelection();
5737
6016
  }
5738
- /**
5739
- * Re-establishes the row baseline for a row added after connect.
5740
- *
5741
- * Each pass walks every row, and Stimulus reports the authored rows one by one
5742
- * before `connect()`, so the mount is gated to keep it linear in the row count
5743
- * rather than quadratic; `connect()` runs the single baseline pass instead.
5744
- */
6017
+ /** Re-establishes the baselines for a row added after connect. */
5745
6018
  rowTargetConnected() {
5746
- if (!this.#connected) return;
5747
- this.#normalizeSelection();
6019
+ this.#reconcile.schedule();
6020
+ }
6021
+ /** Re-establishes the tab stop when a cell joins the grid after connect. */
6022
+ cellTargetConnected() {
6023
+ this.#reconcile.schedule();
6024
+ }
6025
+ /** Re-establishes the tab stop when a cell leaves the grid. */
6026
+ cellTargetDisconnected() {
6027
+ this.#reconcile.schedule();
6028
+ }
6029
+ /** Re-establishes the tab stop when a header joins the grid after connect. */
6030
+ columnHeaderTargetConnected() {
6031
+ this.#reconcile.schedule();
6032
+ }
6033
+ /** Re-establishes the tab stop when a header leaves the grid. */
6034
+ columnHeaderTargetDisconnected() {
6035
+ this.#reconcile.schedule();
5748
6036
  }
5749
6037
  /**
5750
6038
  * Brings the authored rows to the shape the APG requires, without changing
@@ -5789,6 +6077,9 @@ var DataGridController = class extends Controller {
5789
6077
  sort(event) {
5790
6078
  const header = event.currentTarget;
5791
6079
  if (!this.columnHeaderTargets.includes(header)) return;
6080
+ if (event.defaultPrevented) return;
6081
+ const control = this.#claimingControl(event, header);
6082
+ if (control && !(control instanceof HTMLButtonElement)) return;
5792
6083
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
5793
6084
  for (const other of this.columnHeaderTargets) {
5794
6085
  other.setAttribute("aria-sort", other === header ? direction : "none");
@@ -5799,14 +6090,19 @@ var DataGridController = class extends Controller {
5799
6090
  /** Toggles selection of the row owning the event target. Bound optionally. */
5800
6091
  toggleSelect(event) {
5801
6092
  if (this.selectionValue === "none") return;
5802
- const row = event.currentTarget.closest("[role='row']");
6093
+ if (event.defaultPrevented) return;
6094
+ const host = event.currentTarget;
6095
+ if (this.#claimedByDescendant(event, host)) return;
6096
+ const row = host.closest("[role='row']");
5803
6097
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
5804
6098
  }
5805
6099
  /** Grid navigation + sort/select activation. Bound to cells and headers. */
5806
6100
  onKeydown(event) {
5807
6101
  if (event.defaultPrevented) return;
5808
6102
  if (isReservedArrowChord(event)) return;
6103
+ if (event.isComposing) return;
5809
6104
  const cell = event.currentTarget;
6105
+ if (this.#claimedByDescendant(event, cell)) return;
5810
6106
  const matrix = this.#matrix();
5811
6107
  const position = this.#locate(matrix, cell);
5812
6108
  if (!position) return;
@@ -5842,9 +6138,35 @@ var DataGridController = class extends Controller {
5842
6138
  }
5843
6139
  if (target) {
5844
6140
  event.preventDefault();
5845
- this.#setActiveCell(target, { focus: true });
6141
+ this.#setActiveCell(target, { focus: true }, matrix.flat());
5846
6142
  }
5847
6143
  }
6144
+ /**
6145
+ * Whether the event was addressed to a control inside `host` rather than to the
6146
+ * grid.
6147
+ *
6148
+ * Cells and headers hold consumer markup, and APG's grid pattern expects that
6149
+ * markup to include working controls — a row action button, an inline editor.
6150
+ * Those own their own keystrokes and clicks, so the grid stands down entirely
6151
+ * rather than acting in parallel. An editable host (its `contenteditable` state
6152
+ * is inherited, so the walk is explicit) counts the same way.
6153
+ */
6154
+ #claimedByDescendant(event, host) {
6155
+ return this.#claimingControl(event, host) !== null;
6156
+ }
6157
+ /**
6158
+ * The nested control this event belongs to, or `null` when the host owns it.
6159
+ *
6160
+ * Naming the control, rather than answering yes or no, is what lets the click
6161
+ * path treat a sortable header's `<button>` as the activation it is while every
6162
+ * other control still takes the event away.
6163
+ */
6164
+ #claimingControl(event, host) {
6165
+ const source = event.target;
6166
+ const control = source.closest(INTERACTIVE_HOST_SELECTOR);
6167
+ if (control && host.contains(control)) return control;
6168
+ return isInteractiveHost(source) ? source : null;
6169
+ }
5848
6170
  /** Performs a header's sort or a cell row's selection toggle on activation. */
5849
6171
  #activate(cell) {
5850
6172
  if (this.columnHeaderTargets.includes(cell)) {
@@ -5875,11 +6197,22 @@ var DataGridController = class extends Controller {
5875
6197
  const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
5876
6198
  this.dispatch("selectionchange", { detail: { rows } });
5877
6199
  }
5878
- /** Makes `cell` the single tabbable cell (roving) and optionally focuses it. */
5879
- #setActiveCell(cell, { focus }) {
5880
- if (!cell) return;
5881
- for (const candidate of this.#navigableCells()) {
5882
- candidate.tabIndex = candidate === cell ? 0 : -1;
6200
+ /**
6201
+ * Makes `cell` the single tabbable cell (roving) and optionally focuses it.
6202
+ *
6203
+ * `cells` lets a caller that already walked the grid hand its collection over,
6204
+ * so one keystroke rebuilds the matrix once instead of twice. The write is
6205
+ * skipped where the attribute already holds the wanted value — comparing the
6206
+ * attribute rather than the IDL property, because a cell with no `tabindex` at
6207
+ * all reports `-1` and would then never receive the attribute it needs to be
6208
+ * focusable.
6209
+ */
6210
+ #setActiveCell(cell, { focus }, cells) {
6211
+ for (const candidate of cells ?? this.#navigableCells()) {
6212
+ const wanted = candidate === cell ? "0" : "-1";
6213
+ if (candidate.getAttribute("tabindex") !== wanted) {
6214
+ candidate.setAttribute("tabindex", wanted);
6215
+ }
5883
6216
  }
5884
6217
  if (focus) cell.focus();
5885
6218
  }
@@ -7271,33 +7604,64 @@ var EditableController = class extends Controller {
7271
7604
  static values = {
7272
7605
  submitOnBlur: { type: Boolean, default: true }
7273
7606
  };
7274
- static actions = ["edit", "onBlur", "onDisplayKeydown", "onKeydown"];
7607
+ static actions = ["cancel", "edit", "onDisplayKeydown", "onKeydown", "revert", "save"];
7275
7608
  static events = ["cancel", "change"];
7276
7609
  /** The value captured when edit mode began, used to detect real changes. */
7277
7610
  #previousValue = "";
7611
+ /**
7612
+ * The value the last save replaced, or `null` when there is nothing to undo.
7613
+ * Cleared on connect: a value from before a page restore is unrecoverable, so
7614
+ * `revert()` must not resurrect one.
7615
+ */
7616
+ #revertValue = null;
7278
7617
  /**
7279
7618
  * Owns IME lifecycle state for the edit surface, so a keydown that belongs to
7280
7619
  * a composition (cancel or confirm) is never treated as an edit command.
7281
7620
  */
7282
7621
  #composition = new CompositionTracker();
7622
+ /**
7623
+ * Watches focus leaving the editor from wherever it currently sits.
7624
+ *
7625
+ * `focusout` bubbles where `blur` does not, so one listener on the root sees
7626
+ * every departure — including one from a Save or Cancel button the consumer
7627
+ * placed beside the input. Binding the input alone would make the promise
7628
+ * "saves wherever focus moved" true only for focus that leaves the input
7629
+ * itself, and tabbing straight past an inner button would strand the editor
7630
+ * open.
7631
+ */
7632
+ #onFocusOut = (event) => {
7633
+ const from = event.target;
7634
+ if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;
7635
+ const next = event.relatedTarget;
7636
+ if (next instanceof Node && this.element.contains(next)) return;
7637
+ if (this.submitOnBlurValue) this.#commit(false);
7638
+ };
7283
7639
  /** Establishes the initial display mode (display shown, input hidden). */
7284
7640
  connect() {
7285
7641
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
7642
+ this.element.addEventListener("focusout", this.#onFocusOut);
7643
+ this.#revertValue = null;
7286
7644
  this.#setMode("display");
7287
7645
  }
7288
- /** Releases the composition listeners so nothing outlives the element. */
7646
+ /** Releases the composition and focus listeners so nothing outlives the element. */
7289
7647
  disconnect() {
7290
7648
  this.#composition.disconnect();
7649
+ this.element.removeEventListener("focusout", this.#onFocusOut);
7291
7650
  }
7292
7651
  /** Tracks an input added initially or after connect (e.g. a Turbo swap). */
7293
7652
  inputTargetConnected(input) {
7294
7653
  this.#composition.observe(input);
7654
+ this.#applyMode();
7295
7655
  }
7296
7656
  /** Removes composition listeners when the active input is replaced or removed. */
7297
7657
  inputTargetDisconnected(input) {
7298
7658
  this.#composition.unobserve(input);
7299
7659
  }
7300
- /** Enters edit mode: seeds the input from the display text, focuses, selects. */
7660
+ /** Re-hides or re-shows a display element that arrived after the mode was set. */
7661
+ displayTargetConnected() {
7662
+ this.#applyMode();
7663
+ }
7664
+ /** Enters edit mode: seeds the input from the declared value, focuses, selects. */
7301
7665
  edit() {
7302
7666
  if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
7303
7667
  this.#previousValue = this.#currentValue;
@@ -7306,6 +7670,27 @@ var EditableController = class extends Controller {
7306
7670
  this.inputTarget.focus();
7307
7671
  this.inputTarget.select();
7308
7672
  }
7673
+ /** Commits the edit and returns focus to the display element. */
7674
+ save() {
7675
+ this.#commit(true);
7676
+ }
7677
+ /** Discards edits, returns to display mode, and dispatches `cancel`. */
7678
+ cancel() {
7679
+ if (!this.#isEditing) return;
7680
+ this.#setMode("display");
7681
+ if (this.hasDisplayTarget) this.displayTarget.focus();
7682
+ this.dispatch("cancel", { detail: {} });
7683
+ }
7684
+ /**
7685
+ * Puts back the value the last save replaced — for a consumer whose server
7686
+ * rejected it. Silent by design: a `change` here would re-enter the same
7687
+ * handler that asked for the undo. One save, one undo.
7688
+ */
7689
+ revert() {
7690
+ if (this.#revertValue === null || this.#isEditing) return;
7691
+ this.#writeValue(this.#revertValue);
7692
+ this.#revertValue = null;
7693
+ }
7309
7694
  /** Adds `F2` as an editing entry point alongside the button's native activation. */
7310
7695
  onDisplayKeydown(event) {
7311
7696
  if (event.key === "F2") {
@@ -7319,56 +7704,57 @@ var EditableController = class extends Controller {
7319
7704
  if (event.key === "Escape") {
7320
7705
  if (event.defaultPrevented) return;
7321
7706
  event.preventDefault();
7322
- this.#cancel();
7707
+ this.cancel();
7323
7708
  return;
7324
7709
  }
7325
7710
  if (event.key === "Enter") {
7326
7711
  if (event.defaultPrevented) return;
7327
7712
  if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
7328
7713
  event.preventDefault();
7329
- this.#save(true);
7714
+ this.#commit(true);
7330
7715
  }
7331
7716
  }
7332
- /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
7333
- onBlur() {
7334
- if (!this.#isEditing) return;
7335
- if (this.submitOnBlurValue) this.#save(false);
7336
- }
7337
7717
  /**
7338
- * Returns to display mode, reflecting the input into the display text and
7339
- * dispatching `change` when the value differs from where editing began.
7718
+ * Returns to display mode, storing the input's value and dispatching `change`
7719
+ * when it differs from where editing began.
7340
7720
  *
7341
7721
  * @param restoreFocus - Move focus back to the display element (explicit
7342
- * keyboard commit) rather than honoring the user's new focus target (blur).
7722
+ * commit) rather than honoring the user's new focus target (blur).
7343
7723
  */
7344
- #save(restoreFocus) {
7345
- if (!this.#isEditing) return;
7346
- const value = this.inputTarget.value;
7724
+ #commit(restoreFocus) {
7725
+ if (!this.#isEditing || !this.hasInputTarget) return;
7726
+ const value = this.inputTarget.value.trim();
7347
7727
  const previous = this.#previousValue;
7348
- this.displayTarget.textContent = value;
7728
+ this.#writeValue(value);
7349
7729
  this.#setMode("display");
7350
- if (restoreFocus) this.displayTarget.focus();
7730
+ if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();
7351
7731
  if (value !== previous) {
7732
+ this.#revertValue = previous;
7352
7733
  this.dispatch("change", { detail: { value, previous } });
7353
7734
  }
7354
7735
  }
7355
- /** Discards edits, returns to display mode, and dispatches `cancel`. */
7356
- #cancel() {
7357
- if (!this.#isEditing) return;
7358
- this.#setMode("display");
7359
- this.displayTarget.focus();
7360
- this.dispatch("cancel", { detail: {} });
7736
+ /** Stores `value` wherever the display element declares it. */
7737
+ #writeValue(value) {
7738
+ if (!this.hasDisplayTarget) return;
7739
+ const display = this.displayTarget;
7740
+ if (display.hasAttribute("data-value")) display.dataset.value = value;
7741
+ else display.textContent = value;
7361
7742
  }
7362
- /** Toggles the `data-mode` flag and the `hidden` state of both elements. */
7363
- #setMode(mode) {
7364
- this.element.dataset.mode = mode;
7365
- const editing = mode === "editing";
7743
+ /** Derives both elements' visibility from the mode currently in the DOM. */
7744
+ #applyMode() {
7745
+ const editing = this.#isEditing;
7366
7746
  if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
7367
7747
  if (this.hasInputTarget) this.inputTarget.hidden = !editing;
7368
7748
  }
7369
- /** Current display text, trimmed — the value shown when not editing. */
7749
+ /** Records the mode, then brings both elements in line with it. */
7750
+ #setMode(mode) {
7751
+ this.element.dataset.mode = mode;
7752
+ this.#applyMode();
7753
+ }
7754
+ /** The value the display element declares, trimmed. */
7370
7755
  get #currentValue() {
7371
- return (this.displayTarget.textContent ?? "").trim();
7756
+ const display = this.displayTarget;
7757
+ return (display.dataset.value ?? display.textContent ?? "").trim();
7372
7758
  }
7373
7759
  /** Whether the editing control is a multi-line `<textarea>`. */
7374
7760
  get #isMultiline() {
@@ -7980,15 +8366,46 @@ var FilterController = class extends Controller {
7980
8366
  #onChange = () => {
7981
8367
  this.apply();
7982
8368
  };
8369
+ /**
8370
+ * Gates the declaration callback to the connected window.
8371
+ *
8372
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
8373
+ * runtime change; without the gate, merely connecting would emit an evaluation
8374
+ * — and its `change` — before `connect()` runs its own.
8375
+ */
8376
+ #connected = false;
7983
8377
  connect() {
7984
- this.apply();
8378
+ this.#evaluate();
7985
8379
  this.element.addEventListener("change", this.#onChange);
8380
+ this.#connected = true;
7986
8381
  }
7987
8382
  disconnect() {
8383
+ this.#connected = false;
7988
8384
  this.element.removeEventListener("change", this.#onChange);
7989
8385
  }
8386
+ /**
8387
+ * Re-evaluates when the match declaration changes at runtime.
8388
+ *
8389
+ * The declaration decides which items are shown, so an element kept across a
8390
+ * morph — where `connect()` does not run again — still has to follow it instead
8391
+ * of waiting for the next control interaction. The evaluation is the ordinary
8392
+ * one, `change` included: a declaration swap is an evaluation like any other.
8393
+ */
8394
+ matchValueChanged() {
8395
+ if (!this.#connected) return;
8396
+ this.#evaluate();
8397
+ }
7990
8398
  /** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
7991
8399
  apply() {
8400
+ this.#evaluate();
8401
+ }
8402
+ /**
8403
+ * The evaluation itself: every item's visibility from the active tokens, then
8404
+ * the groups and the empty element, then the `change` event.
8405
+ *
8406
+ * @stimeoRenderRoot
8407
+ */
8408
+ #evaluate() {
7992
8409
  const active = this.#activeTokens();
7993
8410
  let visibleCount = 0;
7994
8411
  for (const item of this.itemTargets) {
@@ -10500,14 +10917,37 @@ var LocalTimeController = class extends Controller {
10500
10917
  }
10501
10918
  };
10502
10919
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
10920
+ var DEFAULT_MIN_COLUMN_WIDTH = 240;
10921
+ var DEFAULT_GAP = 16;
10922
+ function usableNumber(value, fallback) {
10923
+ return Number.isFinite(value) ? value : fallback;
10924
+ }
10503
10925
  var MasonryController = class extends Controller {
10504
10926
  static targets = ["item"];
10505
10927
  static values = {
10506
- minColumnWidth: { type: Number, default: 240 },
10507
- gap: { type: Number, default: 16 }
10928
+ minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
10929
+ gap: { type: Number, default: DEFAULT_GAP }
10508
10930
  };
10509
10931
  static events = ["layout"];
10510
- #layout = new LayoutObserver(() => this.#relayout());
10932
+ /**
10933
+ * The declared numbers after validation, so the layout path never sees a value
10934
+ * it cannot compute with. Both are resolved once per declaration change rather
10935
+ * than on every pass.
10936
+ */
10937
+ #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
10938
+ #gap = DEFAULT_GAP;
10939
+ /**
10940
+ * Collapses every re-layout trigger of one DOM mutation into a single pass, and
10941
+ * refuses to run before `connect()` or after `disconnect()`.
10942
+ *
10943
+ * The triggers arrive in bursts — a resize stream, a morph that syncs several
10944
+ * attributes, a batch of rows — and each pass measures every item, so folding
10945
+ * them keeps the work proportional to the batch rather than to the events in it.
10946
+ */
10947
+ #reconcile = new MicrotaskCoalescer(() => this.#relayout());
10948
+ /** Items that left the target set and still carry the column hook. */
10949
+ #released = /* @__PURE__ */ new Set();
10950
+ #layout = new LayoutObserver(() => this.#reconcile.schedule());
10511
10951
  #mutationObserver = null;
10512
10952
  /** Last published column count, so `layout` fires only on real changes. */
10513
10953
  #lastColumns = 0;
@@ -10517,20 +10957,49 @@ var MasonryController = class extends Controller {
10517
10957
  * first pass ran before they settled; `load` does not bubble, so this is bound in
10518
10958
  * the capture phase to catch every descendant.
10519
10959
  */
10520
- #onLoad = () => this.#relayout();
10960
+ #onLoad = () => this.#reconcile.schedule();
10961
+ /** Resolves the declared column width once, falling back when it is unreadable. */
10962
+ minColumnWidthValueChanged() {
10963
+ this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
10964
+ this.#reconcile.schedule();
10965
+ }
10966
+ /** Resolves the declared gap once, falling back when it is unreadable. */
10967
+ gapValueChanged() {
10968
+ this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);
10969
+ this.#reconcile.schedule();
10970
+ }
10971
+ /** Packs an element that became an item without moving in the DOM. */
10972
+ itemTargetConnected() {
10973
+ this.#reconcile.schedule();
10974
+ }
10975
+ /**
10976
+ * Queues the column hook of an element that stopped being an item for removal.
10977
+ *
10978
+ * The removal is queued rather than immediate because teardown reports every
10979
+ * target as disconnected: doing it here would strip the whole grid just before
10980
+ * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
10981
+ * with the pass, so only a genuine target change reaches it.
10982
+ */
10983
+ itemTargetDisconnected(item) {
10984
+ this.#released.add(item);
10985
+ this.#reconcile.schedule();
10986
+ }
10521
10987
  /** Observes size/content changes and performs the first layout pass. */
10522
10988
  connect() {
10523
10989
  this.#layout.observe(this.element);
10524
10990
  this.#layout.observeViewport();
10525
10991
  if (typeof MutationObserver !== "undefined") {
10526
- this.#mutationObserver = new MutationObserver(() => this.#relayout());
10992
+ this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
10527
10993
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
10528
10994
  }
10529
10995
  this.element.addEventListener("load", this.#onLoad, true);
10530
10996
  this.#relayout();
10997
+ this.#reconcile.activate();
10531
10998
  }
10532
10999
  /** Releases both observers and the load listener so nothing fires after detach. */
10533
11000
  disconnect() {
11001
+ this.#reconcile.cancel();
11002
+ this.#released.clear();
10534
11003
  this.#layout.disconnect();
10535
11004
  this.#mutationObserver?.disconnect();
10536
11005
  this.#mutationObserver = null;
@@ -10539,26 +11008,53 @@ var MasonryController = class extends Controller {
10539
11008
  }
10540
11009
  /**
10541
11010
  * Recomputes the column count and assigns every item to the shortest column.
10542
- * Runs automatically on connect, on resize, on item add/remove, and when a
10543
- * descendant resource loads (private — there is no public action; the observers
10544
- * and the capture-phase `load` listener drive it). Items are walked in DOM
10545
- * order; each lands in the column with the least accumulated height, which
10546
- * keeps the packing balanced without reordering the DOM.
11011
+ * Runs automatically on connect, on resize, on item add/remove, when a declared
11012
+ * number changes, and when a descendant resource loads (private — there is no
11013
+ * public action; the observers, the target callbacks and the capture-phase
11014
+ * `load` listener drive it). Items are walked in DOM order; each lands in the
11015
+ * column with the least accumulated height, which keeps the packing balanced
11016
+ * without reordering the DOM.
11017
+ *
11018
+ * Every box is measured before anything is written. Interleaving the two would
11019
+ * make a consumer's `data-column` rule invalidate style once per item, and the
11020
+ * next measurement then has to settle layout again — once per item instead of
11021
+ * once per pass. The assignment is independent of the measurement because the
11022
+ * columns are uniform in width, so the order of the two passes does not change
11023
+ * the result.
11024
+ *
11025
+ * @stimeoRenderRoot
10547
11026
  */
10548
11027
  #relayout() {
10549
11028
  const items = this.itemTargets;
10550
11029
  const columns = this.#columnCount();
11030
+ const boxes = items.map((item) => item.getBoundingClientRect().height);
11031
+ let changed = false;
11032
+ if (this.#released.size > 0) {
11033
+ const owned = new Set(items);
11034
+ for (const released of this.#released) {
11035
+ if (owned.has(released)) continue;
11036
+ if (released.hasAttribute("data-column")) {
11037
+ released.removeAttribute("data-column");
11038
+ changed = true;
11039
+ }
11040
+ }
11041
+ this.#released.clear();
11042
+ }
10551
11043
  const heights = new Array(columns).fill(0);
10552
- for (const item of items) {
11044
+ items.forEach((item, index) => {
10553
11045
  let shortest = 0;
10554
11046
  for (let col = 1; col < columns; col++) {
10555
11047
  if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
10556
11048
  }
10557
- item.setAttribute("data-column", String(shortest));
10558
- heights[shortest] = (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;
10559
- }
11049
+ const assigned = String(shortest);
11050
+ if (item.getAttribute("data-column") !== assigned) {
11051
+ item.setAttribute("data-column", assigned);
11052
+ changed = true;
11053
+ }
11054
+ heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
11055
+ });
10560
11056
  this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
10561
- if (columns !== this.#lastColumns) {
11057
+ if (columns !== this.#lastColumns || changed) {
10562
11058
  this.#lastColumns = columns;
10563
11059
  this.dispatch("layout", { detail: { columns } });
10564
11060
  }
@@ -10571,9 +11067,9 @@ var MasonryController = class extends Controller {
10571
11067
  */
10572
11068
  #columnCount() {
10573
11069
  const width = this.element.getBoundingClientRect().width;
10574
- const denominator = this.minColumnWidthValue + this.gapValue;
11070
+ const denominator = this.#minColumnWidth + this.#gap;
10575
11071
  if (width <= 0 || denominator <= 0) return 1;
10576
- return Math.max(1, Math.floor((width + this.gapValue) / denominator));
11072
+ return Math.max(1, Math.floor((width + this.#gap) / denominator));
10577
11073
  }
10578
11074
  };
10579
11075
  var MenuController = class extends Controller {
@@ -13611,6 +14107,9 @@ function compilePattern(source) {
13611
14107
  function hasModifier2(event) {
13612
14108
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
13613
14109
  }
14110
+ function statesDiffer(left, right) {
14111
+ return left.value !== right.value || left.state !== right.state;
14112
+ }
13614
14113
  var OtpController = class extends Controller {
13615
14114
  static targets = ["field", "value", "error"];
13616
14115
  static values = {
@@ -13622,8 +14121,8 @@ var OtpController = class extends Controller {
13622
14121
  #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
13623
14122
  /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
13624
14123
  #patternSource = DEFAULT_PATTERN;
13625
- /** Combined value carried by the last dispatch; keeps a no-op sync silent. */
13626
- #lastValue = null;
14124
+ /** Public state carried by the last dispatch; keeps a no-op sync silent. */
14125
+ #published = null;
13627
14126
  /** Field whose confirming `input` after `compositionend` is already handled. */
13628
14127
  #confirmedField = null;
13629
14128
  /** True between connect and disconnect, so pre-connect Value changes stay silent. */
@@ -13659,7 +14158,7 @@ var OtpController = class extends Controller {
13659
14158
  this.#beforeCache.activate();
13660
14159
  this.#reconcile.activate();
13661
14160
  this.#adopt();
13662
- this.#lastValue = this.#sync();
14161
+ this.#sync();
13663
14162
  }
13664
14163
  disconnect() {
13665
14164
  this.#connected = false;
@@ -13851,19 +14350,23 @@ var OtpController = class extends Controller {
13851
14350
  this.#markFilled(field, field.value);
13852
14351
  }
13853
14352
  /**
13854
- * Absorbs a batch of field additions or removals as one value transition.
14353
+ * Absorbs a batch of field additions or removals as one state transition.
13855
14354
  *
13856
- * The page, not the user, moved the value here, so it is reported as
14355
+ * The page, not the user, moved the state here, so it is reported as
13857
14356
  * `reconcile`: automation listening for `change` must not read a re-render as
13858
14357
  * an edit, and a passcode that happens to end up full must not fire the
13859
14358
  * `complete` that submits it.
14359
+ *
14360
+ * Completeness moves on its own when the field count changes: dropping a
14361
+ * trailing empty field completes a passcode whose combined value never moved,
14362
+ * and adding one un-completes it. Comparing the whole derived state, not the
14363
+ * string it contains, is what makes those transitions reportable.
13860
14364
  */
13861
14365
  #reconcileFields() {
13862
- const previous = this.#lastValue;
13863
- const combined = this.#sync();
13864
- if (combined === previous) return;
13865
- this.#lastValue = combined;
13866
- this.dispatch("reconcile", { detail: { value: combined } });
14366
+ const previous = this.#published;
14367
+ const current = this.#sync();
14368
+ if (previous && !statesDiffer(previous, current)) return;
14369
+ this.dispatch("reconcile", { detail: { value: current.value } });
13867
14370
  }
13868
14371
  /**
13869
14372
  * Validates the text an entry point received and distributes what it accepts.
@@ -13958,23 +14461,29 @@ var OtpController = class extends Controller {
13958
14461
  const fields = this.fieldTargets;
13959
14462
  return fields.length > 0 && fields.every((field) => field.value.length > 0);
13960
14463
  }
13961
- /** Mirrors the combined value into the form and the root's readable state. */
14464
+ /**
14465
+ * Mirrors the combined value into the form and the root's readable state, and
14466
+ * records what was published so the next pass can compare against it.
14467
+ */
13962
14468
  #sync() {
13963
14469
  const combined = this.#combinedValue();
13964
14470
  if (this.hasValueTarget) {
13965
14471
  this.valueTarget.value = combined;
13966
14472
  }
13967
- this.#state.write(this.element, this.#stateName(combined));
13968
- return combined;
14473
+ const state = this.#stateName(combined);
14474
+ this.#state.write(this.element, state);
14475
+ const published = { value: combined, state };
14476
+ this.#published = published;
14477
+ return published;
13969
14478
  }
13970
14479
  #stateName(combined) {
13971
14480
  if (combined.length === 0) return "empty";
13972
14481
  return this.#isComplete() ? "complete" : "partial";
13973
14482
  }
13974
14483
  #syncAndDispatch() {
13975
- const combined = this.#sync();
13976
- if (combined === this.#lastValue) return;
13977
- this.#lastValue = combined;
14484
+ const previous = this.#published;
14485
+ const { value: combined } = this.#sync();
14486
+ if (previous?.value === combined) return;
13978
14487
  this.dispatch("change", { detail: { value: combined } });
13979
14488
  if (this.#isComplete()) {
13980
14489
  this.dispatch("complete", { detail: { value: combined } });
@@ -16215,25 +16724,6 @@ var ProgressController = class extends Controller {
16215
16724
  this.element.removeAttribute(OWNED_VALUE_TEXT2);
16216
16725
  }
16217
16726
  };
16218
-
16219
- // src/utils/interactive_host.ts
16220
- var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
16221
- function isInteractiveHost(element) {
16222
- if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
16223
- let current = element;
16224
- while (current) {
16225
- const raw = current.getAttribute("contenteditable");
16226
- if (raw !== null) {
16227
- const value = raw.trim().toLowerCase();
16228
- if (value === "false") return false;
16229
- if (value === "" || value === "true" || value === "plaintext-only") return true;
16230
- }
16231
- current = current.parentElement;
16232
- }
16233
- return false;
16234
- }
16235
-
16236
- // src/controllers/radio_group_controller.ts
16237
16727
  var OBSERVED_ATTRIBUTES4 = [
16238
16728
  "aria-checked",
16239
16729
  "aria-disabled",
@@ -16737,61 +17227,6 @@ var RadioGroupController = class extends Controller {
16737
17227
  return radio.getAttribute("data-value") ?? "";
16738
17228
  }
16739
17229
  };
16740
-
16741
- // src/utils/owned_pointer_session.ts
16742
- var OwnedPointerSession = class {
16743
- pointerId;
16744
- #owner;
16745
- #handlers;
16746
- #abort = new AbortController();
16747
- #active = true;
16748
- constructor(start, owner, handlers) {
16749
- this.pointerId = start.pointerId;
16750
- this.#owner = owner;
16751
- this.#handlers = handlers;
16752
- const { signal } = this.#abort;
16753
- owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
16754
- owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
16755
- owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
16756
- owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
16757
- try {
16758
- owner.setPointerCapture?.(this.pointerId);
16759
- } catch {
16760
- }
16761
- }
16762
- /** Whether this session still owns its pointer and listeners. */
16763
- get active() {
16764
- return this.#active;
16765
- }
16766
- /** Whether `event` belongs to the initiating pointer of the live session. */
16767
- owns(event) {
16768
- return this.#active && event.pointerId === this.pointerId;
16769
- }
16770
- /** Releases capture/listeners and invokes the end callback exactly once. */
16771
- end() {
16772
- if (!this.#active) return;
16773
- this.#active = false;
16774
- this.#abort.abort();
16775
- try {
16776
- this.#owner.releasePointerCapture?.(this.pointerId);
16777
- } catch {
16778
- }
16779
- this.#handlers.end?.();
16780
- }
16781
- #onMove = (event) => {
16782
- if (this.owns(event)) this.#handlers.move(event);
16783
- };
16784
- #onEndEvent = (event) => {
16785
- if (this.owns(event)) this.end();
16786
- };
16787
- #onLostCapture = (event) => {
16788
- const pointerId = event.pointerId;
16789
- if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
16790
- this.end();
16791
- };
16792
- };
16793
-
16794
- // src/controllers/range_slider_controller.ts
16795
17230
  var START_PROPERTY = "--stimeo--range-slider-start";
16796
17231
  var END_PROPERTY = "--stimeo--range-slider-end";
16797
17232
  var DEFAULT_MIN = 0;
@@ -17641,6 +18076,26 @@ var RelativeTimeController = class extends Controller {
17641
18076
  return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
17642
18077
  }
17643
18078
  };
18079
+ var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
18080
+ function restoreField(element) {
18081
+ if (element instanceof HTMLTextAreaElement) {
18082
+ element.value = element.defaultValue;
18083
+ return;
18084
+ }
18085
+ if (element instanceof HTMLSelectElement) {
18086
+ for (const option of element.options) option.selected = option.hasAttribute("selected");
18087
+ return;
18088
+ }
18089
+ if (element instanceof HTMLInputElement) {
18090
+ if (element.type === "checkbox" || element.type === "radio") {
18091
+ element.checked = element.defaultChecked;
18092
+ } else if (element.type === "file") {
18093
+ element.value = "";
18094
+ } else if (!STATELESS_INPUT_TYPES.has(element.type)) {
18095
+ element.value = element.defaultValue;
18096
+ }
18097
+ }
18098
+ }
17644
18099
  var ResetBeforeCacheController = class extends Controller {
17645
18100
  static values = {
17646
18101
  scope: { type: String, default: "" },
@@ -17648,8 +18103,32 @@ var ResetBeforeCacheController = class extends Controller {
17648
18103
  };
17649
18104
  static actions = ["reset"];
17650
18105
  static events = ["reset", "request"];
18106
+ /** The `scope` declaration after validation; empty when it cannot be parsed. */
18107
+ #scopeSelector = "";
17651
18108
  /** Runs the reset just before Turbo caches the snapshot. */
17652
18109
  #onBeforeCache = () => this.reset();
18110
+ /**
18111
+ * Validates the scope declaration once, keeping only a selector the engine can
18112
+ * read.
18113
+ *
18114
+ * A selector reads back as an ordinary string, so a malformed one survives
18115
+ * until it is handed to the DOM — and this part runs from a single listener
18116
+ * whose whole job is to keep a cached page from freezing. Falling back to the
18117
+ * default keeps that job running with a visible, findable result instead of
18118
+ * silently taking the sweep down.
18119
+ */
18120
+ scopeValueChanged() {
18121
+ const selector = this.scopeValue;
18122
+ if (selector.length > 0) {
18123
+ try {
18124
+ this.element.matches(selector);
18125
+ this.#scopeSelector = selector;
18126
+ return;
18127
+ } catch {
18128
+ }
18129
+ }
18130
+ this.#scopeSelector = "";
18131
+ }
17653
18132
  connect() {
17654
18133
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
17655
18134
  }
@@ -17660,6 +18139,10 @@ var ResetBeforeCacheController = class extends Controller {
17660
18139
  * Resets transient UI within scope to its initial state. Asks controllers to
17661
18140
  * close (via `request`) first, then applies the declarative `data-reset-*` cleanup,
17662
18141
  * and finally emits `reset`. Safe to call any number of times (idempotent).
18142
+ *
18143
+ * `dispatchReset` decides only whether the `request` ask goes out; the cleanup
18144
+ * and the closing `reset` run either way. Both the listener and this action
18145
+ * reach the same sweep, so `reset` is emitted for a manual call too.
17663
18146
  */
17664
18147
  reset() {
17665
18148
  const root = this.#scopeRoot();
@@ -17678,9 +18161,7 @@ var ResetBeforeCacheController = class extends Controller {
17678
18161
  if (element instanceof HTMLFormElement) element.reset();
17679
18162
  }
17680
18163
  for (const element of root.querySelectorAll("[data-reset-value]")) {
17681
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
17682
- element.value = "";
17683
- }
18164
+ restoreField(element);
17684
18165
  }
17685
18166
  for (const element of root.querySelectorAll("[data-reset-hidden]")) {
17686
18167
  element.hidden = true;
@@ -17692,30 +18173,38 @@ var ResetBeforeCacheController = class extends Controller {
17692
18173
  }
17693
18174
  /** The scan root: a `scope` descendant when set, else the controller element. */
17694
18175
  #scopeRoot() {
17695
- if (!this.scopeValue) return this.element;
17696
- return this.element.querySelector(this.scopeValue) ?? this.element;
18176
+ if (!this.#scopeSelector) return this.element;
18177
+ return this.element.querySelector(this.#scopeSelector) ?? this.element;
17697
18178
  }
17698
18179
  };
18180
+ var DEFAULT_MIN2 = 0;
18181
+ var DEFAULT_MAX2 = 100;
18182
+ var DEFAULT_VALUE = 50;
18183
+ var DEFAULT_STEP = 1;
17699
18184
  var ResizableController = class extends Controller {
17700
18185
  static targets = ["primary", "secondary", "separator"];
17701
18186
  static values = {
17702
- min: { type: Number, default: 0 },
17703
- max: { type: Number, default: 100 },
17704
- step: { type: Number, default: 1 },
17705
- value: { type: Number, default: 50 }
18187
+ min: { type: Number, default: DEFAULT_MIN2 },
18188
+ max: { type: Number, default: DEFAULT_MAX2 },
18189
+ step: { type: Number, default: DEFAULT_STEP },
18190
+ value: { type: Number, default: DEFAULT_VALUE }
17706
18191
  };
17707
18192
  static actions = ["onKeydown", "onPointerDown", "toggle"];
17708
18193
  static events = ["change"];
17709
- /** The value held before the current collapse, restored when toggling back. */
17710
- #valueBeforeCollapse = 50;
18194
+ /** Where the divider sat before the current collapse; `null` when unknown. */
18195
+ #valueBeforeCollapse = null;
17711
18196
  /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
17712
18197
  #dragAbort = null;
17713
18198
  /** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
17714
18199
  #paneTabindex = new TabindexLoan();
17715
18200
  /** Releases the pane cycle listener bound in {@link connect}. */
17716
18201
  #cycleAbort = null;
18202
+ /** Folds the range and position inputs of one morph batch into a single paint. */
18203
+ #repaint = new MicrotaskCoalescer(() => this.#render());
17717
18204
  connect() {
17718
- this.#clampAndSync();
18205
+ this.element.removeAttribute("data-dragging");
18206
+ this.#repaint.activate();
18207
+ this.#render();
17719
18208
  this.#cycleAbort = new AbortController();
17720
18209
  this.element.addEventListener("keydown", this.#onCycleKeydown, {
17721
18210
  signal: this.#cycleAbort.signal
@@ -17727,17 +18216,19 @@ var ResizableController = class extends Controller {
17727
18216
  this.#dragAbort = null;
17728
18217
  this.#cycleAbort?.abort();
17729
18218
  this.#cycleAbort = null;
18219
+ this.#repaint.cancel();
17730
18220
  this.#paneTabindex.returnAll();
18221
+ this.element.removeAttribute("data-dragging");
17731
18222
  }
17732
18223
  /** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
17733
18224
  #onCycleKeydown = (event) => {
17734
18225
  if (event.key !== "F6") return;
18226
+ if (event.isComposing) return;
17735
18227
  if (event.defaultPrevented) return;
17736
18228
  if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
17737
18229
  const panes = [];
17738
18230
  if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
17739
18231
  if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
17740
- if (panes.length === 0) return;
17741
18232
  const target = event.target;
17742
18233
  const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
17743
18234
  const next = panes[(current + 1) % panes.length];
@@ -17746,12 +18237,21 @@ var ResizableController = class extends Controller {
17746
18237
  this.#paneTabindex.lend(next);
17747
18238
  next.focus();
17748
18239
  };
17749
- /**
17750
- * Stimulus lifecycle callback when the valueValue changes.
17751
- * Keeps CSS fractions and ARIA status completely aligned.
17752
- */
18240
+ /** Re-renders when application code (or a Turbo morph) changes `value` at runtime. */
17753
18241
  valueValueChanged() {
17754
- this.#clampAndSync();
18242
+ this.#repaint.schedule();
18243
+ }
18244
+ /** Re-renders when application code (or a Turbo morph) changes `min` at runtime. */
18245
+ minValueChanged() {
18246
+ this.#repaint.schedule();
18247
+ }
18248
+ /** Re-renders when application code (or a Turbo morph) changes `max` at runtime. */
18249
+ maxValueChanged() {
18250
+ this.#repaint.schedule();
18251
+ }
18252
+ /** Re-publishes range and position onto a separator swapped in after connect. */
18253
+ separatorTargetConnected() {
18254
+ this.#repaint.schedule();
17755
18255
  }
17756
18256
  /** Starts active pointer drag tracking and locks capture. */
17757
18257
  onPointerDown(event) {
@@ -17772,44 +18272,44 @@ var ResizableController = class extends Controller {
17772
18272
  onKeydown(event) {
17773
18273
  if (isReservedArrowChord(event)) return;
17774
18274
  if (!this.hasSeparatorTarget) return;
17775
- const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
17776
- const isVertical = orientation === "vertical";
18275
+ const { min, max } = this.#range;
18276
+ const isVertical = this.#isVertical;
17777
18277
  let handled = true;
17778
- let nextValue = this.valueValue;
18278
+ let nextValue = this.#position;
17779
18279
  switch (event.key) {
17780
18280
  case "ArrowLeft":
17781
18281
  if (isVertical) {
17782
- nextValue -= this.stepValue;
18282
+ nextValue -= this.#step;
17783
18283
  } else {
17784
18284
  handled = false;
17785
18285
  }
17786
18286
  break;
17787
18287
  case "ArrowRight":
17788
18288
  if (isVertical) {
17789
- nextValue += this.stepValue;
18289
+ nextValue += this.#step;
17790
18290
  } else {
17791
18291
  handled = false;
17792
18292
  }
17793
18293
  break;
17794
18294
  case "ArrowUp":
17795
18295
  if (!isVertical) {
17796
- nextValue -= this.stepValue;
18296
+ nextValue -= this.#step;
17797
18297
  } else {
17798
18298
  handled = false;
17799
18299
  }
17800
18300
  break;
17801
18301
  case "ArrowDown":
17802
18302
  if (!isVertical) {
17803
- nextValue += this.stepValue;
18303
+ nextValue += this.#step;
17804
18304
  } else {
17805
18305
  handled = false;
17806
18306
  }
17807
18307
  break;
17808
18308
  case "Home":
17809
- nextValue = this.minValue;
18309
+ nextValue = min;
17810
18310
  break;
17811
18311
  case "End":
17812
- nextValue = this.maxValue;
18312
+ nextValue = max;
17813
18313
  break;
17814
18314
  case "Enter":
17815
18315
  event.preventDefault();
@@ -17821,65 +18321,86 @@ var ResizableController = class extends Controller {
17821
18321
  }
17822
18322
  if (handled) {
17823
18323
  event.preventDefault();
17824
- this.valueValue = Math.max(this.minValue, Math.min(nextValue, this.maxValue));
17825
- this.#clampAndSync();
18324
+ this.valueValue = Math.max(min, Math.min(nextValue, max));
18325
+ this.#render();
17826
18326
  this.#dispatchChange();
17827
18327
  }
17828
18328
  }
17829
- /** Double-click or Enter to collapse/restore pane to min/max levels. */
18329
+ /** Double-click or Enter to collapse the primary pane, or put it back. */
17830
18330
  toggle() {
17831
- const threshold = this.minValue + (this.maxValue - this.minValue) / 2;
17832
- if (this.valueValue > this.minValue) {
17833
- this.#valueBeforeCollapse = this.valueValue;
17834
- this.valueValue = this.minValue;
18331
+ const { min, max } = this.#range;
18332
+ if (this.#position > min) {
18333
+ this.#valueBeforeCollapse = this.#position;
18334
+ this.valueValue = min;
17835
18335
  } else {
17836
- this.valueValue = this.#valueBeforeCollapse >= threshold ? this.#valueBeforeCollapse : this.maxValue;
18336
+ this.valueValue = this.#valueBeforeCollapse ?? max;
18337
+ this.#valueBeforeCollapse = null;
17837
18338
  }
17838
- this.#clampAndSync();
18339
+ this.#render();
17839
18340
  this.#dispatchChange();
17840
18341
  }
17841
18342
  #onPointerMove = (event) => {
17842
18343
  if (!this.hasSeparatorTarget) return;
17843
18344
  const rect = this.element.getBoundingClientRect();
17844
- const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
17845
- const isVertical = orientation === "vertical";
17846
- let fraction = 0.5;
17847
- if (isVertical) {
17848
- fraction = (event.clientX - rect.left) / rect.width;
17849
- } else {
17850
- fraction = (event.clientY - rect.top) / rect.height;
17851
- }
17852
- fraction = Math.max(0, Math.min(fraction, 1));
17853
- const percent = Math.round(fraction * 100);
17854
- this.valueValue = Math.max(this.minValue, Math.min(percent, this.maxValue));
17855
- this.#clampAndSync();
18345
+ const raw = this.#isVertical ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height;
18346
+ const fraction = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 1)) : 0;
18347
+ const { min, max } = this.#range;
18348
+ this.valueValue = Math.max(min, Math.min(Math.round(fraction * 100), max));
18349
+ this.#render();
17856
18350
  };
17857
18351
  #onPointerUp = (event) => {
17858
- if (!this.hasSeparatorTarget) return;
17859
- const separator = this.separatorTarget;
17860
- separator.releasePointerCapture(event.pointerId);
17861
18352
  this.element.removeAttribute("data-dragging");
17862
18353
  this.#dragAbort?.abort();
17863
18354
  this.#dragAbort = null;
18355
+ if (this.hasSeparatorTarget) {
18356
+ this.separatorTarget.releasePointerCapture(event.pointerId);
18357
+ }
17864
18358
  this.#dispatchChange();
17865
18359
  };
17866
- #clampAndSync() {
17867
- const clamped = Math.max(this.minValue, Math.min(this.valueValue, this.maxValue));
17868
- if (this.valueValue !== clamped) {
17869
- this.valueValue = clamped;
18360
+ /**
18361
+ * Publishes the position: the fraction consumer CSS multiplies a pane by, and
18362
+ * the range assistive tech reads off the separator. The clamped position is
18363
+ * written back to the Value so it, ARIA, CSS, and dispatched events agree.
18364
+ *
18365
+ * @stimeoRenderRoot
18366
+ */
18367
+ #render() {
18368
+ const { min, max } = this.#range;
18369
+ const position = this.#position;
18370
+ if (this.valueValue !== position) {
18371
+ this.valueValue = position;
17870
18372
  }
17871
- const fraction = clamped / 100;
17872
- this.element.style.setProperty("--stimeo--resizable-fraction", String(fraction));
18373
+ this.element.style.setProperty("--stimeo--resizable-fraction", String(position / 100));
17873
18374
  if (this.hasSeparatorTarget) {
17874
- this.separatorTarget.setAttribute("aria-valuenow", String(clamped));
17875
- this.separatorTarget.setAttribute("aria-valuemin", String(this.minValue));
17876
- this.separatorTarget.setAttribute("aria-valuemax", String(this.maxValue));
18375
+ this.separatorTarget.setAttribute("aria-valuenow", String(position));
18376
+ this.separatorTarget.setAttribute("aria-valuemin", String(min));
18377
+ this.separatorTarget.setAttribute("aria-valuemax", String(max));
17877
18378
  }
17878
18379
  }
17879
18380
  #dispatchChange() {
17880
18381
  const fraction = this.valueValue / 100;
17881
18382
  this.dispatch("change", { detail: { value: this.valueValue, fraction } });
17882
18383
  }
18384
+ /** The declared range after validation; an unreadable bound uses its default. */
18385
+ get #range() {
18386
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN2;
18387
+ const max = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX2;
18388
+ return { min, max: Math.max(min, max) };
18389
+ }
18390
+ /** The declared position, clamped into the validated range. */
18391
+ get #position() {
18392
+ const { min, max } = this.#range;
18393
+ const value = Number.isFinite(this.valueValue) ? this.valueValue : DEFAULT_VALUE;
18394
+ return Math.max(min, Math.min(value, max));
18395
+ }
18396
+ /** The keyboard increment; a non-positive or unreadable one uses the default. */
18397
+ get #step() {
18398
+ return Number.isFinite(this.stepValue) && this.stepValue > 0 ? this.stepValue : DEFAULT_STEP;
18399
+ }
18400
+ /** Whether the divider runs vertically; a separator that does not say is horizontal. */
18401
+ get #isVertical() {
18402
+ return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
18403
+ }
17883
18404
  };
17884
18405
  var RovingController = class extends Controller {
17885
18406
  static targets = ["item"];
@@ -18973,16 +19494,16 @@ var ScrollspyController = class extends Controller {
18973
19494
  return value.substring(1) || null;
18974
19495
  }
18975
19496
  };
18976
- var DEFAULT_MIN2 = 0;
18977
- var DEFAULT_MAX2 = 100;
19497
+ var DEFAULT_MIN3 = 0;
19498
+ var DEFAULT_MAX3 = 100;
18978
19499
  var SeparatorController = class extends Controller {
18979
19500
  static values = {
18980
19501
  orientation: { type: String, default: "horizontal" },
18981
19502
  focusable: { type: Boolean, default: false },
18982
- min: { type: Number, default: DEFAULT_MIN2 },
18983
- max: { type: Number, default: DEFAULT_MAX2 },
19503
+ min: { type: Number, default: DEFAULT_MIN3 },
19504
+ max: { type: Number, default: DEFAULT_MAX3 },
18984
19505
  step: { type: Number, default: 1 },
18985
- value: { type: Number, default: DEFAULT_MIN2 }
19506
+ value: { type: Number, default: DEFAULT_MIN3 }
18986
19507
  };
18987
19508
  static actions = ["onKeydown"];
18988
19509
  static events = ["change"];
@@ -19103,8 +19624,8 @@ var SeparatorController = class extends Controller {
19103
19624
  }
19104
19625
  /** Finite, ordered range shared by rendering and keyboard stepping. */
19105
19626
  get #effectiveRange() {
19106
- const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN2;
19107
- const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX2;
19627
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN3;
19628
+ const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX3;
19108
19629
  return {
19109
19630
  min,
19110
19631
  max: Math.max(min, candidateMax),